add banbanmini

This commit is contained in:
HycJack
2026-03-24 13:15:03 +08:00
parent 42567b7605
commit 0d0f995dc2
59 changed files with 26319 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom'
})

View File

@@ -0,0 +1,195 @@
.bind-page {
min-height: 100%;
background: #F5F7FA;
padding-bottom: 120px;
padding-bottom: calc(120px + constant(safe-area-inset-bottom));
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.icon-bg {
width: 72px;
height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
&.orange {
background: #FFF0E6;
}
.control-icon-img {
width: 36px;
height: 36px;
}
}
.bind-header {
padding: 80px 32px 32px;
text-align: center;
.title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
margin-bottom: 12px;
}
.subtitle {
font-size: 28px;
color: #666666;
}
}
.scan-area {
margin: 0 48px 48px;
position: relative;
}
.scan-frame {
background: #FFFFFF;
border-radius: 24px;
padding: 60px 40px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
&:active {
opacity: 0.9;
}
.scan-text {
font-size: 36px;
font-weight: 600;
color: #FF8C42;
margin-bottom: 12px;
}
.scan-hint {
font-size: 26px;
color: #999999;
}
}
.scan-corners {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
.corner {
position: absolute;
width: 40px;
height: 40px;
border-color: #FF8C42;
border-style: solid;
&.top-left {
top: -4px;
left: -4px;
border-width: 6px 0 0 6px;
border-radius: 24px 0 0 0;
}
&.top-right {
top: -4px;
right: -4px;
border-width: 6px 6px 0 0;
border-radius: 0 24px 0 0;
}
&.bottom-left {
bottom: -4px;
left: -4px;
border-width: 0 0 6px 6px;
border-radius: 0 0 0 24px;
}
&.bottom-right {
bottom: -4px;
right: -4px;
border-width: 0 6px 6px 0;
border-radius: 0 0 24px 0;
}
}
}
.bind-options {
margin: 0 48px 48px;
.option-item {
background: #FFFFFF;
border-radius: 20px;
padding: 28px 32px;
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
&:active {
opacity: 0.8;
}
.option-icon {
font-size: 36px;
margin-right: 20px;
}
.option-text {
flex: 1;
font-size: 30px;
color: #1A1A1A;
font-weight: 500;
}
.option-arrow {
font-size: 32px;
color: #CCCCCC;
}
}
}
.bind-tips {
margin: 0 48px;
.tips-title {
font-size: 32px;
font-weight: 700;
color: #1A1A1A;
margin-bottom: 24px;
display: block;
}
.tip-item {
display: flex;
align-items: flex-start;
margin-bottom: 20px;
.tip-number {
width: 44px;
height: 44px;
background: #FF8C42;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #FFFFFF;
font-weight: 600;
margin-right: 16px;
flex-shrink: 0;
}
.tip-text {
flex: 1;
font-size: 28px;
color: #666666;
line-height: 1.5;
padding-top: 6px;
}
}
}

View File

