初始化

This commit is contained in:
ozh 2026-04-17 14:45:13 +08:00
commit e4a9775327
30 changed files with 2234 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
.codex-tasks/

20
App.vue Normal file
View File

@ -0,0 +1,20 @@
<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>
/*每个页面公共css */
</style>

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

@ -0,0 +1,258 @@
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
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
}
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.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

30
api/user.js Normal file
View File

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

13
constants/auth.js Normal file
View File

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

16
constants/bluetooth.js Normal file
View File

@ -0,0 +1,16 @@
/**
* 目标服务 UUID用于优先识别特定设备
*/
export const TARGET_SERVICE_UUID = '0000F400-0000-1000-8000-00805F9B34FB'
export const TARGET_SHORT_UUID = '00F4'
/**
* 蓝牙常见错误提示映射
*/
export const BLUETOOTH_ERROR_MESSAGES = Object.freeze({
10001: '蓝牙不可用,请先开启手机蓝牙',
10003: '连接失败,请重试',
10012: '操作超时,请重试'
})
export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败'

6
constants/request.js Normal file
View File

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

16
constants/routes.js Normal file
View File

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

View File

@ -0,0 +1,186 @@
import {
BLUETOOTH_ERROR_MESSAGES,
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
TARGET_SERVICE_UUID,
TARGET_SHORT_UUID
} from '@/constants/bluetooth'
import { showToast } from '@/utils/toast'
/**
* 蓝牙设备搜索逻辑封装
* 通过回调把状态同步给页面减少页面文件复杂度
*/
export function createBluetoothDiscovery(options = {}) {
const {
onSearchingChange = () => {},
onDeviceMapChange = () => {},
onDeviceListChange = () => {}
} = options
let isSearching = false
let deviceMap = {}
let deviceFoundHandler = null
function setSearching(value) {
isSearching = Boolean(value)
onSearchingChange(isSearching)
}
function setDeviceMap(nextMap) {
deviceMap = nextMap
onDeviceMapChange({ ...deviceMap })
emitDeviceList()
}
function emitDeviceList() {
const list = Object.values(deviceMap).sort((a, b) => {
if (a.hasTargetService !== b.hasTargetService) {
return a.hasTargetService ? -1 : 1
}
const aSignal = typeof a.RSSI === 'number' ? a.RSSI : -999
const bSignal = typeof b.RSSI === 'number' ? b.RSSI : -999
return bSignal - aSignal
})
onDeviceListChange(list)
}
function normalizeUuid(uuid) {
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
}
function hasTargetService(device) {
if (!Array.isArray(device.advertisServiceUUIDs)) {
return false
}
const targetUuid = normalizeUuid(TARGET_SERVICE_UUID)
return device.advertisServiceUUIDs.some((uuid) => {
const value = normalizeUuid(uuid)
return value === targetUuid || value.endsWith(TARGET_SHORT_UUID)
})
}
function openBluetoothAdapter() {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: resolve,
fail: reject
})
})
}
function startDiscoveryWithServices(services = []) {
return new Promise((resolve, reject) => {
const options = {
allowDuplicatesKey: true,
interval: 0,
success: resolve,
fail: reject
}
if (services.length) {
options.services = services
}
uni.startBluetoothDevicesDiscovery(options)
})
}
function upsertDevice(rawDevice) {
if (!rawDevice || !rawDevice.deviceId) {
return
}
const existing = deviceMap[rawDevice.deviceId] || {}
const merged = {
...existing,
...rawDevice,
showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备',
hasTargetService: hasTargetService(rawDevice) || existing.hasTargetService
}
setDeviceMap({
...deviceMap,
[rawDevice.deviceId]: merged
})
}
function registerDeviceFoundHandler() {
if (deviceFoundHandler) {
return
}
deviceFoundHandler = (res) => {
const devices = Array.isArray(res.devices) ? res.devices : [res]
devices.forEach((device) => {
upsertDevice(device)
})
}
uni.onBluetoothDeviceFound(deviceFoundHandler)
}
function stopSearch(showStopToast = true) {
return new Promise((resolve) => {
uni.stopBluetoothDevicesDiscovery({
complete: () => {
setSearching(false)
if (showStopToast) {
showToast('已停止搜索')
}
resolve()
}
})
})
}
function cleanupDiscovery() {
if (isSearching) {
uni.stopBluetoothDevicesDiscovery({
complete: () => {}
})
}
setSearching(false)
if (deviceFoundHandler && uni.offBluetoothDeviceFound) {
uni.offBluetoothDeviceFound(deviceFoundHandler)
}
deviceFoundHandler = null
}
function getBluetoothErrorText(error) {
const errCode = error && error.errCode
return BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
}
async function startSearch() {
setDeviceMap({})
try {
await openBluetoothAdapter()
registerDeviceFoundHandler()
try {
await startDiscoveryWithServices([TARGET_SERVICE_UUID])
} catch (error) {
await startDiscoveryWithServices([])
}
setSearching(true)
} catch (error) {
setSearching(false)
showToast(getBluetoothErrorText(error))
throw error
}
}
return {
startSearch,
stopSearch,
cleanupDiscovery
}
}

