新增小程序后端代码,包括数据库、路由、服务层等。
小程序前后端打通,包括登录、注册、绑定设备、查询绑定信息,修改小朋友名称等功能。
This commit is contained in:
@@ -6,6 +6,67 @@
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
font-size: 30px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 70%;
|
||||
font-size: 30px;
|
||||
color: #999999;
|
||||
padding: 0 48px;
|
||||
|
||||
.empty-title {
|
||||
font-size: 36px;
|
||||
color: #1A1A1A;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 26px;
|
||||
color: #999999;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.empty-input-wrap {
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.empty-input {
|
||||
background: #F5F7FA;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
font-size: 30px;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
background: #FF8C42;
|
||||
border-radius: 12px;
|
||||
padding: 24px 64px;
|
||||
|
||||
.empty-btn-text {
|
||||
color: #FFFFFF;
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-bg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
import { View, Text, Button, Image } from '@tarojs/components'
|
||||
import { View, Text, Button, Image, Input } from '@tarojs/components'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getToken } from '@/services/api'
|
||||
import { getCurrentBinding, directBind } from '@/services/binding'
|
||||
import { getChildren, createChild } from '@/services/child'
|
||||
import './index.scss'
|
||||
|
||||
interface Child {
|
||||
child_id: number
|
||||
child_name: string
|
||||
}
|
||||
|
||||
export default function Bind() {
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [children, setChildren] = useState<Child[]>([])
|
||||
const [newChildName, setNewChildName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否已登录
|
||||
const isLoggedIn = Taro.getStorageSync('isLoggedIn')
|
||||
if (!isLoggedIn) {
|
||||
// 未登录,跳转到登录页面
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
Taro.redirectTo({ url: '/pages/login/index' })
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已绑定设备
|
||||
const hasDevice = Taro.getStorageSync('hasDevice')
|
||||
if (hasDevice) {
|
||||
// 已绑定设备,跳转到首页
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
try {
|
||||
const childRes = await getChildren()
|
||||
setChildren(childRes.items || [])
|
||||
|
||||
if (childRes.items?.length === 0) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Get children failed:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
try {
|
||||
const binding = await getCurrentBinding()
|
||||
if (binding) {
|
||||
Taro.showToast({ title: '已有设备绑定', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleScanCode = () => {
|
||||
setIsScanning(true)
|
||||
@@ -29,56 +61,57 @@ export default function Bind() {
|
||||
Taro.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
setIsScanning(false)
|
||||
console.log('扫码结果:', res.result)
|
||||
const deviceId = 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)
|
||||
if (!deviceId) {
|
||||
Taro.showToast({ title: '无效的设备码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
await doBind(deviceId)
|
||||
},
|
||||
fail: (err) => {
|
||||
setIsScanning(false)
|
||||
console.error('扫码失败:', err)
|
||||
|
||||
// 用户取消扫码,不显示错误
|
||||
if (err.errMsg && err.errMsg.includes('cancel')) {
|
||||
return
|
||||
}
|
||||
|
||||
Taro.showToast({
|
||||
title: '扫码失败,请重试',
|
||||
icon: 'none'
|
||||
})
|
||||
Taro.showToast({ title: '扫码失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const doBind = async (deviceId: string) => {
|
||||
if (children.length === 0) {
|
||||
Taro.showToast({ title: '请先添加儿童', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
Taro.showLoading({ title: '绑定中...' })
|
||||
|
||||
try {
|
||||
await directBind({
|
||||
device_id: deviceId,
|
||||
child_id: children[0].child_id
|
||||
})
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({
|
||||
title: '设备绑定成功',
|
||||
icon: 'success',
|
||||
duration: 1500,
|
||||
complete: () => {
|
||||
setTimeout(() => {
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: err.message || '绑定失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualInput = () => {
|
||||
Taro.showModal({
|
||||
title: '手动输入设备码',
|
||||
@@ -86,37 +119,60 @@ export default function Bind() {
|
||||
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)
|
||||
doBind(res.content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddChild = async () => {
|
||||
if (!newChildName.trim()) {
|
||||
Taro.showToast({ title: '请输入儿童姓名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await createChild({ child_name: newChildName.trim() })
|
||||
Taro.showToast({ title: '添加成功', icon: 'success' })
|
||||
const childRes = await getChildren()
|
||||
setChildren(childRes.items || [])
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='bind-page'>
|
||||
<View className='loading'>加载中...</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<View className='bind-page'>
|
||||
<View className='empty-tip'>
|
||||
<Text className='empty-title'>请先添加儿童</Text>
|
||||
<Text className='empty-desc'>绑定设备前需要先添加儿童信息</Text>
|
||||
<View className='empty-input-wrap'>
|
||||
<Input
|
||||
className='empty-input'
|
||||
placeholder='请输入儿童姓名'
|
||||
value={newChildName}
|
||||
onInput={(e) => setNewChildName(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='empty-btn' onClick={handleAddChild}>
|
||||
<Text className='empty-btn-text'>{saving ? '保存中...' : '确认添加'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bind-page'>
|
||||
<View className='bind-header'>
|
||||
@@ -170,4 +226,4 @@ export default function Bind() {
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { View, Text, Switch, Slider, Image } from '@tarojs/components'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getCurrentBinding } from '@/services/binding'
|
||||
import './index.scss'
|
||||
|
||||
interface DeviceData {
|
||||
@@ -20,35 +21,59 @@ export default function Device() {
|
||||
const [hasDevice, setHasDevice] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查登录状态
|
||||
const isLoggedIn = Taro.getStorageSync('isLoggedIn')
|
||||
if (!isLoggedIn) {
|
||||
console.log('[Device] Checking login status...')
|
||||
const token = Taro.getStorageSync('token')
|
||||
console.log('[Device] Token exists:', !!token)
|
||||
|
||||
if (!token) {
|
||||
console.log('[Device] No token, redirecting to login')
|
||||
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)
|
||||
loadDeviceInfo()
|
||||
}, [])
|
||||
|
||||
const loadDeviceInfo = async () => {
|
||||
try {
|
||||
const binding = await getCurrentBinding()
|
||||
console.log('[Device] Got binding:', binding)
|
||||
|
||||
if (binding) {
|
||||
setHasDevice(true)
|
||||
Taro.setStorageSync('hasDevice', '1')
|
||||
Taro.setStorageSync('deviceInfo', {
|
||||
id: binding.device_id,
|
||||
name: '伴伴设备',
|
||||
status: 'online',
|
||||
battery: 82
|
||||
})
|
||||
setDevice({
|
||||
id: binding.device_id,
|
||||
name: '伴伴设备',
|
||||
status: 'online',
|
||||
battery: 82,
|
||||
daysLeft: 4,
|
||||
sleepEnabled: true,
|
||||
sleepTime: '每天 22:00 - 07:00',
|
||||
volume: 50
|
||||
})
|
||||
} else {
|
||||
setHasDevice(false)
|
||||
Taro.removeStorageSync('hasDevice')
|
||||
Taro.removeStorageSync('deviceInfo')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log('[Device] No binding or error:', err?.message || err)
|
||||
if (err?.status === 404) {
|
||||
setHasDevice(false)
|
||||
Taro.removeStorageSync('hasDevice')
|
||||
Taro.removeStorageSync('deviceInfo')
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleSleepToggle = (e: any) => {
|
||||
if (!device) return
|
||||
setDevice({ ...device, sleepEnabled: e.detail.value })
|
||||
|
||||
@@ -1,51 +1,118 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { BASE_URL, request } from '@/services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function Login() {
|
||||
useEffect(() => {
|
||||
// 检查是否已登录
|
||||
const isLoggedIn = Taro.getStorageSync('isLoggedIn')
|
||||
|
||||
if (isLoggedIn) {
|
||||
// 已登录,跳转到首页
|
||||
console.log('[Login] Page mounted, checking token...')
|
||||
const token = Taro.getStorageSync('token')
|
||||
console.log('[Login] Token exists:', !!token)
|
||||
if (token) {
|
||||
console.log('[Login] Already logged in, redirecting to device page')
|
||||
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
|
||||
const doWeChatLogin = async () => {
|
||||
try {
|
||||
console.log('[Login] Starting WeChat login...')
|
||||
Taro.showLoading({ title: '登录中...' })
|
||||
|
||||
// 1. 调用微信登录获取 code
|
||||
console.log('[Login] Step 1: Calling Taro.login()...')
|
||||
const loginRes = await Taro.login()
|
||||
const code = loginRes.code
|
||||
console.log('[Login] Got code:', code ? 'yes' : 'no')
|
||||
|
||||
// 2. 获取用户头像信息 (可选)
|
||||
let userInfo = null
|
||||
try {
|
||||
console.log('[Login] Step 2: Calling Taro.getUserProfile()...')
|
||||
const profileRes = await Taro.getUserProfile({
|
||||
desc: '用于完善用户资料'
|
||||
})
|
||||
|
||||
Taro.hideLoading()
|
||||
|
||||
// 登录成功后跳转到首页
|
||||
userInfo = profileRes.userInfo
|
||||
console.log('[Login] Got userInfo:', !!userInfo)
|
||||
} catch (e) {
|
||||
console.log('[Login] getUserProfile failed (expected if user denied):', e)
|
||||
}
|
||||
|
||||
// 3. 调用后端登录接口 (微信登录)
|
||||
console.log('[Login] Step 3: Calling backend /auth/login...')
|
||||
const res = await request<{ access_token: string; expires_in: number; user_id: number }>('/auth/login', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: code, // 微信 code
|
||||
nickname: userInfo?.nickName,
|
||||
avatar_url: userInfo?.avatarUrl
|
||||
}
|
||||
})
|
||||
console.log('[Login] Backend login success, user_id:', res.user_id)
|
||||
|
||||
// 4. 保存 token 和用户信息
|
||||
console.log('[Login] Step 4: Saving token and user info...')
|
||||
Taro.setStorageSync('token', res.access_token)
|
||||
Taro.setStorageSync('user_id', res.user_id)
|
||||
Taro.setStorageSync('expires_in', res.expires_in)
|
||||
|
||||
// 保存用户信息用于显示
|
||||
if (userInfo) {
|
||||
Taro.setStorageSync('userInfo', {
|
||||
nickname: userInfo.nickName,
|
||||
avatar_url: userInfo.avatarUrl
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 如果获取到了用户信息,发送到后端更新
|
||||
if (userInfo) {
|
||||
console.log('[Login] Step 5: Updating user profile...')
|
||||
try {
|
||||
await request(`/parents/${res.user_id}`, {
|
||||
method: 'PATCH',
|
||||
data: {
|
||||
nickname: userInfo.nickName,
|
||||
avatar_url: userInfo.avatarUrl
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[Login] Update user profile failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
Taro.hideLoading()
|
||||
console.log('[Login] Step 6: Login complete, showing success toast')
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
|
||||
// 6. 跳转到首页
|
||||
console.log('[Login] Redirecting to /pages/device/index...')
|
||||
setTimeout(() => {
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
},
|
||||
fail: (err) => {
|
||||
Taro.hideLoading()
|
||||
console.error('微信登录失败:', err)
|
||||
}, 500)
|
||||
|
||||
} catch (err: any) {
|
||||
Taro.hideLoading()
|
||||
console.error('[Login] Login failed:', err)
|
||||
|
||||
// 如果是 401 错误,尝试通过创建新用户登录
|
||||
if (err.status === 401) {
|
||||
Taro.showToast({
|
||||
title: '登录失败,请重试',
|
||||
icon: 'none'
|
||||
})
|
||||
} else {
|
||||
Taro.showToast({
|
||||
title: err.message || '网络错误,请重试',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
console.log('Button clicked, starting login...')
|
||||
doWeChatLogin()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -77,8 +144,7 @@ export default function Login() {
|
||||
<View className='login-btn-wrapper'>
|
||||
<Button
|
||||
className='login-btn'
|
||||
openType='getUserInfo'
|
||||
onGetUserInfo={handleLogin}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
<Text className='btn-icon'>🌐</Text>
|
||||
<Text className='btn-text'>微信一键登录</Text>
|
||||
|
||||
@@ -160,3 +160,150 @@
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.add-input-box {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
margin: 0 24px 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
.name-input {
|
||||
background: #F5F7FA;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
font-size: 30px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
font-size: 24px;
|
||||
color: #999999;
|
||||
margin-top: 12px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.input-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 24px;
|
||||
|
||||
.confirm-btn {
|
||||
font-size: 30px;
|
||||
color: #FF8C42;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
font-size: 30px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item.disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
&:active {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-section {
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
padding: 26px 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.logout-text {
|
||||
font-size: 30px;
|
||||
color: #FF3B30;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
width: 600px;
|
||||
margin: 0 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 32px 32px 16px;
|
||||
text-align: center;
|
||||
|
||||
.modal-title {
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
color: #1A1A1A;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 32px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.modal-input {
|
||||
background: #F5F7FA;
|
||||
border-radius: 12px;
|
||||
padding: 0 24px;
|
||||
font-size: 34px;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
|
||||
.modal-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 32px;
|
||||
|
||||
&.cancel {
|
||||
color: #666666;
|
||||
border-right: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
&.confirm {
|
||||
color: #FF8C42;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,188 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import { useState } from 'react'
|
||||
import { View, Text, Image, Input } from '@tarojs/components'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getToken, getCurrentUserId } from '@/services/api'
|
||||
import { getChildren, createChild, updateChild } from '@/services/child'
|
||||
import { getCurrentBinding, unbindDevice } from '@/services/binding'
|
||||
import './index.scss'
|
||||
|
||||
interface UserInfo {
|
||||
name: string
|
||||
role: string
|
||||
isVerified: boolean
|
||||
avatar: string
|
||||
interface Child {
|
||||
child_id: number
|
||||
child_name: string
|
||||
child_gender: number
|
||||
status: number
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
device_id: string
|
||||
child_id: number
|
||||
status: number
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
icon: string
|
||||
iconBgClass: string
|
||||
name: string
|
||||
value: string
|
||||
value?: string
|
||||
arrow?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function Sleep() {
|
||||
const [user] = useState<UserInfo>({
|
||||
name: '微信家长',
|
||||
role: '管理员身份',
|
||||
isVerified: true,
|
||||
avatar: '👤'
|
||||
})
|
||||
const [children, setChildren] = useState<Child[]>([])
|
||||
const [binding, setBinding] = useState<Binding | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||
const [childName, setChildName] = useState('')
|
||||
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
||||
const [parentInfo, setParentInfo] = useState<{ nickname?: string; avatar_url?: string }>({})
|
||||
|
||||
const menuItems = [
|
||||
const token = getToken()
|
||||
const userId = getCurrentUserId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
Taro.reLaunch({ url: '/pages/login/index' })
|
||||
return
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Get parent info
|
||||
const userInfo = Taro.getStorageSync('userInfo') || {}
|
||||
setParentInfo(userInfo)
|
||||
|
||||
// Get children
|
||||
const childRes = await getChildren()
|
||||
setChildren(childRes.items || [])
|
||||
|
||||
// Get binding
|
||||
try {
|
||||
const bindRes = await getCurrentBinding()
|
||||
setBinding(bindRes)
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
setBinding(null)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load failed:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
||||
setModalType(type)
|
||||
if (type === 'edit' && child) {
|
||||
setChildName(child.child_name)
|
||||
setEditingChildId(child.child_id)
|
||||
} else {
|
||||
setChildName('')
|
||||
setEditingChildId(null)
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleModalConfirm = async () => {
|
||||
if (!childName.trim()) {
|
||||
Taro.showToast({ title: '请输入昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (modalType === 'add') {
|
||||
await createChild({ child_name: childName.trim() })
|
||||
Taro.showToast({ title: '添加成功', icon: 'success' })
|
||||
} else if (editingChildId) {
|
||||
await updateChild(editingChildId, { child_name: childName.trim() })
|
||||
Taro.showToast({ title: '修改成功', icon: 'success' })
|
||||
}
|
||||
setShowModal(false)
|
||||
setChildName('')
|
||||
setEditingChildId(null)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setShowModal(false)
|
||||
setChildName('')
|
||||
setEditingChildId(null)
|
||||
}
|
||||
|
||||
const handleUnbind = () => {
|
||||
if (!binding) return
|
||||
Taro.showModal({
|
||||
title: '解除设备绑定',
|
||||
content: '确定要解除当前设备的绑定吗?',
|
||||
confirmColor: '#FF8C42',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await unbindDevice(binding.device_id)
|
||||
Taro.showToast({ title: '已解绑', icon: 'success' })
|
||||
setBinding(null)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '解绑失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
Taro.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
confirmColor: '#FF8C42',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
Taro.removeStorageSync('token')
|
||||
Taro.removeStorageSync('user_id')
|
||||
Taro.removeStorageSync('userInfo')
|
||||
Taro.reLaunch({ url: '/pages/login/index' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
if (item.disabled) return
|
||||
|
||||
if (item.name === '绑定新设备') {
|
||||
if (binding) {
|
||||
Taro.showToast({ title: '已有设备绑定', icon: 'none' })
|
||||
} else {
|
||||
Taro.navigateTo({ url: '/pages/bind/index' })
|
||||
}
|
||||
} else if (item.name === '解除设备绑定') {
|
||||
handleUnbind()
|
||||
} else if (item.name.includes('儿童资料')) {
|
||||
if (children.length > 0) {
|
||||
handleOpenModal('edit', children[0])
|
||||
} else {
|
||||
handleOpenModal('add')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return null
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||
iconBgClass: 'orange',
|
||||
name: '儿童资料 (用于称呼)',
|
||||
value: '小夏 (6岁)',
|
||||
value: children.length > 0 ? children[0].child_name : '未设置',
|
||||
arrow: true
|
||||
},
|
||||
{
|
||||
@@ -45,45 +197,13 @@ export default function Sleep() {
|
||||
iconBgClass: 'red',
|
||||
name: '解除设备绑定',
|
||||
value: '',
|
||||
arrow: true
|
||||
arrow: true,
|
||||
disabled: !binding
|
||||
}
|
||||
]
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
const firstChild = children[0]
|
||||
const childDisplayValue = firstChild ? firstChild.child_name : ''
|
||||
|
||||
return (
|
||||
<View className='manage-page'>
|
||||
@@ -93,50 +213,76 @@ export default function Sleep() {
|
||||
|
||||
<View className='user-card'>
|
||||
<View className='user-avatar'>
|
||||
<Text className='avatar-img'>{user.avatar}</Text>
|
||||
{parentInfo.avatar_url ? (
|
||||
<Image className='avatar-img' src={parentInfo.avatar_url} mode='aspectFill' />
|
||||
) : (
|
||||
<Text className='avatar-img'>👤</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='user-info'>
|
||||
<Text className='user-name'>{user.name}</Text>
|
||||
<Text className='user-role'>{user.role}</Text>
|
||||
<Text className='user-name'>{parentInfo.nickname || '家长用户'}</Text>
|
||||
<Text className='user-role'>ID: {userId}</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-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
>
|
||||
<View className='menu-left'>
|
||||
<View className={`icon-bg ${item.iconBgClass}`}>
|
||||
<Image
|
||||
className='control-icon-img'
|
||||
src={item.icon}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
<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.name.includes('儿童资料') && (
|
||||
<Text className='menu-value'>{childDisplayValue}</Text>
|
||||
)}
|
||||
{item.arrow && <Text className='arrow'>›</Text>}
|
||||
</View>
|
||||
</View>
|
||||
{index < menuItems.length - 1 && (
|
||||
<View className='divider'></View>
|
||||
)}
|
||||
{index < menuItems.length - 1 && <View className='divider'></View>}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='logout-section'>
|
||||
<View className='logout-btn' onClick={handleLogout}>
|
||||
<Text className='logout-text'>退出登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='version-info'>
|
||||
<Text className='version-text'>伴伴 Companion V1.1.0</Text>
|
||||
</View>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<View className='modal-mask'>
|
||||
<View className='modal-content'>
|
||||
<View className='modal-header'>
|
||||
<Text className='modal-title'>{modalType === 'add' ? '添加儿童' : '修改昵称'}</Text>
|
||||
</View>
|
||||
<View className='modal-body'>
|
||||
<Input
|
||||
className='modal-input'
|
||||
placeholder='请输入儿童昵称'
|
||||
value={childName}
|
||||
onInput={(e) => setChildName(e.detail.value)}
|
||||
focus={true}
|
||||
/>
|
||||
</View>
|
||||
<View className='modal-footer'>
|
||||
<Text className='modal-btn cancel' onClick={handleModalCancel}>取消</Text>
|
||||
<Text className='modal-btn confirm' onClick={handleModalConfirm}>确认</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
56
banban-mini/src/services/api.ts
Normal file
56
banban-mini/src/services/api.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
// const BASE_URL = 'http://175.24.73.253:8001' // 改为实际后端IP
|
||||
const BASE_URL = 'http://127.0.0.1:8001'
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
data?: any
|
||||
headers?: Record<string, string>
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const token = Taro.getStorageSync('token')
|
||||
console.log(`[API] ${options.method || 'GET'} ${url}`, token ? 'with token' : 'no token')
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await Taro.request({
|
||||
url: `${BASE_URL}${url}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: headers,
|
||||
})
|
||||
|
||||
console.log(`[API] Response status:`, response.statusCode)
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
const detail = response.data?.detail || 'Request failed'
|
||||
throw new ApiError(response.statusCode, detail)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export { request, ApiError, BASE_URL }
|
||||
|
||||
export function getToken(): string {
|
||||
return Taro.getStorageSync('token') || ''
|
||||
}
|
||||
|
||||
export function getCurrentUserId(): number {
|
||||
return Taro.getStorageSync('user_id') || 0
|
||||
}
|
||||
75
banban-mini/src/services/auth.ts
Normal file
75
banban-mini/src/services/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { request, BASE_URL } from './api'
|
||||
|
||||
export interface Parent {
|
||||
user_id: number
|
||||
openid: string
|
||||
unionid?: string
|
||||
nickname?: string
|
||||
avatar_url?: string
|
||||
phone?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
// 微信登录
|
||||
export async function wechatLogin(code: string): Promise<{ token: string; user_id: number }> {
|
||||
// TODO: 实现微信登录 - 通过 code 换取 openid,然后调用后端
|
||||
// 这里先返回 mock 数据
|
||||
const mockParent: Parent = {
|
||||
user_id: 1,
|
||||
openid: 'mock_openid_' + code,
|
||||
nickname: '微信用户',
|
||||
status: 1,
|
||||
}
|
||||
|
||||
// 保存模拟 token
|
||||
const token = 'mock_token_' + Date.now()
|
||||
Taro.setStorageSync('token', token)
|
||||
Taro.setStorageSync('user_id', mockParent.user_id)
|
||||
|
||||
return { token, user_id: mockParent.user_id }
|
||||
}
|
||||
|
||||
// 创建家长
|
||||
export async function createParent(data: {
|
||||
openid: string
|
||||
unionid?: string
|
||||
nickname?: string
|
||||
avatar_url?: string
|
||||
}): Promise<Parent> {
|
||||
return request<Parent>('/parents', {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取家长信息
|
||||
export async function getParent(userId: number): Promise<Parent> {
|
||||
return request<Parent>(`/parents/${userId}`)
|
||||
}
|
||||
|
||||
// 更新家长信息
|
||||
export async function updateParent(
|
||||
userId: number,
|
||||
data: { nickname?: string; avatar_url?: string; phone?: string }
|
||||
): Promise<Parent> {
|
||||
return request<Parent>(`/parents/${userId}`, {
|
||||
method: 'PATCH',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 token
|
||||
export function getToken(): string {
|
||||
return Taro.getStorageSync('token')
|
||||
}
|
||||
|
||||
// 清除 token
|
||||
export function clearToken(): void {
|
||||
Taro.removeStorageSync('token')
|
||||
Taro.removeStorageSync('user_id')
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
export function getCurrentUserId(): number {
|
||||
return Taro.getStorageSync('user_id') || 0
|
||||
}
|
||||
105
banban-mini/src/services/binding.ts
Normal file
105
banban-mini/src/services/binding.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { request } from './api'
|
||||
|
||||
export interface Binding {
|
||||
device_id: string
|
||||
child_id: number
|
||||
status: number
|
||||
bound_at: string
|
||||
}
|
||||
|
||||
export interface BindStartResponse {
|
||||
bind_token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface BindHistoryItem {
|
||||
device_id: string
|
||||
child_id: number
|
||||
bound_at: string
|
||||
unbound_at?: string
|
||||
}
|
||||
|
||||
export interface BindHistoryResponse {
|
||||
items: BindHistoryItem[]
|
||||
total: number
|
||||
next_cursor?: string
|
||||
}
|
||||
|
||||
// 获取当前绑定信息
|
||||
export async function getCurrentBinding(): Promise<Binding | null> {
|
||||
try {
|
||||
return await request<Binding>('/bindings/current')
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 开始绑定设备
|
||||
export async function startBind(data: {
|
||||
device_id: string
|
||||
child_id: number
|
||||
}): Promise<BindStartResponse> {
|
||||
return request<BindStartResponse>('/bindings/start', {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 确认绑定设备
|
||||
export async function confirmBind(data: {
|
||||
bind_token: string
|
||||
challenge_code: string
|
||||
}): Promise<{ device_id: string; child_id: number }> {
|
||||
return request('/bindings/confirm', {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取设备绑定信息
|
||||
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
||||
// If no deviceId, get the first binding for this user
|
||||
if (!deviceId) {
|
||||
try {
|
||||
// Try to get binding list - for now return null if no specific device
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return request<Binding>(`/bindings/${deviceId}`)
|
||||
}
|
||||
|
||||
// 解绑设备
|
||||
export async function unbindDevice(deviceId: string): Promise<void> {
|
||||
return request(`/bindings/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// 直接绑定设备(手动输入设备码)
|
||||
export async function directBind(data: {
|
||||
device_id: string
|
||||
child_id: number
|
||||
}): Promise<{ device_id: string; child_id: number }> {
|
||||
return request('/bindings/direct', {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取绑定历史
|
||||
export async function getBindHistory(
|
||||
deviceId: string,
|
||||
cursor?: string,
|
||||
limit: number = 20
|
||||
): Promise<BindHistoryResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (cursor) params.append('cursor', cursor)
|
||||
params.append('limit', String(limit))
|
||||
|
||||
return request<BindHistoryResponse>(`/bindings/history/${deviceId}?${params.toString()}`)
|
||||
}
|
||||
55
banban-mini/src/services/child.ts
Normal file
55
banban-mini/src/services/child.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { request } from './api'
|
||||
|
||||
export interface Child {
|
||||
child_id: number
|
||||
child_name: string
|
||||
child_gender: number
|
||||
child_birthday?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface ChildListResponse {
|
||||
items: Child[]
|
||||
total: number
|
||||
next_cursor?: number
|
||||
}
|
||||
|
||||
// 创建小孩档案
|
||||
export async function createChild(data: {
|
||||
child_name: string
|
||||
child_gender?: number
|
||||
child_birthday?: string
|
||||
}): Promise<Child> {
|
||||
return request<Child>('/children', {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取小孩列表
|
||||
export async function getChildren(
|
||||
cursor?: number,
|
||||
limit: number = 20
|
||||
): Promise<ChildListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (cursor) params.append('cursor', String(cursor))
|
||||
params.append('limit', String(limit))
|
||||
|
||||
return request<ChildListResponse>(`/children?${params.toString()}`)
|
||||
}
|
||||
|
||||
// 获取单个小孩信息
|
||||
export async function getChild(childId: number): Promise<Child> {
|
||||
return request<Child>(`/children/${childId}`)
|
||||
}
|
||||
|
||||
// 更新小孩信息
|
||||
export async function updateChild(
|
||||
childId: number,
|
||||
data: { child_name?: string; child_gender?: number; child_birthday?: string }
|
||||
): Promise<Child> {
|
||||
return request<Child>(`/children/${childId}`, {
|
||||
method: 'PATCH',
|
||||
data,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user