@@ -0,0 +1,173 @@
import { View, Text, Button, Image } from '@tarojs/components'
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import './index.scss'
export default function Bind() {
const [isScanning, setIsScanning] = useState(false)
useEffect(() => {
// 检查是否已登录
const isLoggedIn = Taro.getStorageSync('isLoggedIn')
if (!isLoggedIn) {
// 未登录,跳转到登录页面
Taro.redirectTo({ url: '/pages/login/index' })
return
}
// 检查是否已绑定设备
const hasDevice = Taro.getStorageSync('hasDevice')
if (hasDevice) {
// 已绑定设备,跳转到首页
Taro.reLaunch({ url: '/pages/device/index' })
}
}, [])
const handleScanCode = () => {
setIsScanning(true)
Taro.scanCode({
onlyFromCamera: true,
scanType: ['qrCode', 'barCode'],
success: (res) => {
setIsScanning(false)
console.log('扫码结果:', res.result)
// 模拟绑定设备
Taro.showLoading({ title: '绑定中...' })
setTimeout(() => {
Taro.hideLoading()
// 保存设备信息
Taro.setStorageSync('hasDevice', true)
Taro.setStorageSync('deviceInfo', {
id: res.result || 'DEVICE_' + Date.now(),
name: '小夏的伴伴',
status: 'online',
battery: 82,
bindTime: new Date().toLocaleString()
})
Taro.showToast({
title: '设备绑定成功',
icon: 'success',
duration: 2000,
complete: () => {
// 绑定成功,跳转到首页
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
})
}, 1500)
},
fail: (err) => {
setIsScanning(false)
console.error('扫码失败:', err)
// 用户取消扫码,不显示错误
if (err.errMsg && err.errMsg.includes('cancel')) {
return
}
Taro.showToast({
title: '扫码失败,请重试',
icon: 'none'
})
}
})
}
const handleManualInput = () => {
Taro.showModal({
title: '手动输入设备码',
editable: true,
placeholderText: '请输入设备底部的设备码',
success: (res) => {
if (res.confirm && res.content) {
// 模拟绑定设备
Taro.showLoading({ title: '绑定中...' })
setTimeout(() => {
Taro.hideLoading()
Taro.setStorageSync('hasDevice', true)
Taro.setStorageSync('deviceInfo', {
id: res.content,
name: '小夏的伴伴',
status: 'online',
battery: 82,
bindTime: new Date().toLocaleString()
})
Taro.showToast({
title: '设备绑定成功',
icon: 'success',
duration: 2000,
complete: () => {
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
})
}, 1500)
}
}
})
}
return (
<View className='bind-page'>
<View className='bind-header'>
<Text className='title'></Text>
<Text className='subtitle'></Text>
</View>
<View className='scan-area'>
<View className='scan-frame' onClick={handleScanCode}>
<View className='icon-bg orange'>
<Image
className='control-icon-img'
src={require('../../assets/tab-icons/rings.png')}
mode='aspectFit'
/>
</View>
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
<Text className='scan-hint'></Text>
</View>
<View className='scan-corners'>
<View className='corner top-left'></View>
<View className='corner top-right'></View>
<View className='corner bottom-left'></View>
<View className='corner bottom-right'></View>
</View>
</View>
<View className='bind-options'>
<View className='option-item' onClick={handleManualInput}>
<Text className='option-icon'></Text>
<Text className='option-text'></Text>
<Text className='option-arrow'></Text>
</View>
</View>
<View className='bind-tips'>
<Text className='tips-title'></Text>
<View className='tip-item'>
<Text className='tip-number'>1</Text>
<Text className='tip-text'></Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>2</Text>
<Text className='tip-text'></Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>3</Text>
<Text className='tip-text'></Text>
</View>
</View>
</View>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom'
})

View File

@@ -0,0 +1,206 @@
.chat-detail-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #F5F7FA;
}
.chat-header {
background: #FFFFFF;
padding: 80px 24px 20px;
display: flex;
align-items: center;
border-bottom: 1px solid #EEEEEE;
.back-btn {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
&:active {
opacity: 0.7;
}
.back-arrow-img {
width: 40px;
height: 40px;
}
}
.character-info {
flex: 1;
display: flex;
align-items: center;
.character-avatar {
width: 72px;
height: 72px;
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 36px;
}
.character-text {
display: flex;
flex-direction: column;
.character-name {
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
margin-bottom: 4px;
}
.character-desc {
font-size: 24px;
color: #999999;
}
}
}
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.time-divider {
text-align: center;
margin: 24px 0;
text {
font-size: 24px;
color: #999999;
background: #E5E5E5;
padding: 8px 20px;
border-radius: 12px;
}
}
.message-item {
display: flex;
margin-bottom: 20px;
align-items: flex-start;
&.user {
flex-direction: row-reverse;
}
&.ai {
.ai-avatar {
margin-right: 14px;
flex-shrink: 0;
}
}
}
.user-avatar {
width: 68px;
height: 68px;
background: #FFE4C4;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.avatar-img {
font-size: 36px;
}
}
.ai-avatar {
width: 68px;
height: 68px;
background: #FEF3E8;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
.robot-emoji {
font-size: 36px;
}
}
.message-bubble {
max-width: 68%;
padding: 18px 22px;
font-size: 30px;
line-height: 1.5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
word-break: break-all;
overflow-wrap: break-word;
.user & {
background: #FF8C42;
color: #FFFFFF;
border-radius: 20px 20px 6px 20px;
margin-left: 14px;
margin-right: 14px;
}
.ai & {
background: #FFFFFF;
color: #1A1A1A;
border-radius: 20px 20px 20px 6px;
margin-right: 14px;
}
}
.audio-message {
display: flex;
align-items: center;
.audio-icon {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: inherit;
margin-right: 14px;
flex-shrink: 0;
transition: background 0.2s;
&.playing {
background: rgba(255, 255, 255, 0.6);
}
&.orange {
background: rgba(255, 140, 66, 0.2);
&.playing {
background: rgba(255, 140, 66, 0.4);
}
}
}
.audio-info {
display: flex;
flex-direction: column;
.audio-title {
font-size: 28px;
font-weight: 500;
color: inherit;
margin-bottom: 4px;
}
.audio-duration {
font-size: 24px;
opacity: 0.8;
}
}
}

View File

@@ -0,0 +1,156 @@
import { View, Text, ScrollView, Image } from '@tarojs/components'
import { useState, useEffect, useRef } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { getMessages, getCharacter, ChatMessage, Character } from '../../../services/chat'
import './index.scss'
export default function ChatDetail() {
const router = useRouter()
const { id } = router.params
const conversationId = parseInt(id || '1')
const [character, setCharacter] = useState<Character>({
id: conversationId,
name: decodeURIComponent(router.params.name || 'AI 玩伴'),
description: '温柔音色 / 开朗耐心',
icon: '🎤'
})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [playingId, setPlayingId] = useState<number | null>(null)
const audioCtx = useRef<Taro.InnerAudioContext | null>(null)
useEffect(() => {
loadChatData()
return () => {
// 页面卸载时销毁音频
audioCtx.current?.destroy()
}
}, [conversationId])
const loadChatData = async () => {
const char = await getCharacter(conversationId)
if (char) {
setCharacter(char)
Taro.setNavigationBarTitle({ title: char.name })
}
const msgs = await getMessages(conversationId)
setMessages(msgs)
}
const handlePlayAudio = (msg: ChatMessage) => {
if (!msg.audioUrl) return
// 如果点的是正在播放的那条,就停止
if (playingId === msg.id) {
audioCtx.current?.stop()
audioCtx.current?.destroy()
audioCtx.current = null
setPlayingId(null)
return
}
// 停掉上一条
audioCtx.current?.stop()
audioCtx.current?.destroy()
const ctx = Taro.createInnerAudioContext()
ctx.src = msg.audioUrl
ctx.autoplay = true
ctx.onPlay(() => setPlayingId(msg.id))
ctx.onEnded(() => {
setPlayingId(null)
ctx.destroy()
audioCtx.current = null
})
ctx.onError(() => {
setPlayingId(null)
ctx.destroy()
audioCtx.current = null
Taro.showToast({ title: '播放失败', icon: 'none' })
})
audioCtx.current = ctx
}
const handleBack = () => {
audioCtx.current?.stop()
audioCtx.current?.destroy()
Taro.navigateBack()
}
return (
<View className='chat-detail-page'>
<View className='chat-header'>
<View className='back-btn' onClick={handleBack}>
<Image src={require('../../../assets/tab-icons/arrow-left.png')} className='back-arrow-img' />
</View>
<View className='character-info'>
<View className='character-avatar'>
<Text>{character.icon}</Text>
</View>
<View className='character-text'>
<Text className='character-name'>{character.name}</Text>
<Text className='character-desc'>{character.description}</Text>
</View>
</View>
</View>
<ScrollView className='chat-list' scrollY>
{messages.map((msg, index) => (
<View key={msg.id}>
{(index === 0 || messages[index - 1].type !== msg.type ||
messages[index - 1].time !== msg.time) && (
<View className='time-divider'>
<Text>{msg.time}</Text>
</View>
)}
{msg.type === 'user' ? (
<View className='message-item user'>
<View className='message-bubble'>
{msg.isAudio ? (
<View className='audio-message' onClick={() => handlePlayAudio(msg)}>
<View className={`audio-icon ${playingId === msg.id ? 'playing' : ''}`}>
{playingId === msg.id ? '⏸' : '▶'}
</View>
<View className='audio-info'>
<Text className='audio-title'></Text>
<Text className='audio-duration'>{msg.audioDuration}</Text>
</View>
</View>
) : (
<Text>{msg.content}</Text>
)}
</View>
<View className='user-avatar'>
<Text className='avatar-img'>👦</Text>
</View>
</View>
) : (
<View className='message-item ai'>
<View className='ai-avatar'>
<Text className='robot-emoji'>{character.icon}</Text>
</View>
<View className='message-bubble'>
{msg.isAudio ? (
<View className='audio-message' onClick={() => handlePlayAudio(msg)}>
<View className={`audio-icon orange ${playingId === msg.id ? 'playing' : ''}`}>
{playingId === msg.id ? '⏸' : '▶'}
</View>
<View className='audio-info'>
<Text className='audio-title'></Text>
<Text className='audio-duration'>{msg.audioDuration}</Text>
</View>
</View>
) : (
<Text>{msg.content}</Text>
)}
</View>
</View>
)}
</View>
))}
</ScrollView>
</View>
)
}

View File

@@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '聊天记录',
navigationStyle: 'custom'
}