49
hooks/useCountdown.js Normal file
View File

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

20
index.html Normal file
View File

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

25
main.js Normal file
View File

@ -0,0 +1,25 @@
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'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif

72
manifest.json Normal file
View File

@ -0,0 +1,72 @@
{
"name" : "smarthome",
"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
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}

67
pages.json Normal file
View File

@ -0,0 +1,67 @@
{
"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"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "智慧养老",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F1F1F4"
},
"tabBar": {
"color": "#666666",
"selectedColor": "#2CCB98",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/event/index",
"text": "事件"
},
{
"pagePath": "pages/mine/index",
"text": "我的"
}
]
},
"uniIdRouter": {}
}

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

@ -0,0 +1,37 @@
<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>

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

@ -0,0 +1,298 @@
<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>
</view>
<text class="device-id">{{ device.deviceId }}</text>
<view class="device-actions">
<view class="action-btn">网络配置</view>
<view class="action-btn">参数配置</view>
<view class="action-btn">服务器配置</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery'
import { navigateBack } from '@/utils/navigation'
export default {
data() {
return {
isSearching: false,
deviceList: [],
deviceMap: {},
discoveryController: null
}
},
computed: {
searchBtnText() {
return this.isSearching ? '搜索中' : '搜索'
}
},
created() {
/**
* 蓝牙搜索控制器统一处理设备发现排序清理逻辑
*/
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: {
cleanupDiscovery() {
if (!this.discoveryController) {
return
}
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) {
}
}
}
}
</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;
}
.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;
}
</style>

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

@ -0,0 +1,46 @@
<template>
<view class="page page-base">
<view class="top-nav top-nav-base">
<view class="title top-nav-title-base">事件</view>
</view>
<view class="filters">
<view class="filter-item">全部场所</view>
<view class="filter-item">全部状态</view>
<view class="filter-item">时间</view>
</view>
<view class="empty-area empty-state">
<view class="empty-box empty-state-box"></view>
<text class="empty-text empty-state-text">暂无数据</text>
</view>
</view>
</template>
<script>
export default {}
</script>
<style>
@import '@/styles/common.css';
.filters {
height: 92rpx;
background: #ffffff;
border-top: 1rpx solid #ededed;
display: flex;
align-items: center;
}
.filter-item {
flex: 1;
text-align: center;
font-size: 42rpx;
color: #3a3a3a;
font-weight: 600;
}
.empty-area {
min-height: 980rpx;
}
</style>

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

