Files
shoot-miniprograms/src/pages/device/my-device.vue
T
2026-09-23 15:36:39 +08:00

1488 lines
34 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 { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import DeviceOnlineEffects from "@/components/DeviceOnlineEffects.vue";
import Header from "@/components/Header.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import ModalDialog from "@/components/ModalDialog.vue";
import OtaModal from "@/components/OtaModal.vue";
import {
getHardwareBoxVersionAPI,
laserAimAPI,
updateDeviceAliasAPI,
} from "@/apis";
import useStore from "@/store";
import { storeToRefs } from "pinia";
import { useDeviceBinding } from "./composables/useDeviceBinding";
import {
DEVICE_NAME_STORAGE_KEY,
useDeviceStatus,
} from "./composables/useDeviceStatus";
import { useOtaUpdate } from "@/composables/useOtaUpdate";
const store = useStore();
const { updateDevice, updateDeviceUsageDuration, clearDevice } = store;
const { user, device, deviceStatus, deviceUsageDuration, online } = storeToRefs(store);
const DEVICE_NAME_MAX_LENGTH = 10;
const formatBindingDate = (value) => {
if (!value) return "--";
const text = String(value).trim();
if (!text) return "--";
const dateParts = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (dateParts) {
return `${dateParts[1]}/${dateParts[2].padStart(2, "0")}/${dateParts[3].padStart(2, "0")}`;
}
const numericValue = Number(value);
const date = new Date(
Number.isFinite(numericValue)
? numericValue < 1e12
? numericValue * 1000
: numericValue
: value
);
if (Number.isNaN(date.getTime())) return "--";
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(
date.getDate()
).padStart(2, "0")}`;
};
const showTip = ref(false);
const confirmBindTip = ref(false);
const unbindDialogVisible = ref(false);
const nameEditorVisible = ref(false);
const editingName = ref("");
const renaming = ref(false);
const token = ref("");
const binding = ref(false);
const retryScanOnShow = ref(false);
const calibration = ref(false);
const showDeviceId = ref(false);
const latestVersionDialogVisible = ref(false);
const firmwareConfirmVisible = ref(false);
const wifiRequiredVisible = ref(false);
const otaNeedUpdate = ref(false);
const firmwareActionPending = ref(false);
const pendingOtaInfo = ref(null);
let otaCheckPromise = null;
let otaCheckRequestVersion = 0;
const {
updating: otaUpdating,
progress: otaProgress,
phase: otaPhase,
resultVisible: otaResultVisible,
resultStatus: otaResultStatus,
resultTitle: otaResultTitle,
resultContent: otaResultContent,
startUpdate: startOtaUpdate,
closeResult: closeOtaResult,
} = useOtaUpdate();
const {
batteryText,
deviceDetails,
getDeviceNameOverrides,
isDeviceOnline,
maskedDeviceId,
networkType,
onlineDurationText,
refreshDeviceDetails,
statusClass,
statusText,
syncDeviceBinding,
unbindDevice,
} = useDeviceStatus({
user,
device,
deviceStatus,
deviceUsageDuration,
online,
updateDevice,
updateDeviceUsageDuration,
clearDevice,
unbindDialogVisible,
});
const wifiStatusText = computed(() => {
if (!networkType.value) return "未设置";
return networkType.value === "wifi" ? "已连接" : "未连接";
});
const bindingDateText = computed(() =>
formatBindingDate(deviceDetails.value.bindTime)
);
// 固件版本统一以大写 V 开头,避免接口返回格式不一致影响弹窗展示。
const formatFirmwareVersion = (value) => {
const version = String(value ?? "").trim();
if (!version) return "";
return /^v/i.test(version) ? `V${version.slice(1)}` : `V${version}`;
};
const firmwareConfirmContent = computed(() => {
const currentVersion = formatFirmwareVersion(deviceStatus.value.version);
const latestVersion = formatFirmwareVersion(
pendingOtaInfo.value?.versionNumber
);
if (currentVersion && latestVersion) {
return `当前固件版本为 ${currentVersion},发现新固件版本 ${latestVersion},是否立即更新固件?`;
}
if (latestVersion) {
return `发现新固件版本 ${latestVersion},是否立即更新固件?`;
}
return "发现新固件版本,是否立即更新固件?";
});
const deviceIdText = computed(() =>
showDeviceId.value && device.value.deviceName
? device.value.deviceName
: maskedDeviceId.value
);
// 设备型号直接读取详情接口,缺失时显示占位符。
const designDeviceStats = computed(() => [
{
label: "设备型号",
value: deviceDetails.value.deviceModelName?.trim() || "--",
},
{
label: "剩余电量",
value: !isDeviceOnline.value
? "设备离线"
: batteryText.value === "暂无数据"
? "--"
: batteryText.value,
},
{
label: "累计使用",
value: onlineDurationText.value,
},
{
label: "绑定时间",
value: bindingDateText.value,
},
]);
const { confirmBind, handleScan } = useDeviceBinding({
token,
confirmBindTip,
binding,
updateDevice,
deviceDetails,
});
const isScanPage = computed(() => !device.value.deviceId);
// 解绑前展示统一确认弹窗,避免误触解除绑定。
const openUnbindDialog = () => {
unbindDialogVisible.value = true;
};
const closeUnbindDialog = () => {
unbindDialogVisible.value = false;
};
const openNameEditor = () => {
editingName.value = device.value.deviceName || "我的智能弓";
nameEditorVisible.value = true;
};
const closeNameEditor = () => {
if (renaming.value) return;
nameEditorVisible.value = false;
};
const editingNameLength = computed(() =>
Array.from(String(editingName.value || "")).length
);
const isEditingNameTooLong = computed(
() => editingNameLength.value > DEVICE_NAME_MAX_LENGTH
);
const toggleDeviceId = () => {
if (device.value.deviceId) showDeviceId.value = !showDeviceId.value;
};
const confirmName = async () => {
if (renaming.value) return;
const name = String(editingName.value || "").trim();
if (!name) {
uni.showToast({ title: "请输入设备名", icon: "none" });
return;
}
if (Array.from(name).length > DEVICE_NAME_MAX_LENGTH) {
uni.showToast({ title: "设备名最多支持10个字符", icon: "none" });
return;
}
if (!/^[\u4e00-\u9fa5A-Za-z0-9_-]+$/.test(name)) {
uni.showToast({
title: "仅支持中文、英文、数字、下划线和减号",
icon: "none",
});
return;
}
const deviceId = device.value.deviceId;
if (!deviceId) {
uni.showToast({ title: "暂无绑定设备", icon: "none" });
return;
}
renaming.value = true;
try {
await updateDeviceAliasAPI(deviceId, name);
if (device.value.deviceId !== deviceId) return;
updateDevice(deviceId, name);
deviceDetails.value = {
...deviceDetails.value,
deviceAlias: name,
deviceName: name,
};
// 服务端别名已成为数据源,移除旧版本遗留的本地名称覆盖。
const nameOverrides = getDeviceNameOverrides();
delete nameOverrides[deviceId];
uni.setStorageSync(DEVICE_NAME_STORAGE_KEY, nameOverrides);
nameEditorVisible.value = false;
uni.showToast({ title: "设备名已更新", icon: "success" });
void refreshDeviceDetails();
} catch (error) {
console.error("修改设备名失败", error);
} finally {
renaming.value = false;
}
};
const toDeviceIntroPage = () => {
uni.navigateTo({ url: "/pages/device/device-intro" });
};
const joinWifi = () => {
if (!isDeviceOnline.value) {
uni.showToast({ title: "请先开启智能弓", icon: "none" });
return;
}
uni.navigateTo({ url: "/pages/device/ota-wifi" });
};
const clearOtaState = () => {
otaCheckRequestVersion += 1;
otaCheckPromise = null;
otaNeedUpdate.value = false;
};
const loadOtaVersionInfo = async () => {
if (otaCheckPromise) return otaCheckPromise;
const deviceId = device.value.deviceId;
if (!deviceId || !isDeviceOnline.value) return null;
const requestVersion = ++otaCheckRequestVersion;
const request = getHardwareBoxVersionAPI()
.then((versionInfo) => {
if (
requestVersion !== otaCheckRequestVersion ||
device.value.deviceId !== deviceId ||
!isDeviceOnline.value
) {
return null;
}
if (!versionInfo || typeof versionInfo !== "object") {
throw new Error("固件版本信息为空");
}
const needUpdate =
versionInfo.needUpdate === true || Number(versionInfo.needUpdate) === 1;
otaNeedUpdate.value = needUpdate;
return {
versionNumber: versionInfo.versionNumber || "",
resourceUrl: versionInfo.resourceUrl || "",
needUpdate,
};
})
.finally(() => {
if (otaCheckPromise === request) {
otaCheckPromise = null;
}
});
otaCheckPromise = request;
return request;
};
const refreshOtaUpdateState = async () => {
otaNeedUpdate.value = false;
if (!device.value.deviceId || !isDeviceOnline.value) {
clearOtaState();
return;
}
try {
await loadOtaVersionInfo();
} catch (error) {
clearOtaState();
console.log("检查固件更新失败", error);
}
};
const closeLatestVersionDialog = () => {
latestVersionDialogVisible.value = false;
};
const closeFirmwareConfirm = () => {
firmwareConfirmVisible.value = false;
};
const runFirmwareUpdate = () => {
const versionInfo = pendingOtaInfo.value;
if (!versionInfo) return;
void startOtaUpdate({
versionNumber: versionInfo.versionNumber,
resourceUrl: versionInfo.resourceUrl,
onSuccess: () => {
otaNeedUpdate.value = false;
},
});
};
const confirmFirmwareUpdate = () => {
firmwareConfirmVisible.value = false;
if (!isDeviceOnline.value) {
uni.showToast({ title: "请先开启智能弓", icon: "none" });
return;
}
if (String(networkType.value || "").toLowerCase() === "wifi") {
runFirmwareUpdate();
return;
}
wifiRequiredVisible.value = true;
};
const goWifiForFirmwareUpdate = () => {
const versionInfo = pendingOtaInfo.value;
if (!versionInfo) return;
wifiRequiredVisible.value = false;
const query = [
"source=firmware-update",
`versionNumber=${encodeURIComponent(versionInfo.versionNumber)}`,
`resourceUrl=${encodeURIComponent(versionInfo.resourceUrl)}`,
].join("&");
uni.navigateTo({ url: `/pages/device/ota-wifi?${query}` });
};
const handleOtaResultClose = () => {
closeOtaResult();
void refreshOtaUpdateState();
};
const goFirmwareUpdate = async () => {
if (firmwareActionPending.value) return;
if (!isDeviceOnline.value) {
uni.showToast({ title: "请先开启智能弓", icon: "none" });
return;
}
firmwareActionPending.value = true;
try {
const versionInfo = await loadOtaVersionInfo();
if (!versionInfo) return;
if (!versionInfo.needUpdate) {
latestVersionDialogVisible.value = true;
return;
}
pendingOtaInfo.value = versionInfo;
firmwareConfirmVisible.value = true;
} catch (error) {
uni.showToast({ title: "获取更新版本失败,请重试", icon: "none" });
} finally {
firmwareActionPending.value = false;
}
};
const goCalibration = async () => {
if (!isDeviceOnline.value) {
uni.showToast({ title: "请先开启智能弓", icon: "none" });
return;
}
try {
await laserAimAPI();
uni.navigateTo({ url: "/pages/device/calibration" });
} catch (error) {
uni.showToast({ title: "设备未连接,暂时无法调瞄", icon: "none" });
}
};
const copyEmail = () => {
uni.setClipboardData({
data: "shelingxingqiu@163.com",
success: () => uni.showToast({ title: "邮箱已复制", icon: "success" }),
});
};
const openQr = () => {
if (!device.value.deviceId) {
uni.showToast({ title: "暂无绑定设备", icon: "none" });
return;
}
uni.navigateTo({
url: `/pages/device/device-qrcode?deviceId=${encodeURIComponent(device.value.deviceId)}`,
});
};
const closeTip = () => {
showTip.value = false;
};
const closeConfirmBindTip = () => {
confirmBindTip.value = false;
};
watch(
() => isDeviceOnline.value,
(isOnline, wasOnline) => {
if (isOnline && !wasOnline) {
void refreshDeviceDetails();
void refreshOtaUpdateState();
return;
}
if (!isOnline) {
clearOtaState();
}
}
);
onLoad((options = {}) => {
retryScanOnShow.value = options.retryScan === "1";
});
onMounted(() => {
uni.$on("device-bind-retry-scan", handleScan);
});
onUnmounted(() => {
uni.$off("device-bind-retry-scan", handleScan);
});
onShow(async () => {
calibration.value = uni.getStorageSync("calibration");
await syncDeviceBinding();
void refreshOtaUpdateState();
if (retryScanOnShow.value) {
retryScanOnShow.value = false;
handleScan();
}
});
</script>
<template>
<view class="my-device-page" :class="{ 'my-device-page--scan': isScanPage }">
<Container
:bgType="12"
:scroll="false"
:usePageScroll="false"
>
<template #header>
<view class="device-nav">
<Header title="" />
<text class="device-nav-title">我的设备</text>
<view v-if="device.deviceId" class="device-status" :class="statusClass">
<view class="status-dot"></view>
<text>{{ statusText }}</text>
</view>
</view>
</template>
<view v-if="!device.deviceId" class="scan-code">
<view class="unbound-content">
<button class="scan-entry" hover-class="none" @click="$clickSound(handleScan)">
<image src="https://static.shelingxingqiu.com/shootmini/static/device-assets/my-device-unbound-scan.png" mode="aspectFit" />
</button>
<view class="scan-title">
<text>请扫描</text>
<text class="highlight-text">射灵智能弓箭</text>
<text>设备上的二维码</text>
</view>
<view class="benefit-copy">
<text>新设备首次绑定账号可获赠</text>
<text class="highlight-text">6个月射灵会员礼包</text>
<text></text>
<text>该礼包仅可使用一次请确保当前登录账号为您本人账号</text>
</view>
<button class="help-link" hover-class="none" @click="showTip = true">
<text>没找到二维码或遇到问题&gt;</text>
</button>
<button class="product-link" hover-class="none" @click="toDeviceIntroPage">
<text>还没有射灵智能弓箭点我获取&gt;</text>
</button>
</view>
</view>
<view v-else class="device-page">
<view
class="device-visual"
:class="{ 'device-float-group': isDeviceOnline }"
>
<image
class="device-stage"
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-platform.png"
mode="widthFix"
/>
<DeviceOnlineEffects
v-if="isDeviceOnline"
variant="detail"
/>
<image
class="device-bow"
:class="{ 'device-bow--online': isDeviceOnline }"
:src="isDeviceOnline
? 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-bow.png'
: 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-bow.png'"
:mode="isDeviceOnline ? 'aspectFit' : 'widthFix'"
/>
</view>
<view
class="device-heading"
:class="{ 'device-float-group': isDeviceOnline }"
>
<view class="device-title-row">
<text class="device-name">{{ device.deviceName || "打弓佬" }}</text>
<view class="edit-name" @click="openNameEditor">
<image src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-edit.png" mode="aspectFit" />
</view>
</view>
<view class="device-id-row">
<text>设备ID{{ deviceIdText }}</text>
<view class="device-id-toggle" @click="toggleDeviceId">
<image
class="device-id-eye"
:src="showDeviceId
? 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-id-visible.png'
: 'https://static.shelingxingqiu.com/shootmini/static/home-device/device-id-hidden.png'"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="device-stats">
<view
v-for="(item, index) in designDeviceStats"
:key="item.label"
class="device-stat"
:class="{
'device-stat--divider': index > 0,
'device-stat--usage': index === 2,
}"
>
<text class="device-stat-label">{{ item.label }}</text>
<text class="device-stat-value">{{ item.value }}</text>
</view>
</view>
<view class="device-actions">
<view class="action-item" @click="toDeviceIntroPage">
<view class="action-icon">
<image src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-action-intro.png" mode="aspectFit" />
</view>
<text>设备介绍</text>
</view>
<view class="action-item" @click="goCalibration">
<view class="action-icon">
<image src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-action-calibration.png" mode="aspectFit" />
</view>
<text>弓箭调瞄</text>
</view>
<view class="action-item" @click="goFirmwareUpdate">
<view class="action-icon">
<image src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-action-firmware.png" mode="aspectFit" />
</view>
<view class="action-label-wrap">
<text>固件更新</text>
<text
v-if="otaNeedUpdate"
class="action-badge action-badge--new"
>New</text>
</view>
</view>
<view class="action-item" @click="joinWifi">
<view class="action-icon">
<image src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-action-wifi.png" mode="aspectFit" />
</view>
<view class="action-label-wrap">
<text>WIFI设置</text>
<text
:class="[
'action-badge',
wifiStatusText === '未设置'
? 'action-badge--unset'
: wifiStatusText === '未连接'
? 'action-badge--offline'
: '',
]"
>
{{ wifiStatusText }}
</text>
</view>
</view>
<view class="action-item" @click="openQr">
<view class="action-icon">
<image src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-action-qrcode.png" mode="aspectFit" />
</view>
<text>设备二维码</text>
</view>
</view>
<view class="unbind-entry" @click="openUnbindDialog">
<image
class="unbind-icon"
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-unbind.png"
mode="aspectFit"
/>
<text>解除绑定</text>
</view>
<view v-if="calibration" class="calibration-tip">
<text>如有场地或距离变化请重新校准以保证智能弓射箭精准度</text>
<view @click="goCalibration">重新校准</view>
</view>
</view>
<ModalDialog
:show="showTip"
:showCancel="false"
:showConfirm="false"
:showClose="true"
:onClose="closeTip"
>
<view class="scan-tips">
<text class="scan-tips-title">扫码绑定射灵弓箭</text>
<text class="scan-tips-subtitle">配套令牌样例</text>
<image
class="scan-tips-qr"
src="https://static.shelingxingqiu.com/shootmini/static/device-assets/my-device-unbound-qr-sample.png"
mode="widthFix"
/>
<text class="scan-tips-note">已被绑定的弓箭无法再次绑定</text>
<view class="scan-tips-contact">
<text>如有任何疑问请随时联系</text>
<button hover-class="none" @click="copyEmail">shelingxingqiu@163.com</button>
</view>
</view>
</ModalDialog>
<ScreenHint
:show="confirmBindTip"
:onClose="closeConfirmBindTip"
contentHeight="360rpx"
>
<view class="confirm-bind">
<text>智能弓箭和系统账号需一一对应你确定要将当前登录用户账号绑定这把弓箭吗绑定后不可随意更换</text>
<view class="confirm-actions">
<view
class="primary-action"
:class="{ 'primary-action--disabled': binding }"
@click="confirmBind"
>
{{ binding ? "绑定中..." : "确认绑定" }}
</view>
<view class="secondary-action" @click="confirmBindTip = false">取消</view>
</view>
</view>
</ScreenHint>
<ModalDialog
:show="unbindDialogVisible"
title="解除设备绑定"
content="解除绑定后,将无法使用智能弓箭进行对战,确定继续吗?"
cancelText="取消"
confirmText="解除绑定"
:onCancel="closeUnbindDialog"
:onConfirm="unbindDevice"
></ModalDialog>
<ModalDialog
:show="latestVersionDialogVisible"
title="固件更新"
content="已经是最新版本"
confirmText="确定"
:showCancel="false"
:onConfirm="closeLatestVersionDialog"
></ModalDialog>
<ModalDialog
:show="firmwareConfirmVisible"
title="固件更新"
:content="firmwareConfirmContent"
cancelText="暂不更新"
confirmText="立即更新"
:onCancel="closeFirmwareConfirm"
:onConfirm="confirmFirmwareUpdate"
></ModalDialog>
<ModalDialog
:show="wifiRequiredVisible"
title="固件更新"
content="请在WiFi网络下更新"
confirmText="连接WiFi"
:showCancel="false"
:onConfirm="goWifiForFirmwareUpdate"
></ModalDialog>
<OtaModal
:visible="otaUpdating"
state="update_progress"
:progress="otaProgress"
:phase="otaPhase"
/>
<OtaModal
:visible="otaResultVisible && otaResultStatus === 'success'"
state="update_success"
@done="handleOtaResultClose"
/>
<ModalDialog
:show="otaResultVisible && otaResultStatus === 'failed'"
:title="otaResultTitle"
:content="otaResultContent"
confirmText="关闭"
:showCancel="false"
:onConfirm="handleOtaResultClose"
></ModalDialog>
<view v-if="nameEditorVisible" class="name-mask" @click="closeNameEditor">
<view class="name-panel" @click.stop>
<image
class="name-mascot"
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-name-mascot.png"
mode="aspectFit"
/>
<view class="name-sheet">
<view class="name-input-row">
<input
v-model="editingName"
class="name-input"
focus
:cursor-spacing="24"
:disabled="renaming"
:maxlength="-1"
confirm-type="done"
placeholder="请输入设备名"
placeholder-class="name-placeholder"
@confirm="confirmName"
/>
<view
class="name-confirm"
:class="{ 'name-confirm--disabled': renaming }"
@click="confirmName"
>
<image
class="name-confirm-image"
src="https://static.shelingxingqiu.com/shootmini/static/home-device/device-name-confirm.png"
mode="aspectFit"
/>
</view>
</view>
<view class="name-meta">
<text class="name-helper">仅支持中文英文数字下划线减号</text>
<text
class="name-count"
:class="{ 'name-count--exceeded': isEditingNameTooLong }"
>{{ editingNameLength }}/{{ DEVICE_NAME_MAX_LENGTH }}</text>
</view>
</view>
</view>
</view>
</Container>
</view>
</template>
<style scoped>
.device-nav {
position: relative;
z-index: 3;
width: 100%;
height: 50px;
}
.device-nav-title {
position: absolute;
top: 50%;
left: 50%;
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
line-height: 52rpx;
white-space: nowrap;
transform: translate(-50%, -50%);
}
.device-nav .device-status {
position: absolute;
top: calc(100% + 0);
left: 50%;
white-space: nowrap;
transform: translateX(-50%);
}
.my-device-page {
position: relative;
min-height: 100vh;
background: transparent;
}
.my-device-page--scan {
height: 100vh;
overflow: hidden;
}
.scan-code,
.device-page {
display: flex;
width: 100%;
min-height: calc(100vh - 100rpx);
box-sizing: border-box;
flex-direction: column;
align-items: center;
}
.scan-code {
position: relative;
overflow: hidden;
justify-content: flex-start;
padding-top: 0;
background: transparent;
}
.unbound-content {
position: relative;
z-index: 1;
display: flex;
width: 100%;
min-height: calc(100vh - 100rpx);
box-sizing: border-box;
flex-direction: column;
align-items: center;
padding: 170rpx 32rpx 160rpx;
}
.scan-entry,
.help-link,
.product-link {
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.scan-entry::after,
.help-link::after,
.product-link::after {
border: 0;
}
.scan-entry {
display: flex;
width: 276rpx;
height: 276rpx;
align-items: center;
justify-content: center;
}
.scan-entry image {
width: 276rpx;
height: 276rpx;
}
.scan-title {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 48rpx;
color: #ffffff;
font-size: 28rpx;
font-weight: 500;
line-height: 40rpx;
white-space: nowrap;
}
.benefit-copy {
display: flex;
width: 100%;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-top: 20rpx;
color: rgba(255, 255, 255, 0.74);
font-size: 26rpx;
font-weight: 500;
line-height: 44rpx;
text-align: center;
}
.help-link {
margin-top: 72rpx;
color: #ffd947;
font-size: 26rpx;
line-height: 36rpx;
}
.product-link {
position: fixed;
bottom: 104rpx;
bottom: calc(env(safe-area-inset-bottom) + 104rpx);
left: 0;
z-index: 2;
width: 100%;
color: #ffd947;
font-size: 26rpx;
line-height: 36rpx;
}
.highlight-text {
color: #fed847;
}
.device-page {
position: relative;
display: flex;
min-height: calc(100vh - 100rpx);
box-sizing: border-box;
align-items: center;
padding: 8rpx 24rpx 80rpx;
overflow: hidden;
color: #ffffff;
}
.device-visual {
position: absolute;
top: 146rpx;
left: 0;
z-index: 1;
width: 100%;
height: 610rpx;
pointer-events: none;
}
.device-float-group {
animation: my-device-float 3s ease-in-out infinite;
}
@keyframes my-device-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-20rpx);
}
}
.device-stage {
position: absolute;
top: 214rpx;
left: 50%;
z-index: 1;
width: 750rpx;
transform: translateX(-50%);
}
.device-bow {
position: absolute;
top: -2rpx;
left: 50%;
z-index: 4;
width: 562rpx;
transform: translateX(-50%);
}
.device-bow--online {
height: 466rpx;
}
.device-heading {
position: relative;
z-index: 2;
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
padding-top: 16rpx;
}
.device-status {
display: flex;
align-items: center;
color: rgba(255, 255, 255, 0.9);
font-size: 18rpx;
line-height: 30rpx;
}
.status-dot {
width: 16rpx;
height: 16rpx;
margin-right: 10rpx;
border-radius: 50%;
}
.device-status--online .status-dot {
background: linear-gradient(180deg, #9aff97, #36bb34);
box-shadow: 0 0 16rpx rgba(98, 228, 161, 0.8);
}
.device-status--offline .status-dot {
background: #ff6e6e;
}
.device-title-row {
display: flex;
align-items: center;
margin-top: 566rpx;
height: 52rpx;
line-height: 52rpx;
padding: 0 24rpx;
border-radius: 78rpx 78rpx 78rpx 78rpx;
background: rgba(0,0,0, 0.5);
}
.device-name {
color: #FFD947;
font-size: 28rpx;
font-weight: 500;
line-height: 36rpx;
}
.edit-name {
display: flex;
width: 40rpx;
height: 34rpx;
align-items: center;
justify-content: center;
margin-left: 6rpx;
}
.edit-name image {
width: 40rpx;
height: 34rpx;
}
.device-id-row {
display: flex;
align-items: center;
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.76);
font-size: 24rpx;
line-height: 32rpx;
}
.device-id-toggle {
display: flex;
width: 42rpx;
height: 32rpx;
align-items: center;
justify-content: center;
margin-left: 6rpx;
}
.device-id-eye {
width: 26rpx;
height: 20rpx;
}
.device-stats {
position: relative;
z-index: 2;
display: flex;
width: 704rpx;
align-self: center;
margin-top: 100rpx;
padding: 14rpx 0 18rpx;
}
.device-stat {
display: flex;
width: 168rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
flex-shrink: 0;
min-width: 0;
padding: 0 2rpx;
color: #f3e0b9;
}
.device-stat--usage {
width: 200rpx;
}
.device-stat--divider {
border-left: 1rpx solid rgba(255, 255, 255, 0.3);
}
.device-stat-label {
color: rgba(255, 255, 255, 0.7);
font-size: 24rpx;
line-height: 30rpx;
white-space: nowrap;
}
.device-stat-value {
width: auto;
margin-top: 10rpx;
overflow: visible;
color: #f3e0b9;
font-size: 28rpx;
line-height: 36rpx;
letter-spacing: -0.5rpx;
text-align: center;
white-space: nowrap;
}
.device-actions {
position: relative;
z-index: 2;
display: flex;
width: 100%;
justify-content: space-between;
margin-top: 56rpx;
}
.action-item {
display: flex;
width: 20%;
flex-direction: column;
align-items: center;
color: rgba(255, 255, 255, 0.7);
font-size: 22rpx;
line-height: 30rpx;
text-align: center;
}
.action-icon {
display: flex;
width: 88rpx;
height: 88rpx;
align-items: center;
justify-content: center;
margin-bottom: 10rpx;
border: 2rpx solid rgba(243, 224, 185, 0.75);
border-radius: 50%;
color: #f3e0b9;
font-size: 46rpx;
line-height: 1;
}
.action-icon image {
width: 48rpx;
height: 48rpx;
}
.action-label-wrap {
position: relative;
display: flex;
min-height: 30rpx;
align-items: center;
justify-content: center;
}
.action-badge {
position: absolute;
top: -116rpx;
left: calc(50% + 38rpx);
display: flex;
width: 68rpx;
height: 34rpx;
box-sizing: border-box;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 16rpx 4rpx 20rpx 4rpx;
color: #5c421f;
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
font-size: 18rpx;
line-height: 34rpx;
transform: translateX(-50%);
}
.action-badge--offline {
color: #8b0000;
}
.action-badge--unset {
color: #565656;
}
.unbind-entry {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
margin-top: 92rpx;
color: #ffd947;
font-size: 28rpx;
line-height: 38rpx;
}
.unbind-icon {
width: 37rpx;
height: 30rpx;
margin-right: 10rpx;
}
.calibration-tip {
display: flex;
width: 100%;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding: 20rpx 24rpx;
border-radius: 16rpx;
background: rgba(40, 127, 255, 0.12);
color: rgba(255, 255, 255, 0.65);
font-size: 22rpx;
line-height: 34rpx;
}
.calibration-tip > text {
flex: 1;
}
.calibration-tip > view {
margin-left: 18rpx;
color: #5bbdff;
white-space: nowrap;
}
.primary-action,
.secondary-action {
display: flex;
min-width: 150rpx;
height: 68rpx;
box-sizing: border-box;
align-items: center;
justify-content: center;
border-radius: 34rpx;
font-size: 24rpx;
}
.primary-action {
color: #111111;
background: #fed847;
}
.primary-action--disabled {
opacity: 0.6;
}
.secondary-action {
color: #ffffff;
background: rgba(255, 255, 255, 0.16);
}
.scan-tips {
display: flex;
width: 100%;
box-sizing: border-box;
flex-direction: column;
align-items: flex-start;
color: #ffffff;
font-size: 24rpx;
text-align: left;
}
.scan-tips-title {
color: #fed847;
font-size: 32rpx;
line-height: 42rpx;
}
.scan-tips-subtitle {
margin-top: 14rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 26rpx;
line-height: 36rpx;
}
.scan-tips-qr {
width: 100%;
margin-top: 12rpx;
border-radius: 14rpx;
}
.scan-tips-note {
margin-top: 16rpx;
color: #ffffff;
font-size: 24rpx;
line-height: 36rpx;
}
.scan-tips-contact {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 12rpx;
color: #ffffff;
font-size: 24rpx;
line-height: 36rpx;
}
.scan-tips button {
padding: 0;
color: #39a8ff;
background: transparent;
font-size: 30rpx;
line-height: 40rpx;
}
.confirm-bind {
width: 100%;
box-sizing: border-box;
padding-top: 100rpx;
transform: translateY(24rpx);
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
line-height: 42rpx;
}
.confirm-actions {
display: flex;
justify-content: space-between;
margin: 24rpx 0;
gap: 16rpx;
}
.confirm-actions > view {
flex: 1;
}
.name-mask {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
display: flex;
width: 100vw;
height: 100vh;
box-sizing: border-box;
align-items: flex-end;
background: rgba(0, 0, 0, 0.68);
}
.name-panel {
position: relative;
width: 100%;
}
.name-mascot {
position: absolute;
bottom: calc(100% - 74rpx);
left: 50%;
z-index: 0;
width: 176rpx;
height: 190rpx;
pointer-events: none;
transform: translateX(-50%);
}
.name-sheet {
position: relative;
z-index: 1;
width: 100%;
box-sizing: border-box;
padding: 42rpx 48rpx calc(48rpx + env(safe-area-inset-bottom));
border-top: 2rpx solid rgba(255, 218, 96, 0.5);
background: linear-gradient(180deg, #725323 0%, #4a351d 100%);
}
.name-input-row {
position: relative;
display: flex;
width: 100%;
max-width: 620rpx;
height: 78rpx;
align-items: center;
margin: 0 auto;
overflow: hidden;
border-radius: 40rpx;
background: #ffffff;
}
.name-input {
flex: 1;
min-width: 0;
height: 78rpx;
padding: 0 28rpx 0 38rpx;
box-sizing: border-box;
color: #3f3a34;
background: #ffffff;
font-size: 28rpx;
line-height: 78rpx;
text-align: center;
}
.name-placeholder {
color: #5f5a55;
}
.name-confirm {
position: relative;
z-index: 1;
width: 156rpx;
height: 78rpx;
flex: 0 0 156rpx;
}
.name-confirm-image {
display: block;
width: 100%;
height: 100%;
}
.name-confirm--disabled {
opacity: 0.6;
}
.name-meta {
display: flex;
width: 100%;
max-width: 620rpx;
align-items: flex-start;
justify-content: space-between;
margin-top: 22rpx;
margin-right: auto;
margin-left: auto;
gap: 20rpx;
}
.name-helper,
.name-count {
color: rgba(255, 255, 255, 0.62);
font-size: 24rpx;
line-height: 34rpx;
}
.name-helper {
flex: 1;
}
.name-count {
flex-shrink: 0;
}
.name-count--exceeded {
color: #fed847;
}
</style>