View File

@@ -0,0 +1,125 @@
.chat-page {
min-height: 100%;
background: #F5F7FA;
padding-bottom: 120px;
padding-bottom: calc(120px + constant(safe-area-inset-bottom));
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
margin-bottom: 12px;
line-height: 1.2;
}
.page-subtitle {
font-size: 28px;
color: #666666;
line-height: 1.4;
}
}
.conversation-list {
height: calc(100vh - 200px);
padding: 0 24px;
box-sizing: border-box;
overflow: hidden;
}
.conversation-item {
background: #FFFFFF;
border-radius: 20px;
margin-bottom: 16px;
padding: 24px;
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
transition: transform 0.1s ease;
&:active {
transform: scale(0.98);
}
}
.conversation-avatar {
width: 96px;
height: 96px;
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24px;
flex-shrink: 0;
.avatar-icon {
font-size: 48px;
}
}
.conversation-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.conversation-name {
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
}
.conversation-time {
font-size: 24px;
color: #999999;
flex-shrink: 0;
}
}
.conversation-footer {
display: flex;
justify-content: space-between;
align-items: center;
.last-message {
font-size: 28px;
color: #666666;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16px;
}
}
.unread-badge {
min-width: 36px;
height: 36px;
background: #FF4D4F;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
flex-shrink: 0;
.unread-count {
font-size: 22px;
color: #FFFFFF;
font-weight: 600;
}
}

