feat(scene): 场所选择页支持添加房间,设备添加页扫码区重设计

- api/scene 新增 addScene(mock 走 storage 持久化,重名/空名校验),
  getSceneList mock 数据同样改为本地存储种子初始化
- 场所选择页改为"房间管理":底部虚线添加卡 + showModal editable 输入
  房间名,空列表时保留添加入口
- 设备添加页扫码区重绘:四角 L 形取景框 + 中间横线(纯 CSS),
  scanCode 限定 qrCode/barCode 并加真机排障日志
- 家庭圈卡片头部改双行(角色徽标贴名称、成员/设备数合并为副标题)
- mine 页补场所管理/手机号/版本行,登录方式替代 userId 展示,
  退出登录增加 toast
This commit is contained in:
ozh 2026-09-11 14:43:10 +08:00
parent 368fb3b12c
commit 3f52b493e0
9 changed files with 233 additions and 84 deletions

View File

@ -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>)
}

View File

@ -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')

View File

@ -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;
}

View File

@ -23,8 +23,13 @@ export function FamilyCard({ family, onClick }: Props) {
<Text className={styles.iconText}>{first}</Text>
</View>
<View className={styles.headMain}>
<Text className={styles.name}>{family.name}</Text>
<Text className={styles.memberBadge}>{family.memberCount} 人</Text>
<View className={styles.nameRow}>
<Text className={styles.name}>{family.name}</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>
)
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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>