refactor(app): 项目从 uni-app Vue3 整体迁移至 Taro4 + React18
- 移除 uni-app 入口(main.js/App.vue/pages.json/manifest.json/uni.scss 等), 原 pages/api/utils/store 迁移至 src/ 目录并重写为 TSX + module.scss - 新增 Taro 编译配置(config/index.ts cssModules 修复、babel、tsconfig、 eslint、vitest)与微信项目配置 project.config.json - 页面基础组件自绘替代 uview-plus:navbar/popup/tabs/loading/empty/vital-wave - 状态管理迁移至 Redux Toolkit(auth/device/event/scene slices) - BLE 模块整体迁至 src/ble,新增 adapter 适配层与 useBle hook, 新增 packet/parsers/profiles 单元测试 - 厂家协议文档归档至 docs/vendor/
|
|
@ -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,23 +1,25 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
unpackage/
|
dist/
|
||||||
.hbuilderx/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.codex-tasks/
|
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
.swc/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
.hbuilderx/
|
||||||
|
.codex-tasks/
|
||||||
|
.codegraph/
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
.prettierignore
|
.prettierignore
|
||||||
.prettierrc
|
.prettierrc
|
||||||
|
|
||||||
# Graphify (generated)
|
# Graphify (generated)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
# Claude Code && Codex
|
|
||||||
.claude/
|
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
|
||||||
|
|
||||||
# Codegraph
|
# Codegraph
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
|
||||||
|
|
|
||||||
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,97 +0,0 @@
|
||||||
import request from '@/api/http/client'
|
|
||||||
|
|
||||||
// 后端就绪后置为 false 即可恢复真实请求。
|
|
||||||
const USE_MOCK = true
|
|
||||||
|
|
||||||
// 模拟设备列表数据(后端 /device/list 就绪后删除)。
|
|
||||||
const MOCK_DEVICES = [
|
|
||||||
{ id: 1, name: '客厅体征监测仪', model: 'ED713', online: true },
|
|
||||||
{ id: 2, name: '卧室体征监测仪', model: 'ED713', online: false },
|
|
||||||
{ id: 3, name: '书房跌倒检测雷达', model: 'ED719', online: true },
|
|
||||||
{ id: 4, name: '走廊跌倒检测雷达', model: 'ED719', online: false }
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定场所下的设备列表。
|
|
||||||
* 约定:data 为数组,每项至少含 { id, name, model, online },model 取值 ED713 | ED719。
|
|
||||||
* @param {*} sceneId 场景 id,全部/未选择时可传 null
|
|
||||||
*/
|
|
||||||
export function getDeviceList(sceneId) {
|
|
||||||
if (USE_MOCK) {
|
|
||||||
// 模拟网络延迟,让 loading 态可见。
|
|
||||||
return new Promise((resolve) => setTimeout(() => resolve(MOCK_DEVICES), 500))
|
|
||||||
}
|
|
||||||
return request.get('/device/list', { sceneId })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟 ED713 实时体征数据(后端 /device/vitals 就绪后删除)。
|
|
||||||
// 字段与 web 端 runTimeInfo.properties 对齐;枚举值见 detail 页映射表。
|
|
||||||
const MOCK_VITALS = {
|
|
||||||
heartRate: 72,
|
|
||||||
respiratoryRate: 16,
|
|
||||||
bodyMovement: 3,
|
|
||||||
moveState: 3,
|
|
||||||
onbedStatus: 1,
|
|
||||||
bodyStatus: 2,
|
|
||||||
bodyPosition: 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// 心率读数模拟状态:跨调用保留最近 50 个读数(与 web 端 pageSize:50 一致),
|
|
||||||
// 每次轮询推进 2~3 个新读数、挤出最旧的——等价于 web 端每 3 秒按 createTime 倒序重拉最新 50 条。
|
|
||||||
const heartState = {
|
|
||||||
series: [],
|
|
||||||
value: 72
|
|
||||||
}
|
|
||||||
|
|
||||||
// 心率读数小幅随机游走(60~90 BPM)。
|
|
||||||
function driftHeartRate() {
|
|
||||||
heartState.value += Math.round((Math.random() - 0.5) * 4)
|
|
||||||
heartState.value = Math.min(90, Math.max(60, heartState.value))
|
|
||||||
return heartState.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始预填 50 个读数,首屏即有完整曲线。
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
heartState.series.push(driftHeartRate())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 推进若干新读数并保留最近 50 个。
|
|
||||||
function pushHeartReadings() {
|
|
||||||
const n = 2 + Math.round(Math.random())
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
heartState.series.push(driftHeartRate())
|
|
||||||
}
|
|
||||||
if (heartState.series.length > 50) {
|
|
||||||
heartState.series = heartState.series.slice(-50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 ED713 设备实时体征属性。
|
|
||||||
* 约定:data 含 { heartRate, respiratoryRate, bodyMovement, moveState, onbedStatus, bodyStatus, bodyPosition }。
|
|
||||||
* @param {*} deviceId 设备 id
|
|
||||||
*/
|
|
||||||
export function getVitalSigns(deviceId) {
|
|
||||||
if (USE_MOCK) {
|
|
||||||
const heartRate = driftHeartRate()
|
|
||||||
return new Promise((resolve) =>
|
|
||||||
setTimeout(() => resolve({ ...MOCK_VITALS, heartRate, deviceId }), 300)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return request.get('/device/vitals', { deviceId })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取心率历史序列(与 web 端一致:最近 50 条心率读数、按时间升序喂给面积图)。
|
|
||||||
* 约定:data 为 number 数组;每次调用推进若干新读数,曲线随轮询向左滚动。
|
|
||||||
* @param {*} deviceId 设备 id
|
|
||||||
*/
|
|
||||||
export function getHeartRateHistory(deviceId) {
|
|
||||||
if (USE_MOCK) {
|
|
||||||
pushHeartReadings()
|
|
||||||
return new Promise((resolve) =>
|
|
||||||
setTimeout(() => resolve([...heartState.series]), 300)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return request.get('/device/heart-history', { deviceId })
|
|
||||||
}
|
|
||||||
|
|
@ -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 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
<!-- 心率历史波形:canvas 自绘红心电风格面积图(复刻 web 端 TinyArea 视觉) -->
|
|
||||||
<template>
|
|
||||||
<view class="wave-box">
|
|
||||||
<canvas
|
|
||||||
:id="canvasId"
|
|
||||||
class="wave-canvas"
|
|
||||||
:canvas-id="canvasId"
|
|
||||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
|
||||||
/>
|
|
||||||
<!-- ECG 风格虚线分割:上/中/下三部分 -->
|
|
||||||
<view class="wave-divider wave-divider--top" />
|
|
||||||
<view class="wave-divider wave-divider--bottom" />
|
|
||||||
<view v-if="empty" class="wave-empty">
|
|
||||||
<text>暂无数据</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 与 props.canvasHeight 保持一致;波形上下各留 8px 呼吸空间
|
|
||||||
const V_PADDING = 8
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'vital-wave',
|
|
||||||
props: {
|
|
||||||
// 数据点(升序 number 数组)
|
|
||||||
points: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
// canvas id,多实例场景需外部保证唯一
|
|
||||||
canvasId: {
|
|
||||||
type: String,
|
|
||||||
default: 'vitalWave'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
canvasWidth: 200, // 容器实际宽(px),mounted 后测量
|
|
||||||
canvasHeight: 110
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
empty() {
|
|
||||||
return !this.points || this.points.length < 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
// 数据变化重绘(详情页轮询刷新时触发)
|
|
||||||
points() {
|
|
||||||
this.draw()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.measure()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// 测量容器宽度:宽度自适应父级,高度固定 110px 与 web 端一致
|
|
||||||
measure() {
|
|
||||||
uni
|
|
||||||
.createSelectorQuery()
|
|
||||||
.in(this)
|
|
||||||
.select('.wave-box')
|
|
||||||
.boundingClientRect((rect) => {
|
|
||||||
if (rect && rect.width > 0) {
|
|
||||||
this.canvasWidth = Math.floor(rect.width)
|
|
||||||
this.$nextTick(() => this.draw())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.exec()
|
|
||||||
},
|
|
||||||
// 绘制平滑面积图(对齐 web 端 TinyArea:smooth 曲线 + 自适应 y 值域 + padding:0)
|
|
||||||
draw() {
|
|
||||||
if (this.empty) return
|
|
||||||
const ctx = uni.createCanvasContext(this.canvasId, this)
|
|
||||||
const w = this.canvasWidth
|
|
||||||
const h = this.canvasHeight
|
|
||||||
const pts = this.points
|
|
||||||
const min = Math.min(...pts)
|
|
||||||
const max = Math.max(...pts)
|
|
||||||
// 值域映射到画布;无波动时居中画直线
|
|
||||||
const span = max - min || 1
|
|
||||||
|
|
||||||
const xAt = (i) => (pts.length <= 1 ? 0 : (i / (pts.length - 1)) * w)
|
|
||||||
const yAt = (v) => {
|
|
||||||
const t = (v - min) / span
|
|
||||||
return h - V_PADDING - t * (h - V_PADDING * 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// smooth: true —— Catmull-Rom 样条插值(G2 smooth 的实现方式),
|
|
||||||
// 每个点作为控制点求相邻点间的三次贝塞尔,曲线过每个数据点
|
|
||||||
const pathTo = (closeBottom) => {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(xAt(0), yAt(pts[0]))
|
|
||||||
if (pts.length === 2) {
|
|
||||||
ctx.lineTo(xAt(1), yAt(pts[1]))
|
|
||||||
}
|
|
||||||
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)])
|
|
||||||
// Catmull-Rom → 贝塞尔控制点
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 面积:曲线 + 封底,渐变填充(对齐 l(270) 0:#f5222d 1:transparent)
|
|
||||||
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 && ctx.setFillStyle(grad)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// 描边线:2px
|
|
||||||
pathTo(false)
|
|
||||||
ctx.setStrokeStyle && ctx.setStrokeStyle('#f5222d')
|
|
||||||
ctx.setLineWidth && ctx.setLineWidth(2)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
ctx.draw()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.wave-box {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 220rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-canvas {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-divider {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
border-top: 1px dashed rgba(245, 34, 45, 0.25);
|
|
||||||
|
|
||||||
&--top {
|
|
||||||
top: 33.3%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--bottom {
|
|
||||||
top: 66.6%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-empty {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #b5b9c0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export default {}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge 由 defineConfig 回调注入,无需额外引入 webpack-merge
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return merge({}, baseConfig, dev)
|
||||||
|
}
|
||||||
|
return merge({}, baseConfig, prod)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export default {}
|
||||||
|
|
@ -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,5 +1,9 @@
|
||||||
# 统一 BLE 连接与控制流程(ED713 / ED719)
|
# 统一 BLE 连接与控制流程(ED713 / ED719)
|
||||||
|
|
||||||
|
## 平台注入层(2026-09 迁移新增)
|
||||||
|
|
||||||
|
BLE 运行时调用统一收敛:`createBleCore({ platform })` 接受平台对象(默认 Taro,见 `src/ble/adapter/index.ts`),单元测试可注入 mock;`createBluetoothDiscovery`(`src/ble/discovery.js`)原样保留全部能力(扫描过滤/去重/重启/Android 定位权限/连接/鉴权/WiFi/雷达),仅 3 处 uni.* 换为 Taro.*。React 页面通过 `useBle()`(`src/ble/hooks/useBle.ts`)消费——工厂回调转 state,方法原样透出。
|
||||||
|
|
||||||
## 1. 标准连接流程图(文字版)
|
## 1. 标准连接流程图(文字版)
|
||||||
1. 初始化阶段
|
1. 初始化阶段
|
||||||
- `openBluetoothAdapter`
|
- `openBluetoothAdapter`
|
||||||
|
|
@ -31,7 +35,7 @@
|
||||||
|
|
||||||
## 2. 目录结构
|
## 2. 目录结构
|
||||||
```text
|
```text
|
||||||
utils/ble/
|
src/ble/
|
||||||
core/
|
core/
|
||||||
bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连)
|
bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连)
|
||||||
controller.js # 标准流程编排(scan->connect->discover->auth)
|
controller.js # 标准流程编排(scan->connect->discover->auth)
|
||||||
|
|
@ -84,7 +88,7 @@ utils/ble/
|
||||||
import {
|
import {
|
||||||
createUnifiedBleController,
|
createUnifiedBleController,
|
||||||
DEVICE_PROFILE
|
DEVICE_PROFILE
|
||||||
} from '@/utils/ble'
|
} from '@/ble'
|
||||||
|
|
||||||
const bleController = createUnifiedBleController({
|
const bleController = createUnifiedBleController({
|
||||||
profileId: DEVICE_PROFILE.UNKNOWN,
|
profileId: DEVICE_PROFILE.UNKNOWN,
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.2.0",
|
||||||
|
"@tarojs/components": "^4.0.0",
|
||||||
|
"@tarojs/helper": "^4.0.0",
|
||||||
|
"@tarojs/plugin-framework-react": "^4.0.0",
|
||||||
|
"@tarojs/plugin-platform-weapp": "^4.0.0",
|
||||||
|
"@tarojs/react": "^4.0.0",
|
||||||
|
"@tarojs/runtime": "^4.0.0",
|
||||||
|
"@tarojs/shared": "^4.0.0",
|
||||||
|
"@tarojs/taro": "^4.0.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
"react-redux": "^9.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@babel/preset-react": "^7.29.7",
|
||||||
|
"@tarojs/cli": "^4.0.0",
|
||||||
|
"@tarojs/webpack5-runner": "^4.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/web": "^0.0.100",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"babel-preset-taro": "^4.0.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react": "^7.34.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"prettier": "^3.2.0",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
"uview-plus": "^3.7.36"
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^4.5.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"webpack": "^5.91.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
pages.json
|
|
@ -1,92 +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/device/detail",
|
|
||||||
"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,343 +0,0 @@
|
||||||
<!-- ED713 设备详情页:体征仪表盘(参考 web 端 VitalSignsDashboard 布局) -->
|
|
||||||
<template>
|
|
||||||
<view class="page page-base">
|
|
||||||
<up-navbar :title="deviceName" :fixed="false" :safe-area-inset-top="true" :border="false" auto-back />
|
|
||||||
|
|
||||||
<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="loadVitals" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<!-- 心率区:左侧大数字 + 右侧波形 -->
|
|
||||||
<view class="heart-card">
|
|
||||||
<view class="heart-left">
|
|
||||||
<text class="heart-icon">❤</text>
|
|
||||||
<text class="heart-value">{{ valueOf(vitals.heartRate) }}</text>
|
|
||||||
<text class="heart-unit">BPM</text>
|
|
||||||
<view class="heart-badge" :class="online ? 'heart-badge--on' : 'heart-badge--off'">
|
|
||||||
<view class="badge-dot" :class="online ? 'badge-dot--on' : 'badge-dot--off'" />
|
|
||||||
<text>{{ online ? '正常' : '离线' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="heart-right">
|
|
||||||
<view class="heart-right-title">
|
|
||||||
<text class="heart-health-label">心率健康</text>
|
|
||||||
</view>
|
|
||||||
<vital-wave :points="wavePoints" canvas-id="heartWave" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 六个指标 2 列网格 -->
|
|
||||||
<view class="metric-grid">
|
|
||||||
<view v-for="m in metrics" :key="m.label" class="metric-card">
|
|
||||||
<text class="metric-label">{{ m.label }}</text>
|
|
||||||
<view class="metric-value-row">
|
|
||||||
<text class="metric-value" :style="{ color: m.color }">{{ m.value }}</text>
|
|
||||||
<text v-if="m.unit" class="metric-unit">{{ m.unit }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 设备信息 -->
|
|
||||||
<view class="info-card">
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-label">设备型号</text>
|
|
||||||
<text class="info-value">{{ deviceModel || '-' }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-label">设备 ID</text>
|
|
||||||
<text class="info-value">{{ deviceId || '-' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import vitalWave from '@/components/vital-wave.vue'
|
|
||||||
import { getHeartRateHistory, getVitalSigns } from '@/api/device'
|
|
||||||
|
|
||||||
// 枚举值中文映射,与 web 端 i18n (zh-CN) 一致
|
|
||||||
const MOVE_STATE_MAP = { 0: '无人', 1: '轻微活动', 2: '频繁活动', 3: '静卧' }
|
|
||||||
const BODY_STATUS_MAP = { 0: '无人', 1: '运动', 2: '呼吸', 3: '进入呼吸' }
|
|
||||||
const ONBED_STATUS_MAP = { 0: '不在床', 1: '在床' }
|
|
||||||
|
|
||||||
// 指标卡配色与 web 端 MetricCard color 一致
|
|
||||||
const METRIC_COLORS = {
|
|
||||||
breath: '#1677ff',
|
|
||||||
motion: '#fa8c16',
|
|
||||||
moveState: '#52c41a',
|
|
||||||
bedState: '#f5222d',
|
|
||||||
bodyState: '#1677ff',
|
|
||||||
bodyPosition: '#52c41a'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 与 web 端 SWR refreshInterval 一致的轮询间隔
|
|
||||||
const REFRESH_INTERVAL = 3000
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { vitalWave },
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
deviceId: '',
|
|
||||||
deviceName: '设备详情',
|
|
||||||
deviceModel: '',
|
|
||||||
online: false,
|
|
||||||
loading: true,
|
|
||||||
loadError: false,
|
|
||||||
vitals: {},
|
|
||||||
wavePoints: [],
|
|
||||||
pollTimer: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
// 六个指标卡数据(2 列网格顺序:呼吸率/体动/运动状态/在床状态/人体状态/人体位置)
|
|
||||||
metrics() {
|
|
||||||
const v = this.vitals
|
|
||||||
return [
|
|
||||||
{ label: '呼吸率', value: this.valueOf(v.respiratoryRate), unit: '/min', color: METRIC_COLORS.breath },
|
|
||||||
{ label: '体动', value: this.valueOf(v.bodyMovement), unit: '', color: METRIC_COLORS.motion },
|
|
||||||
{ label: '运动状态', value: MOVE_STATE_MAP[v.moveState] ?? this.valueOf(v.moveState), unit: '', color: METRIC_COLORS.moveState },
|
|
||||||
{ label: '在床状态', value: ONBED_STATUS_MAP[v.onbedStatus] ?? this.valueOf(v.onbedStatus), unit: '', color: METRIC_COLORS.bedState },
|
|
||||||
{ label: '人体状态', value: BODY_STATUS_MAP[v.bodyStatus] ?? this.valueOf(v.bodyStatus), unit: '', color: METRIC_COLORS.bodyState },
|
|
||||||
{ label: '人体位置', value: this.valueOf(v.bodyPosition), unit: '分米', color: METRIC_COLORS.bodyPosition }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLoad(options) {
|
|
||||||
this.deviceId = options.id || ''
|
|
||||||
if (options.name) this.deviceName = decodeURIComponent(options.name)
|
|
||||||
this.deviceModel = options.model || ''
|
|
||||||
this.online = options.online === '1'
|
|
||||||
this.loadVitals()
|
|
||||||
},
|
|
||||||
onShow() {
|
|
||||||
this.startPolling()
|
|
||||||
},
|
|
||||||
onHide() {
|
|
||||||
this.stopPolling()
|
|
||||||
},
|
|
||||||
onUnload() {
|
|
||||||
this.stopPolling()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// 拉取实时体征与心率历史;首拉失败切错误态供重试,轮询失败静默(避免 toast 每 3 秒弹一次)。
|
|
||||||
async loadVitals() {
|
|
||||||
this.loading = true
|
|
||||||
this.loadError = false
|
|
||||||
try {
|
|
||||||
await this.fetchVitals()
|
|
||||||
} catch (error) {
|
|
||||||
this.loadError = true
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 仅拉数据并落 state;wavePoints 引用变化触发波形组件重绘。
|
|
||||||
async fetchVitals() {
|
|
||||||
const [vitals, history] = await Promise.all([
|
|
||||||
getVitalSigns(this.deviceId),
|
|
||||||
getHeartRateHistory(this.deviceId)
|
|
||||||
])
|
|
||||||
this.vitals = vitals || {}
|
|
||||||
this.wavePoints = Array.isArray(history) ? history : []
|
|
||||||
},
|
|
||||||
// 轮询:每 3 秒静默刷新体征与波形(页面隐藏/卸载时停止)。
|
|
||||||
startPolling() {
|
|
||||||
this.stopPolling()
|
|
||||||
this.pollTimer = setInterval(() => {
|
|
||||||
this.fetchVitals().catch(() => {})
|
|
||||||
}, REFRESH_INTERVAL)
|
|
||||||
},
|
|
||||||
stopPolling() {
|
|
||||||
if (this.pollTimer) {
|
|
||||||
clearInterval(this.pollTimer)
|
|
||||||
this.pollTimer = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 空值统一显示 '-'
|
|
||||||
valueOf(v) {
|
|
||||||
return v === undefined || v === null || v === '' ? '-' : v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 心率区 */
|
|
||||||
.heart-card {
|
|
||||||
margin: 24rpx;
|
|
||||||
padding: 32rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 200rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-icon {
|
|
||||||
font-size: 72rpx;
|
|
||||||
color: #f5222d;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-value {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 96rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #2a2d33;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-unit {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #9ea1a6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-badge {
|
|
||||||
margin-top: 20rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8rpx 24rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
|
|
||||||
&--on {
|
|
||||||
background: #f6ffed;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--off {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #999999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-dot {
|
|
||||||
width: 14rpx;
|
|
||||||
height: 14rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 10rpx;
|
|
||||||
|
|
||||||
&--on {
|
|
||||||
background: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--off {
|
|
||||||
background: #999999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-right {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
margin-left: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-right-title {
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-health-label {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #2a2d33;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 六指标 2 列网格 */
|
|
||||||
.metric-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
width: 48.5%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #9ea1a6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value-row {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-unit {
|
|
||||||
margin-left: 8rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #b5b9c0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 设备信息 */
|
|
||||||
.info-card {
|
|
||||||
margin: 4rpx 24rpx 24rpx;
|
|
||||||
padding: 8rpx 32rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
min-height: 104rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
+ .info-row {
|
|
||||||
border-top: 1rpx solid #f0f1f3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #9ea1a6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #2a2d33;
|
|
||||||
}
|
|
||||||
</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,320 +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>
|
|
||||||
|
|
||||||
<!-- 设备列表:loading / 错误 / 空态 / 双列卡片网格 -->
|
|
||||||
<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="loadDevices" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-else-if="deviceList.length === 0" class="state-wrap">
|
|
||||||
<up-empty mode="list" text="暂无设备" />
|
|
||||||
<up-button type="success" shape="circle" text="添加设备" class="retry-btn" @click="goAdd" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-else class="device-grid">
|
|
||||||
<view
|
|
||||||
v-for="device in deviceList"
|
|
||||||
:key="device.id"
|
|
||||||
class="device-card"
|
|
||||||
@click="goDetail(device)"
|
|
||||||
>
|
|
||||||
<view class="device-icon" :class="{ 'device-icon--offline': !device.online }">
|
|
||||||
<up-icon :name="iconOf(device)" :color="device.online ? '#ffffff' : '#b5b9c0'" size="30" />
|
|
||||||
</view>
|
|
||||||
<text class="device-name">{{ device.name }}</text>
|
|
||||||
<view class="device-status">
|
|
||||||
<view class="status-dot" :class="device.online ? 'status-dot--on' : 'status-dot--off'" />
|
|
||||||
<text class="status-text" :class="{ 'status-text--off': !device.online }">
|
|
||||||
{{ device.online ? '在线' : '离线' }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="fab-wrap" @click="goAdd">
|
|
||||||
<up-icon name="plus" color="#ffffff" size="28" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { getDeviceList } from '@/api/device'
|
|
||||||
import { ROUTES } from '@/constants/routes'
|
|
||||||
import { navigateTo } from '@/utils/navigation'
|
|
||||||
import { getCurrentScene } from '@/store/scene'
|
|
||||||
|
|
||||||
// 设备型号 -> 图标映射,未命中走兜底图标。
|
|
||||||
const DEVICE_ICON_MAP = {
|
|
||||||
ED713: 'home',
|
|
||||||
ED719: 'bell'
|
|
||||||
}
|
|
||||||
const FALLBACK_ICON = 'setting'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
noticeText: '点击选择场所后可进入设备配置流程',
|
|
||||||
sceneName: '',
|
|
||||||
loading: true,
|
|
||||||
loadError: false,
|
|
||||||
deviceList: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShow() {
|
|
||||||
this.refreshScene()
|
|
||||||
this.loadDevices()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// 从全局场景状态刷新回显文案。
|
|
||||||
refreshScene() {
|
|
||||||
const scene = getCurrentScene()
|
|
||||||
this.sceneName = scene ? scene.name : ''
|
|
||||||
},
|
|
||||||
// 拉取当前场所的设备列表;在线状态需新鲜,每次 onShow 重拉。
|
|
||||||
async loadDevices() {
|
|
||||||
this.loading = true
|
|
||||||
this.loadError = false
|
|
||||||
try {
|
|
||||||
const scene = getCurrentScene()
|
|
||||||
const data = await getDeviceList(scene ? scene.id : null)
|
|
||||||
this.deviceList = Array.isArray(data) ? data : []
|
|
||||||
} catch (error) {
|
|
||||||
this.loadError = true
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 设备型号对应图标。
|
|
||||||
iconOf(device) {
|
|
||||||
return DEVICE_ICON_MAP[device.model] || FALLBACK_ICON
|
|
||||||
},
|
|
||||||
// 进入场景选择页。
|
|
||||||
goSceneSelect() {
|
|
||||||
navigateTo(ROUTES.SCENE_SELECT)
|
|
||||||
},
|
|
||||||
// 进入设备添加页(后续会承接蓝牙配网流程入口)。
|
|
||||||
goAdd() {
|
|
||||||
navigateTo(ROUTES.DEVICE_ADD)
|
|
||||||
},
|
|
||||||
// 进入设备配置页。
|
|
||||||
goConfig() {
|
|
||||||
navigateTo(ROUTES.DEVICE_CONFIG)
|
|
||||||
},
|
|
||||||
// 进入设备详情页:携带基础信息,后续接详情接口后改为仅传 id。
|
|
||||||
goDetail(device) {
|
|
||||||
const name = encodeURIComponent(device.name || '')
|
|
||||||
navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${device.id}&name=${name}&model=${device.model || ''}&online=${device.online ? 1 : 0}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表三态容器 */
|
|
||||||
.state-wrap {
|
|
||||||
margin-top: 80rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-btn {
|
|
||||||
margin-top: 40rpx;
|
|
||||||
width: 240rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 设备双列网格 */
|
|
||||||
.device-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-card {
|
|
||||||
width: 48.5%;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
padding: 36rpx 20rpx 28rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #f7f8fa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-icon {
|
|
||||||
width: 108rpx;
|
|
||||||
height: 108rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
|
|
||||||
&--offline {
|
|
||||||
background: #eef0f4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-name {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2a2d33;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-status {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 14rpx;
|
|
||||||
height: 14rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 10rpx;
|
|
||||||
|
|
||||||
&--on {
|
|
||||||
background: #2ccb98;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--off {
|
|
||||||
background: #c0c4cc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #12a37a;
|
|
||||||
|
|
||||||
&--off {
|
|
||||||
color: #9ea1a6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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" auto-back />
|
|
||||||
|
|
||||||
<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,25 @@
|
||||||
|
{
|
||||||
|
"setting": {
|
||||||
|
"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": true,
|
||||||
|
"coverView": true,
|
||||||
|
"lazyloadPlaceholderEnable": false,
|
||||||
|
"skylineRenderEnable": false,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"autoAudits": false,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"compileHotReLoad": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { request } from '@/http'
|
||||||
|
|
||||||
|
const USE_MOCK = true
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(params: LoginParams): Promise<LoginResult> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return Promise.resolve({
|
||||||
|
token: 'mock-token-' + Date.now(),
|
||||||
|
userId: 'u-001'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return request.post<LoginResult>('/auth/login', params as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { request } from '@/http'
|
||||||
|
import type { Device } from '@/store/deviceSlice'
|
||||||
|
|
||||||
|
const USE_MOCK = true
|
||||||
|
|
||||||
|
const MOCK_DEVICES: Device[] = [
|
||||||
|
{ id: 1, name: '客厅体征监测仪', model: 'ED713', online: true },
|
||||||
|
{ id: 2, name: '卧室体征监测仪', model: 'ED713', online: false },
|
||||||
|
{ id: 3, name: '书房跌倒检测雷达', model: 'ED719', online: true },
|
||||||
|
{ id: 4, name: '走廊跌倒检测雷达', model: 'ED719', online: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getDeviceList(sceneId: number | null): Promise<Device[]> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return new Promise((resolve) => setTimeout(() => resolve(MOCK_DEVICES), 500))
|
||||||
|
}
|
||||||
|
return request.get<Device[]>('/device/list', { sceneId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVitalSigns / getHeartRateHistory 在 P3 Task 3.2 加入本文件
|
||||||
|
|
||||||
|
const MOCK_VITALS = {
|
||||||
|
heartRate: 72,
|
||||||
|
respiratoryRate: 16,
|
||||||
|
bodyMovement: 3,
|
||||||
|
moveState: 3,
|
||||||
|
onbedStatus: 1,
|
||||||
|
bodyStatus: 2,
|
||||||
|
bodyPosition: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartState: { series: number[]; value: number } = { series: [], value: 72 }
|
||||||
|
|
||||||
|
function driftHeartRate(): number {
|
||||||
|
heartState.value += Math.round((Math.random() - 0.5) * 4)
|
||||||
|
heartState.value = Math.min(90, Math.max(60, heartState.value))
|
||||||
|
return heartState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) heartState.series.push(driftHeartRate())
|
||||||
|
|
||||||
|
function pushHeartReadings() {
|
||||||
|
const n = 2 + Math.round(Math.random())
|
||||||
|
for (let i = 0; i < n; i++) heartState.series.push(driftHeartRate())
|
||||||
|
if (heartState.series.length > 50) heartState.series = heartState.series.slice(-50)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VitalSigns {
|
||||||
|
heartRate: number
|
||||||
|
respiratoryRate: number
|
||||||
|
bodyMovement: number
|
||||||
|
moveState: number
|
||||||
|
onbedStatus: number
|
||||||
|
bodyStatus: number
|
||||||
|
bodyPosition: number
|
||||||
|
deviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVitalSigns(deviceId: string): Promise<VitalSigns> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
const heartRate = driftHeartRate()
|
||||||
|
return new Promise((resolve) =>
|
||||||
|
setTimeout(() => resolve({ ...MOCK_VITALS, heartRate, deviceId }), 300)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return request.get<VitalSigns>('/device/vitals', { deviceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeartRateHistory(deviceId: string): Promise<number[]> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
pushHeartReadings()
|
||||||
|
return new Promise((resolve) => setTimeout(() => resolve([...heartState.series]), 300))
|
||||||
|
}
|
||||||
|
return request.get<number[]>('/device/heart-history', { deviceId })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { request } from '@/http'
|
||||||
|
|
||||||
|
const USE_MOCK = true
|
||||||
|
|
||||||
|
export interface EventItem {
|
||||||
|
id: number
|
||||||
|
sceneName: string
|
||||||
|
deviceName: string
|
||||||
|
status: number
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventQuery {
|
||||||
|
sceneId: number
|
||||||
|
status: number
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件列表接口未定:mock 返回空数组,页面保持空态
|
||||||
|
export function getEventList(_query: EventQuery): Promise<EventItem[]> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
return request.get<EventItem[]>('/event/list', { ..._query })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { request } from '@/http'
|
||||||
|
import type { Scene } from '@/store/sceneSlice'
|
||||||
|
|
||||||
|
const USE_MOCK = true
|
||||||
|
|
||||||
|
const MOCK_SCENES: Scene[] = [
|
||||||
|
{ id: 1, name: '客厅' },
|
||||||
|
{ id: 2, name: '卧室' },
|
||||||
|
{ id: 3, name: '书房' },
|
||||||
|
{ id: 4, name: '走廊' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getSceneList(): Promise<Scene[]> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return new Promise((resolve) => setTimeout(() => resolve(MOCK_SCENES), 300))
|
||||||
|
}
|
||||||
|
return request.get<Scene[]>('/scene/list')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { request } from '@/http'
|
||||||
|
|
||||||
|
const USE_MOCK = true
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserInfo(): Promise<UserInfo> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: 'u-001',
|
||||||
|
name: '张大爷',
|
||||||
|
avatar: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return request.get<UserInfo>('/user/info')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
export default {
|
||||||
|
// P5 收尾:全部 8 个页面齐备
|
||||||
|
pages: [
|
||||||
|
'pages/index/index',
|
||||||
|
'pages/login/index',
|
||||||
|
'pages/mine/index',
|
||||||
|
'pages/scene/select',
|
||||||
|
'pages/device/detail',
|
||||||
|
'pages/device/add',
|
||||||
|
'pages/device/config',
|
||||||
|
'pages/event/index'
|
||||||
|
],
|
||||||
|
window: {
|
||||||
|
navigationStyle: 'custom', // 与现状 pages.json 一致:所有页自绘 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/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
|
||||||
|
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 { toFullUuid, uuidEquals, uuidInList } from '../utils/uuid';
|
||||||
import { runWithRetry } from './retry';
|
import { runWithRetry } from './retry';
|
||||||
import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles';
|
import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles';
|
||||||
|
import { taroPlatform } from '../adapter';
|
||||||
|
|
||||||
// 把 uni 的 success/fail 回调风格统一成 Promise,便于串联 async 流程。
|
// 把平台(Taro/uni)的 success/fail 回调风格统一成 Promise,便于串联 async 流程。
|
||||||
function promisifyUniApi(apiName, params = {}) {
|
// 平台对象通过 createBleCore(options) 可注入,默认 Taro;单测可传 mock。
|
||||||
|
function promisifyApi(platform, apiName, params = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni[apiName]({
|
platform[apiName]({
|
||||||
...params,
|
...params,
|
||||||
success: (res) => resolve(res),
|
success: (res) => resolve(res),
|
||||||
fail: (err) => reject(err),
|
fail: (err) => reject(err),
|
||||||
|
|
@ -23,6 +25,7 @@ function promisifyUniApi(apiName, params = {}) {
|
||||||
*/
|
*/
|
||||||
export function createBleCore(options = {}) {
|
export function createBleCore(options = {}) {
|
||||||
const {
|
const {
|
||||||
|
platform = taroPlatform,
|
||||||
reconnect = {
|
reconnect = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
|
|
@ -92,7 +95,7 @@ export function createBleCore(options = {}) {
|
||||||
/** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */
|
/** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */
|
||||||
async function openAdapter() {
|
async function openAdapter() {
|
||||||
try {
|
try {
|
||||||
await promisifyUniApi('openBluetoothAdapter');
|
await promisifyApi(platform, 'openBluetoothAdapter');
|
||||||
patchState({ available: true });
|
patchState({ available: true });
|
||||||
setStage(BLE_STAGE.ADAPTER_OPENED);
|
setStage(BLE_STAGE.ADAPTER_OPENED);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -104,7 +107,7 @@ export function createBleCore(options = {}) {
|
||||||
/** 关闭蓝牙适配器并重置全部运行时状态。 */
|
/** 关闭蓝牙适配器并重置全部运行时状态。 */
|
||||||
async function closeAdapter() {
|
async function closeAdapter() {
|
||||||
try {
|
try {
|
||||||
await promisifyUniApi('closeBluetoothAdapter');
|
await promisifyApi(platform, 'closeBluetoothAdapter');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[BLE] close adapter failed', error);
|
logger.warn('[BLE] close adapter failed', error);
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +132,7 @@ export function createBleCore(options = {}) {
|
||||||
/** 读取当前适配器状态(可用/搜索中),同步到内部状态。 */
|
/** 读取当前适配器状态(可用/搜索中),同步到内部状态。 */
|
||||||
async function getAdapterState() {
|
async function getAdapterState() {
|
||||||
try {
|
try {
|
||||||
const result = await promisifyUniApi('getBluetoothAdapterState');
|
const result = await promisifyApi(platform, 'getBluetoothAdapterState');
|
||||||
patchState({
|
patchState({
|
||||||
available: Boolean(result.available),
|
available: Boolean(result.available),
|
||||||
discovering: Boolean(result.discovering),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uni.offBluetoothAdapterStateChange) {
|
if (platform.offBluetoothAdapterStateChange) {
|
||||||
uni.offBluetoothAdapterStateChange(adapterStateHandler);
|
platform.offBluetoothAdapterStateChange(adapterStateHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
adapterStateHandler = null;
|
adapterStateHandler = null;
|
||||||
|
|
@ -204,7 +207,7 @@ export function createBleCore(options = {}) {
|
||||||
options.powerLevel = powerLevel;
|
options.powerLevel = powerLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promisifyUniApi('startBluetoothDevicesDiscovery', options);
|
await promisifyApi(platform, 'startBluetoothDevicesDiscovery', options);
|
||||||
patchState({ discovering: true, deviceMap: {} });
|
patchState({ discovering: true, deviceMap: {} });
|
||||||
setStage(BLE_STAGE.DISCOVERING);
|
setStage(BLE_STAGE.DISCOVERING);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -216,7 +219,7 @@ export function createBleCore(options = {}) {
|
||||||
/** 停止设备扫描。 */
|
/** 停止设备扫描。 */
|
||||||
async function stopDiscovery() {
|
async function stopDiscovery() {
|
||||||
try {
|
try {
|
||||||
await promisifyUniApi('stopBluetoothDevicesDiscovery');
|
await promisifyApi(platform, 'stopBluetoothDevicesDiscovery');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[BLE] stop discovery failed', error);
|
logger.warn('[BLE] stop discovery failed', error);
|
||||||
}
|
}
|
||||||
|
|
@ -256,7 +259,7 @@ export function createBleCore(options = {}) {
|
||||||
eventBus.emit('device:found', Object.values(nextMap));
|
eventBus.emit('device:found', Object.values(nextMap));
|
||||||
};
|
};
|
||||||
|
|
||||||
uni.onBluetoothDeviceFound(deviceFoundHandler);
|
platform.onBluetoothDeviceFound(deviceFoundHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除设备发现监听。 */
|
/** 移除设备发现监听。 */
|
||||||
|
|
@ -265,8 +268,8 @@ export function createBleCore(options = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uni.offBluetoothDeviceFound) {
|
if (platform.offBluetoothDeviceFound) {
|
||||||
uni.offBluetoothDeviceFound(deviceFoundHandler);
|
platform.offBluetoothDeviceFound(deviceFoundHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceFoundHandler = null;
|
deviceFoundHandler = null;
|
||||||
|
|
@ -288,7 +291,7 @@ export function createBleCore(options = {}) {
|
||||||
|
|
||||||
const runConnect = async () => {
|
const runConnect = async () => {
|
||||||
setStage(BLE_STAGE.CONNECTING);
|
setStage(BLE_STAGE.CONNECTING);
|
||||||
await promisifyUniApi('createBLEConnection', { deviceId, timeout });
|
await promisifyApi(platform, 'createBLEConnection', { deviceId, timeout });
|
||||||
patchState({ connected: true, deviceId });
|
patchState({ connected: true, deviceId });
|
||||||
setStage(BLE_STAGE.CONNECTED);
|
setStage(BLE_STAGE.CONNECTED);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -319,7 +322,7 @@ export function createBleCore(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await promisifyUniApi('closeBLEConnection', { deviceId });
|
await promisifyApi(platform, 'closeBLEConnection', { deviceId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[BLE] close connection failed', 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uni.offBLEConnectionStateChange) {
|
if (platform.offBLEConnectionStateChange) {
|
||||||
uni.offBLEConnectionStateChange(connectionStateHandler);
|
platform.offBLEConnectionStateChange(connectionStateHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionStateHandler = null;
|
connectionStateHandler = null;
|
||||||
|
|
@ -391,7 +394,7 @@ export function createBleCore(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await promisifyUniApi('getBLEDeviceServices', {
|
const result = await promisifyApi(platform, 'getBLEDeviceServices', {
|
||||||
deviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
const services = result.services || [];
|
const services = result.services || [];
|
||||||
|
|
@ -434,7 +437,7 @@ export function createBleCore(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await promisifyUniApi('getBLEDeviceCharacteristics', {
|
const result = await promisifyApi(platform, 'getBLEDeviceCharacteristics', {
|
||||||
deviceId,
|
deviceId,
|
||||||
serviceId,
|
serviceId,
|
||||||
});
|
});
|
||||||
|
|
@ -478,7 +481,7 @@ export function createBleCore(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await promisifyUniApi('readBLECharacteristicValue', {
|
await promisifyApi(platform, 'readBLECharacteristicValue', {
|
||||||
deviceId,
|
deviceId,
|
||||||
serviceId,
|
serviceId,
|
||||||
characteristicId,
|
characteristicId,
|
||||||
|
|
@ -531,7 +534,7 @@ export function createBleCore(options = {}) {
|
||||||
if (writeType) {
|
if (writeType) {
|
||||||
params.writeType = writeType;
|
params.writeType = writeType;
|
||||||
}
|
}
|
||||||
await promisifyUniApi('writeBLECharacteristicValue', params);
|
await promisifyApi(platform, 'writeBLECharacteristicValue', params);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw emitError(error);
|
throw emitError(error);
|
||||||
|
|
@ -552,7 +555,7 @@ export function createBleCore(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await promisifyUniApi('notifyBLECharacteristicValueChange', {
|
await promisifyApi(platform, 'notifyBLECharacteristicValueChange', {
|
||||||
deviceId,
|
deviceId,
|
||||||
serviceId,
|
serviceId,
|
||||||
characteristicId,
|
characteristicId,
|
||||||
|
|
@ -586,7 +589,7 @@ export function createBleCore(options = {}) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
uni.onBLECharacteristicValueChange(characteristicValueHandler);
|
platform.onBLECharacteristicValueChange(characteristicValueHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除特征值变更监听。 */
|
/** 移除特征值变更监听。 */
|
||||||
|
|
@ -595,8 +598,8 @@ export function createBleCore(options = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uni.offBLECharacteristicValueChange) {
|
if (platform.offBLECharacteristicValueChange) {
|
||||||
uni.offBLECharacteristicValueChange(characteristicValueHandler);
|
platform.offBLECharacteristicValueChange(characteristicValueHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
characteristicValueHandler = null;
|
characteristicValueHandler = null;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// discovery.js(原 hooks/useBluetoothDiscovery.js)为保留的纯 JS 资产,
|
||||||
|
// 不做类型化;此处只声明 useBle 需要的最小工厂签名。
|
||||||
|
export interface DiscoveryOptions {
|
||||||
|
onSearchingChange?: (searching: boolean) => void
|
||||||
|
onConnectedChange?: (connected: boolean) => void
|
||||||
|
onDeviceListChange?: (deviceList: Array<{ deviceId: string; name?: string; RSSI?: number }>) => 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 DiscoveryInstance {
|
||||||
|
startSearch: () => Promise<void>
|
||||||
|
stopSearch: () => Promise<void>
|
||||||
|
connectDevice: (deviceId: string) => Promise<void>
|
||||||
|
prepareWifiConfig: (deviceId: string, options?: Record<string, unknown>) => Promise<void>
|
||||||
|
disconnectDevice: () => Promise<void>
|
||||||
|
configureWifi: (ssid: string, password: string, options?: Record<string, unknown>) => Promise<void>
|
||||||
|
startRadar: () => Promise<void>
|
||||||
|
stopRadar: () => Promise<void>
|
||||||
|
cleanupDiscovery: () => void
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare function createBluetoothDiscovery(options?: DiscoveryOptions): DiscoveryInstance
|
||||||
|
|
@ -7,8 +7,9 @@ import {
|
||||||
TARGET_SERVICE_UUID_COMPAT_FULL,
|
TARGET_SERVICE_UUID_COMPAT_FULL,
|
||||||
TARGET_SHORT_UUID_CANDIDATES
|
TARGET_SHORT_UUID_CANDIDATES
|
||||||
} from '@/constants/bluetooth'
|
} from '@/constants/bluetooth'
|
||||||
import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/utils/ble'
|
import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/ble'
|
||||||
import {showToast} from '@/utils/toast'
|
import {showToast} from '@/utils/toast'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
|
||||||
const FILTER_SCAN_TIMEOUT_MS = 4000
|
const FILTER_SCAN_TIMEOUT_MS = 4000
|
||||||
const RESTART_SCAN_DELAY_MS = 120
|
const RESTART_SCAN_DELAY_MS = 120
|
||||||
|
|
@ -294,7 +295,7 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
let systemInfo = {}
|
let systemInfo = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
systemInfo = uni.getSystemInfoSync() || {}
|
systemInfo = Taro.getSystemInfoSync() || {}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +307,7 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await new Promise((resolve, reject) => {
|
const settings = await new Promise((resolve, reject) => {
|
||||||
uni.getSetting({
|
Taro.getSetting({
|
||||||
success: resolve,
|
success: resolve,
|
||||||
fail: reject
|
fail: reject
|
||||||
})
|
})
|
||||||
|
|
@ -318,7 +319,7 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
uni.authorize({
|
Taro.authorize({
|
||||||
scope: 'scope.userLocation',
|
scope: 'scope.userLocation',
|
||||||
success: resolve,
|
success: resolve,
|
||||||
fail: reject
|
fail: reject
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createBluetoothDiscovery } from '../discovery'
|
||||||
|
|
||||||
|
// 工厂回调 → React state 的映射产物
|
||||||
|
export interface BleUiState {
|
||||||
|
searching: boolean
|
||||||
|
connected: boolean
|
||||||
|
deviceList: Array<{ deviceId: string; name?: string; RSSI?: number }>
|
||||||
|
profileId: string
|
||||||
|
stage: string
|
||||||
|
deviceInfo: Record<string, unknown> | null
|
||||||
|
authResult: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBle() {
|
||||||
|
const [state, setState] = useState<BleUiState>({
|
||||||
|
searching: false,
|
||||||
|
connected: false,
|
||||||
|
deviceList: [],
|
||||||
|
profileId: 'UNKNOWN',
|
||||||
|
stage: 'IDLE',
|
||||||
|
deviceInfo: null,
|
||||||
|
authResult: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// discovery 实例在整个组件生命周期内只创建一次;
|
||||||
|
// 回调里读最新 state 用 setState 函数式更新,不闭包旧值
|
||||||
|
const discoveryRef = useRef<ReturnType<typeof createBluetoothDiscovery> | null>(null)
|
||||||
|
if (discoveryRef.current === null) {
|
||||||
|
discoveryRef.current = createBluetoothDiscovery({
|
||||||
|
onSearchingChange: (searching) => setState((s) => ({ ...s, searching })),
|
||||||
|
onConnectedChange: (connected) => setState((s) => ({ ...s, connected })),
|
||||||
|
onDeviceListChange: (deviceList) => setState((s) => ({ ...s, deviceList })),
|
||||||
|
onProfileChange: (profileId) => setState((s) => ({ ...s, profileId })),
|
||||||
|
onStageChange: (stage) => setState((s) => ({ ...s, stage })),
|
||||||
|
onDeviceInfoChange: (deviceInfo) => setState((s) => ({ ...s, deviceInfo })),
|
||||||
|
onAuthChange: (authResult) => setState((s) => ({ ...s, authResult }))
|
||||||
|
// onError / onRadarData / onProtocolState / onProtocolMsg 按页面需要接入
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卸载时统一清理(停扫描、断连接、解绑事件)
|
||||||
|
useEffect(() => {
|
||||||
|
const discovery = discoveryRef.current
|
||||||
|
return () => {
|
||||||
|
discovery?.cleanupDiscovery()
|
||||||
|
discoveryRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const discovery = discoveryRef.current!
|
||||||
|
|
||||||
|
return { state, discovery }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
mode?: 'list' | 'wifi' | 'error' | 'message'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Empty({ text, mode = 'list' }: Props) {
|
||||||
|
const icon = mode === 'wifi' ? '📡' : mode === 'error' ? '⚠️' : mode === 'message' ? '🔔' : '📋'
|
||||||
|
return (
|
||||||
|
<View className={styles.empty}>
|
||||||
|
<Text className={styles.icon}>{icon}</Text>
|
||||||
|
<Text className={styles.text}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid #eef0f4;
|
||||||
|
border-top-color: #2ccb98;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,32 @@
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 88px;
|
||||||
|
padding: 0 32px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backIcon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #2a2d33;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2d33;
|
||||||
|
margin-right: 44px; // 平衡 back 按钮宽度
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
||||||
|
onBack?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar({
|
||||||
|
title,
|
||||||
|
fixed = false,
|
||||||
|
autoBack = true,
|
||||||
|
border = false,
|
||||||
|
safeAreaTop = true,
|
||||||
|
onBack
|
||||||
|
}: Props) {
|
||||||
|
// getSystemInfoSync 已废弃,改用 getWindowInfo
|
||||||
|
const statusBarHeight = useMemo(() => Taro.getWindowInfo().statusBarHeight ?? 20, [])
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (onBack) onBack()
|
||||||
|
else Taro.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={styles.navbar}
|
||||||
|
style={{
|
||||||
|
paddingTop: safeAreaTop ? `${statusBarHeight}px` : 0,
|
||||||
|
position: fixed ? 'fixed' : 'relative',
|
||||||
|
borderBottom: border ? '1px solid #f0f1f3' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{autoBack && (
|
||||||
|
<View className={styles.back} onClick={handleBack}>
|
||||||
|
<Text className={styles.backIcon}>‹</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text className={styles.title}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
background: #ffffff;
|
||||||
|
z-index: 101;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-top {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-bottom-left-radius: 24px;
|
||||||
|
border-bottom-right-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-center {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 24px;
|
||||||
|
min-width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-top-left-radius: 24px;
|
||||||
|
border-top-right-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean
|
||||||
|
position?: 'top' | 'center' | 'bottom'
|
||||||
|
onClose: () => void
|
||||||
|
children?: ReactNode
|
||||||
|
maxHeight?: string // e.g. '60vh'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Popup({
|
||||||
|
visible,
|
||||||
|
position = 'bottom',
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
maxHeight = '60vh'
|
||||||
|
}: Props) {
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
const positionClass = styles[`pos-${position}`]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className={styles.mask} onClick={onClose} />
|
||||||
|
<View
|
||||||
|
className={`${styles.panel} ${positionClass}`}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<View className={styles.close} onClick={onClose}>
|
||||||
|
<Text>×</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #f0f1f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active .label {
|
||||||
|
color: #2a2d33;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active .label::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 56px;
|
||||||
|
height: 4px;
|
||||||
|
background: #2ccb98;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: TabItem[]
|
||||||
|
activeKey: string
|
||||||
|
onChange: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({ items, activeKey, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<View className={styles.tabs}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<View
|
||||||
|
key={item.key}
|
||||||
|
className={`${styles.tab} ${item.key === activeKey ? styles.active : ''}`}
|
||||||
|
onClick={() => onChange(item.key)}
|
||||||
|
>
|
||||||
|
<Text className={styles.label}>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Canvas, View, Text } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
|
||||||
|
const V_PADDING = 8
|
||||||
|
const HEIGHT = 110
|
||||||
|
|
||||||
|
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)
|
||||||
|
const min = Math.min(...pts)
|
||||||
|
const max = Math.max(...pts)
|
||||||
|
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,33 @@
|
||||||
|
// 设备型号
|
||||||
|
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: '在床'
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
/**
|
|
||||||
* 统一维护页面路由,避免页面内散落硬编码字符串。
|
|
||||||
*/
|
|
||||||
export const ROUTES = Object.freeze({
|
export const ROUTES = Object.freeze({
|
||||||
LOGIN: '/pages/login/index',
|
LOGIN: '/pages/login/index',
|
||||||
HOME: '/pages/index/index',
|
HOME: '/pages/index/index',
|
||||||
|
|
@ -11,8 +8,3 @@ export const ROUTES = Object.freeze({
|
||||||
DEVICE_DETAIL: '/pages/device/detail',
|
DEVICE_DETAIL: '/pages/device/detail',
|
||||||
SCENE_SELECT: '/pages/scene/select'
|
SCENE_SELECT: '/pages/scene/select'
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* TabBar 页面集合,供登录跳转与路由守卫判断使用。
|
|
||||||
*/
|
|
||||||
export const TAB_ROUTES = Object.freeze([ROUTES.HOME, ROUTES.EVENT, ROUTES.MINE])
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
|
import { View } from '@tarojs/components'
|
||||||
|
|
||||||
|
interface Props { children: ReactNode; fallback?: ReactNode }
|
||||||
|
interface State { hasError: boolean }
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { hasError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary]', error, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback ?? <View>页面出错了</View>
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return {
|
||||||
|
show: (title: string, icon: 'success' | 'error' | 'loading' | 'none' = 'none') =>
|
||||||
|
Taro.showToast({ title, icon, duration: 2000 }),
|
||||||
|
success: (title: string) => Taro.showToast({ title, icon: 'success', duration: 2000 }),
|
||||||
|
error: (title: string) => Taro.showToast({ title, icon: 'error', duration: 2000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { store } from '@/store'
|
||||||
|
import { ApiError, BusinessError } from './errors'
|
||||||
|
|
||||||
|
const BASE_URL = '' // 后端就绪后填入
|
||||||
|
|
||||||
|
export interface RequestConfig {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||||
|
headers?: Record<string, string>
|
||||||
|
timeout?: number
|
||||||
|
needAuth?: boolean // 默认 true;登录接口自身传 false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
code: number
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
url: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
config: RequestConfig = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const method = config.method ?? 'GET'
|
||||||
|
const timeout = config.timeout ?? 10000
|
||||||
|
|
||||||
|
// token 注入(spec §3B):从 authSlice 读取
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...config.headers
|
||||||
|
}
|
||||||
|
if (config.needAuth !== false) {
|
||||||
|
const token = store.getState().auth.token
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Taro.request.SuccessCallbackResult<ApiEnvelope<T>>
|
||||||
|
try {
|
||||||
|
res = await Taro.request({
|
||||||
|
url: BASE_URL + url,
|
||||||
|
method,
|
||||||
|
// Taro.request 的 GET 参数同样走 data,运行时自动序列化为 query string
|
||||||
|
data: params,
|
||||||
|
dataType: 'json',
|
||||||
|
timeout,
|
||||||
|
header: headers
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
throw new ApiError(e instanceof Error ? e.message : '网络异常')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = res.data as ApiEnvelope<T>
|
||||||
|
if (body.code !== 0) {
|
||||||
|
throw new BusinessError(body.message ?? '业务错误', body.code)
|
||||||
|
}
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
request.get = <T>(url: string, params?: Record<string, unknown>, config?: RequestConfig) =>
|
||||||
|
request<T>(url, params, { ...config, method: 'GET' })
|
||||||
|
|
||||||
|
request.post = <T>(url: string, params?: Record<string, unknown>, config?: RequestConfig) =>
|
||||||
|
request<T>(url, params, { ...config, method: 'POST' })
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
// 网络/HTTP 错误
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, public readonly statusCode?: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务错误(code !== 0)
|
||||||
|
export class BusinessError extends Error {
|
||||||
|
constructor(message: string, public readonly code: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'BusinessError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { request } from './client'
|
||||||
|
export { ApiError, BusinessError } from './errors'
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
.page { min-height: 100vh; background: #f1f1f4; }
|
||||||
|
|
||||||
|
.body { padding: 32px; }
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanBtn {
|
||||||
|
height: 96px;
|
||||||
|
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
||||||
|
border-radius: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanBtnText {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty { padding: 80px 0; }
|
||||||
|
|
||||||
|
.list { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #2a2d33;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 70%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Navbar } from '@/components/navbar'
|
||||||
|
import { Loading } from '@/components/loading'
|
||||||
|
import { useBle } from '@/ble/hooks/useBle'
|
||||||
|
import { useToast } from '@/hooks/useToast'
|
||||||
|
import { navigateTo } from '@/utils/navigation'
|
||||||
|
import { ROUTES } from '@/constants/routes'
|
||||||
|
import styles from './add.module.scss'
|
||||||
|
|
||||||
|
export default function DeviceAdd() {
|
||||||
|
const toast = useToast()
|
||||||
|
const { state, discovery } = useBle()
|
||||||
|
const [connectingId, setConnectingId] = useState('')
|
||||||
|
|
||||||
|
const startScan = async () => {
|
||||||
|
try {
|
||||||
|
// startSearch 内部自动完成:Android 定位权限检查 + 适配器初始化 +
|
||||||
|
// 服务 UUID 过滤扫描(00F4/F400/FFF4)+ 30s 自动停止
|
||||||
|
await discovery.startSearch()
|
||||||
|
} catch {
|
||||||
|
toast.error('蓝牙搜索启动失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中设备 → 连接(prepareWifiConfig:连接 → 发现服务特征 → 订阅 F301,厂家确认无需鉴权)
|
||||||
|
const handleSelect = async (deviceId: string) => {
|
||||||
|
setConnectingId(deviceId)
|
||||||
|
try {
|
||||||
|
await discovery.prepareWifiConfig(deviceId)
|
||||||
|
navigateTo(ROUTES.DEVICE_CONFIG)
|
||||||
|
} catch {
|
||||||
|
toast.error('连接失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setConnectingId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<Navbar title="添加设备" />
|
||||||
|
<View className={styles.body}>
|
||||||
|
<Text className={styles.tip}>请确保设备处于配网模式,靠近手机</Text>
|
||||||
|
|
||||||
|
{!state.searching ? (
|
||||||
|
<View className={styles.scanBtn} onClick={startScan}>
|
||||||
|
<Text className={styles.scanBtnText}>开始扫描</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className={styles.scanBtn} onClick={() => discovery.stopSearch()}>
|
||||||
|
<Text className={styles.scanBtnText}>停止扫描</Text>
|
||||||
|
</View>
|
||||||
|
{state.deviceList.length === 0 ? (
|
||||||
|
<View className={styles.empty}><Loading text="搜索中..." /></View>
|
||||||
|
) : (
|
||||||
|
<View className={styles.list}>
|
||||||
|
{state.deviceList.map((d) => (
|
||||||
|
<View
|
||||||
|
key={d.deviceId}
|
||||||
|
className={styles.item}
|
||||||
|
onClick={() => handleSelect(d.deviceId)}
|
||||||
|
>
|
||||||
|
<Text className={styles.name}>{d.name || d.deviceId}</Text>
|
||||||
|
<Text className={styles.rssi}>
|
||||||
|
{connectingId === d.deviceId ? '连接中...' : `RSSI ${d.RSSI ?? '-'}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
.page { min-height: 100vh; background: #f1f1f4; }
|
||||||
|
|
||||||
|
.body { padding: 32px; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #2a2d33;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 96px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0 32px;
|
||||||
|
font-size: 30px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #9ea1a6;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
margin-top: 48px;
|
||||||
|
height: 96px;
|
||||||
|
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
||||||
|
border-radius: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitDisabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.submitText {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { View, Text, Input } from '@tarojs/components'
|
||||||
|
import { Navbar } from '@/components/navbar'
|
||||||
|
import { useToast } from '@/hooks/useToast'
|
||||||
|
import { useBle } from '@/ble/hooks/useBle'
|
||||||
|
import { navigateBack } from '@/utils/navigation'
|
||||||
|
import styles from './config.module.scss'
|
||||||
|
|
||||||
|
export default function DeviceConfig() {
|
||||||
|
const toast = useToast()
|
||||||
|
const { state, discovery } = useBle()
|
||||||
|
const [ssid, setSsid] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!state.connected) {
|
||||||
|
toast.error('设备未连接,请返回上一步重新连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ssid || !password) {
|
||||||
|
toast.error('请填写 WiFi 信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
// 内部走 A7 分包写入 UUID_WIFI 特征;profile.wifi 校验 SSID/密码长度与非法字符
|
||||||
|
await discovery.configureWifi(ssid, password)
|
||||||
|
toast.success('配网指令已发送')
|
||||||
|
navigateBack()
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : '配置失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<Navbar title="设备配置" />
|
||||||
|
<View className={styles.body}>
|
||||||
|
<Text className={styles.label}>WiFi 名称</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="请输入 WiFi 名称"
|
||||||
|
value={ssid}
|
||||||
|
onInput={(e) => setSsid(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>WiFi 密码</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="请输入 WiFi 密码(8-63 位)"
|
||||||
|
password
|
||||||
|
value={password}
|
||||||
|
onInput={(e) => setPassword(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.hint}>
|
||||||
|
当前连接:{state.connected ? '已连接' : '未连接'}
|
||||||
|
{state.deviceInfo && state.profileId !== 'UNKNOWN' ? `(${state.profileId})` : ''}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className={`${styles.submit} ${submitting ? styles.submitDisabled : ''}`}
|
||||||
|
onClick={submitting ? undefined : handleSubmit}
|
||||||
|
>
|
||||||
|
<Text className={styles.submitText}>
|
||||||
|
{submitting ? '配置中...' : '开始配网'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
.stateWrap {
|
||||||
|
margin-top: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryBtn {
|
||||||
|
margin-top: 40px;
|
||||||
|
width: 240px;
|
||||||
|
height: 80px;
|
||||||
|
background: #2ccb98;
|
||||||
|
border-radius: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heartCard {
|
||||||
|
margin: 24px;
|
||||||
|
padding: 32px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heartLeft {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heartIcon { font-size: 72px; color: #f5222d; line-height: 1; }
|
||||||
|
|
||||||
|
.heartValue {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 96px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2a2d33;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heartUnit { margin-top: 8px; font-size: 30px; color: #9ea1a6; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeOn { background: #f6ffed; color: #52c41a; }
|
||||||
|
.badgeOff { background: #f5f5f5; color: #999999; }
|
||||||
|
|
||||||
|
.badgeDot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeDotOn { background: #52c41a; }
|
||||||
|
.badgeDotOff { background: #999999; }
|
||||||
|
|
||||||
|
.heartRight { flex: 1; min-width: 0; margin-left: 32px; }
|
||||||
|
|
||||||
|
.heartHealthLabel {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2a2d33;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricCard {
|
||||||
|
width: 48.5%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel { font-size: 26px; color: #9ea1a6; }
|
||||||
|
|
||||||
|
.metricRow {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue { font-size: 44px; font-weight: 700; }
|
||||||
|
|
||||||
|
.metricUnit {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #b5b9c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoCard {
|
||||||
|
margin: 4px 24px 24px;
|
||||||
|
padding: 8px 32px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoRow {
|
||||||
|
min-height: 104px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f0f1f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoRow:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.infoLabel { font-size: 28px; color: #9ea1a6; }
|
||||||
|
.infoValue { font-size: 28px; color: #2a2d33; }
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Navbar } from '@/components/navbar'
|
||||||
|
import { Loading } from '@/components/loading'
|
||||||
|
import { Empty } from '@/components/empty'
|
||||||
|
import { VitalWave } from '@/components/vital-wave'
|
||||||
|
import { getVitalSigns, getHeartRateHistory, type VitalSigns } from '@/api/device'
|
||||||
|
import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP, type DeviceModel } from '@/constants/enums'
|
||||||
|
import styles from './detail.module.scss'
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = 3000
|
||||||
|
|
||||||
|
const METRIC_COLORS = {
|
||||||
|
breath: '#1677ff',
|
||||||
|
motion: '#fa8c16',
|
||||||
|
moveState: '#52c41a',
|
||||||
|
bedState: '#f5222d',
|
||||||
|
bodyState: '#1677ff',
|
||||||
|
bodyPosition: '#52c41a'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Detail() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string }
|
||||||
|
|
||||||
|
const deviceId = params.id ?? ''
|
||||||
|
const deviceName = params.name ? decodeURIComponent(params.name) : '设备详情'
|
||||||
|
const deviceModel = params.model ?? ''
|
||||||
|
const online = params.online === '1'
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
const [vitals, setVitals] = useState<VitalSigns>({} as VitalSigns)
|
||||||
|
const [wavePoints, setWavePoints] = useState<number[]>([])
|
||||||
|
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const valueOf = (v: unknown): string | number =>
|
||||||
|
v === undefined || v === null || v === '' ? '-' : (v as string | number)
|
||||||
|
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const v = vitals as unknown as Record<string, unknown>
|
||||||
|
return [
|
||||||
|
{ label: '呼吸率', value: valueOf(v.respiratoryRate), unit: '/min', color: METRIC_COLORS.breath },
|
||||||
|
{ label: '体动', value: valueOf(v.bodyMovement), unit: '', color: METRIC_COLORS.motion },
|
||||||
|
{ label: '运动状态', value: MOVE_STATE_MAP[v.moveState as number] ?? valueOf(v.moveState), unit: '', color: METRIC_COLORS.moveState },
|
||||||
|
{ label: '在床状态', value: ONBED_STATUS_MAP[v.onbedStatus as number] ?? valueOf(v.onbedStatus), unit: '', color: METRIC_COLORS.bedState },
|
||||||
|
{ label: '人体状态', value: BODY_STATUS_MAP[v.bodyStatus as number] ?? valueOf(v.bodyStatus), unit: '', color: METRIC_COLORS.bodyState },
|
||||||
|
{ label: '人体位置', value: valueOf(v.bodyPosition), unit: '分米', color: METRIC_COLORS.bodyPosition }
|
||||||
|
]
|
||||||
|
}, [vitals])
|
||||||
|
|
||||||
|
const fetchVitals = async () => {
|
||||||
|
const [v, history] = await Promise.all([
|
||||||
|
getVitalSigns(deviceId),
|
||||||
|
getHeartRateHistory(deviceId)
|
||||||
|
])
|
||||||
|
setVitals(v)
|
||||||
|
setWavePoints(Array.isArray(history) ? history : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadVitals = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setLoadError(false)
|
||||||
|
try {
|
||||||
|
await fetchVitals()
|
||||||
|
} catch {
|
||||||
|
setLoadError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询生命周期与 uni-app 版对齐(I1 修复):
|
||||||
|
// 首次挂载拉数据;useDidShow 启动轮询 / useDidHide 停止轮询——
|
||||||
|
// 页面被压栈后台(navigateTo 其它页)时轮询不空跑,回到前台恢复
|
||||||
|
useEffect(() => {
|
||||||
|
loadVitals()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [deviceId])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
fetchVitals().catch(() => {})
|
||||||
|
}, REFRESH_INTERVAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
useDidHide(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载兜底清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<Navbar title={deviceName} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className={styles.stateWrap}><Loading /></View>
|
||||||
|
) : loadError ? (
|
||||||
|
<View className={styles.stateWrap}>
|
||||||
|
<Empty text="加载失败,请检查网络" mode="wifi" />
|
||||||
|
<View className={styles.retryBtn} onClick={loadVitals}><Text>重试</Text></View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className={styles.heartCard}>
|
||||||
|
<View className={styles.heartLeft}>
|
||||||
|
<Text className={styles.heartIcon}>❤</Text>
|
||||||
|
<Text className={styles.heartValue}>{valueOf(vitals.heartRate)}</Text>
|
||||||
|
<Text className={styles.heartUnit}>BPM</Text>
|
||||||
|
<View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}>
|
||||||
|
<View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} />
|
||||||
|
<Text>{online ? '正常' : '离线'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className={styles.heartRight}>
|
||||||
|
<Text className={styles.heartHealthLabel}>心率健康</Text>
|
||||||
|
<VitalWave points={wavePoints} canvasId="heartWave" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.metricGrid}>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<View key={m.label} className={styles.metricCard}>
|
||||||
|
<Text className={styles.metricLabel}>{m.label}</Text>
|
||||||
|
<View className={styles.metricRow}>
|
||||||
|
<Text className={styles.metricValue} style={{ color: m.color }}>{m.value}</Text>
|
||||||
|
{!!m.unit && <Text className={styles.metricUnit}>{m.unit}</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.infoCard}>
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<Text className={styles.infoLabel}>设备型号</Text>
|
||||||
|
<Text className={styles.infoValue}>{deviceModel || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.infoRow}>
|
||||||
|
<Text className={styles.infoLabel}>设备 ID</Text>
|
||||||
|
<Text className={styles.infoValue}>{deviceId || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
.drop-panel-calendar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 66vh;
|
||||||
|
max-height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 11;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-bottom-left-radius: 24px;
|
||||||
|
border-bottom-right-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 20px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f0f1f3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2d33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-row {
|
||||||
|
display: flex;
|
||||||
|
padding: 20px 24px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 16px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f6f8;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #606266;
|
||||||
|
|
||||||
|
&:last-child { margin-right: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip-active {
|
||||||
|
background: #e9fff3;
|
||||||
|
border-color: #2ccb98;
|
||||||
|
color: #12a37a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekdays {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #f0f1f3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll { height: 100%; }
|
||||||
|
|
||||||
|
.month { padding: 8px 16px 0; }
|
||||||
|
|
||||||
|
.month-title {
|
||||||
|
height: 72px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2d33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: calc(100% / 7);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px;
|
||||||
|
aspect-ratio: 1 / 1.15;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-disabled { opacity: 0.3; }
|
||||||
|
|
||||||
|
.day {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-start,
|
||||||
|
.day-end { background: #2ccb98; }
|
||||||
|
|
||||||
|
.day-start .day-num,
|
||||||
|
.day-end .day-num {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-in {
|
||||||
|
background: #e9fff3;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-in .day-num { color: #12a37a; }
|
||||||
|
|
||||||
|
.day-num {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2a2d33;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-tag {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid #f0f1f3;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
height: 80px;
|
||||||
|
background: #2ccb98;
|
||||||
|
border-radius: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.confirm-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import styles from './calendar-drawer.module.scss'
|
||||||
|
|
||||||
|
// 日历可回溯年数(约束最早月份,不影响性能)
|
||||||
|
const CALENDAR_YEARS_BACK = 10
|
||||||
|
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']
|
||||||
|
// 懒渲染窗口参数(rpx):可视高度近似 + 前后缓冲
|
||||||
|
const VIEW_RPX = 1400
|
||||||
|
const BUFFER_RPX = 3000
|
||||||
|
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||||
|
const parseDate = (s: string) => {
|
||||||
|
const [y, m, d] = s.split('-').map(Number)
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
const quickRange = (days: number) => {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - (days - 1))
|
||||||
|
return { label: `近${days}天`, start: fmtDate(start), end: fmtDate(end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthMeta {
|
||||||
|
key: string
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
title: string
|
||||||
|
top: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
day: number
|
||||||
|
date?: string
|
||||||
|
disabled?: boolean
|
||||||
|
dayCls?: string
|
||||||
|
tag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialStart: string
|
||||||
|
initialEnd: string
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: (start: string, end: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthWeeks(y: number, m: number) {
|
||||||
|
const firstDay = new Date(y, m, 1)
|
||||||
|
const lead = (firstDay.getDay() + 6) % 7
|
||||||
|
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||||
|
return Math.ceil((lead + daysInMonth) / 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarDrawer({ initialStart, initialEnd, onClose, onConfirm }: Props) {
|
||||||
|
const [windowWidth, setWindowWidth] = useState(375)
|
||||||
|
const [calScroll, setCalScroll] = useState(0)
|
||||||
|
const [calAnchorId, setCalAnchorId] = useState('')
|
||||||
|
const [pendingStart, setPendingStart] = useState(initialStart)
|
||||||
|
const [pendingEnd, setPendingEnd] = useState(initialEnd)
|
||||||
|
const [activeQuick, setActiveQuick] = useState('')
|
||||||
|
const anchorTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const quickRanges = useMemo(() => [quickRange(7), quickRange(30), quickRange(90)], [])
|
||||||
|
|
||||||
|
const minLimit = useMemo(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setFullYear(d.getFullYear() - CALENDAR_YEARS_BACK)
|
||||||
|
d.setDate(1)
|
||||||
|
return fmtDate(d)
|
||||||
|
}, [])
|
||||||
|
const maxLimit = useMemo(() => fmtDate(new Date()), [])
|
||||||
|
|
||||||
|
// 全部月份元数据:每月份含估算高度(标题行 + 周数×行高)
|
||||||
|
const monthMetas = useMemo<MonthMeta[]>(() => {
|
||||||
|
const metas: MonthMeta[] = []
|
||||||
|
const start = parseDate(minLimit)
|
||||||
|
const now = new Date()
|
||||||
|
let y = start.getFullYear()
|
||||||
|
let m = start.getMonth()
|
||||||
|
let top = 0
|
||||||
|
while (y < now.getFullYear() || (y === now.getFullYear() && m <= now.getMonth())) {
|
||||||
|
const weeks = monthWeeks(y, m)
|
||||||
|
const height = 96 + weeks * Math.round((750 / 7) * 1.15) + 16
|
||||||
|
metas.push({ key: `m-${y}-${m}`, year: y, month: m, title: `${y}年${pad2(m + 1)}月`, top, height })
|
||||||
|
top += height
|
||||||
|
if (m === 11) { y += 1; m = 0 } else { m += 1 }
|
||||||
|
}
|
||||||
|
return metas
|
||||||
|
}, [minLimit])
|
||||||
|
|
||||||
|
// px→rpx 换算基准 + 打开时定位
|
||||||
|
useEffect(() => {
|
||||||
|
setWindowWidth(Taro.getWindowInfo().windowWidth || 375)
|
||||||
|
const focus = initialStart || fmtDate(new Date())
|
||||||
|
const d = parseDate(focus)
|
||||||
|
const meta = monthMetas.find((mm) => mm.year === d.getFullYear() && mm.month === d.getMonth())
|
||||||
|
setCalAnchorId('')
|
||||||
|
// scroll-into-view 需要值变化才生效:先清空再在下个循环赋值
|
||||||
|
const timer = setTimeout(() => setCalAnchorId(meta ? meta.key : ''), 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 单格数据:区间内/端点/禁用态一次算好
|
||||||
|
const buildCell = useCallback((dateStr: string, day: number): Cell => {
|
||||||
|
const disabled = dateStr < minLimit || dateStr > maxLimit
|
||||||
|
let dayCls = ''
|
||||||
|
let tag = ''
|
||||||
|
if (!disabled) {
|
||||||
|
if (pendingStart && dateStr === pendingStart) {
|
||||||
|
dayCls = 'start'
|
||||||
|
tag = pendingStart === pendingEnd ? '起/止' : '开始'
|
||||||
|
} else if (pendingEnd && dateStr === pendingEnd) {
|
||||||
|
dayCls = 'end'
|
||||||
|
tag = '结束'
|
||||||
|
} else if (pendingStart && pendingEnd && dateStr > pendingStart && dateStr < pendingEnd) {
|
||||||
|
dayCls = 'in'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { day, date: dateStr, disabled, dayCls, tag }
|
||||||
|
}, [minLimit, maxLimit, pendingStart, pendingEnd])
|
||||||
|
|
||||||
|
const buildMonthCells = useCallback((y: number, m: number): Cell[] => {
|
||||||
|
const firstDay = new Date(y, m, 1)
|
||||||
|
const lead = (firstDay.getDay() + 6) % 7
|
||||||
|
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||||
|
const cells: Cell[] = []
|
||||||
|
for (let i = 0; i < lead; i++) cells.push({ day: 0 })
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
cells.push(buildCell(`${y}-${pad2(m + 1)}-${pad2(d)}`, d))
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}, [buildCell])
|
||||||
|
|
||||||
|
// 懒渲染:仅渲染滚动窗口(±BUFFER_RPX)内月份,窗口外等高占位
|
||||||
|
const renderedMonths = useMemo(() => {
|
||||||
|
const lo = calScroll - BUFFER_RPX
|
||||||
|
const hi = calScroll + VIEW_RPX + BUFFER_RPX
|
||||||
|
return monthMetas.map((meta) => {
|
||||||
|
const inWindow = meta.top + meta.height > lo && meta.top < hi
|
||||||
|
if (!inWindow) return { ...meta, placeholder: true as const, cells: [] as Cell[] }
|
||||||
|
return { ...meta, placeholder: false as const, cells: buildMonthCells(meta.year, meta.month) }
|
||||||
|
})
|
||||||
|
}, [monthMetas, calScroll, buildMonthCells])
|
||||||
|
|
||||||
|
// 滚动:px → rpx(750/窗口宽),跨过 200rpx 再更新,减少重算
|
||||||
|
const onCalScroll = (e: { detail: { scrollTop: number } }) => {
|
||||||
|
const px = e.detail.scrollTop || 0
|
||||||
|
const rpx = px * (750 / windowWidth)
|
||||||
|
setCalScroll((prev) => (Math.abs(rpx - prev) > 200 ? rpx : prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS Modules 动态 key 映射对象(避免索引签名问题)
|
||||||
|
const dayClsMap: Record<string, string> = {
|
||||||
|
start: styles.dayStart,
|
||||||
|
end: styles.dayEnd,
|
||||||
|
in: styles.dayIn
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一击定起点,第二击定终点(早于起点则重定起点),选满再点重选
|
||||||
|
const pickDate = (cell: Cell) => {
|
||||||
|
if (!cell.day || cell.disabled || !cell.date) return
|
||||||
|
const date = cell.date
|
||||||
|
setActiveQuick('')
|
||||||
|
if (!pendingStart || (pendingStart && pendingEnd)) {
|
||||||
|
setPendingStart(date)
|
||||||
|
setPendingEnd('')
|
||||||
|
} else if (date < pendingStart) {
|
||||||
|
setPendingStart(date)
|
||||||
|
} else {
|
||||||
|
setPendingEnd(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷范围:填满起止并定位起点月
|
||||||
|
const applyQuick = (q: { label: string; start: string; end: string }) => {
|
||||||
|
setPendingStart(q.start)
|
||||||
|
setPendingEnd(q.end)
|
||||||
|
setActiveQuick(q.label)
|
||||||
|
const d = parseDate(q.start)
|
||||||
|
const meta = monthMetas.find((mm) => mm.year === d.getFullYear() && mm.month === d.getMonth())
|
||||||
|
setCalAnchorId('')
|
||||||
|
if (anchorTimer.current) clearTimeout(anchorTimer.current)
|
||||||
|
anchorTimer.current = setTimeout(() => setCalAnchorId(meta ? meta.key : ''), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (anchorTimer.current) clearTimeout(anchorTimer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rangeReady = !!pendingStart && !!pendingEnd
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.dropPanelCalendar}>
|
||||||
|
<View className={styles.header}>
|
||||||
|
<Text className={styles.title}>选择时间范围</Text>
|
||||||
|
<View className={styles.close} onClick={onClose}>
|
||||||
|
<Text className={styles.closeIcon}>×</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.quickRow}>
|
||||||
|
{quickRanges.map((q) => (
|
||||||
|
<View
|
||||||
|
key={q.label}
|
||||||
|
className={`${styles.quickChip} ${activeQuick === q.label ? styles.quickChipActive : ''}`}
|
||||||
|
onClick={() => applyQuick(q)}
|
||||||
|
>
|
||||||
|
<Text>{q.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.weekdays}>
|
||||||
|
{WEEKDAY_LABELS.map((w) => (
|
||||||
|
<Text key={w} className={styles.weekday}>{w}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.scrollWrap}>
|
||||||
|
<ScrollView
|
||||||
|
className={styles.scroll}
|
||||||
|
scrollY
|
||||||
|
scrollIntoView={calAnchorId}
|
||||||
|
scrollAnchoring
|
||||||
|
onScroll={onCalScroll}
|
||||||
|
>
|
||||||
|
{renderedMonths.map((m) => (
|
||||||
|
<View
|
||||||
|
key={m.key}
|
||||||
|
id={m.key}
|
||||||
|
className={styles.month}
|
||||||
|
style={m.placeholder ? { height: `${m.height}rpx` } : undefined}
|
||||||
|
>
|
||||||
|
{!m.placeholder && (
|
||||||
|
<>
|
||||||
|
<View className={styles.monthTitle}>
|
||||||
|
<Text>{m.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={styles.grid}>
|
||||||
|
{m.cells.map((cell, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className={`${styles.cell} ${cell.disabled ? styles.cellDisabled : ''}`}
|
||||||
|
onClick={() => pickDate(cell)}
|
||||||
|
>
|
||||||
|
{!!cell.day && (
|
||||||
|
<View className={`${styles.day} ${cell.dayCls ? dayClsMap[cell.dayCls] : ''}`}>
|
||||||
|
<Text className={styles.dayNum}>{cell.day}</Text>
|
||||||
|
{!!cell.tag && <Text className={styles.dayTag}>{cell.tag}</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={styles.footer}>
|
||||||
|
<View
|
||||||
|
className={`${styles.confirmBtn} ${!rangeReady ? styles.confirmDisabled : ''}`}
|
||||||
|
onClick={() => rangeReady && onConfirm(pendingStart, pendingEnd)}
|
||||||
|
>
|
||||||
|
<Text className={styles.confirmText}>{rangeReady ? '确定' : '请选择起止日期'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
/* 页面容器:纵向 flex,让数据层撑满筛选栏以下全部剩余视口 */
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #ededed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-cell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
& + & { margin-left: 18px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-trigger {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #f5f6f8;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-trigger-active {
|
||||||
|
background: #e9fff3;
|
||||||
|
border-color: #2ccb98;
|
||||||
|
color: #12a37a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-arrow {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-area {
|
||||||
|
min-height: 720px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
margin-top: -40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
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-options {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 11;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-bottom-left-radius: 24px;
|
||||||
|
border-bottom-right-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
padding: 28px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f0f1f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2d33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-list {
|
||||||
|
padding: 12px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 0 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
&:active { background: #f7f8fa; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item-active { background: #e9fff3; }
|
||||||
|
|
||||||
|
.drawer-item-text {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #2a2d33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item-active .drawer-item-text {
|
||||||
|
color: #2ccb98;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #2ccb98;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Navbar } from '@/components/navbar'
|
||||||
|
import { Empty } from '@/components/empty'
|
||||||
|
import { getSceneList } from '@/api/scene'
|
||||||
|
import { getEventList, type EventItem } from '@/api/event'
|
||||||
|
import { CalendarDrawer } from './calendar-drawer'
|
||||||
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Event() {
|
||||||
|
// '' | 'scene' | 'status' | 'time'
|
||||||
|
const [drawer, setDrawer] = useState<'' | 'scene' | 'status' | 'time'>('')
|
||||||
|
// 事件列表:后端接口定型前 mock 返回空数组 → 空态
|
||||||
|
const [eventList, setEventList] = useState<EventItem[]>([])
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
sceneId: 0, // 0 表示全部
|
||||||
|
status: 0,
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
timeText: ''
|
||||||
|
})
|
||||||
|
const [sceneOptions, setSceneOptions] = useState<FilterOption[]>([{ id: 0, name: '全部' }])
|
||||||
|
const statusOptions: FilterOption[] = [
|
||||||
|
{ id: 0, name: '全部' },
|
||||||
|
{ id: 1, name: '未处理' },
|
||||||
|
{ id: 2, name: '已处理' },
|
||||||
|
{ id: 3, name: '已忽略' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 场所列表:复用场景接口;失败时保留"全部"不阻塞筛选
|
||||||
|
useEffect(() => {
|
||||||
|
getSceneList()
|
||||||
|
.then((data) => {
|
||||||
|
setSceneOptions([{ id: 0, name: '全部' }, ...(data ?? [])])
|
||||||
|
})
|
||||||
|
.catch(() => setSceneOptions([{ id: 0, name: '全部' }]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeDrawer = useCallback(() => setDrawer(''), [])
|
||||||
|
|
||||||
|
const sceneLabel = sceneOptions.find((o) => o.id === filters.sceneId)?.name ?? '全部场所'
|
||||||
|
const statusLabel = statusOptions.find((o) => o.id === filters.status)?.name ?? '全部状态'
|
||||||
|
|
||||||
|
// 筛选变化:拉取事件列表(当前 mock 空,保持空态展示)
|
||||||
|
const handleFilter = useCallback(async (next: typeof filters) => {
|
||||||
|
try {
|
||||||
|
const list = await getEventList({
|
||||||
|
sceneId: next.sceneId,
|
||||||
|
status: next.status,
|
||||||
|
startDate: next.startDate,
|
||||||
|
endDate: next.endDate
|
||||||
|
})
|
||||||
|
setEventList(list)
|
||||||
|
} catch {
|
||||||
|
// 失败保持现有列表,toast 由请求封装统一处理
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectScene = (id: number) => {
|
||||||
|
const next = { ...filters, sceneId: id }
|
||||||
|
setFilters(next)
|
||||||
|
closeDrawer()
|
||||||
|
handleFilter(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStatus = (id: number) => {
|
||||||
|
const next = { ...filters, status: id }
|
||||||
|
setFilters(next)
|
||||||
|
closeDrawer()
|
||||||
|
handleFilter(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeConfirm = (start: string, end: string) => {
|
||||||
|
const next = { ...filters, startDate: start, endDate: end, timeText: `${start} 至 ${end}` }
|
||||||
|
setFilters(next)
|
||||||
|
closeDrawer()
|
||||||
|
handleFilter(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderOptionDrawer = (kind: 'scene' | 'status') => {
|
||||||
|
const opts = kind === 'scene' ? sceneOptions : statusOptions
|
||||||
|
const currentId = kind === 'scene' ? filters.sceneId : filters.status
|
||||||
|
const title = kind === 'scene' ? '选择场所' : '选择状态'
|
||||||
|
const onSelect = kind === 'scene' ? selectScene : selectStatus
|
||||||
|
return (
|
||||||
|
<View className={styles.dropPanelOptions}>
|
||||||
|
<View className={styles.drawerHeader}>
|
||||||
|
<Text className={styles.drawerTitle}>{title}</Text>
|
||||||
|
<View className={styles.drawerClose} onClick={closeDrawer}>
|
||||||
|
<Text className={styles.closeIcon}>×</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className={styles.drawerList}>
|
||||||
|
{opts.map((opt) => (
|
||||||
|
<View
|
||||||
|
key={opt.id}
|
||||||
|
className={`${styles.drawerItem} ${currentId === opt.id ? styles.drawerItemActive : ''}`}
|
||||||
|
onClick={() => onSelect(opt.id)}
|
||||||
|
>
|
||||||
|
<Text className={styles.drawerItemText}>{opt.name}</Text>
|
||||||
|
{currentId === opt.id && <Text className={styles.checkIcon}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<Navbar title="事件" autoBack={false} />
|
||||||
|
|
||||||
|
{/* 筛选栏:三格等分 */}
|
||||||
|
<View className={styles.filters}>
|
||||||
|
<View className={styles.filterCell} onClick={() => setDrawer(drawer === 'scene' ? '' : 'scene')}>
|
||||||
|
<View className={`${styles.filterTrigger} ${filters.sceneId !== 0 ? styles.filterTriggerActive : ''}`}>
|
||||||
|
<Text className={styles.filterText}>{sceneLabel}</Text>
|
||||||
|
<Text className={styles.filterArrow}>▾</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className={styles.filterCell} onClick={() => setDrawer(drawer === 'status' ? '' : 'status')}>
|
||||||
|
<View className={`${styles.filterTrigger} ${filters.status !== 0 ? styles.filterTriggerActive : ''}`}>
|
||||||
|
<Text className={styles.filterText}>{statusLabel}</Text>
|
||||||
|
<Text className={styles.filterArrow}>▾</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className={styles.filterCell} onClick={() => setDrawer(drawer === 'time' ? '' : 'time')}>
|
||||||
|
<View className={`${styles.filterTrigger} ${filters.startDate ? styles.filterTriggerActive : ''}`}>
|
||||||
|
<Text className={styles.filterText}>{filters.timeText || '时间'}</Text>
|
||||||
|
<Text className={styles.filterArrow}>▾</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 数据层:抽屉锚点(筛选栏下缘展开,遮罩盖住本层全部) */}
|
||||||
|
<View className={styles.contentArea}>
|
||||||
|
<View className={styles.contentBody}>
|
||||||
|
{eventList.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<View className={styles.emptyArea}>
|
||||||
|
<Empty text="暂无事件数据" mode="message" />
|
||||||
|
</View>
|
||||||
|
<View className={styles.emptyHint}>
|
||||||
|
<Text>调整筛选条件后的事件列表将在此展示</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View className={styles.eventList}>
|
||||||
|
{/* 事件卡片列表:布局等后端接口定型后补(Task 5.1.c 说明) */}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{drawer !== '' && <View className={styles.contentMask} onClick={closeDrawer} catchMove />}
|
||||||
|
|
||||||
|
{drawer === 'scene' && renderOptionDrawer('scene')}
|
||||||
|
{drawer === 'status' && renderOptionDrawer('status')}
|
||||||
|
{drawer === 'time' && (
|
||||||
|
<CalendarDrawer
|
||||||
|
initialStart={filters.startDate}
|
||||||
|
initialEnd={filters.endDate}
|
||||||
|
onClose={closeDrawer}
|
||||||
|
onConfirm={onTimeConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
.page { position: relative; }
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
min-height: 180px;
|
||||||
|
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
||||||
|
padding: 48px 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerLeft { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.bannerTitle {
|
||||||
|
font-size: 52px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerSub {
|
||||||
|
font-size: 34px;
|
||||||
|
color: #e9fff8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.siteRow {
|
||||||
|
height: 108px;
|
||||||
|
padding: 0 32px;
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.siteText {
|
||||||
|
font-size: 34px;
|
||||||
|
color: #222;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.siteArrow {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #26c996;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateWrap {
|
||||||
|
margin-top: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryBtn {
|
||||||
|
margin-top: 40px;
|
||||||
|
width: 240px;
|
||||||
|
height: 80px;
|
||||||
|
background: #2ccb98;
|
||||||
|
border-radius: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 48.5%;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 36px 20px 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconOffline { background: #eef0f4; }
|
||||||
|
|
||||||
|
.iconText {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconOffline .iconText { color: #b5b9c0; }
|
||||||
|
|
||||||
|
.name {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2d33;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRow {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotOn { background: #2ccb98; }
|
||||||
|
.dotOff { background: #c0c4cc; }
|
||||||
|
|
||||||
|
.statusText { font-size: 24px; color: #12a37a; }
|
||||||
|
.statusTextOff { color: #9ea1a6; }
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 40px;
|
||||||
|
bottom: 60px;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(31, 202, 151, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fabIcon { font-size: 48px; color: #ffffff; font-weight: 300; }
|
||||||