View File

@@ -0,0 +1,63 @@
import { View, Text, ScrollView } from '@tarojs/components'
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { getConversations, ChatConversation } from '../../services/chat'
import './index.scss'
export default function Chat() {
const [conversations, setConversations] = useState<ChatConversation[]>([])
useEffect(() => {
loadConversations()
}, [])
const loadConversations = async () => {
const data = await getConversations()
setConversations(data)
}
const handleConversationClick = (conversation: ChatConversation) => {
Taro.navigateTo({
url: `/pages/chat/detail/index?id=${conversation.id}&name=${encodeURIComponent(conversation.name)}`
})
}
return (
<View className='chat-page'>
<View className='page-header'>
<Text className='page-title'>AI </Text>
<Text className='page-subtitle'></Text>
</View>
<ScrollView className='conversation-list' scrollY>
{conversations.map((conversation) => (
<View
key={conversation.id}
className='conversation-item'
onClick={() => handleConversationClick(conversation)}
>
<View className='conversation-avatar'>
<Text className='avatar-icon'>{conversation.avatar}</Text>
</View>
<View className='conversation-content'>
<View className='conversation-header'>
<Text className='conversation-name'>{conversation.name}</Text>
<Text className='conversation-time'>{conversation.time}</Text>
</View>
<View className='conversation-footer'>
<Text className='last-message' numberOfLines={1}>
{conversation.lastMessage}
</Text>
{conversation.unreadCount && conversation.unreadCount > 0 && (
<View className='unread-badge'>
<Text className='unread-count'>{conversation.unreadCount}</Text>
</View>
)}
</View>
</View>
</View>
))}
</ScrollView>
</View>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom'
})

View File

@@ -0,0 +1,291 @@
.device-page {
min-height: 100%;
background: #F5F7FA;
padding-bottom: 160px;
padding-bottom: calc(160px + constant(safe-area-inset-bottom));
padding-bottom: calc(160px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 48px;
font-weight: 700;
color: #1A1A1A;
display: block;
margin-bottom: 12px;
line-height: 1.2;
}
.page-subtitle {
font-size: 28px;
color: #666666;
line-height: 1.4;
}
}
.status-card {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
border-radius: 28px;
padding: 40px 36px;
margin: 0 24px 32px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
right: -20px;
top: 50%;
transform: translateY(-50%);
width: 200px;
height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
}
.status-badge {
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.25);
border-radius: 20px;
padding: 10px 20px;
margin-bottom: 24px;
.status-dot {
width: 12px;
height: 12px;
background: #4CD964;
border-radius: 50%;
margin-right: 8px;
}
.status-text {
font-size: 24px;
color: #FFFFFF;
font-weight: 500;
}
}
.battery-section {
.battery-percent {
font-size: 72px;
font-weight: 700;
color: #FFFFFF;
line-height: 1;
margin-bottom: 12px;
display: block;
.percent {
font-size: 32px;
font-weight: 600;
margin-left: 4px;
}
}
.battery-label {
font-size: 26px;
color: rgba(255, 255, 255, 0.9);
}
}
.device-avatar {
position: absolute;
right: 36px;
top: 50%;
transform: translateY(-50%);
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.robot-icon {
.robot-img {
width: 56px;
height: 56px;
background: #FFFFFF;
border-radius: 50%;
}
}
.section-title {
font-size: 32px;
font-weight: 700;
color: #1A1A1A;
margin: 0 24px 20px;
display: block;
}
.control-card {
background: #FFFFFF;
border-radius: 16px;
margin: 0 24px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.control-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
&.volume-item {
flex-direction: column;
align-items: stretch;
padding: 20px 24px 24px;
}
.volume-header {
display: flex;
align-items: center;
margin-bottom: 16px;
.control-left {
flex: 1;
}
}
.control-left {
display: flex;
align-items: center;
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;
}
}
}
}
.volume-slider {
display: flex;
align-items: center;
padding: 0 8px;
.volume-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.slider {
flex: 1;
margin: 0 16px;
}
}
.divider {
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;
}
.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;
}
}