@ -0,0 +1,149 @@
<template>
<view class="page page-base">
<view class="top-nav top-nav-base">
<view class="title">智慧养老</view>
</view>
<view class="banner">
<view class="banner-left">
<text class="banner-title">智慧养老云助手</text>
<text class="banner-sub">一键开启智慧好生活</text>
</view>
<view class="banner-illus">
<view class="phone"></view>
</view>
</view>
<view class="site-row" @click="goConfig">
<text class="menu"></text>
<text class="site-text">请选择场所</text>
<text class="down"></text>
</view>
<view class="empty-area empty-state">
<view class="empty-box empty-state-box"></view>
<text class="empty-text empty-state-text">暂无数据</text>
</view>
<view class="fab" @click="goAdd">+</view>
</view>
</template>
<script>
import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation'
export default {
methods: {
goAdd() {
navigateTo(ROUTES.DEVICE_ADD)
},
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
}
}
}
</script>
<style>
@import '@/styles/common.css';
.page {
position: relative;
padding-bottom: 160rpx;
}
.title {
font-size: 48rpx;
font-weight: 700;
color: #222;
}
.banner {
margin: 0;
height: 380rpx;
background: #28cda0;
padding: 56rpx 52rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.banner-left {
display: flex;
flex-direction: column;
}
.banner-title {
font-size: 58rpx;
color: #ffffff;
font-weight: 700;
margin-bottom: 24rpx;
}
.banner-sub {
font-size: 46rpx;
color: #e9fff8;
font-weight: 600;
}
.banner-illus {
width: 220rpx;
height: 220rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.phone {
width: 120rpx;
height: 170rpx;
border: 8rpx solid rgba(255, 255, 255, 0.92);
border-radius: 22rpx;
}
.site-row {
height: 108rpx;
padding: 0 36rpx;
background: #ffffff;
display: flex;
align-items: center;
font-size: 46rpx;
color: #222;
}
.menu {
margin-right: 22rpx;
}
.site-text {
font-weight: 600;
}
.down {
margin-left: 10rpx;
color: #26c996;
}
.empty-area {
margin-top: 40rpx;
min-height: 760rpx;
}
.fab {
position: fixed;
right: 38rpx;
bottom: 220rpx;
width: 118rpx;
height: 118rpx;
border-radius: 59rpx;
background: #2cc998;
color: #ffffff;
font-size: 88rpx;
line-height: 110rpx;
text-align: center;
box-shadow: 0 12rpx 24rpx rgba(29, 177, 130, 0.3);
}
</style>

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

@ -0,0 +1,346 @@
<template>
<view class="page">
<view class="hero">
<text class="title">欢迎使用智慧养老</text>
<text class="sub">登录后可管理设备与查看事件</text>
</view>
<view class="card">
<view class="form-row">
<text class="prefix">+86</text>
<input
v-model="mobile"
class="input"
type="number"
maxlength="11"
placeholder="请输入手机号"
/>
</view>
<view class="form-row code-row">
<input
v-model="code"
class="input"
type="number"
maxlength="6"
placeholder="请输入验证码"
/>
<button class="code-btn" :disabled="countdown > 0" @click="sendCode">
{{ codeBtnText }}
</button>
</view>
<button class="login-btn" @click="loginByMobileSubmit">手机号验证码登录</button>
<view class="split">
<view class="line"></view>
<text class="split-text"></text>
<view class="line"></view>
</view>
<button class="wx-btn" @click="loginByWechatSubmit">微信一键登录</button>
</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 {
mobile: '',
code: '',
countdown: 0,
redirect: '',
countdownController: null
}
},
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.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.mobile)
} catch (error) {
return
}
showToast('验证码已发送')
this.countdownController.start()
},
async loginByMobileSubmit() {
if (!this.validateMobileOrToast()) {
return
}
if (!isValidSmsCode(this.code)) {
showToast('请输入6位验证码')
return
}
uni.showLoading({
title: '登录中'
})
try {
const data = await loginByMobile(this.mobile, this.code)
this.finishLogin({
token: data?.token,
user: data?.user || {
name: `用户${this.mobile.slice(-4)}`,
mobile: this.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>
.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: 38rpx 30rpx 42rpx;
box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08);
}
.form-row {
min-height: 98rpx;
border: 1rpx solid #e8eaef;
border-radius: 16rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
box-sizing: border-box;
}
.form-row + .form-row {
margin-top: 22rpx;
}
.prefix {
width: 86rpx;
color: #1f2b37;
font-size: 30rpx;
font-weight: 600;
}
.input {
flex: 1;
height: 98rpx;
font-size: 30rpx;
color: #1f2b37;
}
.code-row {
padding-right: 12rpx;
}
.code-btn {
width: 190rpx;
height: 70rpx;
line-height: 70rpx;
border-radius: 36rpx;
background: #e9faf4;
color: #15b880;
font-size: 26rpx;
border: none;
}
.code-btn::after {
border: none;
}
.code-btn[disabled] {
color: #8f98a3;
background: #f1f3f6;
}
.login-btn,
.wx-btn {
margin-top: 26rpx;
width: 100%;
height: 92rpx;
line-height: 92rpx;
border-radius: 16rpx;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
border: none;
}
.login-btn {
background: linear-gradient(90deg, #23c797, #11b880);
}
.login-btn::after,
.wx-btn::after {
border: none;
}
.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;
}
.wx-btn {
margin-top: 24rpx;
background: #07c160;
}
</style>

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

@ -0,0 +1,173 @@
<template>
<view class="page page-base">
<view class="top-nav top-nav-base">
<view class="title top-nav-title-base">我的</view>
</view>
<view class="user-card" @click="goLogin">
<view class="avatar"></view>
<view>
<text class="user-name">{{ userName }}</text>
<text class="user-tip">{{ loginTip }}</text>
</view>
</view>
<view class="menu-card">
<view class="item" @click="goConfig">
<text class="left">设备配置</text>
<text class="right"></text>
</view>
<view class="item">
<text class="left">分享的设备</text>
<text class="right"></text>
</view>
<view class="item">
<text class="left">场所管理</text>
<text class="right"></text>
</view>
<view class="item">
<text class="left">接警人管理</text>
<text class="right"></text>
</view>
<view class="item">
<text class="left">手机号</text>
<text class="mid">{{ phoneText }}</text>
<text class="right"></text>
</view>
<view class="item">
<text class="left">语音广播</text>
<text class="right"></text>
</view>
<view class="item">
<text class="left">当前版本</text>
<text class="mid">2.8.44</text>
</view>
<view class="item" @click="logout">
<text class="left">退出登录</text>
</view>
</view>
</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>
@import '@/styles/common.css';
.page {
padding-bottom: 120rpx;
}
.user-card {
margin: 24rpx;
min-height: 200rpx;
border-radius: 24rpx;
background: #ffffff;
display: flex;
align-items: center;
padding: 20rpx 34rpx;
}
.avatar {
width: 132rpx;
height: 132rpx;
border-radius: 66rpx;
background: radial-gradient(circle at 35% 35%, #95d347 0 32%, #6ba83a 33% 100%);
margin-right: 24rpx;
}
.user-name {
display: block;
font-size: 46rpx;
color: #2a2d33;
font-weight: 700;
}
.user-tip {
display: block;
margin-top: 12rpx;
color: #9ea1a6;
font-size: 30rpx;
}
.menu-card {
margin: 24rpx;
border-radius: 24rpx;
background: #ffffff;
overflow: hidden;
}
.item {
min-height: 94rpx;
display: flex;
align-items: center;
padding: 0 30rpx;
border-bottom: 1rpx solid #efefef;
}
.item:last-child {
border-bottom: none;
}
.left {
flex: 1;
font-size: 48rpx;
color: #2f3135;
font-weight: 600;
}
.mid {
margin-right: 16rpx;
color: #9ea1a6;
font-size: 44rpx;
}
.right {
color: #9ea1a6;
font-size: 54rpx;
}
</style>

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

46
styles/common.css Normal file
View File

@ -0,0 +1,46 @@
.page-base {
min-height: 100vh;
background: #f1f1f4;
}
.top-nav-base {
height: 120rpx;
padding: 24rpx 24rpx 0;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.top-nav-title-base {
font-size: 50rpx;
font-weight: 700;
color: #222222;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #a7a9ad;
}
.empty-state-box {
width: 220rpx;
height: 180rpx;
border-radius: 12rpx;
background: linear-gradient(180deg, #eef0f4, #e3e6eb);
margin-bottom: 24rpx;
}
.empty-state-text {
font-size: 52rpx;
font-weight: 600;
}
.card-base {
background: #ffffff;
border-radius: 24rpx;
}

13
uni.promisify.adaptor.js Normal file
View File

@ -0,0 +1,13 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});

76
uni.scss Normal file
View File

@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;

6
utils/api/user.js Normal file
View File

@ -0,0 +1,6 @@
export {
getUserProfile,
loginByMobile,
loginByWechat,
sendSmsCode
} from '@/api/user'

210
utils/auth.js Normal file
View File

@ -0,0 +1,210 @@
import { AUTH_ENABLED, AUTH_STORAGE_KEYS } from '@/constants/auth'
import { ROUTES, TAB_ROUTES } from '@/constants/routes'
export const LOGIN_PAGE = ROUTES.LOGIN
export const HOME_PAGE = ROUTES.HOME
export const TAB_PAGES = TAB_ROUTES
let hasSetupInterceptors = false
/**
* 规范化路由保证以 `/` 开头
*/
function normalizeUrl(url = '') {
if (!url) {
return ''
}
if (url.startsWith('/')) {
return url
}
return `/${url}`
}
/**
* 获取不带 query 的路径
*/
function getPath(url = '') {
return normalizeUrl(url).split('?')[0]
}
function safeDecode(value = '') {
try {
return decodeURIComponent(value)
} catch (error) {
return value
}
}
/**
* 把页面 options 组装成 query 字符串
*/
function buildQuery(options = {}) {
const keys = Object.keys(options)
if (!keys.length) {
return ''
}
const query = keys
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(options[key] || '')}`)
.join('&')
return query ? `?${query}` : ''
}
/**
* 获取当前页面完整地址path + query
*/
function getCurrentPageUrl() {
const pages = getCurrentPages()
if (!pages.length) {
return ''
}
const current = pages[pages.length - 1]
const currentPath = current.route ? `/${current.route}` : ''
const currentQuery = current.options ? buildQuery(current.options) : ''
return `${currentPath}${currentQuery}`
}
function isLoginPage(url = '') {
return getPath(url) === LOGIN_PAGE
}
function needLogin(url = '') {
return !isLoginPage(url)
}
export function isAuthEnabled() {
return AUTH_ENABLED
}
export function isTabPage(url = '') {
return TAB_PAGES.includes(getPath(url))
}
export function getToken() {
return uni.getStorageSync(AUTH_STORAGE_KEYS.TOKEN) || ''
}
export function isLoggedIn() {
if (!AUTH_ENABLED) {
return true
}
return Boolean(getToken())
}
export function getUser() {
return uni.getStorageSync(AUTH_STORAGE_KEYS.USER) || null
}
/**
* 保存登录态
*/
export function setAuth({ token, user } = {}) {
if (token) {
uni.setStorageSync(AUTH_STORAGE_KEYS.TOKEN, token)
}
if (user) {
uni.setStorageSync(AUTH_STORAGE_KEYS.USER, user)
}
}
export function clearAuth() {
uni.removeStorageSync(AUTH_STORAGE_KEYS.TOKEN)
uni.removeStorageSync(AUTH_STORAGE_KEYS.USER)
}
/**
* 跳转到登录页并携带重定向地址
*/
export function redirectToLogin(targetUrl = '') {
if (!AUTH_ENABLED) {
return
}
const redirect = normalizeUrl(targetUrl || getCurrentPageUrl())
if (isLoginPage(redirect)) {
return
}
const loginUrl = redirect
? `${LOGIN_PAGE}?redirect=${encodeURIComponent(redirect)}`
: LOGIN_PAGE
uni.reLaunch({
url: loginUrl
})
}
/**
* 登录成功后按 redirect 返回目标页
*/
export function goAfterLogin(redirect = '') {
const target = normalizeUrl(safeDecode(redirect) || HOME_PAGE)
const targetPath = getPath(target)
if (isLoginPage(targetPath)) {
uni.switchTab({
url: HOME_PAGE
})
return
}
if (isTabPage(targetPath)) {
uni.switchTab({
url: targetPath
})
return
}
uni.reLaunch({
url: target
})
}
function guardPage(url = '') {
if (!AUTH_ENABLED) {
return true
}
if (!needLogin(url) || isLoggedIn()) {
return true
}
redirectToLogin(url)
return false
}
/**
* 初始化 uni 路由拦截器只注册一次
*/
export function setupAuthInterceptors() {
if (hasSetupInterceptors) {
return
}
const methods = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab']
methods.forEach((method) => {
uni.addInterceptor(method, {
invoke(args) {
return guardPage(args.url)
}
})
})
hasSetupInterceptors = true
}
export function ensureCurrentPageAuth() {
const currentUrl = getCurrentPageUrl()
if (!currentUrl) {
return
}
guardPage(currentUrl)
}

18
utils/navigation.js Normal file
View File

@ -0,0 +1,18 @@
/**
* 统一路由跳转封装减少页面层重复样板代码
*/
export function navigateTo(url) {
if (!url) {
return
}
uni.navigateTo({
url
})
}
export function navigateBack(delta = 1) {
uni.navigateBack({
delta
})
}

6
utils/request.js Normal file
View File

@ -0,0 +1,6 @@
import request from '@/api/http/client'
/**
* 兼容层保留旧导入路径 `@/utils/request`
*/
export default request

10
utils/toast.js Normal file
View File

@ -0,0 +1,10 @@
/**
* 统一的轻提示封装
* @param {string} title 提示文案
*/
export function showToast(title = '') {
uni.showToast({
title,
icon: 'none'
})
}

17
utils/validators.js Normal file
View File

@ -0,0 +1,17 @@
/**
* 校验中国大陆手机号
* @param {string} value 手机号
* @returns {boolean}
*/
export function isValidMobile(value = '') {
return /^1\d{10}$/.test(String(value))
}
/**
* 校验 6 位短信验证码
* @param {string} value 验证码
* @returns {boolean}
*/
export function isValidSmsCode(value = '') {
return /^\d{6}$/.test(String(value))
}