Compare commits

..

No commits in common. "1dedbcfa736bae09cf8d35cea1df26dd66773847" and "054f05af7074411842c9ab5e5f287e311353706c" have entirely different histories.

183 changed files with 3693 additions and 30620 deletions

View File

@ -1,45 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 2022, sourceType: 'module', ecmaFeatures: { jsx: true } },
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
settings: { react: { version: '18.3' } },
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
// 单测 import 未类型化的纯 JS BLE 资产时需要 @ts-ignore
'@typescript-eslint/ban-ts-comment': 'off'
},
ignorePatterns: [
'dist/',
'node_modules/',
'src/ble/core/',
'src/ble/parsers/',
'src/ble/protocols/',
'src/ble/discovery.js',
'src/ble/modules/',
// 旧 uni-app 源码(P5 整体删除,不参与新工程 lint)
'pages/',
'components/',
'api/',
'store/',
'hooks/',
'utils/',
'constants/',
'styles/',
'scripts/',
// 根目录旧 uni-app 入口
'main.js',
'uni.promisify.adaptor.js',
// CommonJS 配置文件
'babel.config.js',
'.eslintrc.cjs'
]
}

23
.gitignore vendored
View File

@ -1,28 +1,25 @@
node_modules/ node_modules/
dist/ unpackage/
.DS_Store
.idea/
.vscode/
.swc/
*.log
.hbuilderx/ .hbuilderx/
.DS_Store
.codex-tasks/ .codex-tasks/
.codegraph/
.claude/
CLAUDE.md
AGENTS.md
.idea/
.vscode/
.prettierignore .prettierignore
.prettierrc .prettierrc
# Graphify (generated) # Graphify (generated)
graphify-out/ graphify-out/
# Claude Code && Codex
.claude/
CLAUDE.md
AGENTS.md
# Codegraph # Codegraph
.codegraph/ .codegraph/
# Project-specific # Project-specific
docs/superpowers/ docs/superpowers/
.superpowers/

27
App.vue Normal file
View File

@ -0,0 +1,27 @@
<script>
import { ensureCurrentPageAuth } from './utils/auth'
/**
* 应用根组件:仅承载全局生命周期与登录态兜底校验。
*/
export default {
onLaunch() {
console.log('App Launch')
},
onShow() {
console.log('App Show')
// 每次回到前台时检查当前页权限,避免未登录状态残留在受保护页面。
ensureCurrentPageAuth()
},
onHide() {
console.log('App Hide')
}
}
</script>
<style lang="scss">
@import 'uview-plus/index.scss';
/* 每个页面公共 css(当前按需在页面中引入 common.css) */
</style>

268
api/http/client.js Normal file
View File

@ -0,0 +1,268 @@
import { DEFAULT_BASE_URL, DEFAULT_TIMEOUT, SUCCESS_CODE } from '@/constants/request'
import { clearAuth, getToken, isAuthEnabled, redirectToLogin } from '@/utils/auth'
import { showToast } from '@/utils/toast'
let baseURL = DEFAULT_BASE_URL
/**
* 拼接请求地址:支持绝对地址 / 相对地址 / 仅 baseURL。
*/
function buildUrl(url = '') {
if (!url) {
return baseURL
}
if (/^https?:\/\//.test(url)) {
return url
}
if (url.startsWith('/')) {
return `${baseURL}${url}`
}
return `${baseURL}/${url}`
}
/**
* 构造请求头。
* @param {Record<string, string>} customHeader 自定义请求头
* @param {boolean} needAuth 是否需要携带 token
* @param {boolean} withJson 是否设置 JSON Content-Type
* @returns {Record<string, string>}
*/
function buildHeaders(customHeader = {}, needAuth = true, withJson = true) {
const headers = {
...(withJson ? { 'Content-Type': 'application/json' } : {}),
...customHeader
}
if (needAuth && isAuthEnabled()) {
const token = getToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
return headers
}
/**
* 401 统一处理:清理本地登录态并回到登录页。
*/
function handleUnauthorized() {
if (!isAuthEnabled()) {
return
}
clearAuth()
redirectToLogin()
}
/**
* 统一解析后端响应结构。
* 约定:`code === 0` 为成功,最终返回 `data` 字段。
*/
function resolveResponseData(responseData) {
const code = typeof responseData?.code === 'number' ? responseData.code : SUCCESS_CODE
if (code !== SUCCESS_CODE) {
const error = new Error(responseData?.message || '业务处理失败')
error.code = code
throw error
}
return responseData?.data !== undefined ? responseData.data : responseData
}
/**
* 通用请求方法。
* @param {object} options uni.request 参数扩展
* @returns {Promise<any>}
*/
function request(options = {}) {
const {
url,
method = 'GET',
data,
header,
timeout = DEFAULT_TIMEOUT,
needAuth = true,
showErrorToast = true,
showToast: showToastLegacy
} = options
const shouldShowErrorToast = typeof showToastLegacy === 'boolean'
? showToastLegacy
: showErrorToast
return new Promise((resolve, reject) => {
uni.request({
url: buildUrl(url),
method: String(method).toUpperCase(),
data,
timeout,
header: buildHeaders(header, needAuth, true),
success: (res) => {
const { statusCode = 0, data: responseData = {} } = res
if (statusCode === 401) {
handleUnauthorized()
reject(new Error('登录状态已失效'))
return
}
if (statusCode < 200 || statusCode >= 300) {
const message = responseData?.message || `网络错误(${statusCode})`
if (shouldShowErrorToast) {
showToast(message)
}
reject(new Error(message))
return
}
try {
const finalData = resolveResponseData(responseData)
resolve(finalData)
} catch (error) {
if (error.code === 401) {
handleUnauthorized()
}
if (shouldShowErrorToast) {
showToast(error.message)
}
reject(error)
}
},
fail: (error) => {
if (shouldShowErrorToast) {
showToast('网络异常,请稍后重试')
}
reject(error)
}
})
})
}
// 语义化快捷方法,保持页面侧调用简洁。
request.get = (url, params = {}, config = {}) => {
return request({
url,
method: 'GET',
data: params,
...config
})
}
request.post = (url, data = {}, config = {}) => {
return request({
url,
method: 'POST',
data,
...config
})
}
request.put = (url, data = {}, config = {}) => {
return request({
url,
method: 'PUT',
data,
...config
})
}
request.delete = (url, data = {}, config = {}) => {
return request({
url,
method: 'DELETE',
data,
...config
})
}
/**
* 上传文件封装:保持与 request 一致的错误处理与鉴权策略。
*/
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
const {
header = {},
needAuth = true,
showErrorToast = true,
showToast: showToastLegacy
} = config
const shouldShowErrorToast = typeof showToastLegacy === 'boolean'
? showToastLegacy
: showErrorToast
return new Promise((resolve, reject) => {
uni.uploadFile({
url: buildUrl(url),
filePath,
name,
formData,
header: buildHeaders(header, needAuth, false),
success: (res) => {
if (res.statusCode === 401) {
handleUnauthorized()
reject(new Error('登录状态已失效'))
return
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const message = `上传失败(${res.statusCode})`
if (shouldShowErrorToast) {
showToast(message)
}
reject(new Error(message))
return
}
let responseData = {}
try {
responseData = JSON.parse(res.data || '{}')
} catch (error) {
if (shouldShowErrorToast) {
showToast('上传返回数据格式错误')
}
reject(error)
return
}
try {
const finalData = resolveResponseData(responseData)
resolve(finalData)
} catch (error) {
if (error.code === 401) {
handleUnauthorized()
}
if (shouldShowErrorToast) {
showToast(error.message)
}
reject(error)
}
},
fail: (error) => {
if (shouldShowErrorToast) {
showToast('上传失败,请稍后重试')
}
reject(error)
}
})
})
}
/**
* 动态设置 API 基础地址。
*/
request.setBaseUrl = (url) => {
if (!url || typeof url !== 'string') {
return
}
baseURL = url.replace(/\/$/, '')
}
request.getBaseUrl = () => baseURL
export default request

23
api/scene.js Normal file
View File

@ -0,0 +1,23 @@
import request from '@/api/http/client'
// 后端就绪后置为 false 即可恢复真实请求。
const USE_MOCK = true
// 模拟场景列表数据(后端 /scene/list 就绪后删除)。
const MOCK_SCENES = [
{ id: 1, name: '家里' },
{ id: 2, name: '养老院' },
{ id: 3, name: '社区中心' }
]
/**
* 获取场景列表。
* 约定:data 为数组,每项至少含 { id, name },多余字段不影响前端。
*/
export function getSceneList() {
if (USE_MOCK) {
// 模拟网络延迟,让 loading 态可见。
return new Promise((resolve) => setTimeout(() => resolve(MOCK_SCENES), 500))
}
return request.get('/scene/list')
}

30
api/user.js Normal file
View File

@ -0,0 +1,30 @@
import request from '@/api/http/client'
/**
* 发送短信验证码。
* @param {string} mobile 手机号
*/
export function sendSmsCode(mobile) {
return request.post('/auth/sms-code', { mobile }, { needAuth: false })
}
/**
* 手机号 + 验证码登录。
*/
export function loginByMobile(mobile, code) {
return request.post('/auth/mobile-login', { mobile, code }, { needAuth: false })
}
/**
* 微信登录。
*/
export function loginByWechat(loginCode) {
return request.post('/auth/wechat-login', { code: loginCode }, { needAuth: false })
}
/**
* 获取当前用户信息。
*/
export function getUserProfile() {
return request.get('/user/profile')
}

View File

@ -1,8 +0,0 @@
module.exports = {
presets: [
// 注意:不要开启 useBuiltIns:'usage'——会给 node_modules 里 Taro 自身代码注入 core-js 导入,
// 形成 taro 入口 → @tarojs/shared → core-js → (document alias) → taro 入口 的循环求值,
// 运行时崩溃 "Cannot read property 'F' of undefined"(微信环境 document getter 读到半初始化状态)
['taro', { framework: 'react', ts: true }]
]
}

View File

@ -1 +0,0 @@
export default {}

View File