View File

@@ -0,0 +1,174 @@
import { View, Text, Switch, Slider, Image } from '@tarojs/components'
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import './index.scss'
interface DeviceData {
id: string
name: string
status: 'online' | 'offline'
battery: number
daysLeft: number
sleepEnabled: boolean
sleepTime: string
volume: number
}
export default function Device() {
const [device, setDevice] = useState<DeviceData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [hasDevice, setHasDevice] = useState(false)
useEffect(() => {
// 检查登录状态
const isLoggedIn = Taro.getStorageSync('isLoggedIn')
if (!isLoggedIn) {
Taro.redirectTo({ url: '/pages/login/index' })
return
}
// 检查设备绑定状态
const deviceInfo = Taro.getStorageSync('deviceInfo')
const hasDeviceFlag = Taro.getStorageSync('hasDevice')
setHasDevice(!!hasDeviceFlag)
if (deviceInfo && hasDeviceFlag) {
setDevice({
id: deviceInfo.id,
name: deviceInfo.name || '小夏的伴伴',
status: deviceInfo.status || 'online',
battery: deviceInfo.battery || 82,
daysLeft: 4,
sleepEnabled: true,
sleepTime: '每天 22:00 - 07:00',
volume: 50
})
}
setIsLoading(false)
}, [])
const handleSleepToggle = (e: any) => {
if (!device) return
setDevice({ ...device, sleepEnabled: e.detail.value })
Taro.showToast({
title: e.detail.value ? '已开启定时休眠' : '已关闭定时休眠',
icon: 'success'
})
}
const handleVolumeChange = (e: any) => {
if (!device) return
setDevice({ ...device, volume: e.detail.value })
}
const switchTab = (url: string) => {
Taro.switchTab({ url })
}
if (isLoading) {
return (
<View className='device-page'>
<View className='loading'>
<Text>...</Text>
</View>
</View>
)
}
// 未绑定设备,跳转到绑定页面
if (!hasDevice || !device) {
Taro.redirectTo({ url: '/pages/bind/index' })
return null
}
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'>{device.name}</Text>
<Text className='page-subtitle'></Text>
</View>
<View className='status-card'>
<View className='status-badge'>
<View className='status-dot'></View>
<Text className='status-text'>4G 线</Text>
</View>
<View className='battery-section'>
<Text className='battery-percent'>{device.battery}<Text className='percent'>%</Text></Text>
<Text className='battery-label'> ( {device.daysLeft} )</Text>
</View>
<View className='device-avatar'>
<View className='robot-icon'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
</View>
</View>
<View className='section-title'></View>
<View className='control-card'>
<View className='control-item'>
<View className='control-left'>
<View className='icon-bg purple'>
<Image
className='control-icon-img'
src={require('../../assets/tab-icons/moon.png')}
mode='aspectFit'
/>
</View>
<View className='control-info'>
<Text className='control-name'></Text>
<Text className='control-detail'>{device.sleepTime}</Text>
</View>
</View>
<Switch
checked={device.sleepEnabled}
onChange={handleSleepToggle}
color='#FF8C42'
/>
</View>
<View className='divider'></View>
<View className='control-item volume-item'>
<View className='volume-header'>
<View className='control-left'>
<View className='icon-bg orange'>
<Image
className='control-icon-img'
src={require('../../assets/tab-icons/orange-speaker.png')}
mode='aspectFit'
/>
</View>
<Text className='control-name'></Text>
</View>
</View>
<View className='volume-slider'>
<Image
className='volume-icon'
src={require('../../assets/tab-icons/unspeaker.png')}
mode='aspectFit'
/>
<Slider
className='slider'
min={0}
max={100}
value={device.volume}
onChange={handleVolumeChange}
activeColor='#FF8C42'
backgroundColor='#E5E5E5'
blockColor='#FF8C42'
/>
<Image
className='volume-icon'
src={require('../../assets/tab-icons/orange-speaker.png')}
mode='aspectFit'
/>
</View>
</View>
</View>
</View>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom'
})

