Compare commits
109 Commits
054f05af70
...
1dedbcfa73
| Author | SHA1 | Date |
|---|---|---|
|
|
1dedbcfa73 | |
|
|
58561e12e0 | |
|
|
56e990c028 | |
|
|
42b6ceb69c | |
|
|
da86294fde | |
|
|
82aa7d6925 | |
|
|
6ec6fc7138 | |
|
|
5e00280d2c | |
|
|
de8df8bfcd | |
|
|
ff15db2f72 | |
|
|
40783ccabe | |
|
|
9f3ea3d82b | |
|
|
a4b2e13a8e | |
|
|
213d61bbf6 | |
|
|
05d46368d1 | |
|
|
d7bd20eb79 | |
|
|
ccca973d62 | |
|
|
6377e53ecb | |
|
|
cb2dc3d2aa | |
|
|
5761a5fed2 | |
|
|
7bef729e93 | |
|
|
0ef1f490e5 | |
|
|
a027c6ce2f | |
|
|
7f82b2f132 | |
|
|
4b34910b4d | |
|
|
a1901b5611 | |
|
|
2832053569 | |
|
|
f68bd299a7 | |
|
|
83a211ebd6 | |
|
|
e70aa7e3e6 | |
|
|
5582fbb863 | |
|
|
6889e9b4fa | |
|
|
b1a9bef903 | |
|
|
86234d4f1f | |
|
|
d3f9a6ae54 | |
|
|
87e4031fff | |
|
|
026578dfbd | |
|
|
10905df143 | |
|
|
34a3887b2d | |
|
|
96c2255c10 | |
|
|
46c517ff42 | |
|
|
bd71d52c7f | |
|
|
55c3ab8874 | |
|
|
bb9f869a3e | |
|
|
f9d0e3ba7f | |
|
|
9581a991be | |
|
|
f81d80f8ec | |
|
|
90e6262c89 | |
|
|
44918644ef | |
|
|
9871c11476 | |
|
|
c65d179333 | |
|
|
884a7b8343 | |
|
|
3adaaa65e2 | |
|
|
9fbe9266eb | |
|
|
fb66a9efe7 | |
|
|
51747499ed | |
|
|
3f52b493e0 | |
|
|
368fb3b12c | |
|
|
f0dbe5b1c5 | |
|
|
2238e965b9 | |
|
|
5e5cd2bf47 | |
|
|
01fefe373a | |
|
|
1fa4c5586d | |
|
|
ac4cb3b0f9 | |
|
|
2bc487c522 | |
|
|
b0ba12034c | |
|
|
b8937d942f | |
|
|
58d0ee8ad7 | |
|
|
1baca70e35 | |
|
|
5da5dfe3a0 | |
|
|
fbdcdfa6ce | |
|
|
d6333beda9 | |
|
|
1f3c9c842a | |
|
|
8243dfa134 | |
|
|
36aa262834 | |
|
|
1250a54076 | |
|
|
e882d391f7 | |
|
|
95a67ae412 | |
|
|
9d1ea0abbc | |
|
|
a9dfb2485b | |
|
|
0cb618be58 | |
|
|
5627be98f7 | |
|
|
3779a7a019 | |
|
|
576bb7f5a8 | |
|
|
288e681cca | |
|
|
223cd41fec | |
|
|
e88f1f0582 | |
|
|
23e61ba966 | |
|
|
dd92496088 | |
|
|
8ad9ca234d | |
|
|
429ebfc5fb | |
|
|
ae532724f9 | |
|
|
a5e9366b5c | |
|
|
e2d7232f3c | |
|
|
240c235921 | |
|
|
9679e4062b | |
|
|
f408a29e13 | |
|
|
f3f6c0b5ce | |
|
|
84942851cb | |
|
|
0ca714bec5 | |
|
|
08abbb55e1 | |
|
|
727b91fb07 | |
|
|
ed1b3244b8 | |
|
|
e3af7c26fb | |
|
|
0fc0f2b91d | |
|
|
8c2956fcad | |
|
|
d219126a23 | |
|
|
c65f15d3a0 | |
|
|
c7cebc2fc9 |
|
|
@ -0,0 +1,45 @@
|
|||
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'
|
||||
]
|
||||
}
|
||||
|
|
@ -1,25 +1,28 @@
|
|||
node_modules/
|
||||
unpackage/
|
||||
.hbuilderx/
|
||||
dist/
|
||||
.DS_Store
|
||||
.codex-tasks/
|
||||
|
||||
.idea/
|
||||
|
||||
.vscode/
|
||||
|
||||
.swc/
|
||||
|
||||
*.log
|
||||
.hbuilderx/
|
||||
.codex-tasks/
|
||||
.codegraph/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
.prettierignore
|
||||
.prettierrc
|
||||
|
||||
# Graphify (generated)
|
||||
graphify-out/
|
||||
|
||||
# Claude Code && Codex
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# Codegraph
|
||||
.codegraph/
|
||||
|
||||
# Project-specific
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
|
|
|||
27
App.vue
|
|
@ -1,27 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
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
|
|
@ -1,23 +0,0 @@
|
|||
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
|
|
@ -1,30 +0,0 @@
|
|||
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')
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
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 }]
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default {}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
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)
|
||||
})
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
mini: {
|
||||
// 生产关 sourcemap:主包体积硬指标 1.5M,.map 文件约 390K 必须剔除
|
||||
webpackChain(chain: { devtool: (v: false) => void }) {
|
||||
chain.devtool(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* 本地存储 key。
|
||||
*/
|
||||
export const AUTH_STORAGE_KEYS = Object.freeze({
|
||||
TOKEN: 'smarthome_token',
|
||||
USER: 'smarthome_user'
|
||||
})
|
||||
|
||||
/**
|
||||
* 登录开关:当前为 false,便于功能联调。
|
||||
* 恢复鉴权时改为 true 即可。
|
||||
*/
|
||||
export const AUTH_ENABLED = false
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* 接口请求默认配置。
|
||||
*/
|
||||
export const SUCCESS_CODE = 0
|
||||
export const DEFAULT_TIMEOUT = 15000
|
||||
export const DEFAULT_BASE_URL = 'https://api.example.com'
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* 统一维护页面路由,避免页面内散落硬编码字符串。
|
||||
*/
|
||||
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])
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
# 统一 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. 初始化阶段
|
||||
- `openBluetoothAdapter`
|
||||
|
|
@ -31,7 +35,7 @@
|
|||
|
||||
## 2. 目录结构
|
||||
```text
|
||||
utils/ble/
|
||||
src/ble/
|
||||
core/
|
||||
bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连)
|
||||
controller.js # 标准流程编排(scan->connect->discover->auth)
|
||||
|
|
@ -84,7 +88,7 @@ utils/ble/
|
|||
import {
|
||||
createUnifiedBleController,
|
||||
DEVICE_PROFILE
|
||||
} from '@/utils/ble'
|
||||
} from '@/ble'
|
||||
|
||||
const bleController = createUnifiedBleController({
|
||||
profileId: DEVICE_PROFILE.UNKNOWN,
|
||||
|
|
@ -128,6 +132,40 @@ async function runStandardFlow({ key, ssid, password }) {
|
|||
|
||||
## 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 对照协议文档第二轮修复
|
||||
|
||||
#### commonParser.js
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* 创建一个简单倒计时控制器,适配 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
|
|
@ -1,20 +0,0 @@
|
|||
<!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
|
|
@ -1,32 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
45
package.json
|
|
@ -1,6 +1,49 @@
|
|||
{
|
||||
"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": {
|
||||
"@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",
|
||||
"uview-plus": "^3.7.36"
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^4.5.0",
|
||||
"vitest": "^1.6.0",
|
||||
"webpack": "^5.91.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
pages.json
|
|
@ -1,86 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<!-- 设备添加页:用于承接后续蓝牙搜索/连接配网流程 -->
|
||||
<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>
|
||||
|
|
@ -1,941 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,797 +0,0 @@
|
|||
<!-- 事件页:使用 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>
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<!-- 首页:切换为 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>
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<!-- 我的页:引入 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>
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
<!-- 场景选择页:加载场景列表,点选后保存并返回首页 -->
|
||||
<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>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"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": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"libVersion": "3.17.2",
|
||||
"projectname": "SEC_Warehouse",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"compileHotReLoad": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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 })
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
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 })
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
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))
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
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>)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
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>)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 351 B After Width: | Height: | Size: 351 B |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 559 B |
|
Before Width: | Height: | Size: 590 B After Width: | Height: | Size: 590 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1,8 @@
|
|||
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>
|
||||
|
|
@ -5,11 +5,13 @@ import { uint8ArrayToArrayBuffer, toUint8Array } from '../utils/bytes';
|
|||
import { toFullUuid, uuidEquals, uuidInList } from '../utils/uuid';
|
||||
import { runWithRetry } from './retry';
|
||||
import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles';
|
||||
import { taroPlatform } from '../adapter';
|
||||
|
||||
// 把 uni 的 success/fail 回调风格统一成 Promise,便于串联 async 流程。
|
||||
function promisifyUniApi(apiName, params = {}) {
|
||||
// 把平台(Taro/uni)的 success/fail 回调风格统一成 Promise,便于串联 async 流程。
|
||||
// 平台对象通过 createBleCore(options) 可注入,默认 Taro;单测可传 mock。
|
||||
function promisifyApi(platform, apiName, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni[apiName]({
|
||||
platform[apiName]({
|
||||
...params,
|
||||
success: (res) => resolve(res),
|
||||
fail: (err) => reject(err),
|
||||
|
|
@ -23,6 +25,7 @@ function promisifyUniApi(apiName, params = {}) {
|
|||
*/
|
||||
export function createBleCore(options = {}) {
|
||||
const {
|
||||
platform = taroPlatform,
|
||||
reconnect = {
|
||||
enabled: true,
|
||||
retries: 2,
|
||||
|
|
@ -92,7 +95,7 @@ export function createBleCore(options = {}) {
|
|||
/** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */
|
||||
async function openAdapter() {
|
||||
try {
|
||||
await promisifyUniApi('openBluetoothAdapter');
|
||||
await promisifyApi(platform, 'openBluetoothAdapter');
|
||||
patchState({ available: true });
|
||||
setStage(BLE_STAGE.ADAPTER_OPENED);
|
||||
return true;
|
||||
|
|
@ -104,7 +107,7 @@ export function createBleCore(options = {}) {
|
|||
/** 关闭蓝牙适配器并重置全部运行时状态。 */
|
||||
async function closeAdapter() {
|
||||
try {
|
||||
await promisifyUniApi('closeBluetoothAdapter');
|
||||
await promisifyApi(platform, 'closeBluetoothAdapter');
|
||||
} catch (error) {
|
||||
logger.warn('[BLE] close adapter failed', error);
|
||||
}
|
||||
|
|
@ -129,7 +132,7 @@ export function createBleCore(options = {}) {
|
|||
/** 读取当前适配器状态(可用/搜索中),同步到内部状态。 */
|
||||
async function getAdapterState() {
|
||||
try {
|
||||
const result = await promisifyUniApi('getBluetoothAdapterState');
|
||||
const result = await promisifyApi(platform, 'getBluetoothAdapterState');
|
||||
patchState({
|
||||
available: Boolean(result.available),
|
||||
discovering: Boolean(result.discovering),
|
||||
|
|
@ -158,7 +161,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
};
|
||||
|
||||
uni.onBluetoothAdapterStateChange(adapterStateHandler);
|
||||
platform.onBluetoothAdapterStateChange(adapterStateHandler);
|
||||
}
|
||||
|
||||
/** 移除适配器状态变更监听。 */
|
||||
|
|
@ -167,8 +170,8 @@ export function createBleCore(options = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (uni.offBluetoothAdapterStateChange) {
|
||||
uni.offBluetoothAdapterStateChange(adapterStateHandler);
|
||||
if (platform.offBluetoothAdapterStateChange) {
|
||||
platform.offBluetoothAdapterStateChange(adapterStateHandler);
|
||||
}
|
||||
|
||||
adapterStateHandler = null;
|
||||
|
|
@ -204,7 +207,7 @@ export function createBleCore(options = {}) {
|
|||
options.powerLevel = powerLevel;
|
||||
}
|
||||
|
||||
await promisifyUniApi('startBluetoothDevicesDiscovery', options);
|
||||
await promisifyApi(platform, 'startBluetoothDevicesDiscovery', options);
|
||||
patchState({ discovering: true, deviceMap: {} });
|
||||
setStage(BLE_STAGE.DISCOVERING);
|
||||
return true;
|
||||
|
|
@ -216,7 +219,7 @@ export function createBleCore(options = {}) {
|
|||
/** 停止设备扫描。 */
|
||||
async function stopDiscovery() {
|
||||
try {
|
||||
await promisifyUniApi('stopBluetoothDevicesDiscovery');
|
||||
await promisifyApi(platform, 'stopBluetoothDevicesDiscovery');
|
||||
} catch (error) {
|
||||
logger.warn('[BLE] stop discovery failed', error);
|
||||
}
|
||||
|
|
@ -256,7 +259,7 @@ export function createBleCore(options = {}) {
|
|||
eventBus.emit('device:found', Object.values(nextMap));
|
||||
};
|
||||
|
||||
uni.onBluetoothDeviceFound(deviceFoundHandler);
|
||||
platform.onBluetoothDeviceFound(deviceFoundHandler);
|
||||
}
|
||||
|
||||
/** 移除设备发现监听。 */
|
||||
|
|
@ -265,8 +268,8 @@ export function createBleCore(options = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (uni.offBluetoothDeviceFound) {
|
||||
uni.offBluetoothDeviceFound(deviceFoundHandler);
|
||||
if (platform.offBluetoothDeviceFound) {
|
||||
platform.offBluetoothDeviceFound(deviceFoundHandler);
|
||||
}
|
||||
|
||||
deviceFoundHandler = null;
|
||||
|
|
@ -288,7 +291,7 @@ export function createBleCore(options = {}) {
|
|||
|
||||
const runConnect = async () => {
|
||||
setStage(BLE_STAGE.CONNECTING);
|
||||
await promisifyUniApi('createBLEConnection', { deviceId, timeout });
|
||||
await promisifyApi(platform, 'createBLEConnection', { deviceId, timeout });
|
||||
patchState({ connected: true, deviceId });
|
||||
setStage(BLE_STAGE.CONNECTED);
|
||||
return true;
|
||||
|
|
@ -319,7 +322,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
|
||||
try {
|
||||
await promisifyUniApi('closeBLEConnection', { deviceId });
|
||||
await promisifyApi(platform, 'closeBLEConnection', { deviceId });
|
||||
} catch (error) {
|
||||
logger.warn('[BLE] close connection failed', error);
|
||||
}
|
||||
|
|
@ -352,7 +355,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
};
|
||||
|
||||
uni.onBLEConnectionStateChange(connectionStateHandler);
|
||||
platform.onBLEConnectionStateChange(connectionStateHandler);
|
||||
}
|
||||
|
||||
/** 移除连接状态变更监听。 */
|
||||
|
|
@ -361,8 +364,8 @@ export function createBleCore(options = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (uni.offBLEConnectionStateChange) {
|
||||
uni.offBLEConnectionStateChange(connectionStateHandler);
|
||||
if (platform.offBLEConnectionStateChange) {
|
||||
platform.offBLEConnectionStateChange(connectionStateHandler);
|
||||
}
|
||||
|
||||
connectionStateHandler = null;
|
||||
|
|
@ -391,7 +394,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await promisifyUniApi('getBLEDeviceServices', {
|
||||
const result = await promisifyApi(platform, 'getBLEDeviceServices', {
|
||||
deviceId,
|
||||
});
|
||||
const services = result.services || [];
|
||||
|
|
@ -434,7 +437,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await promisifyUniApi('getBLEDeviceCharacteristics', {
|
||||
const result = await promisifyApi(platform, 'getBLEDeviceCharacteristics', {
|
||||
deviceId,
|
||||
serviceId,
|
||||
});
|
||||
|
|
@ -478,7 +481,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
|
||||
try {
|
||||
await promisifyUniApi('readBLECharacteristicValue', {
|
||||
await promisifyApi(platform, 'readBLECharacteristicValue', {
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId,
|
||||
|
|
@ -531,7 +534,7 @@ export function createBleCore(options = {}) {
|
|||
if (writeType) {
|
||||
params.writeType = writeType;
|
||||
}
|
||||
await promisifyUniApi('writeBLECharacteristicValue', params);
|
||||
await promisifyApi(platform, 'writeBLECharacteristicValue', params);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw emitError(error);
|
||||
|
|
@ -552,7 +555,7 @@ export function createBleCore(options = {}) {
|
|||
}
|
||||
|
||||
try {
|
||||
await promisifyUniApi('notifyBLECharacteristicValueChange', {
|
||||
await promisifyApi(platform, 'notifyBLECharacteristicValueChange', {
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId,
|
||||
|
|
@ -586,7 +589,7 @@ export function createBleCore(options = {}) {
|
|||
});
|
||||
};
|
||||
|
||||
uni.onBLECharacteristicValueChange(characteristicValueHandler);
|
||||
platform.onBLECharacteristicValueChange(characteristicValueHandler);
|
||||
}
|
||||
|
||||
/** 移除特征值变更监听。 */
|
||||
|
|
@ -595,8 +598,8 @@ export function createBleCore(options = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (uni.offBLECharacteristicValueChange) {
|
||||
uni.offBLECharacteristicValueChange(characteristicValueHandler);
|
||||
if (platform.offBLECharacteristicValueChange) {
|
||||
platform.offBLECharacteristicValueChange(characteristicValueHandler);
|
||||
}
|
||||
|
||||
characteristicValueHandler = null;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// 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
|
||||
|
|
@ -7,8 +7,9 @@ import {
|
|||
TARGET_SERVICE_UUID_COMPAT_FULL,
|
||||
TARGET_SHORT_UUID_CANDIDATES
|
||||
} from '@/constants/bluetooth'
|
||||
import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/utils/ble'
|
||||
import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/ble'
|
||||
import {showToast} from '@/utils/toast'
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
const FILTER_SCAN_TIMEOUT_MS = 4000
|
||||
const RESTART_SCAN_DELAY_MS = 120
|
||||
|
|
@ -16,9 +17,9 @@ const DISCOVERY_POLL_INTERVAL_MS = 1200
|
|||
const SCAN_DURATION_MS = 30000
|
||||
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
|
||||
|
||||
function promisifyUniApi(apiName, params = {}) {
|
||||
function promisifyTaroApi(apiName, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni[apiName]({
|
||||
Taro[apiName]({
|
||||
...params,
|
||||
success: (res) => resolve(res),
|
||||
fail: (err) => reject(err)
|
||||
|
|
@ -294,7 +295,7 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
let systemInfo = {}
|
||||
|
||||
try {
|
||||
systemInfo = uni.getSystemInfoSync() || {}
|
||||
systemInfo = Taro.getSystemInfoSync() || {}
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -306,7 +307,7 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
|
||||
try {
|
||||
const settings = await new Promise((resolve, reject) => {
|
||||
uni.getSetting({
|
||||
Taro.getSetting({
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
|
|
@ -318,7 +319,7 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.authorize({
|
||||
Taro.authorize({
|
||||
scope: 'scope.userLocation',
|
||||
success: resolve,
|
||||
fail: reject
|
||||
|
|
@ -544,7 +545,7 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await promisifyUniApi('getBluetoothDevices')
|
||||
const result = await promisifyTaroApi('getBluetoothDevices')
|
||||
const list = Array.isArray(result?.devices) ? result.devices : []
|
||||
list.forEach((device) => {
|
||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
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 }
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// 浅绿底卡变体:卡身浅绿 + 右上淡绿圆装饰(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;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
.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); }
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// 项目 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// 外层只负责状态栏留白与白底:高度不能写死——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;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// 微信登录相关常量
|
||||
|
||||
// 二次登录回传 openId 的缓存 key(spec D3)
|
||||
export const WX_OPENID_STORAGE_KEY = 'wx_openid'
|
||||
|
||||
// getAccountInfoSync 取不到 appId 时的回退值(与 project.config.json 一致的测试号)
|
||||
export const FALLBACK_WX_APP_ID = 'wxe9773bb6f025ab00'
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
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')
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// 后端 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/']
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// 设备型号
|
||||
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: '驻留超时'
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// 家庭圈业务常量
|
||||
|
||||
// 每个家庭最多可添加的关爱对象数(设计图 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
|
||||