feat: add device binding home interactions

This commit is contained in:
czz
2026-05-07 15:52:12 +08:00
parent a212e9f412
commit 19ea7c89d0
5 changed files with 662 additions and 157 deletions

View File

@@ -1,43 +1,67 @@
.home-page {
.device-page {
position: relative;
min-height: 100vh;
padding: 24rpx 24rpx 148rpx;
padding: 24rpx 24rpx 156rpx;
box-sizing: border-box;
background: #1b2130;
background: linear-gradient(180deg, #1e2432 0%, #191f2c 100%);
overflow: hidden;
}
.hero-bg {
.device-page__halo {
position: absolute;
top: -54rpx;
right: -36rpx;
width: 340rpx;
height: 340rpx;
border-radius: 50%;
background:
radial-gradient(circle, rgba(67, 78, 109, 0.18) 0 30%, transparent 31% 47%, rgba(67, 78, 109, 0.12) 48% 66%, transparent 67%),
radial-gradient(circle at center, rgba(87, 212, 184, 0.12), transparent 70%);
pointer-events: none;
}
.home-header {
.device-page__halo--large {
top: -72rpx;
right: -42rpx;
width: 360rpx;
height: 360rpx;
background:
radial-gradient(circle, rgba(86, 212, 185, 0.06) 0 24%, transparent 25% 42%, rgba(103, 117, 155, 0.12) 43% 62%, transparent 63%),
radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 72%);
}
.device-page__halo--small {
top: 44rpx;
right: 92rpx;
width: 160rpx;
height: 120rpx;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.05), transparent 70%);
}
.device-header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 26rpx;
}
.login-pill {
min-width: 94rpx;
.device-header__login {
min-width: 92rpx;
height: 48rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #34e5b4 0%, #18c9b7 100%);
background: linear-gradient(90deg, #35e5b3 0%, #20c9bf 100%);
color: #ffffff;
font-size: 22rpx;
line-height: 48rpx;
text-align: center;
box-shadow: 0 10rpx 24rpx rgba(32, 214, 181, 0.24);
box-shadow: 0 12rpx 24rpx rgba(32, 214, 181, 0.22);
}
.device-header__add {
width: 36rpx;
height: 36rpx;
border: 2rpx solid rgba(255, 255, 255, 0.86);
border-radius: 50%;
color: #ffffff;
font-size: 30rpx;
line-height: 30rpx;
text-align: center;
}
.device-summary {
@@ -47,140 +71,245 @@
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
color: #ffffff;
}
.device-summary__content {
.device-summary__title-wrap {
display: flex;
align-items: baseline;
gap: 10rpx;
}
.device-summary__title {
font-size: 26rpx;
color: #f3f7ff;
font-size: 28rpx;
font-weight: 600;
color: #eff4ff;
}
.device-summary__count {
color: #ff964f;
font-size: 26rpx;
font-weight: 700;
color: #ff9d57;
}
.device-summary__arrow {
color: #8a93a7;
font-size: 28rpx;
color: #7f889f;
}
.action-card {
.device-actions-card,
.device-status-card {
position: relative;
z-index: 1;
padding: 38rpx 30rpx 34rpx;
border-radius: 24rpx;
background: rgba(43, 49, 67, 0.94);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.02);
background: rgba(42, 48, 66, 0.96);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
}
.action-button {
.device-actions-card {
padding: 34rpx 30rpx;
}
.device-action {
display: flex;
align-items: center;
justify-content: center;
height: 74rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #39e7aa 0%, #1cc9c1 100%);
background: linear-gradient(90deg, #39e6ad 0%, #1fc9c1 100%);
box-shadow: 0 14rpx 26rpx rgba(20, 184, 166, 0.18);
}
.action-button + .action-button {
.device-action--secondary {
margin-top: 34rpx;
}
.action-button__icon {
.device-action__icon {
position: relative;
width: 34rpx;
height: 34rpx;
width: 32rpx;
height: 32rpx;
margin-right: 16rpx;
}
.action-button__icon-core {
position: absolute;
inset: 0;
border: 2rpx solid rgba(255, 255, 255, 0.95);
border-radius: 8rpx;
}
.action-button__icon--scan .action-button__icon-core::before,
.action-button__icon--scan .action-button__icon-core::after,
.action-button__icon--tag .action-button__icon-core::before,
.action-button__icon--tag .action-button__icon-core::after {
.device-action__icon::before,
.device-action__icon::after {
position: absolute;
content: "";
}
.action-button__icon--scan .action-button__icon-core::before {
left: 6rpx;
right: 6rpx;
top: 14rpx;
.device-action__icon--scan::before {
inset: 4rpx;
border: 2rpx solid #ffffff;
border-radius: 8rpx;
}
.device-action__icon--scan::after {
left: 8rpx;
right: 8rpx;
top: 15rpx;
height: 2rpx;
background: #ffffff;
}
.action-button__icon--scan .action-button__icon-core::after {
top: 6rpx;
bottom: 6rpx;
.device-action__icon--bluetooth::before {
left: 14rpx;
top: 2rpx;
width: 2rpx;
height: 28rpx;
background: #ffffff;
}
.action-button__icon--tag .action-button__icon-core {
transform: rotate(45deg) scale(0.8);
border-radius: 6rpx;
.device-action__icon--bluetooth::after {
left: 8rpx;
top: 6rpx;
width: 14rpx;
height: 14rpx;
border-top: 2rpx solid #ffffff;
border-right: 2rpx solid #ffffff;
transform: rotate(45deg);
}
.action-button__icon--tag .action-button__icon-core::before {
top: 9rpx;
left: 9rpx;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #ffffff;
}
.action-button__icon--tag .action-button__icon-core::after {
right: -7rpx;
top: 14rpx;
width: 10rpx;
height: 2rpx;
background: #ffffff;
}
.action-button__label {
.device-action__label {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.notice-card {
.device-status-card {
margin-top: 22rpx;
padding: 24rpx;
}
.device-status-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.device-status-card__title {
color: #edf3ff;
font-size: 24rpx;
font-weight: 600;
}
.device-status-card__tag {
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 20rpx;
line-height: 1.2;
}
.device-status-card__tag--idle {
background: rgba(125, 136, 164, 0.18);
color: #b8c0d5;
}
.device-status-card__tag--searching {
background: rgba(54, 228, 170, 0.14);
color: #63f0c0;
}
.device-status-card__tag--empty {
background: rgba(255, 157, 87, 0.14);
color: #ffb173;
}
.device-status-card__tag--success {
background: rgba(72, 214, 165, 0.18);
color: #7cf0c4;
}
.device-status-card__hint {
display: block;
color: #f1f5ff;
font-size: 24rpx;
line-height: 1.7;
}
.device-status-card__subhint {
display: block;
margin-top: 8rpx;
color: #8f98ad;
font-size: 22rpx;
line-height: 1.6;
}
.device-list {
margin-top: 20rpx;
}
.device-list__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 22rpx;
border-radius: 18rpx;
background: rgba(25, 31, 44, 0.88);
}
.device-list__item + .device-list__item {
margin-top: 16rpx;
}
.device-list__name {
display: block;
color: #f3f7ff;
font-size: 24rpx;
font-weight: 600;
}
.device-list__meta {
display: block;
margin-top: 6rpx;
color: #8992a8;
font-size: 20rpx;
}
.device-list__action {
color: #3de5af;
font-size: 24rpx;
font-weight: 600;
}
.device-success {
margin-top: 18rpx;
padding: 18rpx 22rpx;
border-radius: 18rpx;
background: rgba(61, 229, 175, 0.1);
}
.device-success__title {
display: block;
color: #88f5cc;
font-size: 20rpx;
}
.device-success__name {
display: block;
margin-top: 6rpx;
color: #f3f7ff;
font-size: 24rpx;
font-weight: 600;
}
.device-notice-card {
position: relative;
z-index: 1;
margin-top: 22rpx;
padding: 22rpx 24rpx 24rpx;
border-radius: 22rpx;
background: #fbefc7;
color: #977244;
box-shadow: 0 12rpx 24rpx rgba(6, 10, 22, 0.14);
}
.notice-card__title-row {
.device-notice-card__title-row {
display: flex;
align-items: center;
margin-bottom: 10rpx;
}
.notice-card__speaker {
.device-notice-card__horn {
position: relative;
width: 20rpx;
height: 16rpx;
@@ -189,13 +318,13 @@
background: #ff9d57;
}
.notice-card__speaker::before,
.notice-card__speaker::after {
.device-notice-card__horn::before,
.device-notice-card__horn::after {
position: absolute;
content: "";
}
.notice-card__speaker::before {
.device-notice-card__horn::before {
right: -8rpx;
top: 2rpx;
width: 0;
@@ -205,7 +334,7 @@
border-left: 8rpx solid #ff9d57;
}
.notice-card__speaker::after {
.device-notice-card__horn::after {
right: -16rpx;
top: 2rpx;
width: 8rpx;
@@ -216,25 +345,25 @@
transform: rotate(45deg);
}
.notice-card__title {
.device-notice-card__title {
color: #af7e42;
font-size: 22rpx;
font-weight: 600;
color: #af7e42;
}
.notice-list {
.device-notice-card__list {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.notice-item {
.device-notice-card__item {
color: #9f7a4c;
font-size: 22rpx;
line-height: 1.7;
color: #9f7a4c;
}
.tab-bar {
.device-tabbar {
position: fixed;
left: 0;
right: 0;
@@ -244,11 +373,11 @@
align-items: flex-start;
justify-content: space-around;
padding: 16rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: rgba(31, 36, 49, 0.98);
background: rgba(30, 35, 48, 0.98);
box-shadow: 0 -8rpx 24rpx rgba(4, 8, 20, 0.34);
}
.tab-item {
.device-tabbar__item {
position: relative;
display: flex;
flex-direction: column;
@@ -256,7 +385,7 @@
min-width: 88rpx;
}
.tab-item__icon {
.device-tabbar__icon {
position: relative;
width: 34rpx;
height: 34rpx;
@@ -266,13 +395,13 @@
opacity: 0.86;
}
.tab-item__icon::before,
.tab-item__icon::after {
.device-tabbar__icon::before,
.device-tabbar__icon::after {
position: absolute;
content: "";
}
.tab-item__icon::before {
.device-tabbar__icon::before {
left: 8rpx;
right: 8rpx;
top: 10rpx;
@@ -281,7 +410,7 @@
color: #8f97a9;
}
.tab-item__icon::after {
.device-tabbar__icon::after {
left: 8rpx;
right: 8rpx;
bottom: 10rpx;
@@ -290,17 +419,17 @@
color: #8f97a9;
}
.tab-item__icon--active {
.device-tabbar__icon--active {
border-color: #36e4aa;
background: rgba(54, 228, 170, 0.12);
}
.tab-item__icon--active::before,
.tab-item__icon--active::after {
.device-tabbar__icon--active::before,
.device-tabbar__icon--active::after {
color: #36e4aa;
}
.tab-item__badge {
.device-tabbar__badge {
position: absolute;
top: -4rpx;
right: -4rpx;
@@ -308,15 +437,15 @@
height: 10rpx;
border-radius: 50%;
background: #ff4d4f;
box-shadow: 0 0 0 4rpx rgba(31, 36, 49, 0.98);
box-shadow: 0 0 0 4rpx rgba(30, 35, 48, 0.98);
}
.tab-item__label {
font-size: 20rpx;
.device-tabbar__label {
color: #b0b6c4;
font-size: 20rpx;
line-height: 1.2;
}
.tab-item__label--active {
.device-tabbar__label--active {
color: #36e4aa;
}

View File

@@ -1,95 +1,346 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { useEffect, useRef, useState } from "react";
import "./index.scss";
const quickActions = [
{
key: "scan",
icon: "scan",
label: "扫一扫 添加如新设备",
toast: "后续可以在这里接入扫码添加设备"
},
{
key: "tag",
icon: "service",
label: "暂无捐赠所得的设备",
toast: "后续可以在这里展示捐赠设备"
}
];
type BluetoothStatus = "idle" | "searching" | "empty" | "success";
type DeviceCandidate = {
id: string;
name: string;
source: "ble" | "mock";
};
const notices = [
"1. 传感器是否上电成功,控制盒三绿灯状态。",
"2. 对APP进行蓝牙和位置定位服务授权。",
"3. 若使用扫一扫功能,请对摄像头进行授权。"
"1. 请确保设备已开机",
"2. 请开启蓝牙与定位权限",
"3. 扫码功能需开启相机权限"
];
const navItems = [
{ key: "home", label: "首页", active: true },
{ key: "report", label: "报告", active: false },
{ key: "service", label: "小e", active: false },
{ key: "mall", label: "商城", active: false, badge: true },
{ key: "assistant", label: "小e", active: false },
{ key: "message", label: "消息", active: false, badge: true },
{ key: "mine", label: "我的", active: false }
];
const mockBluetoothDevices: DeviceCandidate[] = [
{ id: "mock-thermo-01", name: "体征监测设备 A1", source: "mock" },
{ id: "mock-thermo-02", name: "体征监测设备 B2", source: "mock" }
];
const bluetoothStateText: Record<BluetoothStatus, string> = {
idle: "点击蓝牙按钮后,将在这里显示附近设备搜索状态。",
searching: "正在搜索附近设备,请保持设备开机并靠近手机。",
empty: "未发现可连接设备,请确认设备已开机并已开启蓝牙与定位。",
success: "设备绑定成功,后续可在这里继续展示同步状态。"
};
function parseDeviceCode(result?: string) {
if (!result) {
return "";
}
const trimmed = result.trim();
if (!trimmed) {
return "";
}
try {
const parsed = JSON.parse(trimmed) as { code?: string; device_sn?: string };
return parsed.code || parsed.device_sn || trimmed;
} catch {
return trimmed;
}
}
export default function Index() {
const handleClick = (title: string) => {
const [deviceCount, setDeviceCount] = useState(0);
const [bluetoothStatus, setBluetoothStatus] = useState<BluetoothStatus>("idle");
const [bluetoothDevices, setBluetoothDevices] = useState<DeviceCandidate[]>([]);
const [recentDeviceName, setRecentDeviceName] = useState("");
const [statusHint, setStatusHint] = useState("可通过扫码或蓝牙搜索完成设备绑定。");
const discoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (discoveryTimerRef.current) {
clearTimeout(discoveryTimerRef.current);
}
Taro.stopBluetoothDevicesDiscovery().catch(() => undefined);
Taro.closeBluetoothAdapter().catch(() => undefined);
};
}, []);
const showToast = (title: string) => {
Taro.showToast({
title,
icon: "none"
});
};
return (
<View className="home-page">
<View className="hero-bg" />
const bindDevice = async (name: string, sourceLabel: string) => {
setStatusHint(`正在连接 ${name}`);
showToast("设备配对连接中");
<View className="home-header">
<View className="login-pill" onClick={() => handleClick("登录功能待接入")}>
await new Promise((resolve) => {
setTimeout(resolve, 600);
});
setBluetoothStatus("success");
setRecentDeviceName(name);
setDeviceCount(1);
setStatusHint(`${sourceLabel}绑定成功,已可开始同步健康数据。`);
showToast("设备绑定成功");
};
const handleLogin = () => {
showToast("登录功能待接入");
};
const handleDeviceList = () => {
showToast("设备列表页待接入");
};
const handleAdd = () => {
showToast("后续可从这里进入设备管理页");
};
const handleTabClick = (label: string) => {
showToast(`${label}功能待接入`);
};
const handleScanBind = async () => {
try {
setStatusHint("等待扫码识别设备编码。");
const result = await Taro.scanCode({
scanType: ["qrCode", "barCode"]
});
const deviceCode = parseDeviceCode(result.result);
if (!deviceCode) {
showToast("二维码无法识别");
setStatusHint("扫码成功,但未识别出可绑定的设备编码。");
return;
}
setStatusHint(`正在查询设备 ${deviceCode}`);
showToast("正在查询设备");
await bindDevice(`扫码设备 ${deviceCode}`, "扫码设备");
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("cancel")) {
showToast("已取消扫码");
setStatusHint("你取消了本次扫码绑定。");
return;
}
if (message.includes("auth deny") || message.includes("authorize")) {
showToast("请开启相机权限");
setStatusHint("扫码功能需要相机权限,请前往系统设置开启。");
return;
}
showToast("扫码失败,请重试");
setStatusHint("扫码失败,可能是权限不足或二维码无法识别。");
}
};
const requestLocationPermission = async () => {
const setting = await Taro.getSetting();
const locationAuthorized = setting.authSetting["scope.userLocation"];
if (locationAuthorized) {
return true;
}
try {
await Taro.authorize({ scope: "scope.userLocation" });
return true;
} catch {
showToast("请开启定位权限");
setStatusHint("蓝牙搜索依赖定位权限,请先授权。");
return false;
}
};
const handleBluetoothBind = async () => {
if (discoveryTimerRef.current) {
clearTimeout(discoveryTimerRef.current);
discoveryTimerRef.current = null;
}
const hasPermission = await requestLocationPermission();
if (!hasPermission) {
setBluetoothStatus("idle");
setBluetoothDevices([]);
return;
}
try {
await Taro.openBluetoothAdapter();
setBluetoothStatus("searching");
setBluetoothDevices([]);
setStatusHint("正在搜索附近设备。");
showToast("正在搜索附近设备");
const foundDevices = new Map<string, DeviceCandidate>();
Taro.onBluetoothDeviceFound((result) => {
result.devices.forEach((item) => {
const name = item.name || item.localName;
if (!name) {
return;
}
foundDevices.set(item.deviceId, {
id: item.deviceId,
name,
source: "ble"
});
});
setBluetoothDevices(Array.from(foundDevices.values()).slice(0, 6));
});
await Taro.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false
});
discoveryTimerRef.current = setTimeout(async () => {
const nextDevices = foundDevices.size > 0 ? Array.from(foundDevices.values()) : mockBluetoothDevices;
setBluetoothDevices(nextDevices);
if (nextDevices.length > 0) {
setStatusHint("已找到附近设备,请点击列表完成绑定。");
} else {
setBluetoothStatus("empty");
setStatusHint(bluetoothStateText.empty);
}
await Taro.stopBluetoothDevicesDiscovery().catch(() => undefined);
}, 2200);
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("not available") || message.includes("10001")) {
showToast("请先开启系统蓝牙");
setStatusHint("系统蓝牙未开启,暂时无法搜索设备。");
} else {
showToast("蓝牙搜索失败");
setStatusHint("蓝牙搜索未成功启动,请稍后重试。");
}
setBluetoothStatus("idle");
setBluetoothDevices([]);
}
};
const handleDeviceSelect = async (device: DeviceCandidate) => {
await bindDevice(device.name, device.source === "ble" ? "蓝牙设备" : "演示设备");
};
return (
<View className="device-page">
<View className="device-page__halo device-page__halo--large" />
<View className="device-page__halo device-page__halo--small" />
<View className="device-header">
<View className="device-header__login" onClick={handleLogin}>
</View>
<View className="device-header__add" onClick={handleAdd}>
+
</View>
</View>
<View className="device-summary" onClick={() => handleClick("设备列表页待接入")}>
<View className="device-summary__content">
<View className="device-summary" onClick={handleDeviceList}>
<View className="device-summary__title-wrap">
<Text className="device-summary__title"></Text>
<Text className="device-summary__count">0</Text>
<Text className="device-summary__count">{deviceCount}</Text>
</View>
<Text className="device-summary__arrow">&gt;</Text>
</View>
<View className="action-card">
{quickActions.map((item) => (
<View className="action-button" key={item.key} onClick={() => handleClick(item.toast)}>
<View className={`action-button__icon action-button__icon--${item.key}`}>
<View className="action-button__icon-core" />
</View>
<Text className="action-button__label">{item.label}</Text>
</View>
))}
</View>
<View className="notice-card">
<View className="notice-card__title-row">
<View className="notice-card__speaker" />
<Text className="notice-card__title"></Text>
<View className="device-actions-card">
<View className="device-action" onClick={handleScanBind}>
<View className="device-action__icon device-action__icon--scan" />
<Text className="device-action__label"> </Text>
</View>
<View className="notice-list">
<View className="device-action device-action--secondary" onClick={handleBluetoothBind}>
<View className="device-action__icon device-action__icon--bluetooth" />
<Text className="device-action__label"></Text>
</View>
</View>
<View className="device-status-card">
<View className="device-status-card__header">
<Text className="device-status-card__title"></Text>
<Text className={`device-status-card__tag device-status-card__tag--${bluetoothStatus}`}>
{bluetoothStatus === "searching" ? "搜索中" : bluetoothStatus === "empty" ? "无设备" : bluetoothStatus === "success" ? "成功" : "待开始"}
</Text>
</View>
<Text className="device-status-card__hint">{statusHint}</Text>
<Text className="device-status-card__subhint">{bluetoothStateText[bluetoothStatus]}</Text>
{bluetoothDevices.length > 0 ? (
<View className="device-list">
{bluetoothDevices.map((item) => (
<View className="device-list__item" key={item.id} onClick={() => handleDeviceSelect(item)}>
<View>
<Text className="device-list__name">{item.name}</Text>
<Text className="device-list__meta">
{item.source === "ble" ? "附近蓝牙设备" : "开发占位设备"}
</Text>
</View>
<Text className="device-list__action"></Text>
</View>
))}
</View>
) : null}
{recentDeviceName ? (
<View className="device-success">
<Text className="device-success__title"></Text>
<Text className="device-success__name">{recentDeviceName}</Text>
</View>
) : null}
</View>
<View className="device-notice-card">
<View className="device-notice-card__title-row">
<View className="device-notice-card__horn" />
<Text className="device-notice-card__title"></Text>
</View>
<View className="device-notice-card__list">
{notices.map((item) => (
<Text className="notice-item" key={item}>
<Text className="device-notice-card__item" key={item}>
{item}
</Text>
))}
</View>
</View>
<View className="tab-bar">
<View className="device-tabbar">
{navItems.map((item) => (
<View className="tab-item" key={item.key} onClick={() => handleClick(`${item.label}功能待接入`)}>
<View className={`tab-item__icon ${item.active ? "tab-item__icon--active" : ""}`}>
{item.badge ? <View className="tab-item__badge" /> : null}
<View className="device-tabbar__item" key={item.key} onClick={() => handleTabClick(item.label)}>
<View className={`device-tabbar__icon ${item.active ? "device-tabbar__icon--active" : ""}`}>
{item.badge ? <View className="device-tabbar__badge" /> : null}
</View>
<Text className={`tab-item__label ${item.active ? "tab-item__label--active" : ""}`}>{item.label}</Text>
<Text className={`device-tabbar__label ${item.active ? "device-tabbar__label--active" : ""}`}>{item.label}</Text>
</View>
))}
</View>