View File

@@ -0,0 +1,150 @@
.location-page {
min-height: 100%;
background: #F5F7FA;
padding-bottom: 120px;
padding-bottom: calc(120px + constant(safe-area-inset-bottom));
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
margin-bottom: 12px;
line-height: 1.2;
}
.page-subtitle {
font-size: 28px;
color: #666666;
line-height: 1.4;
}
}
.map-section {
height: 50vh;
position: relative;
margin-bottom: 24px;
}
.map {
width: 100%;
height: 100%;
}
.location-marker {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
z-index: 5;
}
.marker-pin {
width: 72px;
height: 72px;
background: #FF8C42;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.4);
.marker-emoji {
font-size: 36px;
}
}
.location-card {
background: #FFFFFF;
border-radius: 20px;
margin: 0 24px 24px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
.header-left {
flex: 1;
.location-title {
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
display: block;
margin-bottom: 8px;
}
.location-update {
font-size: 26px;
color: #666666;
}
}
}
.refresh-btn {
width: 64px;
height: 64px;
background: #E8F8F0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.refresh-icon {
font-size: 28px;
color: #4CD964;
}
&:active {
opacity: 0.7;
}
}
.location-detail {
display: flex;
align-items: flex-start;
background: #F8F9FA;
border-radius: 16px;
padding: 22px;
.location-icon {
width: 64px;
height: 64px;
background: #E8F0FE;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-right: 18px;
flex-shrink: 0;
}
.location-info {
flex: 1;
.location-address {
font-size: 30px;
font-weight: 600;
color: #1A1A1A;
margin-bottom: 8px;
display: block;
}
.location-desc {
font-size: 26px;
color: #666666;
line-height: 1.5;
}
}
}
}

View File

@@ -0,0 +1,128 @@
import { View, Text, Map } from '@tarojs/components'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import './index.scss'
interface LocationInfo {
address: string
updateTime: string
locationType: string
}
interface Coordinates {
latitude: number
longitude: number
}
// 模拟接口:获取设备位置(返回随机经纬度)
const fetchDeviceLocation = async (): Promise<Coordinates> => {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 800))
// 在北京范围内生成随机坐标(大致范围)
const baseLat = 39.9042
const baseLng = 116.4074
const randomOffset = () => (Math.random() - 0.5) * 0.1 // 约 ±5km 的偏移
return {
latitude: baseLat + randomOffset(),
longitude: baseLng + randomOffset()
}
}
export default function Location() {
const [location, setLocation] = useState<LocationInfo>({
address: '阳光社区 3期 附近',
updateTime: '刚刚',
locationType: '网络定位'
})
const [coordinates, setCoordinates] = useState<Coordinates>({
latitude: 39.9042,
longitude: 116.4074
})
// 每次页面显示时获取最新位置
useDidShow(() => {
loadLocation()
})
const loadLocation = async () => {
try {
const coords = await fetchDeviceLocation()
setCoordinates(coords)
} catch (error) {
console.error('获取位置失败:', error)
}
}
const refreshLocation = async () => {
Taro.showToast({ title: '正在刷新位置...', icon: 'loading' })
try {
const coords = await fetchDeviceLocation()
setCoordinates(coords)
setLocation({
...location,
updateTime: '刚刚'
})
Taro.showToast({ title: '位置已更新', icon: 'success' })
} catch (error) {
Taro.showToast({ title: '位置更新失败', icon: 'none' })
}
}
return (
<View className='location-page'>
<View className='page-header'>
<Text className='page-title'></Text>
<Text className='page-subtitle'></Text>
</View>
<View className='map-section'>
<Map
className='map'
latitude={coordinates.latitude}
longitude={coordinates.longitude}
scale={15}
markers={[{
id: 1,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
title: '设备位置',
width: 40,
height: 40
}]}
showLocation
/>
<View className='location-marker'>
<View className='marker-pin'>
<Text className='marker-emoji'>🤖</Text>
</View>
</View>
</View>
<View className='location-card'>
<View className='card-header'>
<View className='header-left'>
<Text className='location-title'></Text>
<Text className='location-update'> {location.updateTime} ({location.locationType})</Text>
</View>
<View className='refresh-btn' onClick={refreshLocation}>
<Text className='refresh-icon'></Text>
</View>
</View>
<View className='location-detail'>
<View className='location-icon'>
<Text>📍</Text>
</View>
<View className='location-info'>
<Text className='location-address'>{location.address}</Text>
<Text className='location-desc'> 4G 50-200 </Text>
</View>
</View>
</View>
</View>
)
}

