设备首页接入真实状态与音量控制
This commit is contained in:
@@ -10,71 +10,74 @@
|
|||||||
padding: 80px 32px 24px;
|
padding: 80px 32px 24px;
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
display: block;
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
|
line-height: 1.2;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
display: block;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 40px 36px;
|
|
||||||
margin: 0 24px 32px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin: 0 24px 32px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -20px;
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
right: -20px;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
background: #4CD964;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4CD964;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #FFFFFF;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-section {
|
.battery-section {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
.battery-percent {
|
.battery-percent {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
font-size: 72px;
|
font-size: 72px;
|
||||||
|
line-height: 1;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.percent {
|
.percent {
|
||||||
|
margin-left: 4px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: 4px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,44 +87,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-meta {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
.device-avatar {
|
.device-avatar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 36px;
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
right: 36px;
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.robot-icon {
|
.robot-icon {
|
||||||
.robot-img {
|
.robot-img {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
background: #FFFFFF;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
margin: 0 24px 20px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
margin: 0 24px 20px;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-card {
|
.control-card {
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 16px;
|
|
||||||
margin: 0 24px 24px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-item {
|
.control-item {
|
||||||
@@ -135,62 +169,70 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 20px 24px 24px;
|
padding: 20px 24px 24px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.volume-header {
|
.control-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.control-left {
|
.icon-bg {
|
||||||
flex: 1;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-right: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&.purple {
|
||||||
|
background: #F0E6FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.orange {
|
||||||
|
background: #FFF0E6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon-img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-name {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-detail {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0 24px;
|
||||||
|
background: #F0F0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
.control-left {
|
.control-left {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
.icon-bg {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 20px;
|
|
||||||
|
|
||||||
&.purple {
|
|
||||||
background: #F0E6FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.orange {
|
|
||||||
background: #FFF0E6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-icon-img {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.control-name {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1A1A1A;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-detail {
|
|
||||||
font-size: 26px;
|
|
||||||
color: #FF8C42;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,9 +242,9 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
|
||||||
.volume-icon {
|
.volume-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
@@ -211,90 +253,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.fold-card {
|
||||||
height: 1px;
|
|
||||||
background: #F0F0F0;
|
|
||||||
margin: 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 24px;
|
|
||||||
margin: 0 24px 24px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
.quick-grid {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-icon {
|
|
||||||
width: 88px;
|
|
||||||
height: 88px;
|
|
||||||
border-radius: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 40px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
&.bg-orange {
|
|
||||||
background: #FEF3E8;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.bg-green {
|
|
||||||
background: #E8F8F0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.bg-blue {
|
|
||||||
background: #E8F0FE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-text {
|
|
||||||
font-size: 26px;
|
|
||||||
color: #666666;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
text {
|
|
||||||
font-size: 32px;
|
|
||||||
color: #999999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 20px;
|
|
||||||
margin: 0 24px 24px;
|
margin: 0 24px 24px;
|
||||||
padding: 10px 24px;
|
border-radius: 20px;
|
||||||
|
background: #FFFFFF;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fold-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-heading {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666666;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 88px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #FEF3E8;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
padding: 0 24px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
border-bottom: 1px solid #F3F3F3;
|
border-bottom: 1px solid #F3F3F3;
|
||||||
|
|
||||||
@@ -311,29 +327,41 @@
|
|||||||
.detail-value {
|
.detail-value {
|
||||||
max-width: 60%;
|
max-width: 60%;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: #1A1A1A;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100%;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-card {
|
.empty-card {
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 24px;
|
|
||||||
margin: 0 24px;
|
margin: 0 24px;
|
||||||
padding: 48px 32px;
|
padding: 48px 32px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #FFFFFF;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-robot {
|
.empty-robot {
|
||||||
width: 132px;
|
|
||||||
height: 132px;
|
|
||||||
background: #FEF3E8;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin: 0 auto 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #FEF3E8;
|
||||||
|
|
||||||
.robot-img {
|
.robot-img {
|
||||||
width: 68px;
|
width: 68px;
|
||||||
@@ -357,55 +385,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
margin-top: 28px;
|
|
||||||
height: 92px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #FF8C42;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
height: 92px;
|
||||||
|
margin-top: 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #FF8C42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn-text {
|
.empty-btn-text {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 20px;
|
|
||||||
margin: 0 24px 24px;
|
|
||||||
padding: 28px 24px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.helper-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1A1A1A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.child-focus-card {
|
.child-focus-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 24px;
|
|
||||||
margin: 0 24px 24px;
|
margin: 0 24px 24px;
|
||||||
padding: 28px 24px;
|
padding: 28px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #FFFFFF;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.child-focus-avatar {
|
.child-focus-avatar {
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #FEF3E8;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #FEF3E8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.child-focus-icon {
|
.child-focus-icon {
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
import { View, Text, Switch, Slider, Image } from '@tarojs/components'
|
import { View, Text, Slider, Image, Switch } from '@tarojs/components'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
|
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
|
||||||
import { Child } from '@/services/child'
|
import { Child } from '@/services/child'
|
||||||
|
import { DeviceStatus, getDeviceStatus, setDeviceVolume } from '@/services/device'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
function formatTime(value?: string | null): string {
|
||||||
|
if (!value) return '--'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||||
|
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${month}-${day} ${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinates(status: DeviceStatus | null): string {
|
||||||
|
if (!status || status.lat === null || status.lat === undefined || status.lng === null || status.lng === undefined) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
return `${status.lat.toFixed(6)}, ${status.lng.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSignalLabel(value?: number | null): string {
|
||||||
|
if (value === null || value === undefined) return '--'
|
||||||
|
const normalized = Number(value)
|
||||||
|
if (!Number.isFinite(normalized)) return '--'
|
||||||
|
if (normalized <= 4) {
|
||||||
|
if (normalized >= 3) return '强'
|
||||||
|
if (normalized >= 2) return '中'
|
||||||
|
return '弱'
|
||||||
|
}
|
||||||
|
if (normalized >= 67) return '强'
|
||||||
|
if (normalized >= 34) return '中'
|
||||||
|
return '弱'
|
||||||
|
}
|
||||||
|
|
||||||
export default function Device() {
|
export default function Device() {
|
||||||
const [binding, setBinding] = useState<Binding | null>(null)
|
const [binding, setBinding] = useState<Binding | null>(null)
|
||||||
const [child, setChild] = useState<Child | null>(null)
|
const [child, setChild] = useState<Child | null>(null)
|
||||||
|
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [sleepEnabled, setSleepEnabled] = useState(true)
|
const [sleepEnabled, setSleepEnabled] = useState(true)
|
||||||
const [volume, setVolume] = useState(50)
|
const [isDetailExpanded, setIsDetailExpanded] = useState(false)
|
||||||
|
const [isSavingVolume, setIsSavingVolume] = useState(false)
|
||||||
|
const [volumeValue, setVolumeValue] = useState(0)
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
void loadDeviceInfo()
|
void loadDeviceInfo()
|
||||||
@@ -28,6 +64,15 @@ export default function Device() {
|
|||||||
const context = await loadCurrentChildBindingContext()
|
const context = await loadCurrentChildBindingContext()
|
||||||
setChild(context.currentChild)
|
setChild(context.currentChild)
|
||||||
setBinding(context.currentBinding)
|
setBinding(context.currentBinding)
|
||||||
|
|
||||||
|
if (context.currentBinding?.device_id) {
|
||||||
|
const nextStatus = await getDeviceStatus(context.currentBinding.device_id)
|
||||||
|
setDeviceStatus(nextStatus)
|
||||||
|
setVolumeValue(nextStatus?.volume ?? 0)
|
||||||
|
} else {
|
||||||
|
setDeviceStatus(null)
|
||||||
|
setVolumeValue(0)
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[device] load failed:', error)
|
console.error('[device] load failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -40,15 +85,45 @@ export default function Device() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSleepToggle = (e: any) => {
|
const handleSleepToggle = (e: any) => {
|
||||||
setSleepEnabled(e.detail.value)
|
const nextValue = Boolean(e.detail?.value)
|
||||||
|
setSleepEnabled(nextValue)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭',
|
title: nextValue ? '休眠展示已开启' : '休眠展示已关闭',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVolumeChange = (e: any) => {
|
const handleToggleDetail = () => {
|
||||||
setVolume(e.detail.value)
|
setIsDetailExpanded((current) => !current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVolumeChanging = (e: any) => {
|
||||||
|
setVolumeValue(Number(e.detail?.value || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVolumeCommit = async (e: any) => {
|
||||||
|
if (!binding?.device_id) return
|
||||||
|
const nextLevel = Number(e.detail?.value || 0)
|
||||||
|
const fallbackLevel = deviceStatus?.volume ?? volumeValue ?? 0
|
||||||
|
setVolumeValue(nextLevel)
|
||||||
|
setIsSavingVolume(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setDeviceVolume(nextLevel, binding.device_id)
|
||||||
|
setDeviceStatus((current) => (current ? { ...current, volume: nextLevel } : current))
|
||||||
|
Taro.showToast({
|
||||||
|
title: '音量指令已发送',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
setVolumeValue(deviceStatus?.volume ?? fallbackLevel)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '音量设置失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSavingVolume(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -115,8 +190,12 @@ export default function Device() {
|
|||||||
|
|
||||||
const childName = child.child_name || '未设置'
|
const childName = child.child_name || '未设置'
|
||||||
const pageTitle = `${childName} 的伴伴`
|
const pageTitle = `${childName} 的伴伴`
|
||||||
const battery = 82
|
const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
|
||||||
const daysLeft = 4
|
const batteryDisplay = batteryValue === null || batteryValue === undefined ? '--' : String(batteryValue)
|
||||||
|
const signalLabel = getSignalLabel(deviceStatus?.signal)
|
||||||
|
const versionLabel = deviceStatus?.version || '--'
|
||||||
|
const coordinateLabel = formatCoordinates(deviceStatus)
|
||||||
|
const volumeLabel = `${volumeValue}%`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='device-page'>
|
<View className='device-page'>
|
||||||
@@ -131,10 +210,20 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='battery-section'>
|
<View className='battery-section'>
|
||||||
<Text className='battery-percent'>
|
<Text className='battery-percent'>
|
||||||
{battery}
|
{batteryDisplay}
|
||||||
<Text className='percent'>%</Text>
|
{batteryDisplay !== '--' && <Text className='percent'>%</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='battery-label'>模拟电量展示 (预计续航 {daysLeft} 天)</Text>
|
<Text className='battery-label'>当前电量</Text>
|
||||||
|
</View>
|
||||||
|
<View className='status-meta'>
|
||||||
|
<View className='status-pill'>
|
||||||
|
<Text className='status-pill-label'>信号</Text>
|
||||||
|
<Text className='status-pill-value'>{signalLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='status-pill'>
|
||||||
|
<Text className='status-pill-label'>版本</Text>
|
||||||
|
<Text className='status-pill-value'>{versionLabel}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='device-avatar'>
|
<View className='device-avatar'>
|
||||||
<View className='robot-icon'>
|
<View className='robot-icon'>
|
||||||
@@ -143,31 +232,12 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='detail-card'>
|
<View className='section-title'>设备控制</View>
|
||||||
<View className='detail-row'>
|
|
||||||
<Text className='detail-label'>当前儿童</Text>
|
|
||||||
<Text className='detail-value'>{childName}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='detail-row'>
|
|
||||||
<Text className='detail-label'>当前设备</Text>
|
|
||||||
<Text className='detail-value'>{binding.device_id}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='detail-row'>
|
|
||||||
<Text className='detail-label'>绑定时间</Text>
|
|
||||||
<Text className='detail-value'>{binding.bound_at || '--'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='section-title'>基础控制</View>
|
|
||||||
<View className='control-card'>
|
<View className='control-card'>
|
||||||
<View className='control-item'>
|
<View className='control-item'>
|
||||||
<View className='control-left'>
|
<View className='control-left'>
|
||||||
<View className='icon-bg purple'>
|
<View className='icon-bg purple'>
|
||||||
<Image
|
<Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
|
||||||
className='control-icon-img'
|
|
||||||
src={require('../../assets/tab-icons/moon.png')}
|
|
||||||
mode='aspectFit'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='control-info'>
|
<View className='control-info'>
|
||||||
<Text className='control-name'>定时休眠</Text>
|
<Text className='control-name'>定时休眠</Text>
|
||||||
@@ -189,7 +259,10 @@ export default function Device() {
|
|||||||
mode='aspectFit'
|
mode='aspectFit'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text className='control-name'>外放音量</Text>
|
<View className='control-info'>
|
||||||
|
<Text className='control-name'>外放音量</Text>
|
||||||
|
<Text className='control-detail'>{isSavingVolume ? '发送中...' : `当前 ${volumeLabel}`}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='volume-slider'>
|
<View className='volume-slider'>
|
||||||
@@ -202,8 +275,9 @@ export default function Device() {
|
|||||||
className='slider'
|
className='slider'
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={volume}
|
value={volumeValue}
|
||||||
onChange={handleVolumeChange}
|
onChanging={handleVolumeChanging}
|
||||||
|
onChange={handleVolumeCommit}
|
||||||
activeColor='#FF8C42'
|
activeColor='#FF8C42'
|
||||||
backgroundColor='#E5E5E5'
|
backgroundColor='#E5E5E5'
|
||||||
blockColor='#FF8C42'
|
blockColor='#FF8C42'
|
||||||
@@ -216,6 +290,45 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='fold-card'>
|
||||||
|
<View className='fold-header' onClick={handleToggleDetail}>
|
||||||
|
<View className='fold-heading'>
|
||||||
|
<Text className='fold-title'>设备详情</Text>
|
||||||
|
<Text className='fold-subtitle'>{binding.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='fold-action'>{isDetailExpanded ? '收起' : '展开'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isDetailExpanded && (
|
||||||
|
<View className='detail-body'>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>当前儿童</Text>
|
||||||
|
<Text className='detail-value'>{childName}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>当前设备</Text>
|
||||||
|
<Text className='detail-value'>{binding.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>绑定时间</Text>
|
||||||
|
<Text className='detail-value'>{formatTime(binding.bound_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>当前坐标</Text>
|
||||||
|
<Text className='detail-value'>{coordinateLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>状态更新时间</Text>
|
||||||
|
<Text className='detail-value'>{formatTime(deviceStatus?.settings_updated_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>定位更新时间</Text>
|
||||||
|
<Text className='detail-value'>{formatTime(deviceStatus?.location_updated_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
62
banban-mini/src/services/device.ts
Normal file
62
banban-mini/src/services/device.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { request } from './api'
|
||||||
|
import { loadCurrentChildBindingContext } from './binding'
|
||||||
|
|
||||||
|
export interface DeviceStatus {
|
||||||
|
device_id: string
|
||||||
|
child_id?: number | null
|
||||||
|
child_name?: string | null
|
||||||
|
power?: number | null
|
||||||
|
volume?: number | null
|
||||||
|
signal?: number | null
|
||||||
|
version?: string | null
|
||||||
|
settings_updated_at?: string | null
|
||||||
|
coord_type?: string | null
|
||||||
|
lat?: number | null
|
||||||
|
lng?: number | null
|
||||||
|
accuracy_m?: number | null
|
||||||
|
altitude_m?: number | null
|
||||||
|
speed_mps?: number | null
|
||||||
|
heading_deg?: number | null
|
||||||
|
source?: number | null
|
||||||
|
battery_pct?: number | null
|
||||||
|
device_time?: string | null
|
||||||
|
server_time?: string | null
|
||||||
|
location_updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceVolumeUpdateResponse {
|
||||||
|
device_id: string
|
||||||
|
level: number
|
||||||
|
msg_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDeviceId(deviceId?: string): Promise<string> {
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim()
|
||||||
|
if (normalizedDeviceId) return normalizedDeviceId
|
||||||
|
const context = await loadCurrentChildBindingContext()
|
||||||
|
return context.currentBinding?.device_id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceStatus(deviceId?: string): Promise<DeviceStatus | null> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await request<DeviceStatus>(`/banban/devices/${resolvedDeviceId}/status`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeviceVolume(level: number, deviceId?: string): Promise<DeviceVolumeUpdateResponse> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
throw new Error('当前没有可用设备')
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<DeviceVolumeUpdateResponse>(`/banban/devices/${resolvedDeviceId}/volume`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { level },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -60,4 +60,58 @@ class DeviceDAO(BaseDAO):
|
|||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
return result.mappings().all()
|
return result.mappings().all()
|
||||||
|
|
||||||
|
async def get_device_status(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
result = await self.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
db.device_id,
|
||||||
|
db.child_id,
|
||||||
|
c.child_name,
|
||||||
|
ds.power,
|
||||||
|
ds.volume,
|
||||||
|
ds.`signal` AS signal_strength,
|
||||||
|
ds.`version` AS version,
|
||||||
|
ds.updated_at AS settings_updated_at,
|
||||||
|
cl.coord_type,
|
||||||
|
cl.lat,
|
||||||
|
cl.lng,
|
||||||
|
cl.accuracy_m,
|
||||||
|
cl.altitude_m,
|
||||||
|
cl.speed_mps,
|
||||||
|
cl.heading_deg,
|
||||||
|
cl.source,
|
||||||
|
cl.battery_pct,
|
||||||
|
cl.device_time,
|
||||||
|
cl.server_time,
|
||||||
|
cl.updated_at AS location_updated_at
|
||||||
|
FROM device_bindings AS db
|
||||||
|
LEFT JOIN children AS c
|
||||||
|
ON c.child_id = db.child_id
|
||||||
|
AND c.status = 1
|
||||||
|
LEFT JOIN device_settings AS ds
|
||||||
|
ON ds.device_id = db.device_id
|
||||||
|
LEFT JOIN child_location_current AS cl
|
||||||
|
ON cl.child_id = db.child_id
|
||||||
|
AND cl.device_id = db.device_id
|
||||||
|
WHERE db.device_id = :device_id
|
||||||
|
AND db.owner_user_id = :user_id
|
||||||
|
AND db.status = 1
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"device_id": device_id, "user_id": user_id},
|
||||||
|
)
|
||||||
|
row = result.mappings().first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="device not found")
|
||||||
|
return row
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from collections.abc import Mapping
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -47,6 +47,39 @@ class DeviceMessageListResponse(BaseModel):
|
|||||||
next_cursor: int | None = None
|
next_cursor: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatusResponse(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
child_id: int | None = None
|
||||||
|
child_name: str | None = None
|
||||||
|
power: int | None = None
|
||||||
|
volume: int | None = None
|
||||||
|
signal: int | None = None
|
||||||
|
version: str | None = None
|
||||||
|
settings_updated_at: datetime | None = None
|
||||||
|
coord_type: str | None = None
|
||||||
|
lat: float | None = None
|
||||||
|
lng: float | None = None
|
||||||
|
accuracy_m: int | None = None
|
||||||
|
altitude_m: float | None = None
|
||||||
|
speed_mps: float | None = None
|
||||||
|
heading_deg: int | None = None
|
||||||
|
source: int | None = None
|
||||||
|
battery_pct: int | None = None
|
||||||
|
device_time: datetime | None = None
|
||||||
|
server_time: datetime | None = None
|
||||||
|
location_updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceVolumeUpdateRequest(BaseModel):
|
||||||
|
level: int = Field(ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceVolumeUpdateResponse(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
level: int
|
||||||
|
msg_id: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +138,31 @@ def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse:
|
||||||
|
return DeviceStatusResponse(
|
||||||
|
device_id=str(row["device_id"]),
|
||||||
|
child_id=int(row["child_id"]) if row["child_id"] is not None else None,
|
||||||
|
child_name=row.get("child_name"),
|
||||||
|
power=row["power"],
|
||||||
|
volume=row["volume"],
|
||||||
|
signal=row["signal_strength"],
|
||||||
|
version=row["version"],
|
||||||
|
settings_updated_at=row["settings_updated_at"],
|
||||||
|
coord_type=row["coord_type"],
|
||||||
|
lat=float(row["lat"]) if row["lat"] is not None else None,
|
||||||
|
lng=float(row["lng"]) if row["lng"] is not None else None,
|
||||||
|
accuracy_m=row["accuracy_m"],
|
||||||
|
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
|
||||||
|
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
|
||||||
|
heading_deg=row["heading_deg"],
|
||||||
|
source=int(row["source"]) if row["source"] is not None else None,
|
||||||
|
battery_pct=row["battery_pct"],
|
||||||
|
device_time=row["device_time"],
|
||||||
|
server_time=row["server_time"],
|
||||||
|
location_updated_at=row["location_updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}/messages", response_model=DeviceMessageListResponse)
|
@router.get("/{device_id}/messages", response_model=DeviceMessageListResponse)
|
||||||
async def list_device_messages(
|
async def list_device_messages(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -142,6 +200,57 @@ async def list_device_messages(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/status", response_model=DeviceStatusResponse)
|
||||||
|
async def get_device_status(
|
||||||
|
device_id: str,
|
||||||
|
request: Request,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> DeviceStatusResponse:
|
||||||
|
row = await device_service.get_device_status(
|
||||||
|
device_id=device_id,
|
||||||
|
user_id=current_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"device status fetched",
|
||||||
|
extra={
|
||||||
|
"event": "device_status",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"user_id": current_user_id,
|
||||||
|
"device_id": device_id,
|
||||||
|
"child_id": row["child_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _row_to_device_status_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{device_id}/volume", response_model=DeviceVolumeUpdateResponse)
|
||||||
|
async def set_device_volume(
|
||||||
|
device_id: str,
|
||||||
|
payload: DeviceVolumeUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> DeviceVolumeUpdateResponse:
|
||||||
|
msg_id = await device_service.set_device_volume(
|
||||||
|
device_id=device_id,
|
||||||
|
user_id=current_user_id,
|
||||||
|
level=payload.level,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"device volume command sent",
|
||||||
|
extra={
|
||||||
|
"event": "device_volume_set",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"user_id": current_user_id,
|
||||||
|
"device_id": device_id,
|
||||||
|
"level": payload.level,
|
||||||
|
"msg_id": msg_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse)
|
@router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse)
|
||||||
async def get_current_device_location(
|
async def get_current_device_location(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -203,4 +312,4 @@ async def get_device_location_trajectory(
|
|||||||
total=len(rows),
|
total=len(rows),
|
||||||
start_at=start_at,
|
start_at=start_at,
|
||||||
end_at=end_at,
|
end_at=end_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from collections.abc import Mapping
|
|||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from services.database_service_base import DatabaseServiceBase
|
from services.database_service_base import DatabaseServiceBase
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from banban.dao.device import DeviceDAO
|
from banban.dao.device import DeviceDAO
|
||||||
|
|
||||||
@@ -38,6 +39,35 @@ class DeviceService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
|
async def get_device_status(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
db_session = await self.get_session()
|
||||||
|
try:
|
||||||
|
dao = DeviceDAO(db_session)
|
||||||
|
return await dao.get_device_status(device_id=device_id, user_id=user_id)
|
||||||
|
finally:
|
||||||
|
await db_session.close()
|
||||||
|
|
||||||
|
async def set_device_volume(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
device_id: str,
|
||||||
|
user_id: int,
|
||||||
|
level: int,
|
||||||
|
) -> str:
|
||||||
|
await self.ensure_device_access(device_id=device_id, user_id=user_id)
|
||||||
|
|
||||||
|
from handlers.mqtt_handler import TalkingQMQTTService
|
||||||
|
|
||||||
|
service = await TalkingQMQTTService.get_instance()
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
|
||||||
|
return await service.send_volume_command(device_id, level)
|
||||||
|
|
||||||
|
|
||||||
# 创建全局 DeviceService 实例
|
# 创建全局 DeviceService 实例
|
||||||
device_service = DeviceService()
|
device_service = DeviceService()
|
||||||
|
|||||||
@@ -93,7 +93,14 @@ class DeviceSettingService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
async def insert_or_update(self, device_id: str, power: int, volume: int, signal_strength: int, version_str: str) -> None:
|
async def insert_or_update(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
power: Optional[int],
|
||||||
|
volume: Optional[int],
|
||||||
|
signal_strength: Optional[int],
|
||||||
|
version_str: Optional[str],
|
||||||
|
) -> None:
|
||||||
try:
|
try:
|
||||||
current_row = await self.get_setting_by_device_id(device_id=device_id)
|
current_row = await self.get_setting_by_device_id(device_id=device_id)
|
||||||
if current_row:
|
if current_row:
|
||||||
@@ -104,4 +111,4 @@ class DeviceSettingService(DatabaseServiceBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# 创建全局 DeviceSettingService 实例
|
# 创建全局 DeviceSettingService 实例
|
||||||
device_setting_service = DeviceSettingService()
|
device_setting_service = DeviceSettingService()
|
||||||
|
|||||||
@@ -256,10 +256,10 @@ class DeviceSetting(Base):
|
|||||||
timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'"))
|
timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'"))
|
||||||
volume: Mapped[Optional[int]] = mapped_column(Integer)
|
volume: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
brightness: Mapped[Optional[int]] = mapped_column(Integer)
|
brightness: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32))
|
|
||||||
power: Mapped[Optional[int]] = mapped_column(Integer)
|
power: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
signal_strength: Mapped[Optional[int]] = mapped_column(Integer)
|
signal: Mapped[Optional[int]] = mapped_column("signal", Integer)
|
||||||
version_str: Mapped[Optional[str]] = mapped_column(String(64))
|
version: Mapped[Optional[str]] = mapped_column("version", String(64))
|
||||||
|
disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32))
|
||||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Awaitable, Callable, Dict, Optional
|
from typing import Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
import aiomqtt
|
import aiomqtt
|
||||||
@@ -9,10 +10,10 @@ from banban.service.binding import BindingService
|
|||||||
from banban.service.device_setting import device_setting_service
|
from banban.service.device_setting import device_setting_service
|
||||||
from banban.service.location import location_service
|
from banban.service.location import location_service
|
||||||
from config import settings
|
from config import settings
|
||||||
from database.models import ChildLocationCurrent
|
|
||||||
from services.card_service import card_service
|
from services.card_service import card_service
|
||||||
from services.device_target_cache import device_target_cache
|
from services.device_target_cache import device_target_cache
|
||||||
from services.offline_audio_cache import offline_audio_cache
|
from services.offline_audio_cache import offline_audio_cache
|
||||||
|
from services.task_manager import task_manager
|
||||||
from utils.logger import session_logger as logger
|
from utils.logger import session_logger as logger
|
||||||
|
|
||||||
|
|
||||||
@@ -90,14 +91,31 @@ class TalkingQMQTTService:
|
|||||||
logger.error("", "", f"mqtt loop failed: {exc}")
|
logger.error("", "", f"mqtt loop failed: {exc}")
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
|
async def _schedule_persistence(self, device_id: str, label: str, coro) -> None:
|
||||||
|
async def _runner():
|
||||||
|
try:
|
||||||
|
await coro
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(device_id, "mqtt_persistence", f"{label} failed: {exc}", exc_info=True)
|
||||||
|
|
||||||
|
await task_manager.create_task(
|
||||||
|
_runner(),
|
||||||
|
device_id=device_id,
|
||||||
|
task_type="persistence",
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_device_info(self, device_id: str, payload: dict):
|
async def _handle_device_info(self, device_id: str, payload: dict):
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
await device_setting_service.insert_or_update(
|
await self._schedule_persistence(
|
||||||
device_id=device_id,
|
device_id,
|
||||||
power=data.get("power"),
|
"device_info",
|
||||||
signal_strength=data.get("signal"),
|
device_setting_service.insert_or_update(
|
||||||
version_str=data.get("version"),
|
device_id=device_id,
|
||||||
volume=data.get("voice"),
|
power=data.get("power"),
|
||||||
|
signal_strength=data.get("signal"),
|
||||||
|
version_str=data.get("version"),
|
||||||
|
volume=data.get("voice"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"})
|
await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"})
|
||||||
|
|
||||||
@@ -107,12 +125,31 @@ class TalkingQMQTTService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
location = ChildLocationCurrent(
|
raw_device_time = data.get("device_time")
|
||||||
device_id=device_id,
|
parsed_device_time = None
|
||||||
lat=data.get("latitude"),
|
if isinstance(raw_device_time, str) and raw_device_time.strip():
|
||||||
lon=data.get("longitude"),
|
try:
|
||||||
|
parsed_device_time = datetime.fromisoformat(raw_device_time.strip())
|
||||||
|
except ValueError:
|
||||||
|
parsed_device_time = None
|
||||||
|
|
||||||
|
await self._schedule_persistence(
|
||||||
|
device_id,
|
||||||
|
"gps",
|
||||||
|
location_service.report_mqtt_device_location(
|
||||||
|
device_id=device_id,
|
||||||
|
latitude=data.get("latitude"),
|
||||||
|
longitude=data.get("longitude"),
|
||||||
|
coord_type=data.get("coord_type"),
|
||||||
|
accuracy_m=data.get("accuracy_m"),
|
||||||
|
altitude_m=data.get("altitude_m"),
|
||||||
|
speed_mps=data.get("speed_mps"),
|
||||||
|
heading_deg=data.get("heading_deg"),
|
||||||
|
source=data.get("source"),
|
||||||
|
battery_pct=data.get("battery_pct"),
|
||||||
|
device_time=parsed_device_time,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await location_service.insert_or_update(device_id=device_id, location=location)
|
|
||||||
|
|
||||||
async def _handle_volume_response(self, device_id: str, payload: dict):
|
async def _handle_volume_response(self, device_id: str, payload: dict):
|
||||||
if payload.get("status") != "success":
|
if payload.get("status") != "success":
|
||||||
@@ -120,13 +157,36 @@ class TalkingQMQTTService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
await device_setting_service.insert_or_update(device_id=device_id, volume=data.get("current_level"))
|
current_level = data.get("current_level")
|
||||||
|
if current_level is None:
|
||||||
|
return
|
||||||
|
await self._schedule_persistence(
|
||||||
|
device_id,
|
||||||
|
"volume",
|
||||||
|
device_setting_service.insert_or_update(
|
||||||
|
device_id=device_id,
|
||||||
|
power=None,
|
||||||
|
volume=current_level,
|
||||||
|
signal_strength=None,
|
||||||
|
version_str=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_ota_response(self, device_id: str, payload: dict):
|
async def _handle_ota_response(self, device_id: str, payload: dict):
|
||||||
status = payload.get("status")
|
status = payload.get("status")
|
||||||
data = payload.get("data", {})
|
data = payload.get("data", {})
|
||||||
if status == "success":
|
if status == "success":
|
||||||
await device_setting_service.insert_or_update(device_id=device_id, version_str=data.get("new_version"))
|
await self._schedule_persistence(
|
||||||
|
device_id,
|
||||||
|
"ota",
|
||||||
|
device_setting_service.insert_or_update(
|
||||||
|
device_id=device_id,
|
||||||
|
power=None,
|
||||||
|
volume=None,
|
||||||
|
signal_strength=None,
|
||||||
|
version_str=data.get("new_version"),
|
||||||
|
),
|
||||||
|
)
|
||||||
elif status != "accepted":
|
elif status != "accepted":
|
||||||
logger.warning(device_id, "", f"[OTA] command failed: {payload}")
|
logger.warning(device_id, "", f"[OTA] command failed: {payload}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user