feat(scene): 场所选择页支持添加房间,设备添加页扫码区重设计
- api/scene 新增 addScene(mock 走 storage 持久化,重名/空名校验), getSceneList mock 数据同样改为本地存储种子初始化 - 场所选择页改为"房间管理":底部虚线添加卡 + showModal editable 输入 房间名,空列表时保留添加入口 - 设备添加页扫码区重绘:四角 L 形取景框 + 中间横线(纯 CSS), scanCode 限定 qrCode/barCode 并加真机排障日志 - 家庭圈卡片头部改双行(角色徽标贴名称、成员/设备数合并为副标题) - mine 页补场所管理/手机号/版本行,登录方式替代 userId 展示, 退出登录增加 toast
This commit is contained in:
parent
368fb3b12c
commit
3f52b493e0
|
|
@ -1,18 +1,53 @@
|
|||
import { request } from '@/http'
|
||||
import { storage } from '@/utils/storage'
|
||||
import type { Scene } from '@/store/sceneSlice'
|
||||
|
||||
const USE_MOCK = true
|
||||
|
||||
const MOCK_SCENES: Scene[] = [
|
||||
const SCENE_STORAGE_KEY = 'sceneData'
|
||||
const MOCK_ID_SEED = 9000
|
||||
|
||||
const SEED_SCENES: Scene[] = [
|
||||
{ id: 1, name: '客厅' },
|
||||
{ id: 2, name: '卧室' },
|
||||
{ id: 3, name: '书房' },
|
||||
{ id: 4, name: '走廊' }
|
||||
]
|
||||
|
||||
// 读全量列表;storage 为空时写入种子(首次启动初始化)
|
||||
function loadScenes(): Scene[] {
|
||||
const saved = storage.get<Scene[]>(SCENE_STORAGE_KEY)
|
||||
if (Array.isArray(saved) && saved.length > 0) return saved
|
||||
storage.set(SCENE_STORAGE_KEY, SEED_SCENES)
|
||||
return SEED_SCENES
|
||||
}
|
||||
|
||||
function saveScenes(scenes: Scene[]): void {
|
||||
storage.set(SCENE_STORAGE_KEY, scenes)
|
||||
}
|
||||
|
||||
// mock 异步语义:与真实 API 调用形式一致
|
||||
const delay = <T>(value: T, ms = 200): Promise<T> =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(value), ms))
|
||||
|
||||
export function getSceneList(): Promise<Scene[]> {
|
||||
if (USE_MOCK) {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(MOCK_SCENES), 300))
|
||||
return delay(loadScenes())
|
||||
}
|
||||
return request.get<Scene[]>('/scene/list')
|
||||
}
|
||||
|
||||
export function addScene(name: string): Promise<Scene> {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return Promise.reject(new Error('请输入房间名称'))
|
||||
if (USE_MOCK) {
|
||||
const scenes = loadScenes()
|
||||
if (scenes.some((s) => s.name === trimmed)) {
|
||||
return Promise.reject(new Error('该房间已存在'))
|
||||
}
|
||||
const scene: Scene = { id: MOCK_ID_SEED + scenes.length, name: trimmed }
|
||||
saveScenes([...scenes, scene])
|
||||
return delay(scene)
|
||||
}
|
||||
return request.post<Scene>('/scene/add', { name: trimmed } as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export interface UserInfo {
|
|||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
mobile: string
|
||||
loginType: 'mobile' | 'wechat'
|
||||
}
|
||||
|
||||
export function getUserInfo(): Promise<UserInfo> {
|
||||
|
|
@ -13,7 +15,9 @@ export function getUserInfo(): Promise<UserInfo> {
|
|||
return Promise.resolve({
|
||||
id: 'u-001',
|
||||
name: '张大爷',
|
||||
avatar: ''
|
||||
avatar: '',
|
||||
mobile: '',
|
||||
loginType: 'mobile'
|
||||
})
|
||||
}
|
||||
return request.get<UserInfo>('/user/info')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
background: $color-primary-light-bg;
|
||||
border: 1px solid $color-primary-light-border;
|
||||
border-radius: $radius-lg;
|
||||
padding: 32px;
|
||||
padding: 36px 32px 12px;
|
||||
}
|
||||
|
||||
.cardPressed {
|
||||
|
|
@ -27,23 +27,24 @@
|
|||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
// 圆图标:品牌渐变底 + 白字首字,对齐设备网格 icon 风格
|
||||
.icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: $radius-circle;
|
||||
background: $color-primary-grad;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
margin-right: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconText {
|
||||
font-size: 36px;
|
||||
font-size: 38px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
|
@ -51,14 +52,18 @@
|
|||
.headMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32px;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
|
|
@ -66,11 +71,11 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 人数徽标:白底胶囊——浅绿卡身上无米色 token,白底对比最清晰
|
||||
.memberBadge {
|
||||
// 角色徽标:白底胶囊,紧贴名称右侧
|
||||
.roleBadge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
padding: 4px 16px;
|
||||
margin-left: 12px;
|
||||
padding: 4px 14px;
|
||||
background: $color-bg-white;
|
||||
border: 1px solid $color-primary-light-border;
|
||||
border-radius: $radius-pill;
|
||||
|
|
@ -78,20 +83,24 @@
|
|||
color: $color-primary-deep;
|
||||
}
|
||||
|
||||
// 成员/设备合并为头部副标题,避免与下方信息行重复占行
|
||||
.subCount {
|
||||
margin-top: 10px;
|
||||
font-size: 24px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
// 信息行:浅绿卡身上用更淡的绿分割线区分,不用灰白 $color-border
|
||||
.rows {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 60px;
|
||||
border-top: 1px solid $color-primary-light-border;
|
||||
}
|
||||
|
||||
.rows:first-of-type {
|
||||
border-top: none;
|
||||
min-height: 72px;
|
||||
border-top: 1px solid rgba(159, 220, 195, 0.45);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex-shrink: 0;
|
||||
font-size: 26px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,13 @@ export function FamilyCard({ family, onClick }: Props) {
|
|||
<Text className={styles.iconText}>{first}</Text>
|
||||
</View>
|
||||
<View className={styles.headMain}>
|
||||
<View className={styles.nameRow}>
|
||||
<Text className={styles.name}>{family.name}</Text>
|
||||
<Text className={styles.memberBadge}>{family.memberCount} 人</Text>
|
||||
{family.role && <Text className={styles.roleBadge}>{family.role}</Text>}
|
||||
</View>
|
||||
<Text className={styles.subCount}>
|
||||
{family.memberCount} 位成员 · {family.deviceCount} 台设备
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.rows}>
|
||||
|
|
@ -35,10 +40,6 @@ export function FamilyCard({ family, onClick }: Props) {
|
|||
<Text className={styles.rowLabel}>关爱地址</Text>
|
||||
<Text className={styles.rowValue}>{family.address}</Text>
|
||||
</View>
|
||||
<View className={styles.rows}>
|
||||
<Text className={styles.rowLabel}>设备数量</Text>
|
||||
<Text className={styles.rowValue}>{family.deviceCount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
}
|
||||
|
||||
.scanArea {
|
||||
padding: 60px 0;
|
||||
padding: 24px 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -70,41 +70,73 @@
|
|||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.scanIcon {
|
||||
font-size: 140px;
|
||||
color: $color-primary;
|
||||
line-height: 1;
|
||||
margin-bottom: 24px;
|
||||
// 扫码取景框:四角 L 形 + 中间横线(纯 CSS 绘制,颜色取品牌主色)
|
||||
.scanFrame {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
// 四角 L 形:每个角直接写完整 border 简写(wxss 会把 border:0 当清边框,
|
||||
// 之后再单独设 border-*-width 不生效,导致四角不渲染)
|
||||
.scanCorner {
|
||||
position: absolute;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.scanCornerTopLeft {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-top: 12px solid $color-primary;
|
||||
border-left: 12px solid $color-primary;
|
||||
border-top-left-radius: 20px;
|
||||
}
|
||||
|
||||
.scanCornerTopRight {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-top: 12px solid $color-primary;
|
||||
border-right: 12px solid $color-primary;
|
||||
border-top-right-radius: 20px;
|
||||
}
|
||||
|
||||
.scanCornerBottomLeft {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-bottom: 12px solid $color-primary;
|
||||
border-left: 12px solid $color-primary;
|
||||
border-bottom-left-radius: 20px;
|
||||
}
|
||||
|
||||
.scanCornerBottomRight {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-bottom: 12px solid $color-primary;
|
||||
border-right: 12px solid $color-primary;
|
||||
border-bottom-right-radius: 20px;
|
||||
}
|
||||
|
||||
// 中间横线:贯穿取景框,与四角同色
|
||||
.scanLine {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 12px;
|
||||
background: $color-primary;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.scanText {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.deviceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.deviceBox {
|
||||
width: 48%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.deviceIcon1,
|
||||
.deviceIcon2 {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: $color-bg-gray;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.deviceLabel {
|
||||
font-size: 26px;
|
||||
color: $color-text;
|
||||
.scanHint {
|
||||
font-size: 24px;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,18 +16,22 @@ export default function DeviceAdd() {
|
|||
showToast('设备添加功能即将接入')
|
||||
}
|
||||
|
||||
// 点击扫码区拉起相机扫码,识别结果回填到输入框,确认后提交
|
||||
const scanQRCode = async () => {
|
||||
// 测试日志:完整输出扫码回调,用于真机验证扫描功能(上线前移除)
|
||||
console.log('[SCAN] scanCode invoked')
|
||||
try {
|
||||
// @ts-ignore Taro 类型未跟进 wx.scanCode 的完整回调(实际运行时存在)
|
||||
const res = await Taro.scanCode({ onlyFromCamera: false })
|
||||
const code = String(res?.result ?? '').trim()
|
||||
const res = await Taro.scanCode({ scanType: ['qrCode', 'barCode'] })
|
||||
console.log('[SCAN] success:', JSON.stringify(res))
|
||||
const code = String(res.result ?? '').trim()
|
||||
if (!code) {
|
||||
showToast('未识别到有效编号')
|
||||
return
|
||||
}
|
||||
setDeviceId(code)
|
||||
showToast('已填入扫码结果')
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.log('[SCAN] fail:', JSON.stringify(error))
|
||||
showToast('扫码取消')
|
||||
}
|
||||
}
|
||||
|
|
@ -56,23 +60,17 @@ export default function DeviceAdd() {
|
|||
|
||||
<View className={styles.section}>
|
||||
<Text className={styles.sectionTitle}>扫码添加</Text>
|
||||
<Text className={styles.sectionDesc}>扫描设备机身或包装盒上的二维码,自动填入编号。</Text>
|
||||
<View className={styles.scanArea} hoverClass={styles.scanAreaPressed} onClick={scanQRCode}>
|
||||
<Text className={styles.scanIcon}>⊡</Text>
|
||||
<Text className={styles.scanText}>点击扫码</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={styles.section}>
|
||||
<Text className={styles.sectionTitle}>设备二维码/编号位置</Text>
|
||||
<View className={styles.deviceRow}>
|
||||
<View className={styles.deviceBox}>
|
||||
<View className={styles.deviceIcon1} />
|
||||
<Text className={styles.deviceLabel}>包装盒侧边</Text>
|
||||
</View>
|
||||
<View className={styles.deviceBox}>
|
||||
<View className={styles.deviceIcon2} />
|
||||
<Text className={styles.deviceLabel}>设备侧面或背面</Text>
|
||||
<View className={styles.scanFrame}>
|
||||
<View className={`${styles.scanCorner} ${styles.scanCornerTopLeft}`} />
|
||||
<View className={`${styles.scanCorner} ${styles.scanCornerTopRight}`} />
|
||||
<View className={`${styles.scanCorner} ${styles.scanCornerBottomLeft}`} />
|
||||
<View className={`${styles.scanCorner} ${styles.scanCornerBottomRight}`} />
|
||||
<View className={styles.scanLine} />
|
||||
</View>
|
||||
<Text className={styles.scanText}>点击开始扫码</Text>
|
||||
<Text className={styles.scanHint}>支持二维码 / 条形码</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
import { useAppDispatch } from '@/store/hooks'
|
||||
import { clearAuth } from '@/store/authSlice'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { ListRow, ListRowGroup } from '@/components/list-row'
|
||||
|
|
@ -13,7 +13,6 @@ import styles from './index.module.scss'
|
|||
export default function Mine() {
|
||||
const dispatch = useAppDispatch()
|
||||
const toast = useToast()
|
||||
const auth = useAppSelector((s) => s.auth)
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -23,6 +22,7 @@ export default function Mine() {
|
|||
|
||||
const logout = () => {
|
||||
dispatch(clearAuth())
|
||||
toast.show('已退出登录')
|
||||
reLaunch(ROUTES.LOGIN)
|
||||
}
|
||||
|
||||
|
|
@ -35,13 +35,18 @@ export default function Mine() {
|
|||
</View>
|
||||
<View className={styles.headerInfo}>
|
||||
<Text className={styles.name}>{user?.name ?? '加载中...'}</Text>
|
||||
<Text className={styles.uid}>{auth.userId ? `ID: ${auth.userId}` : ''}</Text>
|
||||
<Text className={styles.uid}>
|
||||
{user?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={styles.menuCard}>
|
||||
<ListRowGroup>
|
||||
<ListRow label="配置设备" arrow onClick={() => navigateTo(ROUTES.DEVICE_BLE_CONFIG)} />
|
||||
<ListRow label="场所管理" arrow onClick={() => navigateTo(ROUTES.SCENE_SELECT)} />
|
||||
<ListRow label="手机号" value={user?.mobile || '未绑定'} arrow />
|
||||
<ListRow label="当前版本" value="2.8.44" />
|
||||
<ListRow label="退出登录" arrow danger onClick={logout} />
|
||||
</ListRowGroup>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -38,3 +38,29 @@
|
|||
}
|
||||
|
||||
.itemActive .check { color: $color-primary; }
|
||||
|
||||
// 添加房间:虚线边框卡,与家庭圈添加卡风格一致
|
||||
.addBtn {
|
||||
margin-top: 24px;
|
||||
height: 104px;
|
||||
border: 1px dashed $color-primary-light-border;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.addBtnPressed { opacity: 0.8; }
|
||||
|
||||
.addIcon {
|
||||
font-size: 36px;
|
||||
font-weight: 300;
|
||||
color: $color-primary;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.addText {
|
||||
font-size: 28px;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
import { setScene, type Scene } from '@/store/sceneSlice'
|
||||
|
|
@ -6,10 +7,24 @@ import { Navbar } from '@/components/navbar'
|
|||
import { Loading } from '@/components/loading'
|
||||
import { Empty } from '@/components/empty'
|
||||
import { StateView } from '@/components/state-view'
|
||||
import { getSceneList } from '@/api/scene'
|
||||
import { getSceneList, addScene } from '@/api/scene'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { navigateBack } from '@/utils/navigation'
|
||||
import styles from './select.module.scss'
|
||||
|
||||
// Taro 类型未跟进 wx.showModal 的 editable 扩展(微信 2.17.1+),运行时真实存在
|
||||
interface EditableModalOption extends Taro.showModal.Option {
|
||||
editable?: boolean
|
||||
placeholderText?: string
|
||||
}
|
||||
|
||||
interface EditableModalResult extends Taro.showModal.SuccessCallbackResult {
|
||||
content?: string
|
||||
}
|
||||
|
||||
const showEditableModal = (option: EditableModalOption) =>
|
||||
Taro.showModal(option as Taro.showModal.Option) as Promise<EditableModalResult>
|
||||
|
||||
export default function SceneSelect() {
|
||||
const dispatch = useAppDispatch()
|
||||
const current = useAppSelector((s) => s.scene.current)
|
||||
|
|
@ -38,15 +53,31 @@ export default function SceneSelect() {
|
|||
navigateBack()
|
||||
}
|
||||
|
||||
// 添加房间:可编辑弹窗输入名称,成功后刷新列表
|
||||
const addRoom = () => {
|
||||
showEditableModal({
|
||||
title: '添加房间',
|
||||
editable: true,
|
||||
placeholderText: '请输入房间名称(如:阳台)'
|
||||
}).then(async ({ confirm, content }) => {
|
||||
if (!confirm) return
|
||||
try {
|
||||
await addScene(content ?? '')
|
||||
showToast('添加成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '添加失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<Navbar title="选择场所" variant="hero" />
|
||||
<Navbar title="房间管理" variant="hero" />
|
||||
{loading ? (
|
||||
<View className={styles.stateWrap}><Loading /></View>
|
||||
) : loadError ? (
|
||||
<StateView status="error" onRetry={load} />
|
||||
) : list.length === 0 ? (
|
||||
<View className={styles.stateWrap}><Empty text="暂无场景" /></View>
|
||||
) : (
|
||||
<View className={styles.list}>
|
||||
{list.map((scene) => (
|
||||
|
|
@ -59,6 +90,14 @@ export default function SceneSelect() {
|
|||
<Text className={styles.check}>{scene.id === current?.id ? '✓' : '›'}</Text>
|
||||
</View>
|
||||
))}
|
||||
{/* 空列表时也保留添加入口 */}
|
||||
{list.length === 0 && (
|
||||
<View className={styles.stateWrap}><Empty text="暂无房间,点击下方添加" /></View>
|
||||
)}
|
||||
<View className={styles.addBtn} hoverClass={styles.addBtnPressed} onClick={addRoom}>
|
||||
<Text className={styles.addIcon}>+</Text>
|
||||
<Text className={styles.addText}>添加房间</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
|||
Loading…
Reference in New Issue