View File

@@ -0,0 +1,114 @@
.login-page {
min-height: 100%;
background: #F5F7FA;
display: flex;
flex-direction: column;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.login-header {
padding-top: 120px;
padding-bottom: 60px;
display: flex;
flex-direction: column;
align-items: center;
.logo {
width: 160px;
height: 160px;
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
border-radius: 40px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32px;
box-shadow: 0 8px 32px rgba(255, 140, 66, 0.3);
.logo-icon {
font-size: 80px;
}
}
.title {
font-size: 52px;
font-weight: 700;
color: #1A1A1A;
margin-bottom: 16px;
}
.subtitle {
font-size: 30px;
color: #666666;
}
}
.login-content {
flex: 1;
padding: 0 48px;
display: flex;
flex-direction: column;
}
.features {
display: flex;
justify-content: space-around;
margin-bottom: 80px;
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
.feature-icon {
font-size: 48px;
margin-bottom: 12px;
}
.feature-text {
font-size: 26px;
color: #666666;
}
}
}
.login-btn-wrapper {
margin-bottom: 32px;
.login-btn {
width: 100%;
height: 100px;
background: #07C160;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
border: none;
&::after {
display: none;
}
.btn-icon {
font-size: 36px;
margin-right: 12px;
}
.btn-text {
font-size: 32px;
color: #FFFFFF;
font-weight: 600;
}
&:active {
opacity: 0.8;
}
}
}
.agreement {
text-align: center;
font-size: 24px;
color: #999999;
line-height: 1.5;
}

View File

@@ -0,0 +1,94 @@
import { View, Text, Button } from '@tarojs/components'
import { useEffect } from 'react'
import Taro from '@tarojs/taro'
import './index.scss'
export default function Login() {
useEffect(() => {
// 检查是否已登录
const isLoggedIn = Taro.getStorageSync('isLoggedIn')
if (isLoggedIn) {
// 已登录,跳转到首页
Taro.reLaunch({ url: '/pages/device/index' })
}
}, [])
const handleLogin = () => {
Taro.showLoading({ title: '登录中...' })
// 调用微信登录
Taro.login({
success: (loginRes) => {
console.log('微信登录成功:', loginRes)
// 模拟后端验证 - 实际项目中需要将code发送到服务器
// 这里假设登录成功
Taro.setStorageSync('isLoggedIn', true)
Taro.setStorageSync('userInfo', {
nickName: '微信家长',
avatarUrl: '',
isAdmin: true,
openid: loginRes.code
})
Taro.hideLoading()
// 登录成功后跳转到首页
Taro.reLaunch({ url: '/pages/device/index' })
},
fail: (err) => {
Taro.hideLoading()
console.error('微信登录失败:', err)
Taro.showToast({
title: '登录失败,请重试',
icon: 'none'
})
}
})
}
return (
<View className='login-page'>
<View className='login-header'>
<View className='logo'>
<Text className='logo-icon'>🤖</Text>
</View>
<Text className='title'></Text>
<Text className='subtitle'></Text>
</View>
<View className='login-content'>
<View className='features'>
<View className='feature-item'>
<Text className='feature-icon'>💬</Text>
<Text className='feature-text'>AI </Text>
</View>
<View className='feature-item'>
<Text className='feature-icon'>📍</Text>
<Text className='feature-text'></Text>
</View>
<View className='feature-item'>
<Text className='feature-icon'>🔋</Text>
<Text className='feature-text'></Text>
</View>
</View>
<View className='login-btn-wrapper'>
<Button
className='login-btn'
openType='getUserInfo'
onGetUserInfo={handleLogin}
>
<Text className='btn-icon'>🌐</Text>
<Text className='btn-text'></Text>
</Button>
</View>
<Text className='agreement'>
</Text>
</View>
</View>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom'
})

View File