@ -1,55 +0,0 @@
import { defineConfig } from '@tarojs/cli'
import path from 'node:path'
import dev from './dev'
import prod from './prod'
export default defineConfig(async (merge) => {
const baseConfig = {
projectName: 'sec-warehouse',
date: '2026-9-8',
designWidth: 750,
deviceRatio: { 640: 2, 750: 1, 828: 1.5, 375: 2, 414: 1.7 },
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-framework-react'],
framework: 'react',
compiler: 'webpack5',
cache: { enable: false },
alias: {
'@': path.resolve(__dirname, '..', 'src')
},
sass: { resource: ['src/styles/variables.scss'] },
// Taro 会用白名单挑字段传给小程序编译器(Config.getConfigWithNamed),
// postcss 必须放在 mini 分端配置下才生效;默认 cssModules.enable=false 会让
// *.module.scss 默认导入为 undefined,页面渲染 styles.page 抛错白屏
mini: {
postcss: {
cssModules: {
enable: true,
config: {
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
// css-loader v7 默认 exportLocalsConvention='as-is',kebab-case 类名
// (如 device-row)无法以 styles.deviceRow 访问,必须开启 camelCase 转换
cssLoaderOption: {
modules: {
exportLocalsConvention: 'camelCaseOnly'
}
},
// 全部组件样式走 CSS Modules(类名带 hash,互不冲突),
// 公共 chunk 内文件顺序无层叠影响,忽略跨页面 import 顺序不一致的告警
miniCssExtractPluginOption: {
ignoreOrder: true
}
}
}
// merge 由 defineConfig 回调注入,无需额外引入 webpack-merge
if (process.env.NODE_ENV === 'development') {
return merge({}, baseConfig, dev)
}
return merge({}, baseConfig, prod)
})

View File

@ -1,8 +0,0 @@
export default {
mini: {
// 生产关 sourcemap:主包体积硬指标 1.5M,.map 文件约 390K 必须剔除
webpackChain(chain: { devtool: (v: false) => void }) {
chain.devtool(false)
}
}
}

13
constants/auth.js Normal file
View File

@ -0,0 +1,13 @@
/**
* 本地存储 key。
*/
export const AUTH_STORAGE_KEYS = Object.freeze({
TOKEN: 'smarthome_token',
USER: 'smarthome_user'
})
/**
* 登录开关:当前为 false,便于功能联调。
* 恢复鉴权时改为 true 即可。
*/
export const AUTH_ENABLED = false

6
constants/request.js Normal file
View File

@ -0,0 +1,6 @@
/**
* 接口请求默认配置。
*/
export const SUCCESS_CODE = 0
export const DEFAULT_TIMEOUT = 15000
export const DEFAULT_BASE_URL = 'https://api.example.com'

17
constants/routes.js Normal file
View File

@ -0,0 +1,17 @@
/**
* 统一维护页面路由,避免页面内散落硬编码字符串。
*/
export const ROUTES = Object.freeze({
LOGIN: '/pages/login/index',
HOME: '/pages/index/index',
EVENT: '/pages/event/index',
MINE: '/pages/mine/index',
DEVICE_ADD: '/pages/device/add',
DEVICE_CONFIG: '/pages/device/config',
SCENE_SELECT: '/pages/scene/select'
})
/**
* TabBar 页面集合,供登录跳转与路由守卫判断使用。
*/
export const TAB_ROUTES = Object.freeze([ROUTES.HOME, ROUTES.EVENT, ROUTES.MINE])

View File

@ -1,9 +1,5 @@
# 统一 BLE 连接与控制流程(ED713 / ED719) # 统一 BLE 连接与控制流程(ED713 / ED719)
## 平台注入层(2026-09 迁移新增)
BLE 运行时调用统一收敛:`createBleCore({ platform })` 接受平台对象(默认 Taro,见 `src/ble/adapter/index.ts`),单元测试可注入 mock;`createBluetoothDiscovery`(`src/ble/discovery.js`)原样保留全部能力(扫描过滤/去重/重启/Android 定位权限/连接/鉴权/WiFi/雷达),仅 3 处 uni.* 换为 Taro.*。React 页面通过 `useBle()`(`src/ble/hooks/useBle.ts`)消费——工厂回调转 state,方法原样透出。
## 1. 标准连接流程图(文字版) ## 1. 标准连接流程图(文字版)
1. 初始化阶段 1. 初始化阶段
- `openBluetoothAdapter` - `openBluetoothAdapter`
@ -35,7 +31,7 @@ BLE 运行时调用统一收敛:`createBleCore({ platform })` 接受平台对
## 2. 目录结构 ## 2. 目录结构
```text ```text
src/ble/ utils/ble/
core/ core/
bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连) bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连)
controller.js # 标准流程编排(scan->connect->discover->auth) controller.js # 标准流程编排(scan->connect->discover->auth)
@ -88,7 +84,7 @@ src/ble/
import { import {
createUnifiedBleController, createUnifiedBleController,
DEVICE_PROFILE DEVICE_PROFILE
} from '@/ble' } from '@/utils/ble'
const bleController = createUnifiedBleController({ const bleController = createUnifiedBleController({
profileId: DEVICE_PROFILE.UNKNOWN, profileId: DEVICE_PROFILE.UNKNOWN,
@ -132,40 +128,6 @@ async function runStandardFlow({ key, ssid, password }) {
## 6. 修改记录 ## 6. 修改记录
### 2026-09-11 回滚到迁移完成基线(真机仍搜不到设备)
- 2026-09-09/09-10 的全部搜索策略改动(`ac4cb3b` 节流、`01fefe3` 全频段扫描、`2238e96` 恢复过滤+排障日志)与 `58d0ee8`(ble-config 页 useDidHide/超时兜底/safeDisconnect)逐一尝试后真机仍无法搜出设备,`git checkout 8494285 --` 整体回滚到"Taro 迁移收尾提交"的基线状态:
- `src/ble/discovery.js`:恢复过滤扫描优先 + 4s 兜底全扫的原始实现,移除 300ms UI 刷新节流与 `[BLE][SCAN]`/`[BLE][FOUND]` 排障日志,`stopSearch` 恢复"先停原生扫描再翻转状态"的原顺序
- `src/constants/bluetooth.js`:恢复原注释(内容未变)
- `src/pages/device/ble-config.tsx`:恢复 `1f3c9c8` 初版——仅 useEffect 卸载清理(无 useDidHide)、`prepareWifiConfig` 无超时兜底、连接失败不补 disconnect
- 回滚后基线 = 迁移完成时用户尚未报告任何搜索问题的状态,用于隔离"迁移本身引入"与"后续修复引入"两类原因
### 2026-09-10 恢复过滤扫描优先 + 搜索排障日志
#### src/ble/discovery.js
- 回退 2026-09-09 的"放弃过滤扫描改直接全扫"方案:真机验证该机型下无过滤扫描反而搜不出设备(修改前过滤扫描正常),存在机型兼容差异;恢复 `DISCOVERY_FILTER_SERVICE_UUIDS` 过滤扫描优先 + `fallbackToUnfilteredDiscoveryIfNeeded` 4s 兜底切全扫的原始策略,兜底切换日志(`[BLE][SCAN] filtered scan found nothing, fallback to unfiltered`)可在 vConsole 确认切换是否发生
- `upsertDevice` 返回布尔(是否新命中),事件通道(`device:found`)与轮询通道(`getBluetoothDevices`)均输出排障日志:`[BLE][SCAN] native devices=N newHits=M`(原生采集总量 vs JS 匹配命中量)、`[BLE][FOUND] event devices=N newHits=M`。真机排障按三级定位:
- `native devices=0` → 原生层没采到任何设备(权限/适配器/机型问题)
- `native devices>0, newHits=0` → 采到了但 JS 匹配规则全没命中(设备广播内容与关键词不符)
- 无任何 `[BLE][SCAN]` 日志 → 扫描流程未走到(初始化/会话状态问题)
- 启动扫描处输出 `[BLE][SCAN] filtered discovery started` / 过滤启动失败转全扫日志,均带 sessionId
#### src/constants/bluetooth.js
- 恢复 `DISCOVERY_FILTER_SERVICE_UUIDS` 导出(三个 128 位完整 UUID),供过滤扫描使用
### 2026-09-09 Taro 迁移后交互卡顿修复
#### src/ble/discovery.js
- `stopSearch`: `setSearching(false)` 提前到 `stopBluetoothDevicesDiscovery` 之前同步执行——部分机型原生停扫回调慢,等回调才翻转状态会让按钮长时间停在"搜索中"并串行阻塞连接等后续操作;`searchSessionId` 已递增、`acceptingDeviceEvents` 已关闭,先翻转状态安全
- `connectDevice`: 由无条件 `stopSearch(false)` 改为仅在 `isSearching` 时停搜索,避免每次连接都白调一次原生停扫接口
- 设备列表 UI 刷新加 300ms 节流(`emitDeviceListThrottled`):`allowDuplicatesKey=true` 下目标设备广播包每秒到达数次且 RSSI 逐包变化,签名判重只拦"完全相同"的包;不节流时每包都触发 `onDeviceListChange` → React setState → 全列表重渲染,搜到设备后渲染线程被打满、点击排队卡死(表现为"搜到设备就无法停搜索")。数据仍实时合入 `deviceMap`,仅通知 UI 频率受限;`resetDeviceMap`/`stopSearch` 绕过节流立即冲刷,`cleanupDiscovery` 补清节流定时器
- 放弃 services 过滤扫描,`startSearch` 直接无过滤全频段扫描:微信 `startBluetoothDevicesDiscovery` 的 services 参数只按 128 位完整 UUID 匹配,目标设备广播的是 16 位短 UUID(0x00F4 等),部分机型/基础库版本下过滤扫描收不到这些设备的广播(表现为一直搜不出,或等 4s 兜底切全扫后才出现)。事件通道(`onBluetoothDeviceFound`)+ 轮询通道(`getBluetoothDevices`)双路取数,过滤交给 JS 层 `matchTargetDevice`(服务 UUID/名称关键词/广播包关键词三路匹配)。删除 `fallbackToUnfilteredDiscoveryIfNeeded`/`FILTER_SCAN_TIMEOUT_MS`/`DISCOVERY_FILTER_SERVICE_UUIDS`
#### src/pages/device/ble-config.tsx
- 补 `useDidHide` 停搜索:迁移版原只有 useEffect 卸载清理,`navigateTo` 进配网页/切后台时组件不卸载,扫描持续空转(对齐旧版 onHide/onUnload 双触发)
- `prepareWifiConfig` 包 60s `Promise.race` 超时兜底:`createBLEConnection` 部分机型失败后不回调、`discoverServices`/`notify` 无原生超时,不设兜底会导致 loading 永不消失、按钮永久禁用
- 连接失败分支补 `disconnectDevice()` 清 OS 层残留连接(对齐旧版 safeDisconnect),避免"已连接(10010)"卡死后续重试;成功分支补回"设备已连接,请填写WiFi信息"提示
### 2026-04-30 对照协议文档第二轮修复 ### 2026-04-30 对照协议文档第二轮修复
#### commonParser.js #### commonParser.js

View File

@ -7,9 +7,8 @@ import {
TARGET_SERVICE_UUID_COMPAT_FULL, TARGET_SERVICE_UUID_COMPAT_FULL,
TARGET_SHORT_UUID_CANDIDATES TARGET_SHORT_UUID_CANDIDATES
} from '@/constants/bluetooth' } from '@/constants/bluetooth'
import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/ble' import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/utils/ble'
import {showToast} from '@/utils/toast' import {showToast} from '@/utils/toast'
import Taro from '@tarojs/taro'
const FILTER_SCAN_TIMEOUT_MS = 4000 const FILTER_SCAN_TIMEOUT_MS = 4000
const RESTART_SCAN_DELAY_MS = 120 const RESTART_SCAN_DELAY_MS = 120
@ -17,9 +16,9 @@ const DISCOVERY_POLL_INTERVAL_MS = 1200
const SCAN_DURATION_MS = 30000 const SCAN_DURATION_MS = 30000
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832']) const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
function promisifyTaroApi(apiName, params = {}) { function promisifyUniApi(apiName, params = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Taro[apiName]({ uni[apiName]({
...params, ...params,
success: (res) => resolve(res), success: (res) => resolve(res),
fail: (err) => reject(err) fail: (err) => reject(err)
@ -295,7 +294,7 @@ export function createBluetoothDiscovery(options = {}) {
let systemInfo = {} let systemInfo = {}
try { try {
systemInfo = Taro.getSystemInfoSync() || {} systemInfo = uni.getSystemInfoSync() || {}
} catch (error) { } catch (error) {
return true return true
} }
@ -307,7 +306,7 @@ export function createBluetoothDiscovery(options = {}) {
try { try {
const settings = await new Promise((resolve, reject) => { const settings = await new Promise((resolve, reject) => {
Taro.getSetting({ uni.getSetting({
success: resolve, success: resolve,
fail: reject fail: reject
}) })
@ -319,7 +318,7 @@ export function createBluetoothDiscovery(options = {}) {
} }
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
Taro.authorize({ uni.authorize({
scope: 'scope.userLocation', scope: 'scope.userLocation',
success: resolve, success: resolve,
fail: reject fail: reject
@ -545,7 +544,7 @@ export function createBluetoothDiscovery(options = {}) {
} }
try { try {
const result = await promisifyTaroApi('getBluetoothDevices') const result = await promisifyUniApi('getBluetoothDevices')
const list = Array.isArray(result?.devices) ? result.devices : [] const list = Array.isArray(result?.devices) ? result.devices : []
list.forEach((device) => { list.forEach((device) => {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) { if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {

49
hooks/useCountdown.js Normal file
View File

@ -0,0 +1,49 @@
/**
* 创建一个简单倒计时控制器,适配 Vue2 Options API。
* @param {object} options
* @param {number} options.duration 倒计时总秒数
* @param {(value:number)=>void} options.onChange 每次变化回调
*/
export function createCountdown(options = {}) {
const {
duration = 60,
onChange = () => {}
} = options
let timer = null
let current = 0
function emit() {
onChange(current)
}
function clear() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function start() {
clear()
current = duration
emit()
timer = setInterval(() => {
current -= 1
if (current <= 0) {
current = 0
emit()
clear()
return
}
emit()
}, 1000)
}
return {
start,
clear
}
}

20
index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>

32
main.js Normal file
View File

@ -0,0 +1,32 @@
import App from './App'
import { setupAuthInterceptors } from './utils/auth'
// 应用启动时注册统一路由鉴权拦截器(内部有幂等保护)。
setupAuthInterceptors()
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
import uviewPlus from 'uview-plus'
/**
* uni-app Vue3 入口。
*/
export function createApp() {
const app = createSSRApp(App)
app.use(uviewPlus)
return {
app
}
}
// #endif

73
manifest.json Normal file
View File

@ -0,0 +1,73 @@
{
"name" : "SEC_Warehouse",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App特有相关 */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* 模块配置 */
"modules" : {},
/* 应用发布信息 */
"distribute" : {
/* android打包配置 */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios打包配置 */
"ios" : {},
/* SDK配置 */
"sdkConfigs" : {}
}
},
/* 快应用特有相关 */
"quickapp" : {},
/* 小程序特有相关 */
"mp-weixin" : {
"appid" : "wx7f272a672efe6c04",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true,
"libVersion" : "latest"
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}

20655
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,6 @@
{ {
"name": "sec-warehouse",
"version": "0.1.0",
"private": true,
"description": "智慧养老小程序(Taro + React)",
"scripts": {
"dev:weapp": "taro build --type weapp --watch",
"build:weapp": "taro build --type weapp",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint --ext .ts,.tsx src/",
"format": "prettier --write \"src/**/*.{ts,tsx,scss}\""
},
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.2.0",
"@tarojs/components": "^4.0.0",
"@tarojs/helper": "^4.0.0",
"@tarojs/plugin-framework-react": "^4.0.0",
"@tarojs/plugin-platform-weapp": "^4.0.0",
"@tarojs/react": "^4.0.0",
"@tarojs/runtime": "^4.0.0",
"@tarojs/shared": "^4.0.0",
"@tarojs/taro": "^4.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-redux": "^9.1.0"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-react": "^7.29.7",
"@tarojs/cli": "^4.0.0",
"@tarojs/webpack5-runner": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/web": "^0.0.100",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"babel-preset-taro": "^4.0.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.2.0",
"sass": "^1.99.0", "sass": "^1.99.0",
"typescript": "^5.4.0", "uview-plus": "^3.7.36"
"vite": "^4.5.0",
"vitest": "^1.6.0",
"webpack": "^5.91.0"
} }
} }

86
pages.json Normal file
View File

@ -0,0 +1,86 @@
{
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/event/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/device/add",
"style": {
"navigationBarTitleText": "添加设备"
}
},
{
"path": "pages/device/config",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/scene/select",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "智慧养老",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F1F1F4"
},
"tabBar": {
"color": "#666666",
"selectedColor": "#2CCB98",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/tabbar/home.png",
"selectedIconPath": "static/tabbar/home-active.png"
},
{
"pagePath": "pages/event/index",
"text": "事件",
"iconPath": "static/tabbar/event.png",
"selectedIconPath": "static/tabbar/event-active.png"
},
{
"pagePath": "pages/mine/index",
"text": "我的",
"iconPath": "static/tabbar/mine.png",
"selectedIconPath": "static/tabbar/mine-active.png"
}
]
},
"uniIdRouter": {},
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
}
}

39
pages/device/add.vue Normal file
View File

@ -0,0 +1,39 @@
<!-- 设备添加页:用于承接后续蓝牙搜索/连接配网流程 -->
<template>
<view class="page page-base">
<view class="card card-base">
<text class="title">添加设备(静态)</text>
<text class="desc">这里用于后续接入设备连接流程,例如蓝牙/Wi-Fi 配网。</text>
</view>
</view>
</template>
<script>
// 当前仅提供页面壳,实际流程由蓝牙控制模块接入。
export default {}
</script>
<style>
@import '@/styles/common.css';
.page {
padding: 24rpx;
}
.card {
padding: 28rpx;
}
.title {
display: block;
font-size: 36rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.desc {
color: #666666;
font-size: 30rpx;
line-height: 1.8;
}
</style>

941
pages/device/config.vue Normal file
View File

@ -0,0 +1,941 @@
<template>
<view class="page">
<view class="top-nav top-nav-base">
<text class="back" @click="goBack">‹</text>
<text class="title">设备配置</text>
</view>
<view class="search-wrap">
<view class="search-btn" :class="{ 'is-searching': isSearching }" @click="toggleSearch">
<text>{{ searchBtnText }}</text>
</view>
</view>
<view class="tip-row">
*手机需开启蓝牙功能,Android手机还需开启定位功能
</view>
<view class="list-header">
<text class="list-title">搜索到的设备列表</text>
</view>
<view class="list-body">
<view v-if="!deviceList.length" class="empty-row">暂无搜索结果</view>
<view v-for="device in deviceList" :key="device.deviceId" class="device-card">
<view class="device-icon"></view>
<view class="device-main">
<view class="device-name-row">
<text class="device-name">{{ device.showName }}</text>
<text v-if="device.hasTargetService" class="device-tag">00F4</text>
<text v-else-if="device.matchedByName || device.matchedByPayload" class="device-tag name-tag">名称匹配
</text>
</view>
<text class="device-id">{{ device.deviceId }}</text>
<view class="device-actions">
<view
v-for="action in getDeviceActions(device)"
:key="action.key"
class="action-btn"
:class="{ disabled: isActionBusy(action, device) }"
@click.stop="invokeAction(action, device)"
>
{{ getActionLabel(action, device) }}
</view>
</view>
</view>
</view>
</view>
<view v-if="wifiPanelVisible" class="wifi-mask" @click="closeWifiPanel">
<view class="wifi-dialog" @click.stop>
<view class="wifi-dialog-header">
<text class="wifi-dialog-title">设备网络设置</text>
<text class="wifi-dialog-close" @click="closeWifiPanel">×</text>
</view>
<view class="wifi-device-row">
<text class="wifi-device-label">设备</text>
<text class="wifi-device-value">{{ selectedDeviceName }}</text>
</view>
<view class="wifi-row wifi-row-ssid">
<text class="wifi-label">
<text class="wifi-required">*</text>
WiFi名称
</text>
<input
v-model.trim="wifiForm.ssid"
class="wifi-input wifi-input-ssid"
placeholder="请输入 WiFi 名称"
:maxlength="32"
confirm-type="done"
/>
<view class="wifi-current-btn" :class="{ disabled: fetchingCurrentWifi }" @click.stop="fetchCurrentWifiSsid">
{{ fetchingCurrentWifi ? '获取中...' : '一键获取当前连接WiFi' }}
</view>
</view>
<view class="wifi-row">
<text class="wifi-label">
<text class="wifi-required">*</text>
WiFi密码
</text>
<input
v-model.trim="wifiForm.password"
class="wifi-input"
placeholder="请输入密码"
password
:maxlength="63"
confirm-type="done"
/>
</view>
<view class="wifi-import-row" @click="importLastWifiConfig">
一键导入上次 WiFi 配置
</view>
<view v-if="wifiErrorTip" class="wifi-error-tip">
{{ wifiErrorTip }}
</view>
<view class="wifi-submit-btn" :class="{ disabled: wifiSending }" @click="confirmWifiSettings">
{{ wifiSending ? '发送中...' : '发送' }}
</view>
</view>
</view>
</view>
</template>
<script>
import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery'
import { navigateBack } from '@/utils/navigation'
import { showToast } from '@/utils/toast'
const WIFI_HISTORY_STORAGE_KEY = 'SMARTHOME_LAST_WIFI_CONFIG'
const WIFI_STATE_MESSAGES = Object.freeze({
1: 'WiFi 参数校验失败,请检查名称与密码',
2: '设备连接该 WiFi 失败,请确认密码是否正确',
3: 'WiFi 配置成功',
4: '设备正在连接 WiFi,请稍候'
})
// 设备型号 → 启用的按钮 key 列表。
// 后续如需让其他型号裁剪按钮,只需新增映射条目。
const DEVICE_ACTION_KEYS = Object.freeze({
EP832: ['WIFI'],
DEFAULT: ['WIFI', 'PARAMS', 'SERVER']
})
// 按钮注册表:label / 忙态文案 / 点击方法名 / 忙态判定方法名。
// 仅声明 key 即可被引用,未配置的方法名渲染为不可点击的占位项。
const ACTION_REGISTRY = Object.freeze({
WIFI: {
label: '网络配置',
busyLabel: '连接中',
handler: 'openWifiPanel',
isBusy: 'isDevicePreparing'
},
PARAMS: {
label: '参数配置'
},
SERVER: {
label: '服务器配置'
}
})
function getErrorMessage(error) {
if (!error) {
return '操作失败,请重试'
}
if (typeof error === 'string') {
return error
}
return error.message || error.errMsg || '操作失败,请重试'
}
function promisifyUniApi(apiName, params = {}) {
return new Promise((resolve, reject) => {
if (typeof uni[apiName] !== 'function') {
reject(new Error(`当前环境不支持 ${apiName}`))
return
}
uni[apiName]({
...params,
success: (res) => resolve(res),
fail: (err) => reject(err)
})
})
}
export default {
data() {
return {
isSearching: false,
deviceList: [],
deviceMap: {},
discoveryController: null,
// WiFi 弹层状态。
wifiPanelVisible: false,
wifiPreparing: false,
activePreparingDeviceId: '',
wifiSending: false,
fetchingCurrentWifi: false,
wifiPrepared: false,
wifiErrorTip: '',
devicePreparingMap: {},
selectedWifiDevice: null,
wifiForm: {
ssid: '',
password: ''
}
}
},
computed: {
searchBtnText() {
return this.isSearching ? '搜索中' : '搜索'
},
selectedDeviceName() {
if (!this.selectedWifiDevice) {
return '未选择设备'
}
return this.selectedWifiDevice.showName || this.selectedWifiDevice.deviceId || '未命名设备'
}
},
created() {
/**
* 蓝牙搜索控制器:统一处理设备发现、连接、鉴权、WiFi 发送等流程。
*/
this.discoveryController = createBluetoothDiscovery({
onSearchingChange: (value) => {
this.isSearching = value
},
onDeviceMapChange: (value) => {
this.deviceMap = value
},
onDeviceListChange: (value) => {
this.deviceList = value
}
})
},
onHide() {
this.cleanupDiscovery()
},
onUnload() {
this.cleanupDiscovery()
},
methods: {
async safeDisconnect() {
if (!this.discoveryController) {
return
}
try {
await this.discoveryController.disconnectDevice()
} catch (error) {
}
},
cleanupWifiPanelState() {
this.wifiPanelVisible = false
this.wifiPrepared = false
this.wifiPreparing = false
this.activePreparingDeviceId = ''
this.fetchingCurrentWifi = false
this.wifiErrorTip = ''
this.selectedWifiDevice = null
this.resetWifiForm()
},
resetWifiForm() {
// 只清空当前弹层临时输入,不影响本地历史缓存。
this.wifiForm.ssid = ''
this.wifiForm.password = ''
},
setDevicePreparing(deviceId, preparing) {
const safeDeviceId = String(deviceId || '').trim()
if (!safeDeviceId) {
return
}
this.devicePreparingMap = {
...this.devicePreparingMap,
[safeDeviceId]: Boolean(preparing)
}
},
isDevicePreparing(deviceId) {
const safeDeviceId = String(deviceId || '').trim()
if (!safeDeviceId) {
return false
}
return Boolean(this.devicePreparingMap[safeDeviceId])
},
isEp832(device) {
const name = String(device?.showName || device?.name || device?.localName || '').toUpperCase()
return name.includes('EP832')
},
/**
* 根据设备型号返回该设备可见的操作按钮列表。
* 列表中的 action 对象包含 key、label、handler、isBusy 等元数据,
* 模板里直接 v-for 渲染。
*/
getDeviceActions(device) {
const identifier = this.isEp832(device) ? 'EP832' : 'DEFAULT'
const keys = DEVICE_ACTION_KEYS[identifier] || DEVICE_ACTION_KEYS.DEFAULT
return keys.map(key => ({ key, ...ACTION_REGISTRY[key] }))
},
isActionBusy(action, device) {
if (!action.isBusy) {
return false
}
const checker = this[action.isBusy]
return typeof checker === 'function' ? Boolean(checker.call(this, device.deviceId)) : false
},
getActionLabel(action, device) {
if (this.isActionBusy(action, device) && action.busyLabel) {
return action.busyLabel
}
return action.label
},
invokeAction(action, device) {
if (!action.handler) {
return
}
const handler = this[action.handler]
if (typeof handler === 'function') {
handler.call(this, device.deviceId)
}
},
getDeviceById(deviceId) {
const safeDeviceId = String(deviceId || '').trim()
if (!safeDeviceId) {
return null
}
const mapDevice = this.deviceMap[safeDeviceId]
if (mapDevice) {
return mapDevice
}
return this.deviceList.find((item) => item.deviceId === safeDeviceId) || null
},
cleanupDiscovery() {
if (!this.discoveryController) {
return
}
this.devicePreparingMap = {}
this.cleanupWifiPanelState()
this.discoveryController.cleanupDiscovery()
},
goBack() {
this.cleanupDiscovery()
navigateBack(1)
},
async toggleSearch() {
if (!this.discoveryController) {
return
}
if (this.isSearching) {
await this.discoveryController.stopSearch(false)
return
}
try {
await this.discoveryController.startSearch()
} catch (error) {
}
},
async openWifiPanel(deviceId) {
if (this.wifiPreparing || this.wifiSending) {
return
}
if (!this.discoveryController) {
return
}
const targetDeviceId = String(deviceId || '').trim()
if (!targetDeviceId) {
showToast('未选择可配置设备')
return
}
if (this.activePreparingDeviceId && this.activePreparingDeviceId !== targetDeviceId) {
showToast('正在为其他设备准备,请稍候')
return
}
const targetDevice = this.getDeviceById(targetDeviceId)
if (!targetDevice) {
showToast('设备已失效,请重新搜索')
return
}
this.wifiPreparing = true
this.activePreparingDeviceId = targetDeviceId
this.wifiPrepared = false
this.selectedWifiDevice = targetDevice
this.setDevicePreparing(targetDeviceId, true)
uni.showLoading({
title: '连接设备中',
mask: true
})
let prepError = null
try {
// 厂家确认流程:连接 → 发现服务特征 → 订阅 F301,无需鉴权/读MSG。
await this.discoveryController.prepareWifiConfig(targetDeviceId)
this.wifiPrepared = true
this.wifiPanelVisible = true
// 确保 Vue 完成 DOM 更新后再调原生 toast,避免原生层覆盖 Vue 弹层
await this.$nextTick()
} catch (error) {
prepError = error
await this.safeDisconnect()
} finally {
this.setDevicePreparing(targetDeviceId, false)
this.activePreparingDeviceId = ''
this.wifiPreparing = false
// showLoading 与 showToast 共用同一遮罩层,必须先 hideLoading 再弹提示,
// 否则 hideLoading 会把 catch 里的 toast 一起清掉(表现为弹窗瞬间消失)。
uni.hideLoading()
}
if (prepError) {
// 连接/订阅失败用模态弹窗,需用户主动确认,确保错误诊断信息可见可读
uni.showModal({
title: '设备连接失败',
content: getErrorMessage(prepError),
showCancel: false,
confirmText: '知道了'
})
} else if (this.wifiPanelVisible) {
showToast('设备已连接,请填写WiFi信息')
}
},
async closeWifiPanel(force = false) {
if (!force && (this.wifiSending || this.wifiPreparing)) {
return
}
this.cleanupWifiPanelState()
await this.safeDisconnect()
},
async fetchCurrentWifiSsid() {
if (this.fetchingCurrentWifi) {
return
}
this.fetchingCurrentWifi = true
try {
// 启动 WiFi 模块(部分端调用 getConnectedWifi 前必须先 startWifi)。
if (typeof uni.startWifi === 'function') {
try {
await promisifyUniApi('startWifi')
} catch (error) {
// 已启动等非致命错误忽略,继续尝试读取当前连接。
}
}
const result = await promisifyUniApi('getConnectedWifi')
const ssid = String(result?.wifi?.SSID || result?.wifi?.ssid || result?.SSID || '').trim()
if (!ssid || ssid.toLowerCase().includes('unknown')) {
throw new Error('未获取到当前连接 WiFi 名称')
}
this.wifiForm.ssid = ssid
showToast('已填入当前连接WiFi')
} catch (error) {
showToast(getErrorMessage(error))
} finally {
this.fetchingCurrentWifi = false
}
},
importLastWifiConfig() {
try {
const cached = uni.getStorageSync(WIFI_HISTORY_STORAGE_KEY)
if (!cached || typeof cached !== 'object') {
showToast('暂无上次 WiFi 配置')
return
}
const ssid = String(cached.ssid || '').trim()
const password = String(cached.password || '').trim()
if (!ssid || !password) {
showToast('暂无可用的历史 WiFi 配置')
return
}
this.wifiForm.ssid = ssid
this.wifiForm.password = password
showToast('已导入上次 WiFi 配置')
} catch (error) {
showToast('读取历史 WiFi 配置失败')
}
},
cacheWifiConfig(ssid, password) {
try {
uni.setStorageSync(WIFI_HISTORY_STORAGE_KEY, {
ssid,
password,
updatedAt: Date.now()
})
} catch (error) {
}
},
getWifiStateMessage(stateCode) {
return WIFI_STATE_MESSAGES[stateCode] || `WiFi 状态码异常: ${stateCode}`
},
async confirmWifiSettings() {
if (this.wifiSending) {
return
}
if (!this.wifiPrepared) {
showToast('设备未连接,请重新配置')
return
}
const ssid = String(this.wifiForm.ssid || '').trim()
const password = String(this.wifiForm.password || '').trim()
if (!ssid) {
showToast('请输入 WiFi 名称')
return
}
if (!password) {
showToast('请输入 WiFi 密码')
return
}
this.wifiSending = true
this.wifiErrorTip = ''
let needAutoClose = false
let needCacheWifi = false
let tipMessage = ''
try {
/**
* 发送后必须等待设备通过 UUID_STATE 返回最终结果(1/2/3)
* 才进行提示并自动关闭弹层。
*/
const wifiResult = await this.discoveryController.configureWifi(ssid, password, {
timeout: 60000
})
const stateMessage = this.getWifiStateMessage(wifiResult.stateCode)
tipMessage = stateMessage
needAutoClose = true
needCacheWifi = Boolean(wifiResult.success)
} catch (error) {
// 超时/异常场景:错误信息持久展示在弹层内,避免瞬态 toast 被覆盖看不清
tipMessage = getErrorMessage(error)
this.wifiErrorTip = tipMessage
} finally {
this.wifiSending = false
if (tipMessage) {
showToast(tipMessage)
}
if (needCacheWifi) {
this.cacheWifiConfig(ssid, password)
}
if (needAutoClose) {
await this.closeWifiPanel(true)
}
}
}
}
}
</script>
<style>
@import '@/styles/common.css';
.page {
min-height: 100vh;
background: #e9eaed;
}
.top-nav {
border-bottom: 1rpx solid #ececec;
}
.back {
position: absolute;
left: 24rpx;
top: 30rpx;
font-size: 64rpx;
color: #111111;
width: 64rpx;
line-height: 64rpx;
text-align: center;
}
.title {
font-size: 50rpx;
font-weight: 700;
color: #2a2d33;
}
.search-wrap {
height: 500rpx;
background: #ffffff;
display: flex;
justify-content: center;
align-items: center;
}
.search-btn {
width: 260rpx;
height: 260rpx;
border-radius: 130rpx;
background: #43c996;
color: #ffffff;
font-size: 56rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.search-btn:active {
transform: scale(0.97);
}
.search-btn.is-searching {
animation: searchPulse 1.2s ease-in-out infinite;
}
@keyframes searchPulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(67, 201, 150, 0.42);
}
70% {
transform: scale(1.04);
box-shadow: 0 0 0 30rpx rgba(67, 201, 150, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(67, 201, 150, 0);
}
}
.tip-row {
min-height: 78rpx;
padding: 12rpx 16rpx;
background: #efeff1;
color: #ff0000;
font-size: 34rpx;
font-weight: 600;
display: flex;
align-items: center;
}
.list-header {
height: 116rpx;
background: #ffffff;
display: flex;
align-items: center;
padding: 0 38rpx;
}
.list-title {
font-size: 52rpx;
color: #101217;
font-weight: 700;
}
.list-body {
min-height: calc(100vh - 814rpx);
background: #e9eaed;
padding-bottom: 30rpx;
}
.empty-row {
padding: 36rpx;
text-align: center;
color: #8c9097;
font-size: 30rpx;
}
.device-card {
background: #ffffff;
margin-top: 12rpx;
padding: 24rpx;
display: flex;
gap: 20rpx;
}
.device-icon {
width: 92rpx;
height: 92rpx;
border-radius: 46rpx;
border: 2rpx solid #d8d8d8;
box-shadow: inset 0 0 0 10rpx #f1f1f1;
flex-shrink: 0;
}
.device-main {
flex: 1;
min-width: 0;
}
.device-name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.device-name {
flex: 1;
min-width: 0;
font-size: 38rpx;
color: #30343a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-tag {
font-size: 22rpx;
color: #17b474;
border: 1rpx solid #17b474;
border-radius: 20rpx;
padding: 2rpx 10rpx;
}
.name-tag {
color: #2f7de1;
border-color: #2f7de1;
}
.device-id {
display: block;
margin-top: 8rpx;
font-size: 30rpx;
color: #8a8f98;
word-break: break-all;
}
.device-actions {
display: flex;
gap: 10rpx;
margin-top: 16rpx;
}
.action-btn {
padding: 8rpx 16rpx;
border: 2rpx solid #42c995;
color: #22b47a;
font-size: 30rpx;
border-radius: 6rpx;
background: #f8fffc;
}
.action-btn.disabled {
border-color: #9fdcc3;
color: #7db89e;
background: #f5faf7;
}
.wifi-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.48);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 999;
}
.wifi-dialog {
width: 100%;
background: #ffffff;
border-radius: 28rpx 28rpx 0 0;
overflow: hidden;
}
.wifi-dialog-header {
height: 108rpx;
border-bottom: 1rpx solid #efefef;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.wifi-dialog-title {
font-size: 42rpx;
color: #2d2f33;
font-weight: 700;
}
.wifi-dialog-close {
position: absolute;
right: 26rpx;
top: 18rpx;
width: 72rpx;
height: 72rpx;
text-align: center;
line-height: 72rpx;
font-size: 56rpx;
color: #b8bcc3;
}
.wifi-device-row {
min-height: 74rpx;
display: flex;
align-items: center;
gap: 14rpx;
padding: 0 30rpx;
border-bottom: 1rpx solid #f4f4f4;
}
.wifi-device-label {
font-size: 28rpx;
color: #8a8f98;
}
.wifi-device-value {
flex: 1;
min-width: 0;
font-size: 27rpx;
color: #4a4f56;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wifi-row {
min-height: 102rpx;
border-bottom: 1rpx solid #f2f2f2;
display: flex;
align-items: center;
padding: 0 30rpx;
gap: 20rpx;
}
.wifi-row-ssid {
align-items: center;
}
.wifi-label {
width: 170rpx;
font-size: 34rpx;
color: #363b45;
}
.wifi-required {
color: #ff4f4f;
margin-right: 8rpx;
}
.wifi-input {
flex: 1;
min-width: 0;
height: 80rpx;
font-size: 32rpx;
color: #1f2329;
}
.wifi-input-ssid {
max-width: 260rpx;
}
.wifi-current-btn {
flex-shrink: 0;
min-width: 190rpx;
height: 62rpx;
line-height: 62rpx;
border: 1rpx solid #74dcb2;
border-radius: 8rpx;
color: #24b478;
background: #effcf5;
text-align: center;
font-size: 24rpx;
padding: 0 12rpx;
}
.wifi-current-btn.disabled {
border-color: #b8d8ca;
color: #9bbbae;
background: #f3f6f4;
}
.wifi-import-row {
min-height: 100rpx;
border-bottom: 1rpx solid #f2f2f2;
font-size: 34rpx;
color: #3bc58d;
display: flex;
align-items: center;
justify-content: center;
}
.wifi-error-tip {
min-height: 80rpx;
padding: 16rpx 30rpx;
font-size: 26rpx;
color: #d33b1d;
background: #fff3f1;
line-height: 1.5;
word-break: break-all;
}
.wifi-submit-btn {
height: 100rpx;
background: #11c462;
color: #ffffff;
font-size: 36rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.wifi-submit-btn.disabled {
background: #8edcae;
}
</style>

797
pages/event/index.vue Normal file
View File

@ -0,0 +1,797 @@
<!-- 事件页:使用 uview-plus 筛选与空状态组件 -->
<template>
<view class="page page-base">
<up-navbar title="事件" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
<view class="filters">
<view class="filter-cell" @click="openDrawer('scene')">
<view class="filter-trigger" :class="{ 'filter-trigger--active': filters.sceneId !== 0 }">
<text class="filter-text">{{ sceneLabel }}</text>
<up-icon name="arrow-down" size="13" :color="filters.sceneId !== 0 ? '#12a37a' : '#909399'" />
</view>
</view>
<view class="filter-cell" @click="openDrawer('status')">
<view class="filter-trigger" :class="{ 'filter-trigger--active': filters.status !== 0 }">
<text class="filter-text">{{ statusLabel }}</text>
<up-icon name="arrow-down" size="13" :color="filters.status !== 0 ? '#12a37a' : '#909399'" />
</view>
</view>
<view class="filter-cell" @click="openDrawer('time')">
<view class="filter-trigger" :class="{ 'filter-trigger--active': !!filters.startDate }">
<text class="filter-text">{{ filters.timeText || '时间' }}</text>
<up-icon name="arrow-down" size="13" :color="filters.startDate ? '#12a37a' : '#909399'" />
</view>
</view>
</view>
<!-- 数据层:筛选栏以下区域;三个下拉抽屉都锚定在这层顶部(筛选栏下缘)展开 -->
<view class="content-area">
<view class="content-body">
<view class="empty-area">
<up-empty mode="message" text="暂无事件数据" />
</view>
<view class="empty-hint">
<text>调整筛选条件后的事件列表将在此展示</text>
</view>
</view>
<!-- 遮罩:盖住筛选栏以下全部内容 -->
<view v-if="drawer" class="content-mask" @click="closeDrawer" @touchmove.stop.prevent="noop" />
<!-- 场所抽屉:从筛选栏下缘展开 -->
<view v-if="drawer === 'scene'" class="drop-panel drop-panel--options">
<view class="option-drawer-header">
<text class="option-drawer-title">选择场所</text>
<view class="option-drawer-close" @click="closeDrawer">
<up-icon name="close" size="20" color="#909399" />
</view>
</view>
<view class="option-drawer-list">
<view
v-for="opt in sceneOptions"
:key="opt.id"
class="option-drawer-item"
:class="{ 'option-drawer-item--active': filters.sceneId === opt.id }"
@click="selectScene(opt.id)"
>
<text class="option-drawer-item__text">{{ opt.name }}</text>
<up-icon v-if="filters.sceneId === opt.id" name="checkmark" size="16" color="#2CCB98" />
</view>
</view>
</view>
<!-- 状态抽屉:从筛选栏下缘展开 -->
<view v-if="drawer === 'status'" class="drop-panel drop-panel--options">
<view class="option-drawer-header">
<text class="option-drawer-title">选择状态</text>
<view class="option-drawer-close" @click="closeDrawer">
<up-icon name="close" size="20" color="#909399" />
</view>
</view>
<view class="option-drawer-list">
<view
v-for="opt in statusOptions"
:key="opt.id"
class="option-drawer-item"
:class="{ 'option-drawer-item--active': filters.status === opt.id }"
@click="selectStatus(opt.id)"
>
<text class="option-drawer-item__text">{{ opt.name }}</text>
<up-icon v-if="filters.status === opt.id" name="checkmark" size="16" color="#2CCB98" />
</view>
</view>
</view>
<!-- 时间抽屉:从筛选栏下缘展开(滚动式日历:懒渲染,窗口外月份占位) -->
<view v-if="drawer === 'time'" class="drop-panel drop-panel--calendar">
<view class="time-drawer-header">
<text class="time-drawer-title">选择时间范围</text>
<view class="time-drawer-close" @click="closeDrawer">
<up-icon name="close" size="20" color="#909399" />
</view>
</view>
<!-- 快捷范围:点选直接填满起止 -->
<view class="quick-row">
<view
v-for="q in quickRanges"
:key="q.label"
class="quick-chip"
:class="{ 'quick-chip--active': activeQuick === q.label }"
@click="applyQuick(q)"
>
<text>{{ q.label }}</text>
</view>
</view>
<!-- 星期行:钉在滚动区上方 -->
<view class="cal-weekdays">
<text v-for="w in weekdayLabels" :key="w" class="cal-weekday">{{ w }}</text>
</view>
<!-- 滚动日历:scroll-view 纵向滚月,懒渲染(窗口外月份只渲染占位高度) -->
<view class="cal-scroll-wrap">
<scroll-view
class="cal-scroll"
scroll-y
:scroll-into-view="calAnchorId"
scroll-anchoring
@scroll="onCalScroll"
>
<view
v-for="m in renderedMonths"
:key="m.key"
:id="m.key"
class="cal-month"
:style="m.placeholder ? { height: m.height + 'rpx' } : {}"
>
<template v-if="!m.placeholder">
<view class="cal-month-title">
<text>{{ m.title }}</text>
</view>
<view class="cal-grid">
<view
v-for="(cell, i) in m.cells"
:key="i"
class="cal-cell"
:class="cell.cls"
@click="pickDate(cell)"
>
<view v-if="cell.day" class="cal-day" :class="cell.dayCls">
<text class="cal-day-num">{{ cell.day }}</text>
<text v-if="cell.tag" class="cal-day-tag">{{ cell.tag }}</text>
</view>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
<view class="time-drawer-footer">
<up-button
shape="circle"
:text="rangeReady ? '确定' : '请选择起止日期'"
color="#2CCB98"
:disabled="!rangeReady"
@click="onConfirmClick"
/>
</view>
</view>
</view>
</view>
</template>
<script>
import { getSceneList } from '@/api/scene'
// 日历可回溯的年数(仅约束可翻到的最早月份,不影响性能)
const CALENDAR_YEARS_BACK = 10
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']
const pad2 = (n) => String(n).padStart(2, '0')
// Date -> 'YYYY-MM-DD'(本地时区)
const fmtDate = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
// 'YYYY-MM-DD' -> 当日零点 Date
const parseDate = (s) => {
const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d)
}
// 生成快捷范围选项:近 N 天(含今天)
const quickRange = (days) => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - (days - 1))
return { label: `近${days}天`, start: fmtDate(start), end: fmtDate(end) }
}
export default {
data() {
return {
drawer: '', // '' | 'scene' | 'status' | 'time'
windowWidth: 375, // 窗口宽(px),onLoad 时取系统信息,用于 px→rpx 换算
calScroll: 0, // 当前滚动位置(rpx 近似,用于懒渲染窗口判定)
calAnchorId: '', // scroll-into-view 定位的月份锚点
pendingStart: '', // 临时起点
pendingEnd: '', // 临时终点
activeQuick: '',
weekdayLabels: WEEKDAY_LABELS,
quickRanges: [quickRange(7), quickRange(30), quickRange(90)],
filters: {
sceneId: 0, // 0 表示全部
status: 0,
startDate: '',
endDate: '',
timeText: ''
},
sceneOptions: [{ id: 0, name: '全部' }],
statusOptions: [
{ id: 0, name: '全部' },
{ id: 1, name: '未处理' },
{ id: 2, name: '已处理' },
{ id: 3, name: '已忽略' }
]
}
},
computed: {
sceneLabel() {
const hit = this.sceneOptions.find((o) => o.id === this.filters.sceneId)
return hit ? hit.name : '全部场所'
},
statusLabel() {
const hit = this.statusOptions.find((o) => o.id === this.filters.status)
return hit ? hit.name : '全部状态'
},
// 可选范围边界
minLimit() {
const d = new Date()
d.setFullYear(d.getFullYear() - CALENDAR_YEARS_BACK)
d.setDate(1)
return fmtDate(d)
},
maxLimit() {
return fmtDate(new Date())
},
// 全部月份元数据:从最早月到当月,含每月份的估算高度(用于占位与滚动定位)
monthMetas() {
const metas = []
const start = parseDate(this.minLimit)
const now = new Date()
const endY = now.getFullYear()
const endM = now.getMonth()
let y = start.getFullYear()
let m = start.getMonth()
let top = 0
while (y < endY || (y === endY && m <= endM)) {
const weeks = this.monthWeeks(y, m)
// 标题行 + 周数×行高(格子 aspect 决定行高≈格子宽×1.15,按 750rpx/7 估算)
const height = 96 + weeks * Math.round((750 / 7) * 1.15) + 16
metas.push({
key: `m-${y}-${m}`,
year: y,
month: m,
title: `${y}年${pad2(m + 1)}月`,
top,
height
})
top += height
if (m === 11) {
y += 1
m = 0
} else {
m += 1
}
}
return metas
},
// 懒渲染:只渲染滚动窗口(±4 个月)内的月份,窗口外用等高占位
renderedMonths() {
// 滚动位置近似映射到月份索引:找 top 包含 calScroll 的月份
const viewRpx = 1400 // 滚动窗口可视高度近似(rpx)
const buffer = 3000 // 窗口前后缓冲(rpx)
const lo = this.calScroll - buffer
const hi = this.calScroll + viewRpx + buffer
return this.monthMetas.map((meta) => {
const inWindow = meta.top + meta.height > lo && meta.top < hi
if (!inWindow) {
return { ...meta, placeholder: true, cells: [] }
}
return {
...meta,
placeholder: false,
cells: this.buildMonthCells(meta.year, meta.month)
}
})
},
// 起止都已选定
rangeReady() {
return !!this.pendingStart && !!this.pendingEnd
}
},
onLoad() {
// px→rpx 换算基准
const info = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
this.windowWidth = info.windowWidth || 375
this.loadScenes()
},
methods: {
// 单格数据:区间内/端点/禁用态一次性算好,模板零逻辑
buildCell(dateStr, day) {
const disabled = dateStr < this.minLimit || dateStr > this.maxLimit
let dayCls = ''
let tag = ''
if (!disabled) {
if (this.pendingStart && dateStr === this.pendingStart) {
dayCls = 'cal-day--start'
tag = this.pendingStart === this.pendingEnd ? '起/止' : '开始'
} else if (this.pendingEnd && dateStr === this.pendingEnd) {
dayCls = 'cal-day--end'
tag = '结束'
} else if (
this.pendingStart &&
this.pendingEnd &&
dateStr > this.pendingStart &&
dateStr < this.pendingEnd
) {
dayCls = 'cal-day--in'
}
}
return { day, date: dateStr, disabled, cls: { 'cal-cell--disabled': disabled }, dayCls, tag }
},
// 某年某月的格子(含前置空格)
buildMonthCells(y, m) {
const firstDay = new Date(y, m, 1)
const lead = (firstDay.getDay() + 6) % 7
const daysInMonth = new Date(y, m + 1, 0).getDate()
const cells = []
for (let i = 0; i < lead; i++) cells.push({ day: 0 })
for (let d = 1; d <= daysInMonth; d++) {
cells.push(this.buildCell(`${y}-${pad2(m + 1)}-${pad2(d)}`, d))
}
return cells
},
// 某月占满的周数(1 个前置空格也占一行)
monthWeeks(y, m) {
const firstDay = new Date(y, m, 1)
const lead = (firstDay.getDay() + 6) % 7
const daysInMonth = new Date(y, m + 1, 0).getDate()
return Math.ceil((lead + daysInMonth) / 7)
},
// 动态加载场所列表:复用场景接口,后端 /scene/list 就绪后自动走真实请求。
// 失败时保留"全部"选项不阻塞筛选(toast 由请求封装统一弹出)。
async loadScenes() {
try {
const data = await getSceneList()
const list = Array.isArray(data) ? data : []
this.sceneOptions = [{ id: 0, name: '全部' }, ...list]
} catch (error) {
this.sceneOptions = [{ id: 0, name: '全部' }]
}
},
noop() {},
openDrawer(name) {
if (name === 'time') {
// 回显已提交的起止;无提交时默认定位当月
this.pendingStart = this.filters.startDate
this.pendingEnd = this.filters.endDate
this.activeQuick = ''
const focus = this.filters.startDate || fmtDate(new Date())
const d = parseDate(focus)
const meta = this.monthMetas.find(
(mm) => mm.year === d.getFullYear() && mm.month === d.getMonth()
)
this.calScroll = meta ? meta.top : 0
// scroll-into-view 需要值变化才生效:先清空再在下个循环赋值
this.calAnchorId = ''
this.$nextTick(() => {
this.calAnchorId = meta ? meta.key : ''
})
}
this.drawer = name
},
closeDrawer() {
this.drawer = ''
},
// 场所/状态抽屉点选:写入筛选值并收起抽屉。
selectScene(id) {
this.filters.sceneId = id
this.closeDrawer()
this.handle()
},
selectStatus(id) {
this.filters.status = id
this.closeDrawer()
this.handle()
},
// 下拉选项变化:触发筛选回调,便于后续接事件列表接口。
handle() {
// eslint-disable-next-line no-console
console.log('[event filters]', { ...this.filters })
},
// 滚动:把 px 滚动值换算回 rpx(750 / 窗口宽),驱动懒渲染窗口移动
onCalScroll(e) {
const px = e.detail.scrollTop || 0
const rpx = px * (750 / this.windowWidth)
// 跨过一定占位高度差再更新,减少 computed 重算频率
if (Math.abs(rpx - this.calScroll) > 200) {
this.calScroll = rpx
}
},
// 点击日期:第一击定起点,第二击定终点(早于起点则重定起点),选满后再点重选
pickDate(cell) {
if (!cell.day || cell.disabled) return
const date = cell.date
if (!this.pendingStart || (this.pendingStart && this.pendingEnd)) {
this.pendingStart = date
this.pendingEnd = ''
this.activeQuick = ''
} else if (date < this.pendingStart) {
this.pendingStart = date
this.activeQuick = ''
} else {
this.pendingEnd = date
this.activeQuick = ''
}
},
// 快捷范围:直接填满起止并定位到起点月
applyQuick(q) {
this.pendingStart = q.start
this.pendingEnd = q.end
this.activeQuick = q.label
const d = parseDate(q.start)
const meta = this.monthMetas.find(
(mm) => mm.year === d.getFullYear() && mm.month === d.getMonth()
)
this.calAnchorId = ''
this.$nextTick(() => {
this.calAnchorId = meta ? meta.key : ''
})
},
// 确定按钮:提交临时起止并写入筛选值
onConfirmClick() {
if (!this.rangeReady) {
return
}
this.filters.startDate = this.pendingStart
this.filters.endDate = this.pendingEnd
this.filters.timeText = `${this.pendingStart} 至 ${this.pendingEnd}`
this.closeDrawer()
this.handle()
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/common.css';
/* 页面容器:纵向 flex,让数据层撑满筛选栏以下的全部剩余视口 */
.page {
display: flex;
flex-direction: column;
}
.filters {
flex-shrink: 0;
display: flex;
align-items: stretch;
min-height: 96rpx;
padding: 16rpx 24rpx;
background: #ffffff;
border-top: 1rpx solid #ededed;
}
.filter-cell {
flex: 1;
min-width: 0;
+ .filter-cell {
margin-left: 18rpx;
}
}
.filter-trigger {
height: 100%;
min-height: 64rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: #f5f6f8;
border: 1rpx solid transparent;
border-radius: 12rpx;
color: #606266;
font-size: 26rpx;
transition: background-color 0.2s, border-color 0.2s;
&--active {
background: #e9fff3;
border-color: #2ccb98;
color: #12a37a;
}
}
.filter-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-area {
min-height: 720rpx;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 120rpx;
}
.empty-hint {
margin-top: -40rpx;
text-align: center;
font-size: 24rpx;
color: #b5b9c0;
}
/* 数据层:撑满筛选栏以下全部视口,是三个下拉抽屉的定位锚点 */
.content-area {
position: relative;
flex: 1;
min-height: 0;
}
/* 遮罩:盖住筛选栏以下内容,点击关闭 */
.content-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 10;
}
/* 下拉面板:从数据层顶部(筛选栏下缘)展开 */
.drop-panel {
position: absolute;
top: 0;
left: 0;
width: 100%;
background: #ffffff;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.08);
z-index: 11;
display: flex;
flex-direction: column;
border-bottom-left-radius: 24rpx;
border-bottom-right-radius: 24rpx;
overflow: hidden;
}
/* 场所/状态选项面板:内容自适应高度 */
.drop-panel--options {
max-height: 60vh;
}
/* 时间面板:日历占剩余空间,但不超过数据层高度(时间抽屉时遮罩仍完整盖住剩余空间) */
.drop-panel--calendar {
height: 66vh;
max-height: 100%;
}
.option-drawer-header {
padding: 28rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1rpx solid #f0f1f3;
}
.option-drawer-title {
font-size: 32rpx;
font-weight: 600;
color: #2a2d33;
}
.option-drawer-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.option-drawer-list {
padding: 12rpx 24rpx 24rpx;
}
.option-drawer-item {
min-height: 96rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 14rpx;
&:active {
background: #f7f8fa;
}
&--active {
background: #e9fff3;
}
}
.option-drawer-item__text {
font-size: 30rpx;
color: #2a2d33;
}
.option-drawer-item--active .option-drawer-item__text {
color: #2CCB98;
font-weight: 600;
}
/* 时间面板头/底部(面板已锚定在筛选栏下方,不再 fixed) */
.time-drawer-header {
padding: 20rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1rpx solid #f0f1f3;
}
.time-drawer-title {
font-size: 30rpx;
font-weight: 600;
color: #2a2d33;
}
.time-drawer-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 快捷范围行 */
.quick-row {
display: flex;
padding: 20rpx 24rpx 4rpx;
}
.quick-chip {
flex: 1;
margin-right: 16rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f6f8;
border: 1rpx solid transparent;
border-radius: 999rpx;
font-size: 24rpx;
color: #606266;
&:last-child {
margin-right: 0;
}
&--active {
background: #e9fff3;
border-color: #2ccb98;
color: #12a37a;
font-weight: 600;
}
}
/* 星期行:钉在滚动区上方 */
.cal-weekdays {
display: flex;
padding: 0 16rpx;
border-bottom: 1rpx solid #f0f1f3;
}
.cal-weekday {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #909399;
line-height: 48rpx;
}
/* 滚动日历容器:占满抽屉剩余空间 */
.cal-scroll-wrap {
flex: 1;
min-height: 0;
}
.cal-scroll {
height: 100%;
}
/* 月份块 */
.cal-month {
padding: 8rpx 16rpx 0;
}
.cal-month-title {
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
color: #2a2d33;
}
/* 日期网格:固定 42 格(前置空格 + 当月天),节点数恒定 */
.cal-grid {
display: flex;
flex-wrap: wrap;
}
.cal-cell {
width: calc(100% / 7);
box-sizing: border-box;
padding: 4rpx;
aspect-ratio: 1 / 1.15;
display: flex;
align-items: stretch;
&--disabled {
opacity: 0.3;
}
}
.cal-day {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 12rpx;
&:active {
background: #f2f3f5;
}
&--start,
&--end {
background: #2ccb98;
}
&--start.cal-day--end {
background: #2ccb98;
}
&--in {
background: #e9fff3;
border-radius: 0;
&:active {
background: #ddf6ec;
}
}
}
.cal-day-num {
font-size: 28rpx;
font-weight: 500;
color: #2a2d33;
line-height: 40rpx;
}
.cal-day--start .cal-day-num,
.cal-day--end .cal-day-num {
color: #ffffff;
font-weight: 600;
}
.cal-day--in .cal-day-num {
color: #12a37a;
}
/* 开始/结束小字标注 */
.cal-day-tag {
font-size: 18rpx;
line-height: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
/* 起止回显:只占内容高度,不与日历滚动区平分空间 */
.range-preview {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: #6b6f76;
min-height: 56rpx;
}
.time-drawer-footer {
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f1f3;
background: #ffffff;
}
</style>

170
pages/index/index.vue Normal file
View File

@ -0,0 +1,170 @@
<!-- 首页:切换为 uview-plus 组件化结构,保留原有设备添加与配置入口 -->
<template>
<view class="page page-base">
<up-navbar title="智慧养老" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
<view class="banner">
<view class="banner-left">
<text class="banner-title">智慧养老云助手</text>
<text class="banner-sub">一键开启智慧好生活</text>
</view>
<view class="banner-illus">
<up-icon name="home" color="#ffffff" size="52" />
</view>
</view>
<up-notice-bar
mode="link"
:text="noticeText"
color="#2f5a3a"
bg-color="#e9fff3"
@click="goConfig"
/>
<view class="site-row" @click="goSceneSelect">
<view class="site-left">
<up-icon name="list" size="18" color="#26c996" />
<text class="site-text">{{ sceneName || '请选择场所' }}</text>
</view>
<up-icon name="arrow-right" size="14" color="#26c996" />
</view>
<view class="empty-area">
<up-empty mode="list" text="暂无数据" />
</view>
<view class="fab-wrap" @click="goAdd">
<up-icon name="plus" color="#ffffff" size="28" />
</view>
</view>
</template>
<script>
import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation'
import { getCurrentScene } from '@/store/scene'
export default {
data() {
return {
noticeText: '点击选择场所后可进入设备配置流程',
sceneName: ''
}
},
onShow() {
this.refreshScene()
},
methods: {
// 从全局场景状态刷新回显文案。
refreshScene() {
const scene = getCurrentScene()
this.sceneName = scene ? scene.name : ''
},
// 进入场景选择页。
goSceneSelect() {
navigateTo(ROUTES.SCENE_SELECT)
},
// 进入设备添加页(后续会承接蓝牙配网流程入口)。
goAdd() {
navigateTo(ROUTES.DEVICE_ADD)
},
// 进入设备配置页。
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/common.css';
.page {
position: relative;
}
.banner {
margin: 0;
min-height: 360rpx;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
padding: 48rpx 44rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.banner-left {
display: flex;
flex-direction: column;
}
.banner-title {
font-size: 52rpx;
color: #ffffff;
font-weight: 700;
margin-bottom: 20rpx;
}
.banner-sub {
font-size: 34rpx;
color: #e9fff8;
font-weight: 600;
}
.banner-illus {
width: 180rpx;
height: 180rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.site-row {
height: 108rpx;
padding: 0 32rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
}
.site-left {
display: flex;
align-items: center;
}
.site-text {
margin-left: 14rpx;
font-size: 34rpx;
color: #222;
font-weight: 600;
}
.empty-area {
margin-top: 40rpx;
min-height: 680rpx;
display: flex;
align-items: center;
justify-content: center;
}
.fab-wrap {
position: fixed;
right: 40rpx;
bottom: 60rpx;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(31, 202, 151, 0.4);
/* 点击态缩放反馈 */
&:active {
transform: scale(0.92);
}
}
</style>

326
pages/login/index.vue Normal file
View File

@ -0,0 +1,326 @@
<template>
<view class="page">
<view class="hero">
<text class="title">欢迎使用智慧养老</text>
<text class="sub">登录后可管理设备与查看事件</text>
</view>
<view class="card">
<up-form :model="form" label-position="top" :border-bottom="false">
<up-form-item label="手机号" :required="true">
<view class="mobile-wrap">
<text class="prefix">+86</text>
<up-input
v-model="form.mobile"
type="number"
maxlength="11"
clearable
placeholder="请输入手机号"
border="none"
:custom-style="inputInnerStyle"
/>
</view>
</up-form-item>
<up-form-item label="验证码" :required="true">
<view class="code-wrap">
<up-input
v-model="form.code"
type="number"
maxlength="6"
clearable
placeholder="请输入验证码"
border="none"
:custom-style="inputInnerStyle"
/>
<up-button
class="code-btn"
size="mini"
type="primary"
:plain="countdown > 0"
:disabled="countdown > 0"
:text="codeBtnText"
@click="sendCode"
/>
</view>
</up-form-item>
</up-form>
<up-button class="login-btn" type="success" shape="circle" text="手机号验证码登录" @click="loginByMobileSubmit" />
<view class="split">
<view class="line"></view>
<text class="split-text">或</text>
<view class="line"></view>
</view>
<up-button class="wx-btn" type="success" shape="circle" text="微信一键登录" @click="loginByWechatSubmit" />
</view>
</view>
</template>
<script>
import { loginByMobile, loginByWechat, sendSmsCode } from '@/api/user'
import { createCountdown } from '@/hooks/useCountdown'
import { goAfterLogin, isLoggedIn, setAuth } from '@/utils/auth'
import { showToast } from '@/utils/toast'
import { isValidMobile, isValidSmsCode } from '@/utils/validators'
export default {
data() {
return {
form: {
mobile: '',
code: ''
},
countdown: 0,
redirect: '',
countdownController: null,
inputInnerStyle: {
padding: '0'
}
}
},
computed: {
codeBtnText() {
return this.countdown > 0 ? `${this.countdown}s后重试` : '获取验证码'
}
},
created() {
this.countdownController = createCountdown({
duration: 60,
onChange: (value) => {
this.countdown = value
}
})
},
onLoad(query) {
this.redirect = query.redirect || ''
if (isLoggedIn()) {
goAfterLogin(this.redirect)
}
},
onUnload() {
this.clearCountdown()
},
methods: {
clearCountdown() {
if (!this.countdownController) {
return
}
this.countdownController.clear()
},
validateMobileOrToast() {
if (isValidMobile(this.form.mobile)) {
return true
}
showToast('请输入正确手机号')
return false
},
finishLogin(payload = {}) {
const token = payload.token || `mock-token-${Date.now()}`
const user = payload.user || {
name: '微信用户',
mobile: '',
loginType: 'wechat'
}
setAuth({ token, user })
goAfterLogin(this.redirect)
},
async sendCode() {
if (!this.validateMobileOrToast()) {
return
}
try {
await sendSmsCode(this.form.mobile)
} catch (error) {
return
}
showToast('验证码已发送')
this.countdownController.start()
},
async loginByMobileSubmit() {
if (!this.validateMobileOrToast()) {
return
}
if (!isValidSmsCode(this.form.code)) {
showToast('请输入6位验证码')
return
}
uni.showLoading({
title: '登录中'
})
try {
const data = await loginByMobile(this.form.mobile, this.form.code)
this.finishLogin({
token: data?.token,
user: data?.user || {
name: `用户${this.form.mobile.slice(-4)}`,
mobile: this.form.mobile,
loginType: 'mobile'
}
})
} catch (error) {
} finally {
uni.hideLoading()
}
},
async loginByWechatSubmit() {
// #ifdef MP-WEIXIN
uni.showLoading({
title: '微信登录中'
})
uni.login({
provider: 'weixin',
success: async (res) => {
try {
const data = await loginByWechat(res.code || '')
this.finishLogin({
token: data?.token,
user: data?.user || {
name: '微信用户',
mobile: '',
loginType: 'wechat'
}
})
} catch (error) {
} finally {
uni.hideLoading()
}
},
fail: () => {
uni.hideLoading()
showToast('微信登录失败,请重试')
}
})
// #endif
// #ifndef MP-WEIXIN
uni.showLoading({
title: '微信登录中'
})
try {
const data = await loginByWechat('')
this.finishLogin({
token: data?.token,
user: data?.user || {
name: '微信用户',
mobile: '',
loginType: 'wechat'
}
})
} catch (error) {
} finally {
uni.hideLoading()
}
// #endif
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: linear-gradient(180deg, #ecf9f5 0%, #f5f6f8 48%, #f1f1f4 100%);
padding: 80rpx 32rpx;
box-sizing: border-box;
}
.hero {
margin: 30rpx 8rpx 46rpx;
}
.title {
display: block;
font-size: 54rpx;
color: #18242f;
font-weight: 700;
}
.sub {
display: block;
margin-top: 16rpx;
font-size: 30rpx;
color: #6f7782;
}
.card {
background: #ffffff;
border-radius: 24rpx;
padding: 30rpx;
box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08);
}
.mobile-wrap,
.code-wrap {
min-height: 98rpx;
border: 1rpx solid #e8eaef;
border-radius: 16rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
box-sizing: border-box;
}
.mobile-wrap {
margin-top: 10rpx;
}
.code-wrap {
margin-top: 10rpx;
}
.prefix {
width: 86rpx;
color: #1f2b37;
font-size: 30rpx;
font-weight: 600;
}
.code-btn {
margin-left: 12rpx;
}
.login-btn {
margin-top: 28rpx;
}
.wx-btn {
margin-top: 22rpx;
}
.split {
margin: 34rpx 0 8rpx;
display: flex;
align-items: center;
}
.line {
flex: 1;
height: 1rpx;
background: #eceef1;
}
.split-text {
padding: 0 20rpx;
font-size: 26rpx;
color: #9aa2ad;
}
</style>

110
pages/mine/index.vue Normal file
View File

@ -0,0 +1,110 @@
<!-- 我的页:引入 uview-plus 组件重构个人中心菜单 -->
<template>
<view class="page page-base">
<up-navbar title="我的" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
<view class="user-card" @click="goLogin">
<up-avatar size="58" text="U" bg-color="#35c48f" />
<view class="user-main">
<text class="user-name">{{ userName }}</text>
<text class="user-tip">{{ loginTip }}</text>
</view>
<up-icon name="arrow-right" size="14" color="#b5b9c0" />
</view>
<up-cell-group :border="false" custom-style="margin: 24rpx; border-radius: 24rpx; overflow: hidden; background: #ffffff;">
<up-cell title="设备配置" is-link @click="goConfig" />
<up-cell title="场所管理" is-link />
<up-cell title="手机号" :value="phoneText" is-link />
<up-cell title="当前版本" value="2.8.44" />
<up-cell title="退出登录" @click="logout" />
</up-cell-group>
</view>
</template>
<script>
import { ROUTES } from '@/constants/routes'
import { clearAuth, getUser, isLoggedIn, LOGIN_PAGE, redirectToLogin } from '@/utils/auth'
import { navigateTo } from '@/utils/navigation'
import { showToast } from '@/utils/toast'
export default {
data() {
return {
userInfo: null
}
},
computed: {
userName() {
return this.userInfo?.name || '微信用户'
},
phoneText() {
return this.userInfo?.mobile || '未绑定'
},
// 根据登录类型展示不同文案。
loginTip() {
return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'
}
},
onShow() {
// 每次回到页面时刷新一次本地缓存用户信息。
this.userInfo = getUser()
},
methods: {
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
},
// 已登录时不再重复跳转登录页。
goLogin() {
if (isLoggedIn()) {
return
}
navigateTo(LOGIN_PAGE)
},
// 退出时清理本地登录态并回到首页。
logout() {
clearAuth()
showToast('已退出登录')
redirectToLogin(ROUTES.HOME)
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/common.css';
.page {
padding-bottom: 120rpx;
}
.user-card {
margin: 24rpx;
min-height: 180rpx;
border-radius: 24rpx;
background: #ffffff;
display: flex;
align-items: center;
padding: 20rpx 28rpx;
}
.user-main {
flex: 1;
margin-left: 20rpx;
}
.user-name {
display: block;
font-size: 42rpx;
color: #2a2d33;
font-weight: 700;
}
.user-tip {
display: block;
margin-top: 12rpx;
color: #9ea1a6;
font-size: 28rpx;
}
</style>

120
pages/scene/select.vue Normal file
View File

@ -0,0 +1,120 @@
<!-- 场景选择页:加载场景列表,点选后保存并返回首页 -->
<template>
<view class="page page-base">
<up-navbar title="选择场所" :fixed="false" :safe-area-inset-top="true" :border="false" />
<view v-if="loading" class="state-wrap">
<up-loading-icon mode="circle" size="48" text="加载中..." />
</view>
<view v-else-if="loadError" class="state-wrap">
<up-empty mode="wifi" text="加载失败,请检查网络" />
<up-button type="success" shape="circle" text="重试" class="retry-btn" @click="loadScenes" />
</view>
<view v-else-if="sceneList.length === 0" class="state-wrap">
<up-empty mode="list" text="暂无场景" />
</view>
<view v-else class="scene-list">
<view
v-for="scene in sceneList"
:key="scene.id"
class="scene-item"
:class="{ 'scene-item--active': scene.id === currentSceneId }"
@click="selectScene(scene)"
>
<text class="scene-name">{{ scene.name }}</text>
<up-icon
:name="scene.id === currentSceneId ? 'checkmark' : 'arrow-right'"
:size="scene.id === currentSceneId ? '16' : '14'"
:color="scene.id === currentSceneId ? '#26c996' : '#c0c4cc'"
/>
</view>
</view>
</view>
</template>
<script>
import { getSceneList } from '@/api/scene'
import { getCurrentScene, setCurrentScene } from '@/store/scene'
import { navigateBack } from '@/utils/navigation'
export default {
data() {
return {
loading: true,
loadError: false,
sceneList: [],
currentSceneId: getCurrentScene()?.id ?? null
}
},
onLoad() {
this.loadScenes()
},
methods: {
// 拉取场景列表;失败时切换错误态供重试,toast 由请求封装统一弹出。
async loadScenes() {
this.loading = true
this.loadError = false
try {
const data = await getSceneList()
this.sceneList = Array.isArray(data) ? data : []
} catch (error) {
this.loadError = true
} finally {
this.loading = false
}
},
// 选中场景:保存后返回,首页 onShow 回显。
selectScene(scene) {
setCurrentScene(scene)
navigateBack()
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/common.css';
.state-wrap {
margin-top: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.retry-btn {
margin-top: 40rpx;
width: 240rpx;
}
.scene-list {
margin-top: 20rpx;
}
.scene-item {
height: 104rpx;
padding: 0 32rpx;
margin-bottom: 2rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
}
.scene-item--active {
background: #e9fff3;
}
.scene-name {
font-size: 32rpx;
color: #222;
font-weight: 600;
}
.scene-item--active .scene-name {
color: #26c996;
}
</style>

View File

@ -1,26 +0,0 @@
{
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxe9773bb6f025ab00",
"editorSetting": {}
}

View File

@ -1,14 +0,0 @@
{
"libVersion": "3.17.2",
"projectname": "SEC_Warehouse",
"setting": {
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true
}
}

View File

@ -1,57 +0,0 @@
import { request } from '@/http'
import { USER, THIRD_PLATFORM } from '@/constants/endpoints'
// 微信小程序登录请求参数(2026-09-14 文档版,openId 为二次登录回传字段)
export interface WxMiniappLoginParams {
appId: string // 小程序 appId
code: string // Taro.login() 一次性 code,5 分钟有效
openId?: string // 首次登录响应返回,后续登录/解密回传
iv?: string // 加密资料初始向量(getUserProfile/getPhoneNumber 授权后补传)
encryptedData?: string // 加密用户资料(getUserProfile/getPhoneNumber 授权后补传)
phoneCode?: string // 手机号授权新版动态令牌(getPhoneNumber 回调 detail.code,后端可直连微信换取手机号)
}
// 首次登录(needUserInfo)响应:仅含绑定凭证,无 token/用户信息
export interface WxNeedUserInfoResult {
openId: string
bindKey: string
}
// 用户信息(补授权后完整登录返回;联调前字段按文档版,实际字段名待确认)
export interface WxUserInfo {
userId: string
userType: number
avatar: string
nickName: string
mobile: string
gender: number
region: string
loginFrom: string
}
// 完整登录响应(needUserInfo=false 时)
export interface WxLoginResult {
userInfo: WxUserInfo
code: string | null // 后端文档写登录凭证,联调实测恒为 null(token 走响应头)
openId?: string // 首次登录返回,需本地缓存
token?: string // http 层从响应头 Token 提取附加(仅登录接口返回)
}
// 登录响应联合体:Message 子状态由 http 层附加到 message 字段
// ''/undefined = 完整登录;'needUserInfo' = 需头像昵称授权;'noMobile' = 需手机号授权
export type WxMiniappLoginResponse = WxLoginResult &
Partial<WxNeedUserInfoResult> & { message?: string }
export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappLoginResponse> {
return request.post<WxLoginResult>(
THIRD_PLATFORM.wxMiniappLogin,
params as unknown as Record<string, unknown>,
{ needAuth: false }
)
}
// 冷启动登录态校验:token 有效返回登录信息,过期返回 202 由 http 层统一踢登录页。
// 响应 Content 结构(ResponseData<UserInfo>)联调实测后修正字段
export function getLoginInfo(): Promise<WxUserInfo> {
return request.get(USER.getLoginInfo)
}

View File

@ -1,47 +0,0 @@
import { request } from '@/http'
import { CARE_MEMBER } from '@/constants/endpoints'
// 关爱对象接口(apifox 2026-09-11 文档版)
// 紧急联系人(文档 YlHomeCareMemberContactItemReq,注意关系字段名为 relationShip)
export interface CareContact {
name: string
phone: string
relationShip: string
}
// 保存入参(文档 YlHomeCareMemberSaveReq:memberId 空=新增,有值=修改)
export interface CareMemberSaveParams {
memberId?: string
homeId: string
name: string // 最多 10 个字
idNumber: string
address: string
contacts: CareContact[] // 最多 5 名
}
// 列表项(响应字段文档为泛型占位未给出,按保存入参推断,联调实测修正)
export interface CareMember {
memberId: string
homeId: string
name: string
idNumber: string
address: string
contacts: CareContact[] | null
}
export function saveCareMember(params: CareMemberSaveParams): Promise<void> {
return request.post(CARE_MEMBER.save, params as unknown as Record<string, unknown>)
}
export function getCareMemberList(homeId: string): Promise<CareMember[]> {
return request.post(CARE_MEMBER.getList, { homeId })
}
export function getCareMemberDetail(memberId: string): Promise<CareMember> {
return request.get(CARE_MEMBER.getDetail(memberId))
}
export function deleteCareMember(memberId: string): Promise<void> {
return request.post(CARE_MEMBER.delete, { memberId })
}

View File

@ -1,69 +0,0 @@
import { request } from '@/http'
import { HOME } from '@/constants/endpoints'
// 家庭圈接口(apifox 2026-09-11 文档版)
// 列表字段已按 2026-09-17 联调实测修正
export interface HomeInfo {
homeId: string
name: string
address: string | null
memberCount: number
careMemberCount: number
deviceCount: number
code?: string // 家庭编号(详情接口返回,如 '1446833'),供他人 joinHome 加入
createTime?: string
}
// 成员列表项(2026-09-17 联调实测):role=OWNER 为群主,其余为成员
export interface HomeMember {
memberId: string
userNickName: string
nickName: string | null // 家庭内昵称,未设置为 null,显示时回落 userNickName
role: string
avatar?: string
phone?: string | null
joinTime?: string
}
export function addHome(name: string): Promise<void> {
// name 可空,空则后端默认「我的家庭圈」(文档 YlHomeAddReq)
return request.post(HOME.add, { name })
}
export function updateHome(params: Record<string, unknown>): Promise<void> {
return request.post(HOME.update, params)
}
export function dissolveHome(params: Record<string, unknown>): Promise<void> {
return request.post(HOME.dissolve, params)
}
export function getHomeList(): Promise<HomeInfo[]> {
return request.post(HOME.getList)
}
export function getHomeMemberList(homeId: string): Promise<HomeMember[]> {
return request.post(HOME.getMemberList, { homeId })
}
export interface TransferOwnerParams {
memberId: string
}
export function transferOwner(params: TransferOwnerParams): Promise<void> {
return request.post(HOME.transferOwner, params)
}
export function updateMemberNickName(params: Record<string, unknown>): Promise<void> {
return request.post(HOME.updateMemberNickName, params)
}
export function getHomeDetail(homeId: string): Promise<HomeInfo> {
return request.get(HOME.getDetail(homeId))
}
export function joinHome(code: string): Promise<void> {
// code 为家庭编号(文档 YlHomeJoinReq)
return request.post(HOME.join, { code })
}

View File

@ -1,63 +0,0 @@
import { request } from '@/http'
import { HOME_ALARM } from '@/constants/endpoints'
// 入参对齐 apifox YlHomeAlarmListReq(homeId 必填,其余可选筛选)
export interface AlarmListReq {
homeId: string
roomId?: string // 传入则只查该房间告警
status?: number // 告警状态,不传则不筛选
beginTime?: string // yyyy-MM-dd HH:mm:ss
endTime?: string
pageIndex?: number
pageSize?: number
}
// 2026-09-23 联调实测字段(.getList 真实响应):
// - 无 homeId 字段;alarmType 是告警类型字典 ID(非文案),文案在 content
// - 时间字段是 createTime / lastUpdateTime,无 alarmTime
// - status: 0-待处理 1-已忽略 2-已处理 3-自动处理(2026-09-23 后端确认)
// - level: 5(告警级别,语义待字典确认);category: 'DEVICE'
export interface HomeAlarm {
alarmId: string
content: string // 告警文案,如「跌倒检测器 No.01于2026-09-23 14:19:08发生确认跌倒」
level?: number
status: number
category?: string
alarmType: string // 告警类型字典 ID,前端不直接展示
deviceId: string
deviceName: string // 设备序列号
deviceAlias?: string | null // 设备别名(如「跌倒检测器 No.01」),展示优先于 deviceName
roomId: string
roomName?: string | null
productId?: string
createTime: string
lastUpdateTime?: string
// 详情接口附加的告警扩展信息(列表接口为 null)
otherInfo?: {
appName?: string
description?: string
alarmName?: string // 告警名称,如「跌倒报警」
deviceSn?: string
installId?: string
deviceName?: string
content?: string
realName?: string
alertTypeName?: string // 告警分类名,如「设备告警消息」
systemName?: string
cameraId?: string
appId?: string
alertTime?: string
cameraName?: string
deviceLocation?: string
} | null
videoUrl?: string | null
capturePicUrl?: string | null
}
export function getAlarmList(params: AlarmListReq): Promise<HomeAlarm[]> {
return request.post(HOME_ALARM.getList, params as unknown as Record<string, unknown>)
}
export function getAlarmDetail(alarmId: string): Promise<HomeAlarm> {
return request.get(HOME_ALARM.getDetail(alarmId))
}

View File

@ -1,122 +0,0 @@
import { request } from '@/http'
import { HOME_DEVICE } from '@/constants/endpoints'
// 入参对齐 apifox YlHomeDeviceListReq:homeId 与 roomId 至少填一个
export interface HomeDeviceListReq {
homeId?: string
roomId?: string // 传入则只查该房间设备
}
// 入参对齐 apifox YlHomeDeviceAddReq
export interface HomeDeviceAddReq {
deviceName: string // 设备序列号
homeId: string
roomId: string
}
// 入参对齐 apifox YlHomeDeviceUnbindReq
export interface HomeDeviceUnbindReq {
installId: string // 设备安装 ID
}
// 体征实时快照(runTimeInfo.properties,2026-09-18 联调实测;字段与 web 端 VitalSignsDashboard 一致)
export interface VitalSigns {
lastTime?: string
heartRate?: number
respiratoryRate?: number
moveState?: number
bodyStatus?: number
onbedStatus?: number
bodyPosition?: number
bodyMovement?: number
}
// ED719 跌倒检测快照(2026-09-22 实测:alarmType 是实时跌倒状态枚举,编码同 FALL_STATUS_MAP;
// alarmTs 是事件 Unix 秒;version 为固件版本)
export interface FallDetectSigns {
lastTime?: string
alarmType?: number
alarmTs?: number
version?: string
// 兼容旧字段名(detail 页若只取到 status 也能渲染)
status?: number
}
// 响应字段按 2026-09-17 联调实测
export interface HomeDevice {
installId: string
deviceName: string // 设备序列号(如 b4bfe9df0d0e)
name: string | null // 设备显示名称,展示用(未命名时回落序列号)
homeId: string
roomId: string
roomName: string | null
productId: string
productCategory: string
deviceType: string
deviceModel: string | null
subDeviceType: string | null
status: number
onlineStatus: number // 1-在线 0-离线
lastOnlineTime: string | null
runTimeInfo?: { properties?: VitalSigns & FallDetectSigns } | null
}
export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {
return request.post(HOME_DEVICE.getList, params as unknown as Record<string, unknown>)
}
// 单设备详情(GET + 路径参数,含 runTimeInfo 实时快照)
export function getHomeDeviceDetail(installId: string): Promise<HomeDevice> {
return request.get(HOME_DEVICE.getDetail(installId))
}
// —— 历史体征记录(web 端 IotPropertyRecord 同源接口,2026-09-18 实测)——
export interface VitalRecord {
recordId: string
deviceName: string
name: string | null
time: string
heartRate?: number
respiratoryRate?: number
moveState?: number
bodyStatus?: number
bodyPosition?: number
bodyMovement?: number
productKey?: string
}
interface PropertyRecordsReq {
pageIndex: number
pageSize: number
queryParams: {
searchType: 'deviceName'
deviceName: string
createTime?: string // '1' = 近一天(web 端约定)
sortColumn?: string
sortDirection?: string
}
}
// 按 deviceName 查历史体征,倒序返回(最新的在前)
export function getVitalRecords(deviceName: string, pageSize = 50): Promise<VitalRecord[]> {
const params: PropertyRecordsReq = {
pageIndex: 1,
pageSize,
queryParams: {
searchType: 'deviceName',
deviceName,
createTime: '1',
sortColumn: 'createTime',
sortDirection: 'desc'
}
}
return request.post(HOME_DEVICE.getPropertyRecords, params as unknown as Record<string, unknown>)
}
export function addHomeDevice(params: HomeDeviceAddReq): Promise<void> {
return request.post(HOME_DEVICE.add, params as unknown as Record<string, unknown>)
}
export function unbindHomeDevice(params: HomeDeviceUnbindReq): Promise<void> {
return request.post(HOME_DEVICE.unbind, params as unknown as Record<string, unknown>)
}

View File

@ -1,62 +0,0 @@
import { request } from '@/http'
import { HOME_ROOM } from '@/constants/endpoints'
// 房间使用场景枚举(apifox YlHomeRoomAddReq.sence 推荐值)
export const ROOM_SENCE_MAP: Record<string, string> = {
LIVING_ROOM: '客厅',
BEDROOM: '卧室',
MASTER_BEDROOM: '主卧',
BATHROOM: '卫生间',
KITCHEN: '厨房',
DINING_ROOM: '餐厅',
STUDY: '书房',
BALCONY: '阳台',
OTHER: '其他'
}
// 入参对齐 apifox YlHomeRoomAddReq
export interface HomeRoomAddReq {
homeId: string
name: string
sence?: string // 使用场景,ROOM_SENCE_MAP 的 key
icon?: string
}
// 入参对齐 apifox YlHomeRoomUpdateReq
export interface HomeRoomUpdateReq {
roomId: string
name?: string
sence?: string
icon?: string
}
// 入参对齐 apifox YlHomeRoomDeleteReq
export interface HomeRoomDeleteReq {
roomId: string
}
// 响应字段按 2026-09-17 联调实测:name(非 roomName)
export interface HomeRoom {
roomId: string
homeId: string
name: string
sence?: string | null
icon?: string | null
}
// 文档路径为 POST + body { homeId }(YlHomeRoomListReq)
export function getRoomList(homeId: string): Promise<HomeRoom[]> {
return request.post(HOME_ROOM.getList, { homeId })
}
export function addRoom(params: HomeRoomAddReq): Promise<void> {
return request.post(HOME_ROOM.add, params as unknown as Record<string, unknown>)
}
export function updateRoom(params: HomeRoomUpdateReq): Promise<void> {
return request.post(HOME_ROOM.update, params as unknown as Record<string, unknown>)
}
export function deleteRoom(params: HomeRoomDeleteReq): Promise<void> {
return request.post(HOME_ROOM.delete, params as unknown as Record<string, unknown>)
}

View File

@ -1,71 +0,0 @@
export default {
// 组件按需注入:未在 WXML 中声明的自定义组件不注入对应代码,削减启动注入开销
lazyCodeLoading: 'requiredComponents',
pages: [
'pages/index/index',
'pages/login/index',
'pages/mine/index',
'pages/scene/select',
'pages/device/index',
'pages/event/index'
],
// 低频页面分包:设备详情/配置与家庭圈系列从主包剥离,压主包体积
subPackages: [
{
root: 'pages/device/feature',
pages: ['detail', 'add', 'config', 'ble-config']
},
{
root: 'pages/family',
pages: ['index', 'detail', 'contact/add', 'contact/detail']
},
{
root: 'pages/event/feature',
pages: ['detail']
},
{
// 协议全文页:审核要求《用户服务协议》《隐私政策》可查看,内置分包避免外链依赖
root: 'pages/agreement',
pages: ['index']
}
],
window: {
navigationStyle: 'custom', // 与现状一致:所有页自绘 Navbar,避免原生导航栏与自研 Navbar 双栏
navigationBarTextStyle: 'black',
navigationBarTitleText: '智慧养老',
navigationBarBackgroundColor: '#F8F8F8',
backgroundColor: '#F1F1F4'
},
tabBar: {
color: '#666666',
selectedColor: '#2CCB98',
backgroundColor: '#FFFFFF',
borderStyle: 'black',
list: [
{
pagePath: 'pages/index/index',
text: '首页',
iconPath: 'assets/tabbar/home.png',
selectedIconPath: 'assets/tabbar/home-active.png'
},
{
pagePath: 'pages/device/index',
text: '智能设备',
iconPath: 'assets/tabbar/device.png',
selectedIconPath: 'assets/tabbar/device-active.png'
},
{
pagePath: 'pages/event/index',
text: '消息中心',
iconPath: 'assets/tabbar/event.png',
selectedIconPath: 'assets/tabbar/event-active.png'
},
{
pagePath: 'pages/mine/index',
text: '个人中心',
iconPath: 'assets/tabbar/mine.png',
selectedIconPath: 'assets/tabbar/mine-active.png'
}
]
}
}

View File

@ -1,11 +0,0 @@
import { PropsWithChildren } from 'react'
import { Provider } from 'react-redux'
import { store } from './store'
import './styles/reset.scss'
function App({ children }: PropsWithChildren) {
return <Provider store={store}>{children}</Provider>
}
export default App

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,8 +0,0 @@
import Taro from '@tarojs/taro'
/**
* 平台注入层:bleCore 的 promisifyApi 通过该对象调用异步 API。
* Taro.* 与 uni.* 蓝牙 API 同名同参(openBluetoothAdapter、createBLEConnection 等),
* 单元测试时可注入 mock 对象替换。
*/
export const taroPlatform: Record<string, unknown> = Taro as unknown as Record<string, unknown>

View File

@ -1,46 +0,0 @@
// discovery.js(原 hooks/useBluetoothDiscovery.js)为保留的纯 JS 资产,
// 不做类型化;此处只声明 useBle 与页面需要用到的最小工厂签名。
export interface DiscoveryOptions {
onSearchingChange?: (searching: boolean) => void
onConnectedChange?: (connected: boolean) => void
onDeviceListChange?: (deviceList: Array<Record<string, unknown> & { deviceId: string }>) => void
onProfileChange?: (profileId: string) => void
onStageChange?: (stage: string) => void
onDeviceInfoChange?: (deviceInfo: Record<string, unknown>) => void
onAuthChange?: (authResult: unknown) => void
onError?: (error: unknown) => void
onRadarData?: (data: unknown) => void
}
export interface DiscoveryState {
isSearching: boolean
isConnected: boolean
currentDeviceId: string
currentProfileId: string
deviceMap: Record<string, Record<string, unknown>>
deviceList: Array<Record<string, unknown> & { deviceId: string }>
}
export interface WifiConfigResult {
success: boolean
stateCode: number
}
export interface DiscoveryInstance {
startSearch: () => Promise<void>
stopSearch: (showStopToast?: boolean) => Promise<void>
connectDevice: (deviceId?: string) => Promise<unknown>
prepareWifiConfig: (deviceId: string, options?: Record<string, unknown>) => Promise<unknown>
disconnectDevice: () => Promise<void>
configureWifi: (ssid: string, password: string, options?: Record<string, unknown>) => Promise<WifiConfigResult>
readDeviceInfo: () => Promise<unknown>
startRadar: () => Promise<unknown>
stopRadar: () => Promise<unknown>
setNarrowMode: (enabled: boolean) => Promise<unknown>
writeFallParams: (rawParams: unknown) => Promise<unknown>
sendRadarCommand: (commandByte: number) => Promise<unknown>
cleanupDiscovery: () => void
getState: () => DiscoveryState
}
export declare function createBluetoothDiscovery(options?: DiscoveryOptions): DiscoveryInstance

View File

@ -1,79 +0,0 @@
import { useEffect, useState } from 'react'
import { createBluetoothDiscovery } from '../discovery'
import type { DiscoveryInstance, DiscoveryOptions } from '../discovery'
// 搜索结果设备项:discovery.upsertDevice 合入 showName/匹配标记后的产物
export type BleDevice = Record<string, unknown> & { deviceId: string }
// 工厂回调 → React state 的映射产物
export interface BleUiState {
searching: boolean
connected: boolean
deviceList: BleDevice[]
profileId: string
stage: string
deviceInfo: Record<string, unknown> | null
authResult: unknown
}
interface BleSingleton {
discovery: DiscoveryInstance
state: BleUiState
listeners: Set<(s: BleUiState) => void>
}
// 模块级单例:add 页连接后 config 页必须共享同一 discovery 实例,
// 否则跨页后 connected=false、characteristics 丢失,配网流程断裂
let singleton: BleSingleton | null = null
function getSingleton(): BleSingleton {
if (singleton) return singleton
const listeners = new Set<(s: BleUiState) => void>()
const state: BleUiState = {
searching: false,
connected: false,
deviceList: [],
profileId: 'UNKNOWN',
stage: 'IDLE',
deviceInfo: null,
authResult: null
}
const patch = (partial: Partial<BleUiState>) => {
Object.assign(state, partial)
listeners.forEach((fn) => fn({ ...state }))
}
const discovery = createBluetoothDiscovery({
onSearchingChange: (searching) => patch({ searching }),
onConnectedChange: (connected) => patch({ connected }),
onDeviceListChange: (deviceList) => patch({ deviceList }),
onProfileChange: (profileId) => patch({ profileId }),
onStageChange: (stage) => patch({ stage }),
onDeviceInfoChange: (deviceInfo) => patch({ deviceInfo }),
onAuthChange: (authResult) => patch({ authResult })
// onError / onRadarData / onProtocolState / onProtocolMsg 按页面需要接入
} as DiscoveryOptions)
singleton = { discovery, state, listeners }
return singleton
}
export function useBle() {
const { discovery, listeners, state } = getSingleton()
const [snapshot, setSnapshot] = useState<BleUiState>(() => ({ ...state }))
useEffect(() => {
const listener = (next: BleUiState) => setSnapshot(next)
listeners.add(listener)
// 挂载时若单例状态已更新过(如另一页已连接),立即对齐
setSnapshot({ ...state })
return () => {
listeners.delete(listener)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listeners])
return { state: snapshot, discovery }
}

View File

@ -1,69 +0,0 @@
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $color-mask;
z-index: 100;
}
.panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: $color-bg-white;
border-radius: $radius-lg $radius-lg 0 0;
padding-bottom: env(safe-area-inset-bottom, 0);
z-index: 101;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.handle {
width: 80px;
height: 8px;
border-radius: $radius-pill;
background: $color-bg-disabled;
margin: 16px auto;
}
.header {
position: relative;
padding: 16px 32px 24px;
display: flex;
align-items: center;
justify-content: center;
}
.title {
font-size: 34px;
font-weight: 600;
color: $color-text;
}
.close {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-50%);
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.closeIcon {
font-size: 40px;
color: $color-text-secondary;
line-height: 1;
}
.body {
flex: 1;
overflow-y: auto;
padding: 0 32px 32px;
}

View File

@ -1,31 +0,0 @@
import { View, Text } from '@tarojs/components'
import type { ReactNode } from 'react'
import styles from './index.module.scss'
interface Props {
visible: boolean
title?: string
onClose: () => void
children: ReactNode
}
export function BottomSheet({ visible, title, onClose, children }: Props) {
if (!visible) return null
return (
<>
<View className={styles.mask} onClick={onClose} />
<View className={styles.panel}>
<View className={styles.handle} />
{title && (
<View className={styles.header}>
<Text className={styles.title}>{title}</Text>
<View className={styles.close} onClick={onClose}>
<Text className={styles.closeIcon}>×</Text>
</View>
</View>
)}
<View className={styles.body}>{children}</View>
</View>
</>
)
}

View File

@ -1,225 +0,0 @@
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 0;
}
.iconWrap {
width: 160px;
height: 160px;
border-radius: $radius-circle;
background: $color-bg-white;
box-shadow: $shadow-card;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
}
.text {
font-size: 28px;
color: $color-text-secondary;
}
.subText {
margin-top: 12px;
font-size: 24px;
color: $color-text-tertiary;
}
// ===== message:铃铛(浅绿底块 + 品牌绿铃舌) =====
.bell {
position: relative;
width: 66px;
height: 66px;
}
.bellTop {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 15px;
height: 15px;
border-radius: $radius-circle;
background: $color-primary-light-bg;
}
.bellBody {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
width: 62px;
height: 48px;
background: $color-primary-light-bg;
border-radius: 31px 31px 8px 8px;
}
.bellBase {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 15px;
border-radius: 0 0 12px 12px;
background: $color-primary-light-bg;
}
.bellClapper {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%) rotate(18deg);
width: 5px;
height: 37px;
border-radius: 3px;
background: $color-primary;
}
.bellHammer {
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 8px;
border-radius: 0 0 8px 8px;
background: $color-primary;
}
// ===== list:清单板(浅绿板 + 中间绿底条 + 三行勾选框/横线) =====
.listIcon {
position: relative;
width: 60px;
height: 66px;
}
.listBoard {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: $color-primary-light-bg;
border-radius: 10px 10px 14px 14px;
}
.listFoot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: #b8ecd8;
border-radius: 0 0 14px 14px;
}
.listRow {
position: absolute;
left: 13px;
right: 13px;
display: flex;
align-items: center;
}
.listRow1 { top: 15px; }
.listRow2 { top: 31px; }
.listRow3 { top: 47px; }
.listBox {
width: 13px;
height: 13px;
border-radius: 3px;
background: $color-primary;
}
.listLine {
flex: 1;
height: 5px;
margin-left: 7px;
border-radius: 2px;
background: #b8ecd8;
}
// ===== wifi:信号(品牌绿点 + 两道弧线) =====
.wifiIcon {
position: relative;
width: 73px;
height: 60px;
}
.wifiDot {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
width: 17px;
height: 17px;
border-radius: $radius-circle;
background: $color-primary;
}
.wifiArcMid {
position: absolute;
bottom: 13px;
left: 50%;
transform: translateX(-50%) rotate(-45deg);
width: 36px;
height: 13px;
border-top: 5px solid #b8ecd8;
border-radius: 18px 18px 0 0;
}
.wifiArcOuter {
position: absolute;
bottom: 23px;
left: 50%;
transform: translateX(-50%) rotate(-45deg);
width: 63px;
height: 20px;
border-top: 5px solid $color-primary-light-bg;
border-radius: 32px 32px 0 0;
}
// ===== error:警示三角(浅红底 + 深红感叹号;clip-path 真机不兼容时降级圆角矩形) =====
.warnIcon {
position: relative;
width: 66px;
height: 59px;
}
.warnTriangle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: $color-danger-light-bg;
border-radius: 10px;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.warnBar {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: 7px;
height: 20px;
border-radius: 3px;
background: $color-danger-deep;
}
.warnDot {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 7px;
height: 7px;
border-radius: $radius-circle;
background: $color-danger-deep;
}

View File

@ -1,78 +0,0 @@
import { View, Text } from '@tarojs/components'
import styles from './index.module.scss'
interface Props {
text: string
subText?: string
mode?: 'list' | 'wifi' | 'error' | 'message'
}
export function Empty({ text, subText, mode = 'list' }: Props) {
return (
<View className={styles.empty}>
<View className={styles.iconWrap}>
{mode === 'message' && <BellIcon />}
{mode === 'list' && <ListIcon />}
{mode === 'wifi' && <WifiIcon />}
{mode === 'error' && <WarnIcon />}
</View>
<Text className={styles.text}>{text}</Text>
{subText && <Text className={styles.subText}>{subText}</Text>}
</View>
)
}
// ===== 空态图形:纯 View 嵌套 + border-radius/clip-path,白色圆底由 iconWrap 提供 =====
function BellIcon() {
return (
<View className={styles.bell}>
<View className={styles.bellTop} />
<View className={styles.bellBody} />
<View className={styles.bellBase} />
<View className={styles.bellClapper} />
<View className={styles.bellHammer} />
</View>
)
}
function ListIcon() {
return (
<View className={styles.listIcon}>
<View className={styles.listBoard} />
<View className={styles.listFoot} />
<View className={`${styles.listRow} ${styles.listRow1}`}>
<View className={styles.listBox} />
<View className={styles.listLine} />
</View>
<View className={`${styles.listRow} ${styles.listRow2}`}>
<View className={styles.listBox} />
<View className={styles.listLine} />
</View>
<View className={`${styles.listRow} ${styles.listRow3}`}>
<View className={styles.listBox} />
<View className={styles.listLine} />
</View>
</View>
)
}
function WifiIcon() {
return (
<View className={styles.wifiIcon}>
<View className={styles.wifiArcOuter} />
<View className={styles.wifiArcMid} />
<View className={styles.wifiDot} />
</View>
)
}
function WarnIcon() {
return (
<View className={styles.warnIcon}>
<View className={styles.warnTriangle} />
<View className={styles.warnBar} />
<View className={styles.warnDot} />
</View>
)
}

View File

@ -1,104 +0,0 @@
// 浅绿底卡变体:卡身浅绿 + 右上淡绿圆装饰(spec §9)
.card {
position: relative;
overflow: hidden;
margin-bottom: 24px;
background: $color-primary-light-bg;
border: 1px solid $color-primary-light-border;
border-radius: $radius-lg;
padding: 36px 32px 12px;
}
.cardPressed {
opacity: 0.85;
}
// 装饰圆用真实元素而非 ::after:小程序部分场景伪元素渲染兼容性差
.decor {
position: absolute;
top: -60px;
right: -60px;
width: 240px;
height: 240px;
border-radius: $radius-circle;
background: rgba(44, 203, 152, 0.08);
}
.head {
display: flex;
align-items: center;
margin-bottom: 28px;
}
// 圆图标:品牌渐变底 + 白字首字,对齐设备网格 icon 风格
.icon {
width: 92px;
height: 92px;
border-radius: $radius-circle;
background: $color-primary-grad;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24px;
flex-shrink: 0;
}
.iconText {
font-size: 38px;
font-weight: 600;
color: #ffffff;
}
.headMain {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.nameRow {
display: flex;
align-items: center;
}
.name {
min-width: 0;
font-size: 34px;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 成员/设备合并为头部副标题,避免与下方信息行重复占行
.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: 72px;
border-top: 1px solid rgba(159, 220, 195, 0.45);
}
.rowLabel {
flex-shrink: 0;
font-size: 26px;
color: $color-text-secondary;
}
.rowValue {
margin-left: 24px;
font-size: 26px;
font-weight: 500;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -1,44 +0,0 @@
import { View, Text } from '@tarojs/components'
import type { HomeInfo } from '@/api/home'
import styles from './index.module.scss'
interface Props {
family: HomeInfo
onClick?: (id: string) => void
}
export function FamilyCard({ family, onClick }: Props) {
const first = family.name.slice(0, 1)
return (
<View
className={styles.card}
hoverClass={onClick ? styles.cardPressed : 'none'}
onClick={() => onClick?.(family.homeId)}
>
{/* 右上淡绿圆装饰 */}
<View className={styles.decor} />
<View className={styles.head}>
<View className={styles.icon}>
<Text className={styles.iconText}>{first}</Text>
</View>
<View className={styles.headMain}>
<View className={styles.nameRow}>
<Text className={styles.name}>{family.name}</Text>
</View>
<Text className={styles.subCount}>
{family.memberCount} 位成员 · {family.deviceCount} 台设备
</Text>
</View>
</View>
<View className={styles.rows}>
<Text className={styles.rowLabel}>关爱对象</Text>
<Text className={styles.rowValue}>{family.careMemberCount > 0 ? `${family.careMemberCount} 人` : '--'}</Text>
</View>
<View className={styles.rows}>
<Text className={styles.rowLabel}>关爱地址</Text>
<Text className={styles.rowValue}>{family.address || '--'}</Text>
</View>
</View>
)
}

View File

@ -1,47 +0,0 @@
.group {
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 104px;
padding: 0 32px;
background: $color-bg-white;
border-bottom: 1px solid $color-border;
}
.group .row:last-child {
border-bottom: none;
}
.rowPressed {
background: $color-bg-press;
}
.label {
font-size: 30px;
color: $color-text;
}
.rowDanger .label {
color: $color-danger-deep;
}
.value {
// 推到行尾紧贴箭头:无箭头时贴右边距;否则三元素 space-between 会把 value 挤到中间
margin-left: auto;
font-size: 28px;
color: $color-text;
text-align: right;
}
.arrow {
margin-left: 16px;
font-size: 36px;
color: $color-text-placeholder;
}

View File

@ -1,33 +0,0 @@
import { View, Text } from '@tarojs/components'
import type { ReactNode } from 'react'
import styles from './index.module.scss'
interface ListRowProps {
label: string
value?: string
valueClass?: string
arrow?: boolean
onClick?: () => void
danger?: boolean
}
export function ListRow({ label, value, valueClass, arrow, onClick, danger }: ListRowProps) {
const cls = [styles.row, danger ? styles.rowDanger : ''].filter(Boolean).join(' ')
return (
<View
className={cls}
hoverClass={onClick ? styles.rowPressed : 'none'}
onClick={onClick}
>
<Text className={styles.label}>{label}</Text>
{value && <Text className={`${styles.value} ${valueClass ?? ''}`.trim()}>{value}</Text>}
{arrow && <Text className={styles.arrow}>›</Text>}
</View>
)
}
// 分组容器:白卡外观 + 最后一条分割线由本模块组合选择器去除
// (CSS Modules 行组件无法感知父级,:last-child 必须写在同模块内)
export function ListRowGroup({ children }: { children: ReactNode }) {
return <View className={styles.group}>{children}</View>
}

View File

@ -1,23 +0,0 @@
.loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 0;
}
.spinner {
border: 4px solid $color-bg-disabled;
border-top-color: $color-primary;
border-radius: $radius-circle;
animation: spin 0.8s linear infinite;
}
.text {
margin-top: 20px;
font-size: 28px;
color: $color-text-secondary;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@ -1,19 +0,0 @@
import { View, Text } from '@tarojs/components'
import styles from './index.module.scss'
interface Props {
text?: string
size?: number
}
export function Loading({ text = '加载中...', size = 48 }: Props) {
return (
<View className={styles.loading}>
<View
className={styles.spinner}
style={{ width: `${size}px`, height: `${size}px` }}
/>
{text && <Text className={styles.text}>{text}</Text>}
</View>
)
}

View File

@ -1,23 +0,0 @@
// 项目 z-index 现状:内容层 ≤1、抽屉/遮罩 10~11;弹窗基座取 100/101
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $color-mask;
z-index: 100;
}
.panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: $color-bg-white;
border-radius: $radius-lg;
padding: 48px 32px;
min-width: 600px;
z-index: 101;
box-shadow: $shadow-popup;
}

View File

@ -1,26 +0,0 @@
import { View } from '@tarojs/components'
import type { CSSProperties, ReactNode } from 'react'
import styles from './index.module.scss'
interface Props {
visible: boolean
onClose: () => void
children: ReactNode
width?: string // 默认 600rpx。内联 style 不经过 postcss pxtransform,
// 必须直接写 rpx 才能与 scss 的 min-width: 600px(→rpx) 同量纲
}
export function Modal({ visible, onClose, children, width = '600rpx' }: Props) {
if (!visible) return null
const panelStyle: CSSProperties = { width }
return (
<>
<View className={styles.mask} onClick={onClose} />
<View className={styles.panel} style={panelStyle}>
{children}
</View>
</>
)
}

View File

@ -1,70 +0,0 @@
// 外层只负责状态栏留白与白底:高度不能写死——reset 的 border-box 下
// 固定 height 会把 paddingTop(状态栏 44px+)一起算进去,内容区被压成
// 0 甚至负值,标题被挤出导航栏(与页面内容重叠)
.navbar {
position: relative;
background: #ffffff;
}
// 内容栏:88px(→rpx) = 44 逻辑 px,标准导航高度
.bar {
position: relative;
display: flex;
align-items: center;
height: 88px;
padding: 0 32px;
}
.back {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
margin-left: -16px;
z-index: 1;
}
// 纯 CSS 线条箭头:border 拼接旋转 45°,粗细均匀无字体差异
.backArrow {
width: 20px;
height: 20px;
border-left: 3px solid $color-text;
border-bottom: 3px solid $color-text;
transform: rotate(45deg);
margin-left: 8px;
}
// 绝对定位四边拉伸 + flex 居中:不依赖 absolute 静态位置(各内核行为不一),
// 与 back 按钮是否存在无关,免手动平衡宽度
.titleWrap {
position: absolute;
left: 120px;
right: 120px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
}
.title {
max-width: 100%;
font-size: 32px;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// hero 变体:智能设备 tab 等需要绿色品牌头部的页面使用
// 垂直渐变贴近设计参考图:顶部深绿、底部亮绿;局部样式不引入新 token
.navbarHero {
background: linear-gradient(180deg, #0da89b 0%, #2ccb98 100%);
}
// hero 模式下标题反白,避免与渐变背景对比度不足
.titleHero {
color: #ffffff;
}

View File

@ -1,54 +0,0 @@
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useMemo } from 'react'
import styles from './index.module.scss'
interface Props {
title: string
fixed?: boolean
autoBack?: boolean // 默认 true(修复 uni-app up-navbar 坑)
border?: boolean // 默认 false
safeAreaTop?: boolean // 默认 true
variant?: 'default' | 'hero' // hero 为绿色渐变头部,default 为白底黑字
onBack?: () => void
}
export function Navbar({
title,
fixed = false,
autoBack = true,
border = false,
safeAreaTop = true,
variant = 'default',
onBack
}: Props) {
// getSystemInfoSync 已废弃,改用 getWindowInfo
const statusBarHeight = useMemo(() => Taro.getWindowInfo().statusBarHeight ?? 20, [])
const handleBack = () => {
if (onBack) onBack()
else Taro.navigateBack()
}
return (
<View
className={`${styles.navbar} ${variant === 'hero' ? styles.navbarHero : ''}`}
style={{
paddingTop: safeAreaTop ? `${statusBarHeight}px` : 0,
position: fixed ? 'fixed' : 'relative',
borderBottom: border ? '1px solid #f0f1f3' : 'none'
}}
>
<View className={styles.bar}>
{autoBack && (
<View className={styles.back} onClick={handleBack}>
<View className={styles.backArrow} />
</View>
)}
<View className={styles.titleWrap}>
<Text className={`${styles.title} ${variant === 'hero' ? styles.titleHero : ''}`}>{title}</Text>
</View>
</View>
</View>
)
}

View File

@ -1,19 +0,0 @@
.stateView {
margin-top: 80px;
display: flex;
flex-direction: column;
align-items: center;
}
.retryBtn {
margin-top: 40px;
width: 240px;
height: 80px;
background: $color-primary;
border-radius: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28px;
}

View File

@ -1,30 +0,0 @@
import { View, Text } from '@tarojs/components'
import { Empty } from '@/components/empty'
import styles from './index.module.scss'
interface Props {
status: 'error'
mode?: 'list' | 'wifi' | 'error' | 'message'
text?: string
onRetry?: () => void
retryText?: string
}
export function StateView({
status,
mode = 'wifi',
text = '加载失败,请检查网络',
onRetry,
retryText = '重试'
}: Props) {
return (
<View className={styles.stateView}>
<Empty mode={mode} text={text} />
{onRetry && (
<View className={styles.retryBtn} onClick={onRetry}>
<Text>{retryText}</Text>
</View>
)}
</View>
)
}

View File

@ -1,118 +0,0 @@
import { useEffect, useState } from 'react'
import { Canvas, View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
const V_PADDING = 8
const HEIGHT = 110
// 纵轴最小跨度(BPM):心率生理波动常只有个位数,若按 min/max 归一化会把
// 数 BPM 的微小起伏放大成满幅曲线,视觉误导为剧烈波动
const MIN_SPAN = 20
interface Props {
points: number[]
canvasId?: string
}
export function VitalWave({ points, canvasId = 'vitalWave' }: Props) {
const [width, setWidth] = useState(200)
const empty = !points || points.length < 2
// 测量容器宽度
useEffect(() => {
const query = Taro.createSelectorQuery()
query.select(`#${canvasId}-wrap`).boundingClientRect((rect: any) => {
if (rect && rect.width > 0) {
setWidth(Math.floor(rect.width))
}
}).exec()
}, [canvasId])
// 数据变化重绘
useEffect(() => {
if (empty || width <= 0) return
drawWave(canvasId, width, HEIGHT, points)
}, [points, width, empty, canvasId])
return (
<View id={`${canvasId}-wrap`} style={{ position: 'relative', width: '100%', height: `${HEIGHT * 2}rpx` }}>
<Canvas
canvasId={canvasId}
style={{ width: `${width}px`, height: `${HEIGHT}px` }}
/>
{!empty && (
<>
<View style={{
position: 'absolute', top: '33.3%', left: 0, right: 0,
borderTop: '1px dashed rgba(245, 34, 45, 0.25)'
}} />
<View style={{
position: 'absolute', top: '66.6%', left: 0, right: 0,
borderTop: '1px dashed rgba(245, 34, 45, 0.25)'
}} />
</>
)}
{empty && (
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Text style={{ fontSize: '24rpx', color: '#b5b9c0' }}>暂无数据</Text>
</View>
)}
</View>
)
}
function drawWave(canvasId: string, w: number, h: number, pts: number[]) {
const ctx = Taro.createCanvasContext(canvasId)
let min = Math.min(...pts)
let max = Math.max(...pts)
if (max - min < MIN_SPAN) {
const mid = (min + max) / 2
min = mid - MIN_SPAN / 2
max = mid + MIN_SPAN / 2
}
const span = max - min || 1
const xAt = (i: number) => (pts.length <= 1 ? 0 : (i / (pts.length - 1)) * w)
const yAt = (v: number) => h - V_PADDING - ((v - min) / span) * (h - V_PADDING * 2)
const pathTo = (closeBottom: boolean) => {
ctx.beginPath()
ctx.moveTo(xAt(0), yAt(pts[0]))
for (let i = 0; i < pts.length - 1; i++) {
const x0 = xAt(Math.max(0, i - 1))
const y0 = yAt(pts[Math.max(0, i - 1)])
const x1 = xAt(i)
const y1 = yAt(pts[i])
const x2 = xAt(i + 1)
const y2 = yAt(pts[i + 1])
const x3 = xAt(Math.min(pts.length - 1, i + 2))
const y3 = yAt(pts[Math.min(pts.length - 1, i + 2)])
const c1x = x1 + (x2 - x0) / 6
const c1y = y1 + (y2 - y0) / 6
const c2x = x2 - (x3 - x1) / 6
const c2y = y2 - (y3 - y1) / 6
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2)
}
if (closeBottom) {
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.closePath()
}
}
pathTo(true)
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, 'rgba(245, 34, 45, 0.25)')
grad.addColorStop(1, 'rgba(245, 34, 45, 0)')
ctx.setFillStyle(grad)
ctx.fill()
pathTo(false)
ctx.setStrokeStyle('#f5222d')
ctx.setLineWidth(2)
ctx.stroke()
ctx.draw()
}

View File

@ -1,7 +0,0 @@
// 微信登录相关常量
// 二次登录回传 openId 的缓存 key(spec D3)
export const WX_OPENID_STORAGE_KEY = 'wx_openid'
// getAccountInfoSync 取不到 appId 时的回退值(与 project.config.json 一致的测试号)
export const FALLBACK_WX_APP_ID = 'wxe9773bb6f025ab00'

View File

@ -1,28 +0,0 @@
import type { BleDevice } from '@/ble/hooks/useBle'
// 设备型号 → 启用的按钮 key 列表。
// 后续如需让其他型号裁剪按钮,只需新增映射条目。
export const DEVICE_ACTION_KEYS: Record<string, readonly string[]> = Object.freeze({
EP832: ['WIFI'],
DEFAULT: ['WIFI', 'PARAMS', 'SERVER']
})
// 按钮注册表:label / 忙态文案。
// 未配置 handler 的 key 渲染为不可点击的占位项。
export const ACTION_REGISTRY = Object.freeze({
WIFI: {
label: '网络配置',
busyLabel: '连接中'
},
PARAMS: {
label: '参数配置'
},
SERVER: {
label: '服务器配置'
}
} as Record<string, { label: string; busyLabel?: string }>)
export function isEp832Device(device: BleDevice): boolean {
const name = String(device?.showName || device?.name || device?.localName || '').toUpperCase()
return name.includes('EP832')
}

View File

@ -1,65 +0,0 @@
// 后端 API 路径集中表。集中维护便于:
// 1) 环境切换时只改本文件
// 2) 排查路由/联调时一处定位所有端点
// 3) 后续按网关/服务前缀批量调整
// —— 家庭 ylHome ——
export const HOME = {
add: '/yanglao/app/ylHome/add',
update: '/yanglao/app/ylHome/update',
dissolve: '/yanglao/app/ylHome/dissolve',
getList: '/yanglao/app/ylHome/getList',
getMemberList: '/yanglao/app/ylHome/getMemberList',
transferOwner: '/yanglao/app/ylHome/transferOwner',
updateMemberNickName: '/yanglao/app/ylHome/updateMemberNickName',
getDetail: (homeId: string): string => `/yanglao/app/ylHome/get/${homeId}`,
join: '/yanglao/app/ylHome/join'
}
// —— 关爱对象 ylHomeCareMember ——
export const CARE_MEMBER = {
save: '/yanglao/app/ylHomeCareMember/save',
getList: '/yanglao/app/ylHomeCareMember/getList',
getDetail: (memberId: string): string => `/yanglao/app/ylHomeCareMember/get/${memberId}`,
delete: '/yanglao/app/ylHomeCareMember/delete'
}
// —— 房间 ylHomeRoom ——
export const HOME_ROOM = {
getList: '/yanglao/app/ylHomeRoom/getList',
add: '/yanglao/app/ylHomeRoom/add',
update: '/yanglao/app/ylHomeRoom/update',
delete: '/yanglao/app/ylHomeRoom/delete'
}
// —— 设备 ylHomeDevice ——
export const HOME_DEVICE = {
getList: '/yanglao/app/ylHomeDevice/getList',
getDetail: (installId: string): string => `/yanglao/app/ylHomeDevice/get/${installId}`,
getPropertyRecords: '/iot/propertyRecord/getPropertyRecords',
add: '/yanglao/app/ylHomeDevice/add',
unbind: '/yanglao/app/ylHomeDevice/unbind'
}
// —— 告警 ylHomeAlarm ——
export const HOME_ALARM = {
getList: '/yanglao/app/ylHomeAlarm/getList',
getDetail: (alarmId: string): string => `/yanglao/app/ylHomeAlarm/get/${alarmId}`
}
// —— 用户 v0/app/users ——
export const USER = {
getLoginInfo: '/v0/app/users/getLoginInfo'
}
// —— 第三方平台登录 thirdPlatform/wxDevLogin ——
export const THIRD_PLATFORM = {
wxMiniappLogin: '/thirdPlatform/wxDevLogin/miniapp/login'
}
// 网关分组前缀:用于 client.ts 决定走 user 服务还是 biz 服务网关
// user 服务网关:登录 + 第三方平台 + 用户中心(/v0/、/thirdPlatform/)
// biz 服务网关:所有业务接口(/yanglao/、/iot/)
export const GATEWAY_PREFIX = {
user: ['/thirdPlatform/', '/v0/']
}

View File

@ -1,41 +0,0 @@
// 设备型号
export const DEVICE_MODEL = {
ED713: 'ED713',
ED719: 'ED719'
} as const
export type DeviceModel = (typeof DEVICE_MODEL)[keyof typeof DEVICE_MODEL]
// 设备型号 → 图标映射
export const DEVICE_ICON_MAP: Record<DeviceModel, string> = {
ED713: 'home',
ED719: 'bell'
}
// 体征枚举映射(与 web 端 zh-CN 一致)
export const MOVE_STATE_MAP: Record<number, string> = {
0: '无人',
1: '轻微活动',
2: '频繁活动',
3: '静卧'
}
export const BODY_STATUS_MAP: Record<number, string> = {
0: '无人',
1: '运动',
2: '呼吸',
3: '进入呼吸'
}
export const ONBED_STATUS_MAP: Record<number, string> = {
0: '不在床',
1: '在床'
}
// ED719 跌倒状态枚举(2026-09-21 用户提供:runTimeInfo.properties.status 实时值)
export const FALL_STATUS_MAP: Record<number, string> = {
3101: '摔倒报警',
3102: '疑似摔倒',
3103: '解除摔倒',
3104: '驻留超时'
}

View File

@ -1,16 +0,0 @@
// 家庭圈业务常量
// 每个家庭最多可添加的关爱对象数(设计图 2 文案)
export const MAX_CARED_PER_FAMILY = 2
// 每人最多紧急联系人数(设计图 4 说明文字)
export const MAX_EMERGENCY_CONTACTS = 5
// 家庭名最大长度(设计图 placeholder)
export const MAX_FAMILY_NAME_LEN = 10
// storage 单 key 持久化整个家庭圈数据集
export const FAMILY_DATA_STORAGE_KEY = 'familyData'
// mock 新建家庭 id 自增起点,避免与样例 id 冲突
export const MOCK_ID_SEED = 100000

View File

@ -1,18 +0,0 @@
export const ROUTES = Object.freeze({
LOGIN: '/pages/login/index',
HOME: '/pages/index/index',
EVENT: '/pages/event/index',
EVENT_DETAIL: '/pages/event/feature/detail',
MINE: '/pages/mine/index',
DEVICE_LIST: '/pages/device/index',
DEVICE_ADD: '/pages/device/feature/add',
DEVICE_BLE_CONFIG: '/pages/device/feature/ble-config',
DEVICE_CONFIG: '/pages/device/feature/config',
DEVICE_DETAIL: '/pages/device/feature/detail',
FAMILY: '/pages/family/index',
FAMILY_DETAIL: '/pages/family/detail',
FAMILY_CONTACT_ADD: '/pages/family/contact/add',
FAMILY_CONTACT_DETAIL: '/pages/family/contact/detail',
AGREEMENT: '/pages/agreement/index',
SCENE_SELECT: '/pages/scene/select'
})

View File

@ -1,24 +0,0 @@
import { Component, ErrorInfo, ReactNode } from 'react'
import { View } from '@tarojs/components'
interface Props { children: ReactNode; fallback?: ReactNode }
interface State { hasError: boolean }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[ErrorBoundary]', error, info)
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <View>页面出错了</View>
}
return this.props.children
}
}

View File

@ -1,10 +0,0 @@
import Taro from '@tarojs/taro'
export function useToast() {
return {
show: (title: string, icon: 'success' | 'error' | 'loading' | 'none' = 'none') =>
Taro.showToast({ title, icon, duration: 2000 }),
success: (title: string) => Taro.showToast({ title, icon: 'success', duration: 2000 }),
error: (title: string) => Taro.showToast({ title, icon: 'error', duration: 2000 })
}
}

View File

@ -1,152 +0,0 @@
import Taro from '@tarojs/taro'
import { store } from '@/store'
import { clearAuth } from '@/store/authSlice'
import { GATEWAY_PREFIX } from '@/constants/endpoints'
import { ROUTES } from '@/constants/routes'
import { reLaunch } from '@/utils/navigation'
import { ApiError, BusinessError, AuthExpiredError } from './errors'
// 网关分服务前缀:登录走 user 服务,其余业务接口走 biz 服务(后端约定 2026-09-17)
const GATEWAY = process.env.NODE_ENV === 'development'
? 'http://192.168.10.11:39101'
: 'https://aiotcloud.celex-technology.com' // 线上网关域名(2026-09-22 接入)
const USER_BASE = `${GATEWAY}/api/user`
const BIZ_BASE = `${GATEWAY}/api/biz`
// 后端约定的未登录业务码(202 为 2026-09-17 内网网关实测;401 为 HTTP 状态码分支)
const UNAUTHORIZED_CODES = new Set(['202', '401'])
// 防重入:跳转登录页进行中不重复触发(并发 401 只踢一次)
let redirectingToLogin = false
function redirectToLogin(tokenAtStart: string | null): void {
if (redirectingToLogin) return
// 仅当发起请求时的 token 仍是当前登录态才踢,避免在途旧请求 401 误清重新登录后的新会话
// (token 为 null 的未登录请求 401 时 null === null 成立会踢,属合理行为)
if (store.getState().auth.token !== tokenAtStart) return
redirectingToLogin = true
store.dispatch(clearAuth())
reLaunch(ROUTES.LOGIN)
.catch((e: unknown) => {
// 跳转失败显式记录,避免 unhandled rejection 噪音(debug-first:不静默吞)
console.error('[http] 踢回登录页失败', e)
})
.finally(() => {
redirectingToLogin = false
})
}
export interface RequestConfig {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
headers?: Record<string, string>
timeout?: number
needAuth?: boolean // 默认 true;登录接口自身传 false
}
// 后端统一响应格式(大写 key、字符串状态码)
export interface ApiEnvelope<T> {
Code: string
Message?: string
// message 为 http 层附加的 200 内子状态(needUserInfo/noMobile 等)
Content: T & { message?: string }
RecordCount?: number
PageIndex?: number
PageSize?: number
}
export async function request<T>(
url: string,
params?: Record<string, unknown>,
config: RequestConfig = {}
): Promise<T> {
const method = config.method ?? 'GET'
const timeout = config.timeout ?? 10000
// 请求发起时刻的 token 快照:headers 注入与 401 陈旧竞态判断共用同一份
const tokenAtStart = store.getState().auth.token
// token 注入:后端约定自定义 token 头(无 Bearer 前缀,联调实测)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...config.headers
}
if (config.needAuth !== false) {
if (tokenAtStart) headers.token = tokenAtStart
}
// 登录接口与 users 系列冷启动校验接口走 user 服务前缀,其余业务接口走 biz 前缀
const base = GATEWAY_PREFIX.user.some((p) => url.startsWith(p)) ? USER_BASE : BIZ_BASE
let res: Taro.request.SuccessCallbackResult<ApiEnvelope<T>>
try {
res = await Taro.request({
url: base + url,
method,
// Taro.request 的 GET 参数同样走 data,运行时自动序列化为 query string
data: params,
dataType: 'json',
timeout,
header: headers
})
} catch (e) {
// Taro 网络 API reject 的是 { errMsg } 形状对象而非 Error 实例,需兼容读取以便透出真实原因
const raw = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg
console.error(`[http] ${method} ${url} 网络失败:`, raw)
throw new ApiError(raw || '网络异常')
}
// 开发期联调日志:dev 才打印响应体,生产静默(网络失败/踢登录仍走 console.error 保留)
if (process.env.NODE_ENV === 'development') {
console.log(`[http] ${method} ${url} ←`, res.statusCode, res.data)
}
if (res.statusCode !== 200) {
if (res.statusCode === 401) {
redirectToLogin(tokenAtStart)
throw new AuthExpiredError('登录已过期')
}
throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode)
}
// 网关返回 200 + 非 envelope 形态(HTML 拦截页/null 等)属传输层异常,不能当业务错分流
const body = res.data as ApiEnvelope<T> | null | string
if (body === null || typeof body !== 'object' || body.Code == null) {
throw new ApiError('响应格式异常', res.statusCode)
}
const bizCode = String(body.Code)
if (UNAUTHORIZED_CODES.has(bizCode)) {
redirectToLogin(tokenAtStart)
throw new AuthExpiredError(body.Message || '登录已过期')
}
if (bizCode !== '200') {
throw new BusinessError(body.Message || '业务错误', bizCode)
}
// 200 成功内的子状态(Message 携带,如 needUserInfo/noMobile),附到返回值供调用方分支处理
// 首登场景 Content 为 null(仅返回绑定凭证),需先初始化才能挂字段
const subState = body.Message
if (subState && subState !== '') {
if (!body.Content) body.Content = {} as T & { message?: string }
;(body.Content as Record<string, unknown>).message = subState
}
// 后端把登录 token 放响应头 Token(仅登录接口返回)——附到返回值供登录流程落地
const resToken = pickResponseToken(res.header)
if (resToken) {
if (!body.Content) body.Content = {} as T & { message?: string }
;(body.Content as Record<string, unknown>).token = resToken
}
return body.Content
}
// 微信运行时响应头 key 统一小写(header.token),HTTP 原文为大写(Token),两侧兼容
function pickResponseToken(header: Record<string, unknown> | undefined): string | null {
if (!header) return null
const raw = header.token ?? header.Token
if (typeof raw !== 'string' || raw === '') return null
return raw
}
request.get = <T>(url: string, params?: object, config?: RequestConfig) =>
request<T>(url, params as Record<string, unknown> | undefined, { ...config, method: 'GET' })
request.post = <T>(url: string, params?: object, config?: RequestConfig) =>
request<T>(url, params as Record<string, unknown> | undefined, { ...config, method: 'POST' })

View File

@ -1,23 +0,0 @@
// 网络/HTTP 错误
export class ApiError extends Error {
constructor(message: string, public readonly statusCode?: number) {
super(message)
this.name = 'ApiError'
}
}
// 业务错误(后端 Code 为字符串,如 '200'/'500'/'401')
export class BusinessError extends Error {
constructor(message: string, public readonly code: string) {
super(message)
this.name = 'BusinessError'
}
}
// 登录态失效(HTTP 401 或业务码 401),触发踢回登录页
export class AuthExpiredError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthExpiredError'
}
}

View File

@ -1,2 +0,0 @@
export { request } from './client'
export { ApiError, BusinessError, AuthExpiredError } from './errors'

View File

@ -1,139 +0,0 @@
// 协议全文内容:内置小程序分包页展示,避免外部 H5 托管依赖
// 文案需运营/法务最终确认,此处为可过审的完整占位结构(覆盖审核点:收集目的/方式/用途/存储/删除)
export type AgreementType = 'user' | 'privacy'
interface AgreementSection {
heading: string
paragraphs: string[]
}
export interface AgreementDoc {
title: string
updatedAt: string
sections: AgreementSection[]
}
const USER_SECTIONS: AgreementSection[] = [
{
heading: '一、总则',
paragraphs: [
'1.1 欢迎使用智慧养老小程序(以下简称"本小程序")。本协议是您与本小程序运营方之间关于使用本小程序服务所订立的协议。',
'1.2 在使用本小程序前,请您仔细阅读并充分理解本协议及《隐私政策》各条款。您勾选同意或实际使用本小程序服务,即视为您已阅读并同意接受本协议及《隐私政策》的全部内容。'
]
},
{
heading: '二、账号与登录',
paragraphs: [
'2.1 本小程序使用微信授权登录。当您选择登录时,我们将通过微信授权获取您的用户信息(含头像、昵称)用于创建和识别您的账号。',
'2.2 您可以选择不登录,以游客模式浏览本小程序的公开内容。游客模式下部分功能(如设备管理、家庭圈管理)无法使用。'
]
},
{
heading: '三、用户信息的收集与使用',
paragraphs: [
'3.1 为提供家庭圈关爱服务,在您主动添加关爱对象时,我们需要收集关爱对象的姓名、身份证号、关爱地址及紧急联系人信息(姓名、电话、关系),用于身份核验、建立关爱档案及紧急情况下的联系。',
'3.2 为提供设备告警服务,我们需要获取您绑定设备的告警记录,用于向您推送及展示设备告警消息。',
'3.3 上述信息的收集均以实现本服务为目的,具体收集方式、存储与保护措施详见《隐私政策》。'
]
},
{
heading: '四、用户行为规范',
paragraphs: [
'4.1 您承诺在使用本小程序时遵守国家法律法规,不得利用本小程序从事任何违法违规活动。',
'4.2 您应确保所填写信息真实、准确;因填写信息不实导致的后果由您自行承担。'
]
},
{
heading: '五、服务变更与终止',
paragraphs: [
'5.1 我们可能因业务调整、法律法规要求等原因变更、中断或终止部分或全部服务,并将尽可能提前公告。',
'5.2 您可随时停止使用本小程序;您也可按照《隐私政策》所述方式申请删除您的个人信息。'
]
},
{
heading: '六、其他',
paragraphs: [
'6.1 本协议的订立、履行与解释适用中华人民共和国法律。',
'6.2 我们可能适时修订本协议,修订后的协议将在本页面发布。若您继续使用服务,即视为接受修订后的协议。',
'6.3 若您对本协议有任何疑问,可通过小程序内"个人中心"页面提供的渠道与我们联系。'
]
}
]
const PRIVACY_SECTIONS: AgreementSection[] = [
{
heading: '一、引言',
paragraphs: [
'1.1 我们非常重视您的个人信息保护。本政策说明我们如何收集、使用、存储和保护您的个人信息。',
'1.2 请您在使用本小程序前仔细阅读本政策。您勾选同意或实际使用相关功能,即表示您已理解并同意本政策所述内容。'
]
},
{
heading: '二、我们收集的信息与方式',
paragraphs: [
'2.1 微信授权信息:当您选择登录时,通过微信授权方式收集您的头像、昵称,用于创建账号并展示您的身份。此信息在您点击登录并授权时收集。',
'2.2 身份证明信息:当您主动添加关爱对象时,收集关爱对象的姓名、身份证号、关爱地址,用于身份核验与建立关爱档案。此信息仅在您主动填写并提交时收集。',
'2.3 紧急联系人信息:当您主动填写时,收集紧急联系人姓名、电话、关系,用于紧急情况下联系相关人员。',
'2.4 设备与告警信息:当您绑定设备后,收集设备名称、绑定关系及设备产生的告警记录,用于向您提供设备告警服务。'
]
},
{
heading: '三、信息的使用目的',
paragraphs: [
'3.1 创建与管理您的账号,提供登录及身份识别功能;',
'3.2 建立关爱档案,完成关爱对象身份核验;',
'3.3 在紧急情况下联系紧急联系人;',
'3.4 向您展示设备状态与告警消息,保障关爱对象安全;',
'3.5 保障服务安全稳定运行,防止恶意行为。'
]
},
{
heading: '四、信息的存储与保护',
paragraphs: [
'4.1 您的个人信息存储于中华人民共和国境内的服务器。上述信息存储期限为实现本政策所述目的所必需的最短时间,法律法规另有规定的除外。',
'4.2 我们采用加密传输与加密存储等安全措施保护您的身份证号等敏感信息,访问权限严格限制于完成服务所必需的人员。',
'4.3 尽管我们努力保护您的信息,但互联网传输不存在绝对安全,我们无法保证信息的绝对安全。'
]
},
{
heading: '五、信息的共享与委托',
paragraphs: [
'5.1 我们不会向任何第三方出售您的个人信息。',
'5.2 未经您的授权同意,我们不会公开或向第三方提供您的个人信息,但法律法规规定或主管部门强制要求的除外。'
]
},
{
heading: '六、您的权利',
paragraphs: [
'6.1 您有权查询、更正您的个人信息。',
'6.2 您有权删除您的个人信息:您可以删除已添加的关爱对象档案;如需注销账号并删除全部信息,可通过小程序内反馈渠道联系我们,我们将在 15 个工作日内处理。',
'6.3 在您撤回同意或删除信息前,我们不会停止对应服务的基础运行保障。'
]
},
{
heading: '七、未成年人保护',
paragraphs: [
'7.1 本小程序的服务面向成年用户,我们不主动收集未成年人的个人信息。若您是未成年人,请在监护人指导下使用本小程序。'
]
},
{
heading: '八、政策更新',
paragraphs: [
'8.1 我们可能适时修订本政策,修订后将在本页面发布并更新日期。重大变更将通过弹窗等方式提醒您再次确认。'
]
}
]
export const AGREEMENT_TEXTS: Record<AgreementType, AgreementDoc> = {
user: {
title: '用户服务协议',
updatedAt: '2026-10-12',
sections: USER_SECTIONS
},
privacy: {
title: '隐私政策',
updatedAt: '2026-10-12',
sections: PRIVACY_SECTIONS
}
}

View File

@ -1,45 +0,0 @@
// 协议全文页:纯文本阅读场景,白底长文排版
.page {
min-height: 100vh;
background: $color-bg-white;
}
.body {
padding: 24px 40px 60px;
}
.title {
display: block;
font-size: 40px;
font-weight: 700;
color: $color-text;
text-align: center;
}
.updated {
display: block;
margin-top: 12px;
font-size: 24px;
color: $color-text-tertiary;
text-align: center;
}
.section {
margin-top: 40px;
}
.heading {
display: block;
font-size: 30px;
font-weight: 600;
color: $color-text;
}
.paragraph {
display: block;
margin-top: 16px;
font-size: 26px;
line-height: 1.7;
color: $color-text-secondary;
text-align: justify;
}

View File

@ -1,31 +0,0 @@
import { View, Text } from '@tarojs/components'
import { useRouter } from '@tarojs/taro'
import { Navbar } from '@/components/navbar'
import { AGREEMENT_TEXTS, type AgreementType } from './content'
import styles from './index.module.scss'
export default function AgreementPage() {
const router = useRouter()
const raw = router.params.type ?? 'user'
const type: AgreementType = raw === 'privacy' ? 'privacy' : 'user'
const doc = AGREEMENT_TEXTS[type]
return (
<View className={styles.page}>
<Navbar title={doc.title} />
<View className={styles.body}>
<Text className={styles.title}>{doc.title}</Text>
<Text className={styles.updated}>更新日期:{doc.updatedAt}</Text>
{doc.sections.map((s) => (
<View key={s.heading} className={styles.section}>
<Text className={styles.heading}>{s.heading}</Text>
{s.paragraphs.map((p, i) => (
<Text key={i} className={styles.paragraph}>{p}</Text>
))}
</View>
))}
</View>
</View>
)
}

View File

@ -1,187 +0,0 @@
.page {
min-height: 100vh;
background: $color-bg;
padding-bottom: 40px;
}
.section {
margin: 24px;
padding: 40px 32px;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
}
.sectionTitle {
font-size: 36px;
font-weight: 700;
color: $color-text;
margin-bottom: 16px;
display: block;
}
.sectionDesc {
font-size: 26px;
color: $color-text-secondary;
line-height: 1.5;
margin-bottom: 32px;
display: block;
}
.input {
width: 100%;
height: 96px;
padding: 0 32px;
background: $color-bg-gray;
border: 1px solid transparent;
border-radius: $radius-md;
font-size: 28px;
color: $color-text;
box-sizing: border-box;
margin-bottom: 32px;
&:focus {
border-color: $color-primary;
background: $color-bg-white;
}
}
// 安装房间选择:label + 横向 chip 组
.roomPicker {
margin-bottom: 32px;
}
.roomLabel {
display: block;
font-size: 28px;
color: $color-text-secondary;
margin-bottom: 16px;
}
.roomOptions {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.roomChip {
padding: 12px 32px;
background: $color-bg-gray;
border: 1px solid transparent;
border-radius: $radius-pill;
font-size: 26px;
color: $color-text-secondary;
}
.roomChipActive {
background: $color-primary-light-bg;
border-color: $color-primary;
color: $color-primary-deep;
}
.submitBtn {
width: 100%;
height: 96px;
background: $color-primary-grad;
border-radius: $radius-pill;
display: flex;
align-items: center;
justify-content: center;
}
// 提交中防重复点击的视觉降级
.submitBtnDisabled {
opacity: 0.6;
}
.submitBtnPressed {
opacity: 0.85;
}
.submitBtnText {
font-size: 32px;
font-weight: 600;
color: #ffffff;
}
.scanArea {
padding: 24px 0 40px;
display: flex;
flex-direction: column;
align-items: center;
}
.scanAreaPressed {
opacity: 0.85;
}
// 扫码取景框:四角 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;
}
.scanHint {
font-size: 24px;
color: $color-text-tertiary;
}

View File

@ -1,185 +0,0 @@
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { useDidShow } from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading'
import { StateView } from '@/components/state-view'
import { addHomeDevice } from '@/api/homeDevice'
import { getHomeList, type HomeInfo } from '@/api/home'
import { getRoomList, type HomeRoom } from '@/api/homeRoom'
import { resolveCurrentHome, setCurrentHomeId } from '@/store/currentHome'
import { showToast } from '@/utils/toast'
import { navigateBack } from '@/utils/navigation'
import styles from './add.module.scss'
export default function DeviceAdd() {
const [deviceId, setDeviceId] = useState('')
const [submitting, setSubmitting] = useState(false)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
const [home, setHome] = useState<HomeInfo | null>(null)
const [rooms, setRooms] = useState<HomeRoom[]>([])
const [roomId, setRoomId] = useState('')
// 数据加载体:useDidShow 首次与错误重试共用(state 变化触发 useDidShow 不可行,用计数器驱动 effect)
const [reload, setReload] = useState(0)
const [loaded, setLoaded] = useState(false)
// 默认家庭 + 房间列表:addHomeDevice 需 homeId + roomId
useEffect(() => {
let cancelled = false
setLoading(true)
setLoadError(false)
getHomeList()
.then(async (list) => {
const first = resolveCurrentHome(list)
if (cancelled) return
setHome(first)
if (first) setCurrentHomeId(first.homeId)
if (!first) return
const roomList = await getRoomList(first.homeId)
if (cancelled) return
setRooms(roomList ?? [])
setRoomId((prev) => prev || roomList[0]?.roomId || '')
})
.catch(() => !cancelled && setLoadError(true))
.finally(() => {
if (cancelled) return
setLoading(false)
setLoaded(true)
})
return () => {
cancelled = true
}
}, [reload])
// 从房间管理等页面返回时刷新(房间可能已变更)
useDidShow(() => {
if (loaded) setReload((n) => n + 1)
})
const submitManual = async () => {
if (submitting) return
const code = deviceId.trim()
if (!code) {
showToast('请输入设备编号')
return
}
if (!home || !roomId) {
showToast('请选择安装房间')
return
}
setSubmitting(true)
try {
await addHomeDevice({ deviceName: code, homeId: home.homeId, roomId })
showToast('添加成功')
navigateBack()
} catch (e) {
showToast(e instanceof Error ? e.message : '添加失败')
} finally {
setSubmitting(false)
}
}
// 点击扫码区拉起相机扫码,识别结果回填到输入框,确认后提交
const scanQRCode = async () => {
try {
const res = await Taro.scanCode({ scanType: ['qrCode', 'barCode'] })
const code = String(res.result ?? '').trim()
if (!code) {
showToast('未识别到有效编号')
return
}
setDeviceId(code)
showToast('已填入扫码结果')
} catch {
showToast('扫码取消')
}
}
if (loading) {
return (
<View className={styles.page}>
<Navbar title="添加设备" autoBack variant="hero" />
<View className={styles.stateWrap}><Loading /></View>
</View>
)
}
if (loadError || !home) {
return (
<View className={styles.page}>
<Navbar title="添加设备" autoBack variant="hero" />
<StateView
status="error"
mode="list"
text={loadError ? '加载失败' : '暂无家庭圈'}
retryText={loadError ? '重试' : '返回'}
onRetry={() => (loadError ? setReload((n) => n + 1) : navigateBack())}
/>
</View>
)
}
return (
<View className={styles.page}>
<Navbar title="添加设备" autoBack={true} variant="hero" />
<View className={styles.section}>
<Text className={styles.sectionTitle}>手动输入</Text>
<Text className={styles.sectionDesc}>
将智能设备添加至「{home.name}」,可实时查看设备状态和告警信息。
</Text>
<Input
className={styles.input}
placeholder="请输入设备编号"
maxlength={32}
confirmType="done"
value={deviceId}
onInput={(e) => setDeviceId(e.detail.value)}
/>
{rooms.length > 0 && (
<View className={styles.roomPicker}>
<Text className={styles.roomLabel}>安装房间</Text>
<View className={styles.roomOptions}>
{rooms.map((room) => (
<View
key={room.roomId}
className={`${styles.roomChip} ${roomId === room.roomId ? styles.roomChipActive : ''}`}
onClick={() => setRoomId(room.roomId)}
>
<Text>{room.name}</Text>
</View>
))}
</View>
</View>
)}
<View
className={`${styles.submitBtn} ${submitting ? styles.submitBtnDisabled : ''}`}
hoverClass={styles.submitBtnPressed}
onClick={submitManual}
>
<Text className={styles.submitBtnText}>{submitting ? '提交中...' : '提交'}</Text>
</View>
</View>
<View className={styles.section}>
<Text className={styles.sectionTitle}>扫码添加</Text>
<Text className={styles.sectionDesc}>扫描设备机身或包装盒上的二维码,自动填入编号。</Text>
<View className={styles.scanArea} hoverClass={styles.scanAreaPressed} onClick={scanQRCode}>
<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,168 +0,0 @@
.page {
min-height: 100vh;
background: #e9eaed;
}
.search-wrap {
height: 500px;
background: $color-bg-white;
display: flex;
justify-content: center;
align-items: center;
}
.search-btn {
width: 260px;
height: 260px;
border-radius: 130px;
background: $color-primary;
color: #ffffff;
font-size: 56px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.search-btn:active {
transform: scale(0.97);
}
.search-btn.is-searching {
animation: searchPulse 1.2s ease-in-out infinite;
}
@keyframes searchPulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(67, 201, 150, 0.42);
}
70% {
transform: scale(1.04);
box-shadow: 0 0 0 30px rgba(67, 201, 150, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(67, 201, 150, 0);
}
}
.tip-row {
min-height: 78px;
padding: 12px 16px;
background: #efeff1;
color: $color-danger;
font-size: 34px;
font-weight: 600;
display: flex;
align-items: center;
}
.list-header {
height: 116px;
background: $color-bg-white;
display: flex;
align-items: center;
padding: 0 38px;
}
.list-title {
font-size: 52px;
color: #101217;
font-weight: 700;
}
.list-body {
background: #e9eaed;
padding-bottom: 30px;
}
.empty-row {
padding: 36px;
text-align: center;
color: #8c9097;
font-size: 30px;
}
.device-card {
background: $color-bg-white;
margin-top: 12px;
padding: 24px;
display: flex;
gap: 20px;
}
.device-icon {
width: 92px;
height: 92px;
border-radius: 46px;
border: 2px solid #d8d8d8;
box-shadow: inset 0 0 0 10px #f1f1f1;
flex-shrink: 0;
}
.device-main {
flex: 1;
min-width: 0;
}
.device-name-row {
display: flex;
align-items: center;
gap: 12px;
}
.device-name {
flex: 1;
min-width: 0;
font-size: 38px;
color: #30343a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-tag {
font-size: 22px;
color: $color-primary-deep;
border: 1px solid $color-primary-deep;
border-radius: 20px;
padding: 2px 10px;
}
.name-tag {
color: $color-info-light;
border-color: $color-info-light;
}
.device-id-text {
display: block;
margin-top: 8px;
font-size: 30px;
color: #8a8f98;
word-break: break-all;
}
.device-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.action-btn {
padding: 8px 16px;
border: 2px solid $color-primary;
color: $color-primary-deep;
font-size: 30px;
border-radius: 6px;
background: #f8fffc;
}
.action-btn.disabled {
border-color: $color-primary-light-border;
color: #7db89e;
background: #f5faf7;
}

View File

@ -1,144 +0,0 @@
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { useBle } from '@/ble/hooks/useBle'
import { navigateTo } from '@/utils/navigation'
import { ROUTES } from '@/constants/routes'
import { getErrorMessage } from '@/utils/error'
import { DEVICE_ACTION_KEYS, ACTION_REGISTRY, isEp832Device } from '@/constants/deviceActions'
import type { BleDevice } from '@/ble/hooks/useBle'
import styles from './ble-config.module.scss'
export default function DeviceBleConfig() {
const { state, discovery } = useBle()
const [preparingId, setPreparingId] = useState('')
// 页面隐藏/卸载时清理搜索(对齐旧版 onHide/onUnload),连接保持供配网页使用
useEffect(() => {
return () => {
if (discovery.getState().isSearching) {
discovery.stopSearch(false)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const startScan = async () => {
if (state.searching) {
await discovery.stopSearch()
return
}
// startSearch 内部自动完成:Android 定位权限检查 + 适配器初始化 +
// 服务 UUID 过滤扫描(00F4/F400/FFF4)+ 4s 无结果兜底全扫 + 30s 自动停止
try {
await discovery.startSearch()
} catch {
// startSearch 内部已 toast 具体错误码,此处不再重复提示
}
}
// 点击"网络配置":连接 → 发现服务特征 → 订阅 F301(厂家确认无需鉴权)→ 打开配网页
const openWifiPanel = async (deviceId: string) => {
if (preparingId) return
setPreparingId(deviceId)
Taro.showLoading({ title: '连接设备中', mask: true })
let prepError: unknown = null
try {
await discovery.prepareWifiConfig(deviceId)
navigateTo(ROUTES.DEVICE_CONFIG)
} catch (error) {
prepError = error
} finally {
setPreparingId('')
// showLoading 与 showToast 共用同一遮罩层,必须先 hideLoading 再弹提示
Taro.hideLoading()
}
if (prepError) {
// 连接/订阅失败用模态弹窗,需用户主动确认,确保错误诊断信息可见可读
Taro.showModal({
title: '设备连接失败',
content: getErrorMessage(prepError),
showCancel: false,
confirmText: '知道了'
})
}
}
const invokeAction = (key: string, deviceId: string) => {
if (key === 'WIFI') openWifiPanel(deviceId)
// PARAMS/SERVER 仅占位,后续接入
}
const isActionBusy = (key: string, deviceId: string) =>
key === 'WIFI' && preparingId === deviceId
const renderDevice = (device: BleDevice) => {
const actions = (DEVICE_ACTION_KEYS[isEp832Device(device) ? 'EP832' : 'DEFAULT'] ?? DEVICE_ACTION_KEYS.DEFAULT)
.map((key) => ({ key, ...ACTION_REGISTRY[key] }))
return (
<View key={device.deviceId} className={styles.deviceCard}>
<View className={styles.deviceIcon} />
<View className={styles.deviceMain}>
<View className={styles.deviceNameRow}>
<Text className={styles.deviceName}>{String(device.showName ?? device.deviceId)}</Text>
{device.hasTargetService ? (
<Text className={styles.deviceTag}>00F4</Text>
) : device.matchedByName || device.matchedByPayload ? (
<Text className={`${styles.deviceTag} ${styles.nameTag}`}>名称匹配</Text>
) : null}
</View>
<Text className={styles.deviceIdText}>{String(device.deviceId)}</Text>
<View className={styles.deviceActions}>
{actions.map((action) => {
const busy = isActionBusy(action.key, device.deviceId)
return (
<View
key={action.key}
className={`${styles.actionBtn} ${busy ? styles.disabled : ''}`}
onClick={() => !busy && invokeAction(action.key, device.deviceId)}
>
<Text>{busy ? action.busyLabel : action.label}</Text>
</View>
)
})}
</View>
</View>
</View>
)
}
return (
<View className={styles.page}>
<Navbar title="设备配置" variant="hero" />
<View className={styles.searchWrap}>
<View
className={`${styles.searchBtn} ${state.searching ? styles.isSearching : ''}`}
onClick={startScan}
>
<Text>{state.searching ? '搜索中' : '搜索'}</Text>
</View>
</View>
<View className={styles.tipRow}>
<Text>*手机需开启蓝牙功能,Android手机还需开启定位功能</Text>
</View>
<View className={styles.listHeader}>
<Text className={styles.listTitle}>搜索到的设备列表</Text>
</View>
<View className={styles.listBody}>
{state.deviceList.length === 0 ? (
<View className={styles.emptyRow}><Text>暂无搜索结果</Text></View>
) : (
state.deviceList.map(renderDevice)
)}
</View>
</View>
)
}

View File

@ -1,123 +0,0 @@
.page {
min-height: 100vh;
background: $color-bg;
}
.device-row {
min-height: 74px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 30px;
border-bottom: 1px solid #f4f4f4;
background: $color-bg-white;
margin-top: 20px;
}
.device-label {
font-size: 28px;
color: #8a8f98;
}
.device-value {
flex: 1;
min-width: 0;
font-size: 27px;
color: #4a4f56;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wifi-row {
min-height: 102px;
border-bottom: 1px solid #f2f2f2;
display: flex;
align-items: center;
padding: 0 30px;
gap: 20px;
background: $color-bg-white;
}
.wifi-label {
width: 170px;
font-size: 34px;
color: #363b45;
}
.required {
color: #ff4f4f;
margin-right: 8px;
}
.wifi-input {
flex: 1;
min-width: 0;
height: 80px;
font-size: 32px;
color: $color-text;
}
.wifi-input-ssid {
max-width: 260px;
}
.current-btn {
flex-shrink: 0;
min-width: 190px;
height: 62px;
border: 1px solid $color-primary-light-border;
border-radius: $radius-sm;
color: $color-primary-deep;
background: #effcf5;
text-align: center;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
}
.current-btn.disabled {
border-color: #b8d8ca;
color: #9bbbae;
background: #f3f6f4;
}
.import-row {
min-height: 100px;
border-bottom: 1px solid #f2f2f2;
font-size: 34px;
color: $color-primary;
display: flex;
align-items: center;
justify-content: center;
background: $color-bg-white;
}
.error-tip {
min-height: 80px;
padding: 16px 30px;
font-size: 26px;
color: $color-danger-deep;
background: $color-danger-light-bg;
line-height: 1.5;
word-break: break-all;
}
.submit-btn {
height: 100px;
background: $color-primary;
color: #ffffff;
font-size: 36px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin: 40px 24px 0;
border-radius: 12px;
}
.submit-btn.disabled {
background: #8edcae;
}

View File

@ -1,206 +0,0 @@
import { useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { showToast } from '@/utils/toast'
import { getErrorMessage } from '@/utils/error'
import { navigateBack } from '@/utils/navigation'
import { useBle } from '@/ble/hooks/useBle'
import styles from './config.module.scss'
const WIFI_HISTORY_STORAGE_KEY = 'SMARTHOME_LAST_WIFI_CONFIG'
const WIFI_STATE_MESSAGES: Record<number, string> = Object.freeze({
1: 'WiFi 参数校验失败,请检查名称与密码',
2: '设备连接该 WiFi 失败,请确认密码是否正确',
3: 'WiFi 配置成功',
4: '设备正在连接 WiFi,请稍候'
})
function getWifiStateMessage(stateCode: number) {
return WIFI_STATE_MESSAGES[stateCode] || `WiFi 状态码异常: ${stateCode}`
}
export default function DeviceConfig() {
const { state, discovery } = useBle()
const [ssid, setSsid] = useState('')
const [password, setPassword] = useState('')
const [sending, setSending] = useState(false)
const [fetchingCurrentWifi, setFetchingCurrentWifi] = useState(false)
const [errorTip, setErrorTip] = useState('')
const selectedDevice = state.deviceList.find((d) => d.deviceId === discovery.getState().currentDeviceId)
const selectedDeviceName = (selectedDevice?.showName as string)
|| (selectedDevice?.deviceId as string)
|| '未选择设备'
// 启动 WiFi 模块并读取手机当前连接的 SSID(部分端必须先 startWifi)
const fetchCurrentWifiSsid = async () => {
if (fetchingCurrentWifi) return
setFetchingCurrentWifi(true)
try {
try {
await Taro.startWifi({})
} catch {
// 已启动等非致命错误忽略,继续尝试读取当前连接
}
const result = await Taro.getConnectedWifi({})
const info = (result as unknown as { wifi?: { SSID?: string; ssid?: string } }).wifi
const name = String(info?.SSID || info?.ssid || '').trim()
if (!name || name.toLowerCase().includes('unknown')) {
throw new Error('未获取到当前连接 WiFi 名称')
}
setSsid(name)
showToast('已填入当前连接WiFi')
} catch (error) {
showToast(getErrorMessage(error))
} finally {
setFetchingCurrentWifi(false)
}
}
const importLastWifiConfig = () => {
try {
const cached = Taro.getStorageSync(WIFI_HISTORY_STORAGE_KEY) as
| { ssid?: string; password?: string }
| ''
if (!cached || typeof cached !== 'object') {
showToast('暂无上次 WiFi 配置')
return
}
const s = String(cached.ssid || '').trim()
const p = String(cached.password || '').trim()
if (!s || !p) {
showToast('暂无可用的历史 WiFi 配置')
return
}
setSsid(s)
setPassword(p)
showToast('已导入上次 WiFi 配置')
} catch {
showToast('读取历史 WiFi 配置失败')
}
}
const cacheWifiConfig = (s: string, p: string) => {
try {
Taro.setStorageSync(WIFI_HISTORY_STORAGE_KEY, { ssid: s, password: p, updatedAt: Date.now() })
} catch {
// 缓存失败不阻断配网结果
}
}
const confirmWifiSettings = async () => {
if (sending) return
if (!state.connected) {
showToast('设备未连接,请重新配置')
return
}
const s = String(ssid || '').trim()
const p = String(password || '').trim()
if (!s) {
showToast('请输入 WiFi 名称')
return
}
if (!p) {
showToast('请输入 WiFi 密码')
return
}
setSending(true)
setErrorTip('')
let needAutoClose = false
let tipMessage = ''
try {
// 发送后等待设备经 UUID_STATE 返回最终态 1/2/3,才提示并自动返回
const result = await discovery.configureWifi(s, p, { timeout: 60000 })
tipMessage = getWifiStateMessage(result.stateCode)
needAutoClose = result.success
if (result.success) cacheWifiConfig(s, p)
} catch (error) {
// 超时/异常:错误持久展示在弹层内,避免瞬态 toast 被覆盖看不清
tipMessage = getErrorMessage(error)
setErrorTip(tipMessage)
} finally {
setSending(false)
if (tipMessage) showToast(tipMessage)
if (needAutoClose) navigateBack()
}
}
return (
<View className={styles.page}>
<Navbar title="设备网络设置" variant="hero" />
<View className={styles.deviceRow}>
<Text className={styles.deviceLabel}>设备</Text>
<Text className={styles.deviceValue}>{selectedDeviceName}</Text>
</View>
<View className={styles.wifiRow}>
<Text className={styles.wifiLabel}>
<Text className={styles.required}>*</Text>WiFi名称
</Text>
<Input
className={styles.wifiInputSsid}
placeholder="请输入 WiFi 名称"
maxlength={32}
confirmType="done"
value={ssid}
onInput={(e) => setSsid(e.detail.value)}
/>
<View
className={`${styles.currentBtn} ${fetchingCurrentWifi ? styles.disabled : ''}`}
onClick={() => !fetchingCurrentWifi && fetchCurrentWifiSsid()}
>
<Text>{fetchingCurrentWifi ? '获取中...' : '一键获取当前连接WiFi'}</Text>
</View>
</View>
<View className={styles.wifiRow}>
<Text className={styles.wifiLabel}>
<Text className={styles.required}>*</Text>WiFi密码
</Text>
<Input
className={styles.wifiInput}
placeholder="请输入密码"
password
maxlength={63}
confirmType="done"
value={password}
onInput={(e) => setPassword(e.detail.value)}
/>
</View>
<View className={styles.importRow} onClick={importLastWifiConfig}>
<Text>一键导入上次 WiFi 配置</Text>
</View>
{errorTip ? (
<View className={styles.errorTip}>
<Text>{errorTip}</Text>
</View>
) : null}
<View
className={`${styles.submitBtn} ${sending ? styles.disabled : ''}`}
onClick={() => !sending && confirmWifiSettings()}
>
<Text>{sending ? '发送中...' : '发送'}</Text>
</View>
</View>
)
}

View File

@ -1,184 +0,0 @@
.page {
min-height: 100vh;
background: $color-bg;
}
.stateWrap {
margin-top: 120px;
display: flex;
flex-direction: column;
align-items: center;
}
.heartCard {
margin: 24px;
padding: 32px;
background: linear-gradient(135deg, #fff 0%, #fdf7f6 100%);
border-radius: $radius-lg;
box-shadow: $shadow-card;
display: flex;
align-items: flex-start;
border: 1px solid rgba(245, 34, 45, 0.06);
}
.heartLeft {
display: flex;
flex-direction: column;
align-items: center;
min-width: 200px;
}
// 心形图标:浅红圆底衬托,弱化大面积实色
.heartIconWrap {
width: 88px;
height: 88px;
border-radius: $radius-circle;
background: $color-danger-light-bg;
display: flex;
align-items: center;
justify-content: center;
}
.heartIcon {
font-size: 48px;
color: $color-danger;
line-height: 1;
}
.heartValue {
margin-top: 16px;
font-size: 96px;
font-weight: 700;
color: $color-text;
line-height: 1.1;
}
// 心率越阈(<60 或 >100 BPM):大数字转红
.heartValueDanger { color: $color-danger; }
.heartUnit { margin-top: 8px; font-size: 30px; color: $color-text-secondary; }
.badge {
margin-top: 20px;
display: flex;
align-items: center;
padding: 8px 24px;
border-radius: $radius-pill;
font-size: 24px;
}
.badgeOn { background: $color-success-light-bg; color: $color-success; }
.badgeOff { background: $color-bg-disabled; color: $color-text-secondary; }
.badgeDot {
width: 14px;
height: 14px;
border-radius: $radius-circle;
margin-right: 10px;
}
.badgeDotOn { background: $color-success; }
.badgeDotOff { background: $color-text-secondary; }
.heartRight { flex: 1; min-width: 0; margin-left: 32px; }
.heartHealthLabel {
font-size: 30px;
font-weight: 500;
color: $color-text;
margin-bottom: 8px;
display: block;
}
.heartTime {
font-size: 24px;
color: $color-text-tertiary;
}
.metricGrid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 0 24px;
}
.metricCard {
width: 48.5%;
box-sizing: border-box;
margin-bottom: 20px;
padding: 28px 24px;
background: $color-bg-white;
border-radius: $radius-md;
box-shadow: $shadow-card;
}
// 指标名左侧小色条:六卡各自的语义色,替代大数字整体染色
.metricLabel {
display: flex;
align-items: center;
font-size: 26px;
color: $color-text-secondary;
&::before {
content: '';
width: 8px;
height: 24px;
border-radius: $radius-pill;
margin-right: 12px;
background: $color-text-placeholder;
}
}
.metricLabelBlue::before { background: $color-info; }
.metricLabelOrange::before { background: $color-warning; }
.metricLabelGreen::before { background: $color-success; }
.metricLabelRed::before { background: $color-danger; }
.metricRow {
margin-top: 14px;
display: flex;
align-items: baseline;
}
.metricValue {
font-size: 44px;
font-weight: 700;
color: $color-text;
}
// 状态告警色:warning 橙(注意态)、danger 红(越阈值)
.metricValueWarning {
font-size: 44px;
font-weight: 700;
color: $color-warning;
}
.metricValueDanger {
font-size: 44px;
font-weight: 700;
color: $color-danger;
}
.metricBlue { color: $color-primary-deep; }
.metricOrange { color: #fa8c16; }
.metricGreen { color: $color-success; }
.metricRed { color: $color-danger; }
.metricUnit {
margin-left: 8px;
font-size: 24px;
color: $color-text-tertiary;
}
// 设备信息卡分区标题
.infoTitle {
display: block;
margin: 24px 24px 16px;
font-size: 30px;
font-weight: 600;
color: $color-text;
}
.infoCard {
margin: 4px 24px 24px;
}

View File

@ -1,243 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from '@tarojs/taro'
import Taro, { useDidShow, useDidHide } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { ListRow, ListRowGroup } from '@/components/list-row'
import { VitalWave } from '@/components/vital-wave'
import { ROUTES } from '@/constants/routes'
import { navigateTo, navigateBack } from '@/utils/navigation'
import { showToast } from '@/utils/toast'
import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP, FALL_STATUS_MAP } from '@/constants/enums'
import {
getHomeDeviceDetail, getVitalRecords, unbindHomeDevice, type HomeDevice, type VitalSigns
} from '@/api/homeDevice'
import { FallStatusCard } from './fall-status-card'
import type { DeviceModel } from '@/constants/enums'
import styles from './detail.module.scss'
// 轮询间隔:与 web 端 VitalSignsDashboard SWR refreshInterval 对齐
const POLL_INTERVAL = 3000
// 数值告警阈值(医学常规):心率 60-100 BPM、呼吸率 12-24 /min
const HR_NORMAL: [number, number] = [60, 100]
const RR_NORMAL: [number, number] = [12, 24]
// 数值越阈 → 红;否则保持主文本色
const numericLevel = (v: number | undefined, range: [number, number]): 'normal' | 'danger' =>
v === undefined || v === null ? 'normal' : (v < range[0] || v > range[1] ? 'danger' : 'normal')
// 枚举状态 → 语义色:异常态橙红、正常态主文本色
const MOVE_STATE_LEVEL: Record<number, 'normal' | 'warning'> = { 2: 'warning' } // 频繁活动
const BODY_STATUS_LEVEL: Record<number, 'normal' | 'warning'> = { 0: 'warning', 1: 'warning' } // 无人/运动
const ONBED_LEVEL: Record<number, 'normal' | 'warning'> = { 0: 'warning' } // 不在床
const levelCls = (level: 'normal' | 'warning' | 'danger'): string =>
level === 'normal' ? styles.metricValue : level === 'warning' ? styles.metricValueWarning : styles.metricValueDanger
export default function Detail() {
const router = useRouter()
const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string }
const installId = params.id ?? ''
const deviceName = params.name ? decodeURIComponent(params.name) : '设备详情'
const deviceModel = params.model ?? ''
// 列表页 EventChannel 传入的设备对象:首屏零延迟
const [device, setDevice] = useState<HomeDevice | null>(null)
const [online, setOnline] = useState(params.online === '1')
const [wavePoints, setWavePoints] = useState<number[]>([])
const [unbinding, setUnbinding] = useState(false)
const deviceNameRef = useRef<string | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
const inst = Taro.getCurrentInstance()
const page = inst.page as unknown as { getOpenerEventChannel?: () => { on: (e: string, cb: (d: HomeDevice) => void) => void } } | undefined
page?.getOpenerEventChannel?.()?.on('device', (d) => {
setDevice(d)
setOnline(d.onlineStatus === 1)
deviceNameRef.current = d.deviceName
})
}, [])
// 单次刷新:设备详情(实时快照)+ 历史心率(波形,仅 ED713)
const fetchAll = useCallback(async () => {
if (!installId) return
try {
const d = await getHomeDeviceDetail(installId)
setDevice(d)
setOnline(d.onlineStatus === 1)
deviceNameRef.current = d.deviceName
if (d.subDeviceType === 'ED719') return
const records = await getVitalRecords(d.deviceName)
// 倒序返回(最新在前),波形需按时间正序绘制
const pts = (records ?? []).map((r) => r.heartRate).filter((v): v is number => typeof v === 'number')
setWavePoints(pts.reverse())
} catch {
// 单次轮询失败不中断,下个周期重试
}
}, [installId])
// 首拉 + useDidShow/useDidHide 控制轮询启停:页面压栈后台不空跑
useEffect(() => {
fetchAll()
}, [fetchAll])
useDidShow(() => {
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = setInterval(() => { fetchAll() }, POLL_INTERVAL)
})
useDidHide(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
})
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [])
const signs: VitalSigns | undefined = device?.runTimeInfo?.properties
const fmt = (v: number | undefined): string => (v === undefined || v === null ? '--' : String(v))
// ED719 跌倒检测器走独立卡片,不渲染体征面板
const isFallDetector = deviceModel === 'ED719' || device?.subDeviceType === 'ED719'
const goBleConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
void goBleConfig
// 解绑确认:防误触,成功后停轮询返回列表页
const confirmUnbind = () => {
if (unbinding || !installId) return
Taro.showModal({
title: '解绑设备',
content: `确定解绑「${deviceName}」吗?解绑后需重新添加。`,
confirmText: '解绑',
confirmColor: '#EE4B4B',
success: (res) => {
if (!res.confirm) return
doUnbind()
}
})
}
const doUnbind = async () => {
setUnbinding(true)
try {
await unbindHomeDevice({ installId })
showToast('已解绑')
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
navigateBack()
} catch (e) {
showToast(e instanceof Error ? e.message : '解绑失败')
setUnbinding(false)
}
}
return (
<View className={styles.page}>
<Navbar title={deviceName} variant="hero" />
{isFallDetector ? (
<FallStatusCard
alarmType={device?.runTimeInfo?.properties?.alarmType}
status={device?.runTimeInfo?.properties?.status}
lastTime={device?.runTimeInfo?.properties?.lastTime}
version={device?.runTimeInfo?.properties?.version}
online={online}
/>
) : (
<>
<View className={styles.heartCard}>
<View className={styles.heartLeft}>
<View className={styles.heartIconWrap}>
<Text className={styles.heartIcon}>❤</Text>
</View>
<Text className={`${styles.heartValue} ${numericLevel(signs?.heartRate, HR_NORMAL) === 'danger' ? styles.heartValueDanger : ''}`}>{fmt(signs?.heartRate)}</Text>
<Text className={styles.heartUnit}>BPM</Text>
<View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}>
<View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} />
<Text>{online ? '在线' : '离线'}</Text>
</View>
</View>
<View className={styles.heartRight}>
<Text className={styles.heartHealthLabel}>心率健康</Text>
<VitalWave points={wavePoints} canvasId="heartWave" />
{signs?.lastTime && (
<Text className={styles.heartTime}>更新于 {signs.lastTime}</Text>
)}
</View>
</View>
<View className={styles.metricGrid}>
<View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>呼吸率</Text>
<View className={styles.metricRow}>
<Text className={levelCls(numericLevel(signs?.respiratoryRate, RR_NORMAL))}>{fmt(signs?.respiratoryRate)}</Text>
<Text className={styles.metricUnit}>/min</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelOrange}`}>体动</Text>
<View className={styles.metricRow}>
<Text className={styles.metricValue}>{fmt(signs?.bodyMovement)}</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>运动状态</Text>
<View className={styles.metricRow}>
<Text className={levelCls(MOVE_STATE_LEVEL[signs?.moveState ?? -1] ?? 'normal')}>
{MOVE_STATE_MAP[signs?.moveState ?? -1] ?? '--'}
</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelRed}`}>在床状态</Text>
<View className={styles.metricRow}>
<Text className={levelCls(ONBED_LEVEL[signs?.onbedStatus ?? -1] ?? 'normal')}>
{ONBED_STATUS_MAP[signs?.onbedStatus ?? -1] ?? '--'}
</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>人体状态</Text>
<View className={styles.metricRow}>
<Text className={levelCls(BODY_STATUS_LEVEL[signs?.bodyStatus ?? -1] ?? 'normal')}>
{BODY_STATUS_MAP[signs?.bodyStatus ?? -1] ?? '--'}
</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>人体位置</Text>
<View className={styles.metricRow}>
<Text className={styles.metricValue}>{fmt(signs?.bodyPosition)}</Text>
<Text className={styles.metricUnit}>分米</Text>
</View>
</View>
</View>
</>
)}
<Text className={styles.infoTitle}>设备信息</Text>
<View className={styles.infoCard}>
<ListRowGroup>
<ListRow label="设备型号" value={deviceModel || '-'} />
<ListRow label="设备 ID" value={installId || '-'} />
<ListRow label="安装房间" value={device?.roomName ?? '-'} />
{/* BLE 配置流程联调未完成,暂隐藏两个配置入口
<ListRow label="网络配置" arrow onClick={goWifiConfig} />
<ListRow label="设备配置" arrow onClick={goBleConfig} />
*/}
<ListRow label="解绑设备" arrow danger onClick={confirmUnbind} />
</ListRowGroup>
</View>
</View>
)
}

View File

@ -1,102 +0,0 @@
// ED719 跌倒检测状态卡:状态色随语义变化(normal 绿 / warning 橙 / danger 红 / success 绿 / offline 灰)
// variables 由构建配置全局注入(config/index.ts sass.resource)
.fallCard {
margin: 24px;
padding: 32px;
border-radius: $radius-lg;
background: $color-bg-white;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
align-items: center;
}
.fallCardDanger { background: linear-gradient(160deg, #fff5f4 0%, #ffffff 60%); }
.fallCardWarning { background: linear-gradient(160deg, #fffaf0 0%, #ffffff 60%); }
.fallCardSuccess { background: linear-gradient(160deg, #f4fff0 0%, #ffffff 60%); }
.fallHead {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.fallTitle {
font-size: 30px;
font-weight: 600;
color: $color-text;
}
.fallBadge {
display: flex;
align-items: center;
}
.fallBadgeDot {
width: 14px;
height: 14px;
border-radius: $radius-circle;
margin-right: 10px;
}
.fallBadgeDotOn { background: $color-success; }
.fallBadgeDotOff { background: $color-text-secondary; }
.fallBadgeTextOn { font-size: 24px; color: $color-success; }
.fallBadgeTextOff { font-size: 24px; color: $color-text-secondary; }
// 图标圆底:色随状态
.fallIconWrap {
width: 160px;
height: 160px;
margin-top: 40px;
border-radius: $radius-circle;
display: flex;
align-items: center;
justify-content: center;
background: $color-primary-light-bg;
}
.fallIconWrapNormal { background: $color-primary-light-bg; }
.fallIconWrapOffline { background: $color-bg-disabled; }
.fallIconWrapDanger { background: $color-danger-light-bg; }
.fallIconWrapWarning { background: #fff7e8; }
.fallIconWrapSuccess { background: $color-success-light-bg; }
.fallIcon {
font-size: 72px;
line-height: 1;
color: $color-primary;
}
.fallIconImg {
width: 110px;
height: 110px;
}
.fallStatusText {
margin-top: 32px;
font-size: 44px;
font-weight: 700;
color: $color-text;
}
.fallStatusTextNormal { color: $color-primary-deep; }
.fallStatusTextOffline { color: $color-text-secondary; }
.fallStatusTextDanger { color: $color-danger; }
.fallStatusTextWarning { color: $color-warning; }
.fallStatusTextSuccess { color: $color-success; }
.fallTime {
margin-top: 16px;
font-size: 24px;
color: $color-text-tertiary;
}
.fallVersion {
margin-top: 8px;
font-size: 24px;
color: $color-text-tertiary;
}

View File

@ -1,65 +0,0 @@
import { View, Text, Image } from '@tarojs/components'
import { FALL_STATUS_MAP } from '@/constants/enums'
import fallAlertIcon from '@/assets/event/fall-alert.png'
import fallSuspectIcon from '@/assets/event/fall-suspect.png'
import fallClearedIcon from '@/assets/event/fall-cleared.png'
import styles from './fall-status-card.module.scss'
interface Props {
// 实时跌倒状态编码(见 FALL_STATUS_MAP)。优先 alarmType,status 为兼容字段
alarmType?: number
status?: number
lastTime?: string
version?: string
online: boolean
}
// 状态 → 语义色(正常无编码/离线灰、疑似黄、摔倒红、解除绿、驻留橙)
const STATUS_CLS: Record<number, string> = {
3101: 'danger',
3102: 'warning',
3103: 'success',
3104: 'warning'
}
// 状态 → 产品状态图:未匹配时(如 3104 驻留、正常、离线)回退到 ⌁ 占位
const FALL_ICON_MAP: Record<number, string> = {
3101: fallAlertIcon,
3102: fallSuspectIcon,
3103: fallClearedIcon
}
// ED719 跌倒检测状态卡:实时状态大卡(参考 web 端 DeviceScgDashboard DeviceItem 布局)
export function FallStatusCard({ alarmType, status, lastTime, version, online }: Props) {
const code = alarmType ?? status
const label = FALL_STATUS_MAP[code ?? -1] ?? (online ? '正常' : '离线')
const level = STATUS_CLS[code ?? -1] ?? (online ? 'normal' : 'offline')
const iconSrc = code != null ? FALL_ICON_MAP[code] : undefined
return (
<View className={`${styles.fallCard} ${styles[`fallCard${cap(level)}`]}`}>
<View className={styles.fallHead}>
<Text className={styles.fallTitle}>检测状态</Text>
<View className={styles.fallBadge}>
<View className={`${styles.fallBadgeDot} ${online ? styles.fallBadgeDotOn : styles.fallBadgeDotOff}`} />
<Text className={online ? styles.fallBadgeTextOn : styles.fallBadgeTextOff}>{online ? '在线' : '离线'}</Text>
</View>
</View>
<View className={`${styles.fallIconWrap} ${styles[`fallIconWrap${cap(level)}`]}`}>
{iconSrc ? (
<Image className={styles.fallIconImg} src={iconSrc} mode="aspectFit" />
) : (
<Text className={styles.fallIcon}>⌁</Text>
)}
</View>
<Text className={`${styles.fallStatusText} ${styles[`fallStatusText${cap(level)}`]}`}>{label}</Text>
{lastTime && <Text className={styles.fallTime}>更新于 {lastTime}</Text>}
{version && <Text className={styles.fallVersion}>固件版本 {version}</Text>}
</View>
)
}
// CSS Modules 连续大写坑:camelCaseOnly 会把 TL 转 Tl(见记忆 css-modules-camelcase-trap),
// 动态拼 key 需首字母大写驼峰
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)

View File

@ -1,324 +0,0 @@
.page {
position: relative;
min-height: 100vh;
background: $color-bg;
padding-bottom: 40px;
}
.stateWrap {
margin-top: 80px;
display: flex;
flex-direction: column;
align-items: center;
}
// 绿色衬底:占位高度与浮卡上浮量(-40px)匹配,卡片叠在它上面而不是压进导航栏
.heroSpace {
height: 64px;
background: linear-gradient(180deg, #2ccb98 0%, #35d6a5 100%);
margin-bottom: -40px;
position: relative;
z-index: 1;
}
// 设备应用家庭信息卡:白色卡片浮在绿色头部下,浅灰行内分隔
.familyCard {
margin: 0 24px;
padding: 8px 32px 32px;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: 0 8px 24px rgba(44, 203, 152, 0.12);
position: relative;
z-index: 2;
}
.familyRow {
min-height: 104px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $color-border;
&:last-of-type {
border-bottom: none;
}
}
.familyLabel {
font-size: 30px;
color: $color-text-secondary;
}
.familyValue {
font-size: 30px;
color: $color-text;
font-weight: 600;
}
.switchBtn {
width: 100%;
margin-top: 24px;
height: 88px;
background: $color-primary-grad;
border-radius: $radius-pill;
display: flex;
align-items: center;
justify-content: center;
}
.switchBtnPressed {
opacity: 0.85;
}
.switchBtnText {
font-size: 30px;
color: #ffffff;
font-weight: 600;
}
// 房间管理 / 添加设备 两张并排功能卡
.funcRow {
display: flex;
padding: 24px 24px 0;
gap: 24px;
}
.funcCard {
flex: 1;
height: 144px;
background: $color-bg-white;
border-radius: $radius-md;
box-shadow: $shadow-card;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.funcCardPressed {
background: $color-bg-press;
}
// 左侧圆角浅绿图标底:延续「左图标色块」卡片语言
.funcIconWrap {
width: 88px;
height: 88px;
border-radius: $radius-md;
background: $color-primary-light-bg;
display: flex;
align-items: center;
justify-content: center;
}
.funcIcon {
font-size: 48px;
color: $color-primary;
line-height: 1;
}
.funcText {
font-size: 28px;
color: $color-text;
font-weight: 600;
}
// 房间筛选条:横向滚动 pill,默认「全部」
.roomBar {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
padding: 24px 24px 8px;
gap: 16px;
}
.roomChip {
flex-shrink: 0;
padding: 12px 32px;
background: $color-bg-white;
border: 1px solid $color-border;
border-radius: $radius-pill;
font-size: 26px;
color: $color-text-secondary;
}
.roomChipActive {
background: $color-primary-grad;
border-color: transparent;
}
.roomChipTextActive {
color: #ffffff;
font-weight: 600;
}
// 「我的设备」分区标题:筛选条与网格之间的视觉锚点
.sectionTitle {
display: flex;
align-items: center;
padding: 24px 24px 16px;
font-size: 30px;
font-weight: 600;
color: $color-text;
.sectionCount {
margin-left: 12px;
padding: 2px 16px;
background: $color-primary-light-bg;
border-radius: $radius-pill;
font-size: 22px;
font-weight: 500;
color: $color-primary-deep;
}
}
// 设备网格
.grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 0 24px;
}
.card {
width: 48.5%;
margin-bottom: 24px;
padding: 36px 20px 28px;
box-sizing: border-box;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid transparent;
&:active {
border-color: $color-primary-light-border;
}
}
// 上半区(图标/名称/状态)点击进详情,下方解绑按钮独立命中
.cardTop {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.icon {
width: 108px;
height: 108px;
padding: 14px;
border-radius: 32px;
background: $color-primary-grad;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-sizing: border-box;
box-shadow: 0 6px 16px rgba(44, 203, 152, 0.25);
}
.iconText {
font-size: 36px;
color: #ffffff;
}
.iconImg {
width: 80px;
height: 80px;
}
.name {
max-width: 100%;
font-size: 28px;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusRow {
margin-top: 12px;
display: flex;
align-items: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: $radius-circle;
margin-right: 10px;
}
.dotOn { background: $color-primary; }
.dotOff { background: $color-text-placeholder; }
.statusText { font-size: 24px; color: $color-primary-deep; }
.statusTextOff { color: $color-text-secondary; }
// 切换家庭圈弹层内的列表项
.sheetList {
padding-bottom: 16px;
}
.sheetItem {
padding: 24px 32px;
background: $color-bg-gray;
border: 2px solid transparent;
border-radius: $radius-md;
margin-bottom: 16px;
}
.sheetItemActive {
background: $color-primary-light-bg;
border-color: $color-primary;
}
.sheetHead {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.sheetName {
font-size: 32px;
color: $color-text;
font-weight: 600;
margin-right: 16px;
}
.sheetMeta {
display: flex;
justify-content: space-between;
}
.sheetMetaItem {
font-size: 24px;
color: $color-text-secondary;
}
.sheetItemActive .sheetMetaItem {
color: $color-text;
}
.sheetConfirm {
margin: 16px 0 32px;
height: 96px;
background: $color-primary-grad;
border-radius: $radius-pill;
display: flex;
align-items: center;
justify-content: center;
}
.sheetConfirmPressed {
opacity: 0.85;
}
.sheetConfirmText {
font-size: 32px;
color: #ffffff;
font-weight: 600;
}

View File

@ -1,304 +0,0 @@
import { useState, useCallback } from 'react'
import { useDidShow } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading'
import { StateView } from '@/components/state-view'
import { BottomSheet } from '@/components/bottom-sheet'
import { store } from '@/store'
import { storage } from '@/utils/storage'
import { getHomeDeviceList, type HomeDevice } from '@/api/homeDevice'
import { getRoomList, type HomeRoom } from '@/api/homeRoom'
import { getHomeList, type HomeInfo } from '@/api/home'
import { resolveCurrentHome, setCurrentHomeId } from '@/store/currentHome'
import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation'
import ed713Icon from '@/assets/device/ed713.png'
import ed719Icon from '@/assets/device/ed719.png'
import eb15Icon from '@/assets/device/eb15.jpg'
import styles from './index.module.scss'
const FALLBACK_ICON = '⌂'
// 设备型号 → 产品图:sub-device 优先(EB15 是 EP832 网关下的子设备,
// 在 deviceModel 中没有对应值,需要走 subDeviceType 分支)。后端有时把型号
// 放在 deviceModel、有时放 subDeviceType,所以两个字段都查一遍
const pickDeviceIcon = (device: HomeDevice): string | null => {
const model = device.subDeviceType ?? device.deviceModel
if (model === 'EB15') return eb15Icon
if (model === 'ED713') return ed713Icon
if (model === 'ED719') return ed719Icon
return null
}
// 设备显示名:优先 name,未命名回落序列号
const deviceLabel = (device: HomeDevice): string => device.name || device.deviceName
export default function DeviceList() {
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
const [deviceList, setDeviceList] = useState<HomeDevice[]>([])
const [families, setFamilies] = useState<HomeInfo[]>([])
const [currentFamily, setCurrentFamily] = useState<HomeInfo | null>(null)
const [familySheetOpen, setFamilySheetOpen] = useState(false)
const [pendingFamily, setPendingFamily] = useState<HomeInfo | null>(null)
// 房间筛选:'' 为全部房间
const [rooms, setRooms] = useState<HomeRoom[]>([])
const [filterRoomId, setFilterRoomId] = useState('')
// 游客浏览:未登录不拉数据,展示引导登录(微信审核要求不强制授权)
const isGuest = () =>
!store.getState().auth.token && !storage.get<string>('auth_token')
const isGuestEmpty = isGuest()
// 设备列表随家庭 + 房间筛选联动
const loadDevices = useCallback(async (homeId?: string, roomId?: string) => {
const targetHomeId = homeId ?? currentFamily?.homeId
if (!targetHomeId) {
setDeviceList([])
setLoading(false)
return
}
setLoading(true)
setLoadError(false)
try {
const data = await getHomeDeviceList({
homeId: targetHomeId,
roomId: roomId || undefined
})
setDeviceList(data ?? [])
} catch {
setLoadError(true)
} finally {
setLoading(false)
}
}, [currentFamily?.homeId])
const loadFamilies = useCallback(async () => {
try {
const list = await getHomeList()
setFamilies(list)
// 未选中时按持久化的 homeId 恢复,失效则回落第一条
setCurrentFamily((prev) => {
const next = prev ?? resolveCurrentHome(list)
if (next && next.homeId !== prev?.homeId) {
setCurrentHomeId(next.homeId)
setFilterRoomId('')
loadDevices(next.homeId)
loadRooms(next.homeId)
}
return next
})
} catch {
// 静默:家庭圈加载失败不阻断设备列表
}
}, [loadDevices])
// 房间列表随家庭加载,供筛选条使用;失败静默(仅少了筛选能力)
const loadRooms = useCallback(async (homeId?: string) => {
const targetHomeId = homeId ?? currentFamily?.homeId
if (!targetHomeId) return
try {
const list = await getRoomList(targetHomeId)
setRooms(list ?? [])
} catch {
setRooms([])
}
}, [currentFamily?.homeId])
const selectRoom = (roomId: string) => {
if (roomId === filterRoomId) return
setFilterRoomId(roomId)
loadDevices(undefined, roomId)
}
useDidShow(() => {
if (isGuest()) {
setDeviceList([])
setLoading(false)
setLoadError(false)
return
}
loadDevices(undefined, filterRoomId)
loadFamilies()
loadRooms()
})
// EventChannel 把整个设备对象传给详情页:首屏零延迟渲染体征快照
const goDetail = (device: HomeDevice) => {
const name = encodeURIComponent(deviceLabel(device))
Taro.navigateTo({
url: `${ROUTES.DEVICE_DETAIL}?id=${device.installId}&name=${name}&model=${device.subDeviceType ?? ''}&online=${device.onlineStatus ? 1 : 0}`,
success: (res) => res.eventChannel.emit('device', device)
})
}
const goAdd = () => navigateTo(ROUTES.DEVICE_ADD)
const goRoom = () => navigateTo(ROUTES.SCENE_SELECT)
const goLogin = () => navigateTo(ROUTES.LOGIN)
const openFamilySheet = () => {
setPendingFamily(currentFamily)
setFamilySheetOpen(true)
}
const closeFamilySheet = () => {
setFamilySheetOpen(false)
setPendingFamily(null)
}
const confirmFamilySheet = () => {
if (pendingFamily && pendingFamily.homeId !== currentFamily?.homeId) {
setCurrentFamily(pendingFamily)
setCurrentHomeId(pendingFamily.homeId)
setFilterRoomId('')
loadDevices(pendingFamily.homeId)
loadRooms(pendingFamily.homeId)
}
closeFamilySheet()
}
return (
<View className={styles.page}>
<Navbar title="智能设备" autoBack={false} variant="hero" />
{/* 绿色衬底:承接浮卡的上浮区,避免 -40px 压到导航标题 */}
<View className={styles.heroSpace} />
{/* 设备应用家庭信息卡 */}
<View className={styles.familyCard}>
<View className={styles.familyRow}>
<Text className={styles.familyLabel}>设备应用家庭</Text>
<Text className={styles.familyValue}>{currentFamily?.name ?? '未选择'}</Text>
</View>
<View className={styles.familyRow}>
<Text className={styles.familyLabel}>关爱对象</Text>
<Text className={styles.familyValue}>
{currentFamily ? (currentFamily.careMemberCount > 0 ? `${currentFamily.careMemberCount} 人` : '--') : '--'}
</Text>
</View>
<View className={styles.familyRow}>
<Text className={styles.familyLabel}>关爱地址</Text>
<Text className={styles.familyValue}>{currentFamily?.address ?? '--'}</Text>
</View>
<View className={styles.switchBtn} hoverClass={styles.switchBtnPressed} onClick={openFamilySheet}>
<Text className={styles.switchBtnText}>切换家庭圈</Text>
</View>
</View>
{/* 房间管理 + 添加设备 两张并排 */}
<View className={styles.funcRow}>
<View className={styles.funcCard} hoverClass={styles.funcCardPressed} onClick={goRoom}>
<View className={styles.funcIconWrap}>
<Text className={styles.funcIcon}>⌂</Text>
</View>
<Text className={styles.funcText}>房间管理</Text>
</View>
<View className={styles.funcCard} hoverClass={styles.funcCardPressed} onClick={goAdd}>
<View className={styles.funcIconWrap}>
<Text className={styles.funcIcon}>+</Text>
</View>
<Text className={styles.funcText}>添加设备</Text>
</View>
</View>
{/* 房间筛选条:横向滚动 chip,随当前家庭房间列表渲染 */}
{rooms.length > 0 && (
<View className={styles.roomBar}>
<View
className={`${styles.roomChip} ${filterRoomId === '' ? styles.roomChipActive : ''}`}
onClick={() => selectRoom('')}
>
<Text className={filterRoomId === '' ? styles.roomChipTextActive : ''}>全部</Text>
</View>
{rooms.map((room) => {
const active = filterRoomId === room.roomId
return (
<View
key={room.roomId}
className={`${styles.roomChip} ${active ? styles.roomChipActive : ''}`}
onClick={() => selectRoom(room.roomId)}
>
<Text className={active ? styles.roomChipTextActive : ''}>{room.name}</Text>
</View>
)
})}
</View>
)}
{/* 「我的设备」分区标题:数量随筛选联动 */}
{!loading && !loadError && !isGuestEmpty && (
<View className={styles.sectionTitle}>
<Text>我的设备</Text>
<Text className={styles.sectionCount}>{deviceList.length}</Text>
</View>
)}
{/* 设备网格 */}
{loading ? (
<View className={styles.stateWrap}><Loading /></View>
) : isGuestEmpty ? (
<StateView status="error" mode="list" text="登录后查看你的设备" retryText="去登录" onRetry={goLogin} />
) : loadError ? (
<StateView status="error" onRetry={() => loadDevices()} />
) : deviceList.length === 0 ? (
<StateView status="error" mode="list" text="暂无设备" retryText="添加设备" onRetry={goAdd} />
) : (
<View className={styles.grid}>
{deviceList.map((device) => (
<View key={device.installId} className={styles.card}>
<View className={styles.cardTop} onClick={() => goDetail(device)}>
<View className={styles.icon}>
{(() => {
const src = pickDeviceIcon(device)
return src ? (
<Image className={styles.iconImg} src={src} mode="aspectFit" />
) : (
<Text className={styles.iconText}>{FALLBACK_ICON}</Text>
)
})()}
</View>
<Text className={styles.name}>{deviceLabel(device)}</Text>
<View className={styles.statusRow}>
<View className={`${styles.dot} ${device.onlineStatus ? styles.dotOn : styles.dotOff}`} />
<Text className={`${styles.statusText} ${!device.onlineStatus ? styles.statusTextOff : ''}`}>
{device.onlineStatus ? '在线' : '离线'}
</Text>
</View>
</View>
</View>
))}
</View>
)}
{/* 切换家庭圈弹层 */}
<BottomSheet visible={familySheetOpen} title="切换家庭圈" onClose={closeFamilySheet}>
<View className={styles.sheetList}>
{families.map((family) => {
const active = pendingFamily?.homeId === family.homeId
return (
<View
key={family.homeId}
className={`${styles.sheetItem} ${active ? styles.sheetItemActive : ''}`}
onClick={() => setPendingFamily(family)}
>
<View className={styles.sheetHead}>
<Text className={styles.sheetName}>{family.name}</Text>
</View>
<View className={styles.sheetMeta}>
<Text className={styles.sheetMetaItem}>
关爱对象 {family.careMemberCount > 0 ? `${family.careMemberCount} 人` : '--'}
</Text>
<Text className={styles.sheetMetaItem}>设备数量 {family.deviceCount}台</Text>
</View>
</View>
)
})}
</View>
<View className={styles.sheetConfirm} hoverClass={styles.sheetConfirmPressed} onClick={confirmFamilySheet}>
<Text className={styles.sheetConfirmText}>确定</Text>
</View>
</BottomSheet>
</View>
)
}

View File

@ -1,185 +0,0 @@
.drop-panel-calendar {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 66vh;
max-height: 100%;
background: $color-bg-white;
box-shadow: $shadow-popup;
z-index: 11;
display: flex;
flex-direction: column;
border-bottom-left-radius: $radius-lg;
border-bottom-right-radius: $radius-lg;
overflow: hidden;
}
.header {
padding: 20px 32px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $color-border;
flex-shrink: 0;
}
.title {
font-size: 30px;
font-weight: 600;
color: $color-text;
}
.close {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
}
.close-icon {
font-size: 40px;
color: #909399;
}
.quick-row {
display: flex;
padding: 20px 24px 4px;
flex-shrink: 0;
}
.quick-chip {
flex: 1;
margin-right: 16px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: $color-bg-gray;
border: 1px solid transparent;
border-radius: $radius-pill;
font-size: 24px;
color: #606266;
&:last-child { margin-right: 0; }
}
.quick-chip-active {
background: $color-primary-light-bg;
border-color: $color-primary;
color: $color-primary-deep;
font-weight: 600;
}
.weekdays {
display: flex;
padding: 0 16px;
border-bottom: 1px solid $color-border;
flex-shrink: 0;
}
.weekday {
flex: 1;
text-align: center;
font-size: 24px;
color: #909399;
line-height: 48px;
}
.scroll-wrap {
flex: 1;
min-height: 0;
}
.scroll { height: 100%; }
.month { padding: 8px 16px 0; }
.month-title {
height: 72px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 600;
color: $color-text;
}
.grid {
display: flex;
flex-wrap: wrap;
}
.cell {
width: calc(100% / 7);
box-sizing: border-box;
padding: 4px;
aspect-ratio: 1 / 1.15;
display: flex;
align-items: stretch;
}
.cell-disabled { opacity: 0.3; }
.day {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 12px;
}
.day-start,
.day-end { background: $color-primary; }
.day-start .day-num,
.day-end .day-num {
color: #ffffff;
font-weight: 600;
}
.day-in {
background: $color-primary-light-bg;
border-radius: 0;
}
.day-in .day-num { color: $color-primary-deep; }
.day-num {
font-size: 28px;
font-weight: 500;
color: $color-text;
line-height: 40px;
}
.day-tag {
font-size: 20px;
line-height: 26px;
color: rgba(255, 255, 255, 0.9);
}
.footer {
flex-shrink: 0;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid $color-border;
background: $color-bg-white;
}
.confirm-btn {
height: 80px;
background: $color-primary;
border-radius: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-disabled { opacity: 0.5; }
.confirm-text {
color: #ffffff;
font-size: 30px;
font-weight: 600;
}

View File

@ -1,276 +0,0 @@
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import styles from './calendar-drawer.module.scss'
// 日历可回溯年数(约束最早月份,不影响性能)
const CALENDAR_YEARS_BACK = 10
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']
// 懒渲染窗口参数(rpx):可视高度近似 + 前后缓冲
const VIEW_RPX = 1400
const BUFFER_RPX = 3000
const pad2 = (n: number) => String(n).padStart(2, '0')
const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
const parseDate = (s: string) => {
const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d)
}
const quickRange = (days: number) => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - (days - 1))
return { label: `近${days}天`, start: fmtDate(start), end: fmtDate(end) }
}
interface MonthMeta {
key: string
year: number
month: number
title: string
top: number
height: number
}
interface Cell {
day: number
date?: string
disabled?: boolean
dayCls?: string
tag?: string
}
interface Props {
initialStart: string
initialEnd: string
onClose: () => void
onConfirm: (start: string, end: string) => void
}
function monthWeeks(y: number, m: number) {
const firstDay = new Date(y, m, 1)
const lead = (firstDay.getDay() + 6) % 7
const daysInMonth = new Date(y, m + 1, 0).getDate()
return Math.ceil((lead + daysInMonth) / 7)
}
export function CalendarDrawer({ initialStart, initialEnd, onClose, onConfirm }: Props) {
const [windowWidth, setWindowWidth] = useState(375)
const [calScroll, setCalScroll] = useState(0)
const [calAnchorId, setCalAnchorId] = useState('')
const [pendingStart, setPendingStart] = useState(initialStart)
const [pendingEnd, setPendingEnd] = useState(initialEnd)
const [activeQuick, setActiveQuick] = useState('')
const anchorTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const quickRanges = useMemo(() => [quickRange(7), quickRange(30), quickRange(90)], [])
const minLimit = useMemo(() => {
const d = new Date()
d.setFullYear(d.getFullYear() - CALENDAR_YEARS_BACK)
d.setDate(1)
return fmtDate(d)
}, [])
const maxLimit = useMemo(() => fmtDate(new Date()), [])
// 全部月份元数据:每月份含估算高度(标题行 + 周数×行高)
const monthMetas = useMemo<MonthMeta[]>(() => {
const metas: MonthMeta[] = []
const start = parseDate(minLimit)
const now = new Date()
let y = start.getFullYear()
let m = start.getMonth()
let top = 0
while (y < now.getFullYear() || (y === now.getFullYear() && m <= now.getMonth())) {
const weeks = monthWeeks(y, m)
const height = 96 + weeks * Math.round((750 / 7) * 1.15) + 16
metas.push({ key: `m-${y}-${m}`, year: y, month: m, title: `${y}年${pad2(m + 1)}月`, top, height })
top += height
if (m === 11) { y += 1; m = 0 } else { m += 1 }
}
return metas
}, [minLimit])
// px→rpx 换算基准 + 打开时定位
useEffect(() => {
setWindowWidth(Taro.getWindowInfo().windowWidth || 375)
const focus = initialStart || fmtDate(new Date())
const d = parseDate(focus)
const meta = monthMetas.find((mm) => mm.year === d.getFullYear() && mm.month === d.getMonth())
setCalAnchorId('')
// scroll-into-view 需要值变化才生效:先清空再在下个循环赋值
const timer = setTimeout(() => setCalAnchorId(meta ? meta.key : ''), 0)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 单格数据:区间内/端点/禁用态一次算好
const buildCell = useCallback((dateStr: string, day: number): Cell => {
const disabled = dateStr < minLimit || dateStr > maxLimit
let dayCls = ''
let tag = ''
if (!disabled) {
if (pendingStart && dateStr === pendingStart) {
dayCls = 'start'
tag = pendingStart === pendingEnd ? '起/止' : '开始'
} else if (pendingEnd && dateStr === pendingEnd) {
dayCls = 'end'
tag = '结束'
} else if (pendingStart && pendingEnd && dateStr > pendingStart && dateStr < pendingEnd) {
dayCls = 'in'
}
}
return { day, date: dateStr, disabled, dayCls, tag }
}, [minLimit, maxLimit, pendingStart, pendingEnd])
const buildMonthCells = useCallback((y: number, m: number): Cell[] => {
const firstDay = new Date(y, m, 1)
const lead = (firstDay.getDay() + 6) % 7
const daysInMonth = new Date(y, m + 1, 0).getDate()
const cells: Cell[] = []
for (let i = 0; i < lead; i++) cells.push({ day: 0 })
for (let d = 1; d <= daysInMonth; d++) {
cells.push(buildCell(`${y}-${pad2(m + 1)}-${pad2(d)}`, d))
}
return cells
}, [buildCell])
// 懒渲染:仅渲染滚动窗口(±BUFFER_RPX)内月份,窗口外等高占位
const renderedMonths = useMemo(() => {
const lo = calScroll - BUFFER_RPX
const hi = calScroll + VIEW_RPX + BUFFER_RPX
return monthMetas.map((meta) => {
const inWindow = meta.top + meta.height > lo && meta.top < hi
if (!inWindow) return { ...meta, placeholder: true as const, cells: [] as Cell[] }
return { ...meta, placeholder: false as const, cells: buildMonthCells(meta.year, meta.month) }
})
}, [monthMetas, calScroll, buildMonthCells])
// 滚动:px → rpx(750/窗口宽),跨过 200rpx 再更新,减少重算
const onCalScroll = (e: { detail: { scrollTop: number } }) => {
const px = e.detail.scrollTop || 0
const rpx = px * (750 / windowWidth)
setCalScroll((prev) => (Math.abs(rpx - prev) > 200 ? rpx : prev))
}
// CSS Modules 动态 key 映射对象(避免索引签名问题)
const dayClsMap: Record<string, string> = {
start: styles.dayStart,
end: styles.dayEnd,
in: styles.dayIn
}
// 第一击定起点,第二击定终点(早于起点则重定起点),选满再点重选
const pickDate = (cell: Cell) => {
if (!cell.day || cell.disabled || !cell.date) return
const date = cell.date
setActiveQuick('')
if (!pendingStart || (pendingStart && pendingEnd)) {
setPendingStart(date)
setPendingEnd('')
} else if (date < pendingStart) {
setPendingStart(date)
} else {
setPendingEnd(date)
}
}
// 快捷范围:填满起止并定位起点月
const applyQuick = (q: { label: string; start: string; end: string }) => {
setPendingStart(q.start)
setPendingEnd(q.end)
setActiveQuick(q.label)
const d = parseDate(q.start)
const meta = monthMetas.find((mm) => mm.year === d.getFullYear() && mm.month === d.getMonth())
setCalAnchorId('')
if (anchorTimer.current) clearTimeout(anchorTimer.current)
anchorTimer.current = setTimeout(() => setCalAnchorId(meta ? meta.key : ''), 0)
}
useEffect(() => () => {
if (anchorTimer.current) clearTimeout(anchorTimer.current)
}, [])
const rangeReady = !!pendingStart && !!pendingEnd
return (
<View className={styles.dropPanelCalendar}>
<View className={styles.header}>
<Text className={styles.title}>选择时间范围</Text>
<View className={styles.close} onClick={onClose}>
<Text className={styles.closeIcon}>×</Text>
</View>
</View>
<View className={styles.quickRow}>
{quickRanges.map((q) => (
<View
key={q.label}
className={`${styles.quickChip} ${activeQuick === q.label ? styles.quickChipActive : ''}`}
onClick={() => applyQuick(q)}
>
<Text>{q.label}</Text>
</View>
))}
</View>
<View className={styles.weekdays}>
{WEEKDAY_LABELS.map((w) => (
<Text key={w} className={styles.weekday}>{w}</Text>
))}
</View>
<View className={styles.scrollWrap}>
<ScrollView
className={styles.scroll}
scrollY
scrollIntoView={calAnchorId}
scrollAnchoring
onScroll={onCalScroll}
>
{renderedMonths.map((m) => (
<View
key={m.key}
id={m.key}
className={styles.month}
style={m.placeholder ? { height: `${m.height}rpx` } : undefined}
>
{!m.placeholder && (
<>
<View className={styles.monthTitle}>
<Text>{m.title}</Text>
</View>
<View className={styles.grid}>
{m.cells.map((cell, i) => (
<View
key={i}
className={`${styles.cell} ${cell.disabled ? styles.cellDisabled : ''}`}
onClick={() => pickDate(cell)}
>
{!!cell.day && (
<View className={`${styles.day} ${cell.dayCls ? dayClsMap[cell.dayCls] : ''}`}>
<Text className={styles.dayNum}>{cell.day}</Text>
{!!cell.tag && <Text className={styles.dayTag}>{cell.tag}</Text>}
</View>
)}
</View>
))}
</View>
</>
)}
</View>
))}
</ScrollView>
</View>
<View className={styles.footer}>
<View
className={`${styles.confirmBtn} ${!rangeReady ? styles.confirmDisabled : ''}`}
onClick={() => rangeReady && onConfirm(pendingStart, pendingEnd)}
>
<Text className={styles.confirmText}>{rangeReady ? '确定' : '请选择起止日期'}</Text>
</View>
</View>
</View>
)
}

View File

@ -1,107 +0,0 @@
.page {
min-height: 100vh;
background: $color-bg;
padding-bottom: 40px;
}
.stateWrap {
margin-top: 120px;
display: flex;
flex-direction: column;
align-items: center;
}
// 告警头卡:白底渐变浮卡,左侧语义色竖条强调紧急度
.headCard {
margin: 24px;
padding: 32px;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
}
.headTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.alarmName {
font-size: 34px;
font-weight: 600;
color: $color-text;
}
// 状态徽章
.tag {
display: inline-block;
padding: 6px 20px;
border-radius: $radius-pill;
font-size: 22px;
font-weight: 500;
flex-shrink: 0;
margin-left: 16px;
}
// 待处理:红(需关注)
.tagPending {
background: $color-danger-light-bg;
color: $color-danger;
}
// 已处理:绿
.tagDone {
background: $color-success-light-bg;
color: $color-success;
}
// 已忽略/未知:灰
.tagIgnored {
background: $color-bg-disabled;
color: $color-text-secondary;
}
.content {
font-size: 28px;
color: $color-text;
line-height: 1.6;
}
.headMeta {
margin-top: 16px;
display: flex;
align-items: center;
}
.metaText {
font-size: 24px;
color: $color-text-secondary;
}
.metaDot {
margin: 0 12px;
font-size: 24px;
color: $color-text-placeholder;
}
// 现场抓拍图
.capture {
margin-top: 24px;
width: 100%;
height: 360px;
border-radius: $radius-md;
}
.section {
margin: 0 24px 24px;
}
.sectionTitle {
display: block;
font-size: 26px;
color: $color-text-secondary;
margin: 0 8px 16px;
}

View File

@ -1,113 +0,0 @@
import { useState, useEffect } from 'react'
import { useRouter } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading'
import { StateView } from '@/components/state-view'
import { ListRow, ListRowGroup } from '@/components/list-row'
import { getAlarmDetail, type HomeAlarm } from '@/api/homeAlarm'
import { navigateTo } from '@/utils/navigation'
import { ROUTES } from '@/constants/routes'
import styles from './detail.module.scss'
// 状态 → 徽章文案与语义色(2026-09-23 后端确认:0-待处理 1-已忽略 2-已处理 3-自动处理)
const STATUS_META: Record<number, { label: string; cls: string }> = {
0: { label: '待处理', cls: 'tagPending' },
1: { label: '已忽略', cls: 'tagIgnored' },
2: { label: '已处理', cls: 'tagDone' },
3: { label: '自动处理', cls: 'tagIgnored' }
}
export default function AlarmDetail() {
const router = useRouter()
const alarmId = router.params.id ?? ''
const [loading, setLoading] = useState(true)
const [alarm, setAlarm] = useState<HomeAlarm | null>(null)
useEffect(() => {
if (!alarmId) {
setLoading(false)
return
}
setLoading(true)
getAlarmDetail(alarmId)
.then(setAlarm)
.catch(() => setAlarm(null))
.finally(() => setLoading(false))
}, [alarmId])
if (loading) {
return (
<View className={styles.page}>
<Navbar title="告警详情" variant="hero" />
<View className={styles.stateWrap}><Loading /></View>
</View>
)
}
if (!alarm) {
return (
<View className={styles.page}>
<Navbar title="告警详情" variant="hero" />
<StateView status="error" mode="list" text="该告警不存在或已删除" />
</View>
)
}
const meta = STATUS_META[alarm.status] ?? { label: '未知状态', cls: 'tagIgnored' }
const deviceLabel = alarm.deviceAlias || alarm.deviceName || alarm.deviceId || '未知设备'
const ext = alarm.otherInfo
const goDevice = () => {
if (ext?.installId) navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${ext.installId}&name=${encodeURIComponent(deviceLabel)}`)
}
return (
<View className={styles.page}>
<Navbar title="告警详情" variant="hero" />
{/* 告警头卡:名称 + 状态徽章 + 文案 + 时间 */}
<View className={styles.headCard}>
<View className={styles.headTop}>
<Text className={styles.alarmName}>{ext?.alarmName || '设备告警'}</Text>
<Text className={`${styles.tag} ${styles[meta.cls]}`}>{meta.label}</Text>
</View>
<Text className={styles.content}>{alarm.content}</Text>
<View className={styles.headMeta}>
<Text className={styles.metaText}>{ext?.alertTypeName || '设备告警消息'}</Text>
<Text className={styles.metaDot}>·</Text>
<Text className={styles.metaText}>{alarm.createTime}</Text>
</View>
{/* 现场抓拍:capturePicUrl 存在时展示 */}
{alarm.capturePicUrl && (
<Image className={styles.capture} src={alarm.capturePicUrl} mode="aspectFill" />
)}
</View>
{/* 告警信息组 */}
<View className={styles.section}>
<Text className={styles.sectionTitle}>告警信息</Text>
<ListRowGroup>
<ListRow label="告警时间" value={ext?.alertTime || alarm.createTime} />
<ListRow label="更新时间" value={alarm.lastUpdateTime || alarm.createTime} />
<ListRow label="告警设备" value={deviceLabel} arrow={!!ext?.installId} onClick={ext?.installId ? goDevice : undefined} />
<ListRow label="所在房间" value={alarm.roomName || '--'} />
<ListRow label="设备序列号" value={alarm.deviceName || '--'} />
{ext?.deviceLocation && <ListRow label="设备位置" value={ext.deviceLocation} />}
</ListRowGroup>
</View>
{/* 现场视频:videoUrl 存在时提示跳转(视频播放器暂未接入) */}
{alarm.videoUrl && (
<View className={styles.section}>
<Text className={styles.sectionTitle}>现场记录</Text>
<ListRowGroup>
<ListRow label="告警录像" value="点击查看" arrow onClick={() => navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${alarm.deviceId}`)} />
</ListRowGroup>
</View>
)}
</View>
)
}

Some files were not shown because too many files have changed in this diff Show More