@@ -0,0 +1,162 @@
.manage-page {
min-height: 100%;
background: #F5F7FA;
padding-bottom: 120px;
padding-bottom: calc(120px + constant(safe-area-inset-bottom));
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
}
}
.user-card {
background: #FFFFFF;
border-radius: 20px;
margin: 0 24px 24px;
padding: 28px 24px;
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
.user-avatar {
width: 100px;
height: 100px;
background: #FEF3E8;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 22px;
flex-shrink: 0;
.avatar-img {
font-size: 52px;
}
}
.user-info {
flex: 1;
.user-name {
font-size: 34px;
font-weight: 600;
color: #1A1A1A;
display: block;
margin-bottom: 8px;
}
.user-role {
font-size: 26px;
color: #666666;
}
}
.verified-badge {
background: #E8F8F0;
border-radius: 14px;
padding: 10px 16px;
text {
font-size: 24px;
color: #4CD964;
font-weight: 500;
}
}
}
.menu-card {
background: #FFFFFF;
border-radius: 20px;
margin: 0 24px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26px 24px;
&:active {
background: #F8F9FA;
}
.menu-left {
display: flex;
align-items: center;
.icon-bg {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 18px;
&.orange {
background: #FFF0E6;
}
&.green {
background: #E8F8F0;
}
&.red {
background: #FFE8E8;
}
.control-icon-img {
width: 36px;
height: 36px;
}
}
.menu-name {
font-size: 30px;
color: #1A1A1A;
font-weight: 500;
}
}
.menu-right {
display: flex;
align-items: center;
.menu-value {
font-size: 28px;
color: #999999;
margin-right: 10px;
}
.arrow {
font-size: 30px;
color: #CCCCCC;
}
}
}
.divider {
height: 1px;
background: #F0F0F0;
margin: 0 24px;
}
.version-info {
text-align: center;
padding: 48px 0;
.version-text {
font-size: 26px;
color: #999999;
}
}

View File

@@ -0,0 +1,142 @@
import { View, Text, Image } from '@tarojs/components'
import { useState } from 'react'
import Taro from '@tarojs/taro'
import './index.scss'
interface UserInfo {
name: string
role: string
isVerified: boolean
avatar: string
}
interface MenuItem {
icon: string
name: string
value: string
arrow?: boolean
}
export default function Sleep() {
const [user] = useState<UserInfo>({
name: '微信家长',
role: '管理员身份',
isVerified: true,
avatar: '👤'
})
const menuItems = [
{
icon: require('../../assets/tab-icons/orange-robot.png'),
iconBgClass: 'orange',
name: '儿童资料 (用于称呼)',
value: '小夏 (6岁)',
arrow: true
},
{
icon: require('../../assets/tab-icons/rings.png'),
iconBgClass: 'green',
name: '绑定新设备',
value: '',
arrow: true
},
{
icon: require('../../assets/tab-icons/broken-rings.png'),
iconBgClass: 'red',
name: '解除设备绑定',
value: '',
arrow: true
}
]
const handleMenuClick = (item: MenuItem) => {
if (item.name === '绑定新设备') {
// 跳转到绑定设备页面
Taro.navigateTo({ url: '/pages/bind/index' })
} else if (item.name === '解除设备绑定') {
Taro.showModal({
title: '解除设备绑定',
content: '确定要解除当前设备的绑定吗?解除后需要重新扫码绑定。',
confirmColor: '#FF8C42',
success: (res) => {
if (res.confirm) {
// 清除设备信息
Taro.removeStorageSync('hasDevice')
Taro.removeStorageSync('deviceInfo')
Taro.showToast({
title: '已解除绑定',
icon: 'success',
duration: 1500,
complete: () => {
setTimeout(() => {
Taro.redirectTo({ url: '/pages/bind/index' })
}, 500)
}
})
}
}
})
} else {
Taro.showToast({
title: `点击了 ${item.name}`,
icon: 'none'
})
}
}
return (
<View className='manage-page'>
<View className='page-header'>
<Text className='page-title'></Text>
</View>
<View className='user-card'>
<View className='user-avatar'>
<Text className='avatar-img'>{user.avatar}</Text>
</View>
<View className='user-info'>
<Text className='user-name'>{user.name}</Text>
<Text className='user-role'>{user.role}</Text>
</View>
{user.isVerified && (
<View className='verified-badge'>
<Text></Text>
</View>
)}
</View>
<View className='menu-card'>
{menuItems.map((item, index) => (
<View key={index}>
<View className='menu-item' onClick={() => handleMenuClick(item)}>
<View className='menu-left'>
<View className={`icon-bg ${item.iconBgClass}`}>
<Image
className='control-icon-img'
src={item.icon}
mode='aspectFit'
/>
</View>
<Text className='menu-name'>{item.name}</Text>
</View>
<View className='menu-right'>
{item.value && (
<Text className='menu-value'>{item.value}</Text>
)}
{item.arrow && <Text className='arrow'></Text>}
</View>
</View>
{index < menuItems.length - 1 && (
<View className='divider'></View>
)}
</View>
))}
</View>
<View className='version-info'>
<Text className='version-text'> Companion V1.1.0</Text>
</View>
</View>
)
}