feat:登录注册完成

This commit is contained in:
李伊帆
2026-10-09 14:29:58 +08:00
parent da83a58948
commit ee3ecc00eb
106 changed files with 4937 additions and 59 deletions

View File

@@ -0,0 +1,52 @@
---
description: src 目录结构、就近聚合与全局/局部划分(uni-app / Vue3)
alwaysApply: true
---
# 目录与就近聚合
源码在 `src`。全局目录:`components`(kebab-case + `index.vue`)、`hooks`(`use-xxx.ts`)、`types`、`api`(含唯一 `request.ts`)、`static`、`store`、`utils`、`styles`。
## 页面文件夹(`pages/<name>/`)
可就近存放该页专属内容:
| 文件 | 说明 |
|------|------|
| `index.vue` | 页面入口 |
| `<name>.types.ts` | 仅本页使用的类型 |
| `use-<name>.ts` | 仅本页使用的 hooks |
| `<name>.api.ts` | 仅本页的接口函数(调用 `src/api/request.ts`,不重复写拦截) |
| `<name>.scss` | 本页独立样式(可选) |
| `components/` | 页面私有子组件;子组件目录内可有 `types.ts` |
命名示例(页面 `home`):`home.types.ts`、`use-home.ts`、`home.api.ts`。
## 局部 vs 全局
- 仅单页使用 → 放在 `pages/<page>/` 下就近管理
- 多页复用 → `src/types`、`src/hooks`、`src/api`(按业务模块拆分,非每页一份 request)
## 禁止
- 页面 `.vue` 在 `pages` 根目录;静态资源在 `src` 外
- 页面私有组件/类型/hooks/api 随意提到 `src` 顶层
- 每页重复实现请求封装(拦截器只在 `src/api/request.ts`)
- 在 `.vue` 里堆应抽离的共享类型(简单局部类型除外)
## 示例
```
src/pages/home/
index.vue
home.types.ts
use-home.ts
home.api.ts
components/feed-item/index.vue
src/api/request.ts
src/api/user.ts
src/hooks/use-auth.ts
```
新建与重构时:先判断是否多页复用,再选页面内就近或顶层全局。

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://192.168.0.222:8082
VITE_API_PROXY_TARGET=http://192.168.0.222:8082

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# 接口根地址(App / 小程序 / H5 直连)
VITE_API_BASE_URL=http://192.168.0.222:8082
# H5 开发:BASE_URL 留空时可走 vite 代理 /app
VITE_API_PROXY_TARGET=http://192.168.0.222:8082

382
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003", "@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003",
"@vue/runtime-core": "^3.4.21", "@vue/runtime-core": "^3.4.21",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"sass": "^1.105.1",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vite": "5.2.8", "vite": "5.2.8",
"vue-tsc": "^1.0.24" "vue-tsc": "^1.0.24"
@@ -4664,6 +4665,312 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@parcel/watcher": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.6.0.tgz",
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.6.0",
"@parcel/watcher-darwin-arm64": "2.6.0",
"@parcel/watcher-darwin-x64": "2.6.0",
"@parcel/watcher-freebsd-x64": "2.6.0",
"@parcel/watcher-linux-arm-glibc": "2.6.0",
"@parcel/watcher-linux-arm-musl": "2.6.0",
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
"@parcel/watcher-linux-arm64-musl": "2.6.0",
"@parcel/watcher-linux-x64-glibc": "2.6.0",
"@parcel/watcher-linux-x64-musl": "2.6.0",
"@parcel/watcher-win32-arm64": "2.6.0",
"@parcel/watcher-win32-x64": "2.6.0"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/pluginutils": { "node_modules/@rollup/pluginutils": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz",
@@ -7075,6 +7382,16 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -8123,6 +8440,13 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/immutable": {
"version": "5.1.9",
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/import-local": { "node_modules/import-local": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz", "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz",
@@ -11027,6 +11351,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-int64": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
@@ -12134,6 +12465,57 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": {
"version": "1.105.1",
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.105.1.tgz",
"integrity": "sha512-1CnAWPyrrnVQzXGB/DUep1vYCKCm1CIBr+YxjjiW6wk9qtzemnGP4qicH56T2F9S4Hjtv/Ne3jDz8JqfdDU4oA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass/node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz",
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sax": { "node_modules/sax": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz",

View File

@@ -62,8 +62,9 @@
"@dcloudio/uni-cli-shared": "3.0.0-5020420260813003", "@dcloudio/uni-cli-shared": "3.0.0-5020420260813003",
"@dcloudio/uni-stacktracey": "3.0.0-5020420260813003", "@dcloudio/uni-stacktracey": "3.0.0-5020420260813003",
"@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003", "@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003",
"@vue/tsconfig": "^0.1.3",
"@vue/runtime-core": "^3.4.21", "@vue/runtime-core": "^3.4.21",
"@vue/tsconfig": "^0.1.3",
"sass": "^1.105.1",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vite": "5.2.8", "vite": "5.2.8",
"vue-tsc": "^1.0.24" "vue-tsc": "^1.0.24"

View File

@@ -10,4 +10,6 @@ onHide(() => {
console.log("App Hide"); console.log("App Hide");
}); });
</script> </script>
<style></style> <style lang="scss">
@import '@/styles/index.scss';
</style>

25
src/api/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
import { request } from './request'
import type { AuthTokenResult, SendSmsCodeBody, SmsCodeScene } from '@/types/api'
import { ApiError } from '@/utils/api-error'
/** POST /app/auth/sendSmsCode */
export function sendSmsCode(phone: string, scene: SmsCodeScene) {
const data: SendSmsCodeBody = {
phonenumber: phone.trim(),
scene,
}
return request<void>({
url: '/app/auth/sendSmsCode',
method: 'POST',
data,
})
}
export function saveAuthToken(data: AuthTokenResult) {
const token = data?.token ?? data?.accessToken
if (!token) {
throw new ApiError('登录失败,未获取到令牌')
}
uni.setStorageSync('token', token)
return token
}

49
src/api/request.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { ApiResponse } from '@/types/api'
import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error'
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''
export interface RequestOptions extends Omit<UniApp.RequestOptions, 'url'> {
url: string
}
export function request<T = unknown>(options: RequestOptions): Promise<T> {
const token = uni.getStorageSync('token') as string
return new Promise((resolve, reject) => {
uni.request({
...options,
url: `${BASE_URL}${options.url}`,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.header,
},
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const body = res.data as ApiResponse | undefined
reject(
new ApiError(
body?.msg || body?.message || `请求失败(${res.statusCode})`,
body?.code ?? res.statusCode,
),
)
return
}
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown>
if (!isApiSuccess(body)) {
reject(
new ApiError(body.msg || body.message || '请求失败', body.code),
)
return
}
resolve(unwrapApiData<T>(body))
},
fail: (err) => {
reject(new ApiError(err.errMsg || '网络异常'))
},
})
})
}

View File

@@ -0,0 +1,88 @@
<template>
<view class="agreement-checkbox" @tap="toggle">
<view
class="agreement-checkbox__box"
:class="{ 'agreement-checkbox__box--checked': modelValue }"
>
<image
v-if="modelValue"
class="agreement-checkbox__checked"
:src="staticAssets.iconChecked"
mode="aspectFit"
/>
</view>
<view class="agreement-checkbox__text">
<text class="agreement-checkbox__plain">我已阅读并同意</text>
<text class="agreement-checkbox__link" @tap.stop="openAgreement('service')">《用户服务协议》</text>
<text class="agreement-checkbox__plain">和</text>
<text class="agreement-checkbox__link" @tap.stop="openAgreement('privacy')">《隐私政策》</text>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function toggle() {
emit('update:modelValue', !props.modelValue)
}
function openAgreement(type: 'service' | 'privacy') {
uni.navigateTo({
url: `/pages/agreement/index?type=${type}`,
})
}
</script>
<style lang="scss" scoped>
.agreement-checkbox {
display: flex;
align-items: flex-start;
gap: 16rpx;
padding: 0 8rpx;
}
.agreement-checkbox__box {
width: 36rpx;
height: 36rpx;
border: 2rpx solid #ccc;
border-radius: 50%;
flex-shrink: 0;
margin-top: 4rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.agreement-checkbox__box--checked {
border: none;
}
.agreement-checkbox__checked {
width: 36rpx;
height: 36rpx;
}
.agreement-checkbox__text {
flex: 1;
font-size: 24rpx;
line-height: 1.6;
}
.agreement-checkbox__plain {
color: $color-text-secondary;
}
.agreement-checkbox__link {
color: $color-link;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<view class="app-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="app-navbar__bar">
<view class="app-navbar__back" @tap="onBack">
<text class="app-navbar__back-icon">‹</text>
</view>
<text class="app-navbar__title">{{ title }}</text>
<view class="app-navbar__placeholder" />
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
title: string
}>()
const statusBarHeight = ref(0)
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight ?? 0
function onBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({ url: '/pages/device/index' })
}
}
</script>
<style lang="scss" scoped>
.app-navbar {
background: $color-bg-page;
}
.app-navbar__bar {
height: 88rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
}
.app-navbar__back {
width: 80rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.app-navbar__back-icon {
font-size: 52rpx;
line-height: 1;
color: $color-text;
font-weight: 300;
}
.app-navbar__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 500;
color: $color-text;
}
.app-navbar__placeholder {
width: 80rpx;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<view class="app-tab-bar" :style="{ paddingBottom: safeBottom + 'px' }">
<view
class="app-tab-bar__item"
:class="{ 'app-tab-bar__item--active': activeTab === 'device' }"
@tap="switchTo('device')"
>
<view class="app-tab-bar__icon-box">
<image
class="app-tab-bar__icon"
:src="activeTab === 'device' ? staticAssets.tabDeviceActive : staticAssets.tabDevice"
mode="aspectFit"
/>
</view>
<text class="app-tab-bar__text">设备</text>
</view>
<view
class="app-tab-bar__item"
:class="{ 'app-tab-bar__item--active': activeTab === 'mine' }"
@tap="switchTo('mine')"
>
<view class="app-tab-bar__icon-box">
<image
class="app-tab-bar__icon"
:src="activeTab === 'mine' ? staticAssets.tabMineActive : staticAssets.tabMine"
mode="aspectFit"
/>
</view>
<text class="app-tab-bar__text">我的</text>
</view>
</view>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { staticAssets } from '@/utils/static-assets'
export type TabKey = 'device' | 'mine'
const props = defineProps<{
current: TabKey
}>()
const safeBottom = uni.getSystemInfoSync().safeAreaInsets?.bottom ?? 0
const routes: Record<TabKey, string> = {
device: '/pages/device/index',
mine: '/pages/mine/index',
}
function resolveTabFromRoute(): TabKey {
const route = getCurrentPages().at(-1)?.route ?? ''
return route.includes('mine') ? 'mine' : 'device'
}
const activeTab = ref<TabKey>(props.current)
watch(
() => props.current,
(value) => {
activeTab.value = value
},
{ immediate: true },
)
onMounted(() => {
activeTab.value = resolveTabFromRoute()
})
function switchTo(key: TabKey) {
if (activeTab.value === key) return
activeTab.value = key
uni.reLaunch({ url: routes[key] })
}
</script>
<style lang="scss" scoped>
.app-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: space-around;
min-height: 100rpx;
padding-top: 10rpx;
background: #ffffff;
border-top: 1rpx solid #eeeeee;
box-sizing: border-box;
}
.app-tab-bar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
min-height: 96rpx;
}
.app-tab-bar__icon-box {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.app-tab-bar__icon {
width: 48rpx;
height: 48rpx;
}
/* 设备 / 我的:未选中、选中文字样式完全一致 */
.app-tab-bar__text {
font-size: 22rpx;
font-weight: 400;
color: #999999;
line-height: 1.2;
}
.app-tab-bar__item--active .app-tab-bar__text {
color: #333333;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<view class="base-input">
<view v-if="iconSrc" class="base-input__icon">
<image
class="base-input__icon-img"
:class="{ 'base-input__icon-img--lock': icon === 'lock' }"
:src="iconSrc"
mode="aspectFit"
/>
</view>
<input
class="base-input__field"
:type="inputType"
:password="password"
:value="modelValue"
:placeholder="placeholder"
:placeholder-class="placeholderClass"
:maxlength="maxlength"
@input="onInput"
/>
<view v-if="$slots.suffix" class="base-input__suffix">
<slot name="suffix" />
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { staticAssets } from '@/utils/static-assets'
const props = withDefaults(
defineProps<{
modelValue: string
placeholder?: string
icon?: 'phone' | 'shield' | 'lock'
type?: 'text' | 'password' | 'number'
maxlength?: number
}>(),
{
placeholder: '',
type: 'text',
maxlength: 140,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const password = computed(() => props.type === 'password')
const inputType = computed(() => {
if (props.type === 'number') return 'number'
if (props.type === 'password') return 'text'
return 'text'
})
const iconSrc = computed(() => {
if (!props.icon) return ''
if (props.icon === 'phone') return staticAssets.iconPhone
if (props.icon === 'lock') return staticAssets.iconPassword
return staticAssets.iconCode
})
const placeholderClass = 'base-input__placeholder'
function onInput(e: Event) {
const { value } = (e as CustomEvent<{ value: string }>).detail
emit('update:modelValue', value)
}
</script>
<style lang="scss" scoped>
.base-input {
display: flex;
align-items: center;
height: 96rpx;
padding: 0 32rpx;
background: $color-bg-input;
border: 2rpx solid $color-border;
border-radius: $radius-input;
margin-bottom: 24rpx;
}
.base-input__icon {
flex-shrink: 0;
margin-right: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.base-input__icon-img {
width: 40rpx;
height: 40rpx;
}
.base-input__icon-img--lock {
width: 32rpx;
height: 32rpx;
}
.base-input__field {
flex: 1;
height: 100%;
font-size: 28rpx;
color: $color-text;
min-width: 0;
}
.base-input__suffix {
flex-shrink: 0;
margin-left: 16rpx;
}
:deep(.base-input__placeholder) {
color: $color-text-placeholder;
font-size: 28rpx;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<view class="brand-logo">
<image
class="brand-logo__image"
:src="staticAssets.logo"
mode="aspectFit"
/>
<text class="brand-logo__name">虫子智家</text>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.brand-logo {
display: flex;
flex-direction: column;
align-items: center;
}
.brand-logo__image {
width: 200rpx;
height: 200rpx;
margin-bottom: 24rpx;
}
.brand-logo__name {
font-size: 36rpx;
font-weight: 600;
color: $color-text;
letter-spacing: 4rpx;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<view
class="primary-button"
:class="{ 'primary-button--disabled': disabled }"
@tap="onTap"
>
<text class="primary-button__text">{{ text }}</text>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
text: string
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
click: []
}>()
function onTap() {
if (props.disabled) return
emit('click')
}
</script>
<style lang="scss" scoped>
.primary-button {
height: 96rpx;
background: $color-primary;
border-radius: $radius-button;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-button;
}
.primary-button--disabled {
opacity: 0.45;
box-shadow: none;
}
.primary-button__text {
font-size: 32rpx;
font-weight: 500;
color: #fff;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<view
class="sms-code-btn"
:class="{ 'sms-code-btn--disabled': disabled || counting }"
@tap="onTap"
>
<text class="sms-code-btn__text">{{ label }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
counting: boolean
seconds: number
disabled?: boolean
}>()
const emit = defineEmits<{
send: []
}>()
const label = computed(() => {
if (props.counting) return `${props.seconds}s`
return '获取验证码'
})
function onTap() {
if (props.disabled || props.counting) return
emit('send')
}
</script>
<style lang="scss" scoped>
.sms-code-btn {
padding: 12rpx 20rpx;
border: 2rpx solid $color-primary;
border-radius: 12rpx;
}
.sms-code-btn--disabled {
opacity: 0.5;
border-color: #ccc;
}
.sms-code-btn__text {
font-size: 24rpx;
color: $color-primary;
white-space: nowrap;
}
.sms-code-btn--disabled .sms-code-btn__text {
color: #999;
}
</style>

View File

@@ -0,0 +1,37 @@
import { onUnmounted, ref } from 'vue'
export function useCountdown(duration = 60) {
const counting = ref(false)
const seconds = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function clearTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function start() {
clearTimer()
seconds.value = duration
counting.value = true
timer = setInterval(() => {
if (seconds.value <= 1) {
stop()
} else {
seconds.value -= 1
}
}, 1000)
}
function stop() {
clearTimer()
counting.value = false
seconds.value = 0
}
onUnmounted(stop)
return { counting, seconds, start, stop }
}

26
src/hooks/use-sms-code.ts Normal file
View File

@@ -0,0 +1,26 @@
import { sendSmsCode } from '@/api/auth'
import type { SmsCodeScene } from '@/types/api'
import { getErrorMessage } from '@/utils/api-error'
import { isValidPhone } from '@/utils/validate'
import { useCountdown } from './use-countdown'
export function useSmsCode(scene: SmsCodeScene) {
const { counting, seconds, start } = useCountdown(60)
async function sendCode(phone: string) {
if (counting.value) return
if (!isValidPhone(phone)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
try {
await sendSmsCode(phone, scene)
uni.showToast({ title: '验证码已发送', icon: 'none' })
start()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '发送失败,请稍后重试'), icon: 'none' })
}
}
return { counting, seconds, sendCode }
}

14
src/hooks/use-tab-page.ts Normal file
View File

@@ -0,0 +1,14 @@
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import type { TabKey } from '@/components/app-tab-bar/index.vue'
/** Tab 页在 onShow 时同步底栏高亮 */
export function useTabPage(tab: TabKey) {
const tabCurrent = ref<TabKey>(tab)
onShow(() => {
tabCurrent.value = tab
})
return { tabCurrent }
}

View File

@@ -1,7 +1,10 @@
import { createSSRApp } from "vue"; import { createSSRApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import { registerGlobalComponents } from "./plugins/register-global-components";
export function createApp() { export function createApp() {
const app = createSSRApp(App); const app = createSSRApp(App);
registerGlobalComponents(app);
return { return {
app, app,
}; };

View File

@@ -1,16 +1,93 @@
{ {
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages "easycom": {
"autoscan": true,
"custom": {
"^([a-z][a-z0-9]*(?:-[a-z0-9]+)+)$": "@/components/$1/index.vue"
}
},
"pages": [
{ {
"path": "pages/index/index", "path": "pages/login/index",
"style": { "style": {
"navigationBarTitleText": "uni-app" "navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/device/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "设备"
}
},
{
"path": "pages/add-device/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "添加设备"
}
},
{
"path": "pages/add-device/scan/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "扫描设备"
}
},
{
"path": "pages/add-device/pair/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "设备配置"
}
},
{
"path": "pages/add-device/complete/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "设备配置"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationBarTitleText": "我的",
"navigationStyle": "custom"
}
},
{
"path": "pages/register/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "注册"
}
},
{
"path": "pages/forgot-password/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "忘记密码"
}
},
{
"path": "pages/change-password/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "修改密码"
}
},
{
"path": "pages/agreement/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "用户服务协议"
} }
} }
], ],
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app", "navigationBarTitleText": "虫子智家",
"navigationBarBackgroundColor": "#F8F8F8", "navigationBarBackgroundColor": "#F7F8FA",
"backgroundColor": "#F8F8F8" "backgroundColor": "#F7F8FA"
} }
} }

View File

@@ -0,0 +1,91 @@
.add-device-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.add-device-page__scroll {
flex: 1;
height: 0;
}
.add-device-page__body {
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.add-device-page__title {
display: block;
padding: 0 40rpx;
font-size: 40rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 20rpx;
text-align: center;
}
.add-device-page__desc {
display: block;
padding: 0 48rpx;
font-size: 26rpx;
color: #98A2B3;
line-height: 1.65;
margin-bottom: 32rpx;
text-align: center;
}
.add-device-page__footer {
padding: 40rpx 16rpx 0;
margin-top: 8rpx;
}
.add-device-page__btn {
width: 100%;
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
margin-bottom: 28rpx;
}
.add-device-page__btn-text {
font-size: 32rpx;
font-weight: 500;
color: #fff;
}
.add-device-page__btn-badge {
min-width: 44rpx;
height: 44rpx;
padding: 0 12rpx;
background: #ffc940;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid #fff;
}
.add-device-page__btn-badge-num {
font-size: 26rpx;
font-weight: 700;
color: #fff;
}
.add-device-page__later-wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx 0 8rpx;
}
.add-device-page__later {
font-size: 28rpx;
color: #999;
text-align: center;
}

View File

@@ -0,0 +1,11 @@
export interface BindStepItem {
id: number
label: string
}
export interface BindFeatureItem {
id: string
title: string
desc: string
icon: string
}

View File

@@ -0,0 +1,219 @@
.complete-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.complete-page__scroll {
flex: 1;
height: 0;
}
.complete-page__body {
padding-bottom: 24rpx;
}
.complete-page__status {
padding: 0 40rpx;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.complete-page__title {
font-size: 40rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
}
.complete-page__desc {
font-size: 26rpx;
color: #98a2b3;
line-height: 1.65;
text-align: center;
}
.complete-page__device-card {
margin: 0 32rpx 24rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 20rpx;
}
.complete-page__thumb {
width: 88rpx;
height: 88rpx;
border-radius: 16rpx;
background: #f0f2f5;
flex-shrink: 0;
}
.complete-page__device-info {
flex: 1;
min-width: 0;
}
.complete-page__device-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.complete-page__device-serial {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
margin-bottom: 8rpx;
}
.complete-page__bt-status {
display: flex;
align-items: center;
gap: 8rpx;
}
.complete-page__bt-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $color-primary;
}
.complete-page__bt-text {
font-size: 24rpx;
color: $color-primary;
font-weight: 500;
}
.complete-page__form {
margin: 0 32rpx 24rpx;
padding: 8rpx 24rpx 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.complete-page__field {
padding-top: 24rpx;
}
.complete-page__label {
display: block;
font-size: 28rpx;
font-weight: 500;
color: $color-text;
margin-bottom: 16rpx;
}
.complete-page__input-wrap {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 16rpx;
gap: 16rpx;
}
.complete-page__input {
flex: 1;
font-size: 28rpx;
color: $color-text;
min-width: 0;
}
.complete-page__edit-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.complete-page__room-row {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 16rpx;
gap: 12rpx;
}
.complete-page__room-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
}
.complete-page__room-text {
flex: 1;
font-size: 28rpx;
color: $color-text;
}
.complete-page__room-arrow {
width: 24rpx;
height: 24rpx;
flex-shrink: 0;
opacity: 0.5;
}
.complete-page__notice {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin: 0 32rpx 24rpx;
padding: 0 8rpx;
}
.complete-page__notice-icon {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: #4a9eff;
color: #fff;
font-size: 22rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2rpx;
}
.complete-page__notice-text {
flex: 1;
font-size: 24rpx;
color: #98a2b3;
line-height: 1.6;
}
.complete-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
}
.complete-page__submit {
height: 96rpx;
border-radius: 24rpx;
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.complete-page__submit-text {
font-size: 32rpx;
font-weight: 500;
color: #fff;
}

View File

@@ -0,0 +1,71 @@
<template>
<view class="complete-success-hero">
<view class="complete-success-hero__visual">
<view class="complete-success-hero__glow" aria-hidden="true" />
<image
class="complete-success-hero__illus"
:src="staticAssets.iconBindComplete"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.complete-success-hero {
display: flex;
justify-content: center;
padding: 8rpx 0 24rpx;
}
.complete-success-hero__visual {
position: relative;
width: 280rpx;
height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
}
.complete-success-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 220rpx;
height: 220rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.4) 0%,
rgba(201, 239, 217, 0.15) 45%,
transparent 70%
);
animation: complete-success-glow 2.4s ease-in-out infinite;
}
.complete-success-hero__illus {
position: relative;
z-index: 1;
width: 200rpx;
height: 200rpx;
flex-shrink: 0;
}
@keyframes complete-success-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.88;
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<view class="complete-page">
<app-navbar title="设备配置" />
<scroll-view class="complete-page__scroll" scroll-y>
<view class="complete-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<complete-success-hero />
<view class="complete-page__status">
<text class="complete-page__title">配对成功!</text>
<text class="complete-page__desc">请确认设备信息后完成添加</text>
</view>
<view class="complete-page__device-card">
<image
class="complete-page__thumb"
:src="staticAssets.logo"
mode="aspectFill"
/>
<view class="complete-page__device-info">
<text class="complete-page__device-name">{{ deviceName }}</text>
<text class="complete-page__device-serial">
设备编号:{{ serialMasked }}
</text>
<view class="complete-page__bt-status">
<view class="complete-page__bt-dot" />
<text class="complete-page__bt-text">蓝牙已连接</text>
</view>
</view>
</view>
<view class="complete-page__form">
<view class="complete-page__field">
<text class="complete-page__label">设备名称</text>
<view class="complete-page__input-wrap">
<input
v-model="deviceName"
class="complete-page__input"
type="text"
maxlength="32"
placeholder="请输入设备名称"
/>
<image
class="complete-page__edit-icon"
:src="staticAssets.iconDeviceNameEdit"
mode="aspectFit"
/>
</view>
</view>
<view class="complete-page__field">
<text class="complete-page__label">所在房间</text>
<view class="complete-page__room-row" @tap="onPickRoom">
<image
class="complete-page__room-icon"
:src="staticAssets.iconHomeSelector"
mode="aspectFit"
/>
<text class="complete-page__room-text">{{ roomName }}</text>
<image
class="complete-page__room-arrow"
:src="staticAssets.iconHomeArrow"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="complete-page__notice">
<text class="complete-page__notice-icon">i</text>
<text class="complete-page__notice-text">
设备名称和房间可随时在设备详情中修改。添加后设备将出现在首页设备列表中。
</text>
</view>
</view>
</scroll-view>
<view class="complete-page__footer">
<view class="complete-page__submit" @tap="onFinishAdd">
<text class="complete-page__submit-text">完成添加</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { staticAssets } from '@/utils/static-assets'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import CompleteSuccessHero from './components/complete-success-hero/index.vue'
import { useCompleteDevice } from './use-complete-device'
const {
currentStep,
deviceName,
serialMasked,
roomName,
onPickRoom,
onFinishAdd,
} = useCompleteDevice()
onLoad((query) => {
if (query?.name) {
deviceName.value = decodeURIComponent(String(query.name))
}
if (query?.serial) {
serialMasked.value = decodeURIComponent(String(query.serial))
}
})
</script>
<style lang="scss" scoped>
@import './complete.scss';
</style>

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
const ROOM_OPTIONS = ['客厅', '主卧', '卧室', '厨房', '卫生间']
export function useCompleteDevice() {
const currentStep = 4
const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2')
const roomName = ref('客厅')
const roomOptions = ROOM_OPTIONS
function onPickRoom() {
uni.showActionSheet({
itemList: roomOptions,
success: (res) => {
const picked = roomOptions[res.tapIndex]
if (picked) roomName.value = picked
},
})
}
function onFinishAdd() {
const name = deviceName.value.trim()
if (!name) {
uni.showToast({ title: '请输入设备名称', icon: 'none' })
return
}
uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' })
}, 400)
}
return {
currentStep,
deviceName,
serialMasked,
roomName,
onPickRoom,
onFinishAdd,
}
}

View File

@@ -0,0 +1,127 @@
<template>
<view class="bind-bluetooth-hero">
<view class="bind-bluetooth-hero__visual">
<view class="bind-bluetooth-hero__glow" aria-hidden="true" />
<view class="bind-bluetooth-hero__pulses" aria-hidden="true">
<view
v-for="i in 3"
:key="i"
class="bind-bluetooth-hero__pulse"
:style="{ animationDelay: `${(i - 1) * 0.9}s` }"
/>
</view>
<image
class="bind-bluetooth-hero__icon"
:src="staticAssets.iconBluetooth"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.bind-bluetooth-hero {
display: flex;
justify-content: center;
padding: 16rpx 0 40rpx;
}
.bind-bluetooth-hero__visual {
position: relative;
width: 280rpx;
height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
}
.bind-bluetooth-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 220rpx;
height: 220rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.45) 0%,
rgba(201, 239, 217, 0.18) 42%,
transparent 72%
);
animation: bind-bt-glow 2.8s ease-in-out infinite;
}
.bind-bluetooth-hero__pulses {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.bind-bluetooth-hero__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
border: 3rpx solid rgba(33, 194, 94, 0.28);
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, -50%) scale(1);
animation: bind-bt-pulse 2.7s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
}
.bind-bluetooth-hero__icon {
position: relative;
z-index: 2;
width: 104rpx;
height: 104rpx;
flex-shrink: 0;
animation: bind-bt-icon-breathe 2.8s ease-in-out infinite;
}
@keyframes bind-bt-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
70% {
opacity: 0.08;
}
100% {
transform: translate(-50%, -50%) scale(2.65);
opacity: 0;
}
}
@keyframes bind-bt-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.85;
}
}
@keyframes bind-bt-icon-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<view class="bind-feature-list">
<view
v-for="(item, index) in items"
:key="item.id"
class="bind-feature-list__row"
:class="{ 'bind-feature-list__row--border': index < items.length - 1 }"
>
<image
class="bind-feature-list__icon"
:src="item.icon"
mode="aspectFit"
/>
<view class="bind-feature-list__text">
<text class="bind-feature-list__title">{{ item.title }}</text>
<text class="bind-feature-list__desc">{{ item.desc }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { BindFeatureItem } from '../../add-device.types'
defineProps<{
items: BindFeatureItem[]
}>()
</script>
<style lang="scss" scoped>
.bind-feature-list {
margin: 0 32rpx;
background: #fff;
border-radius: 24rpx;
padding: 8rpx 24rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.bind-feature-list__row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 28rpx 0;
}
.bind-feature-list__row--border {
border-bottom: 1rpx solid #f0f0f0;
}
.bind-feature-list__icon {
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
}
.bind-feature-list__text {
flex: 1;
min-width: 0;
}
.bind-feature-list__title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.bind-feature-list__desc {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<view class="bind-steps">
<view
v-for="(step, index) in steps"
:key="step.id"
class="bind-steps__item"
>
<view class="bind-steps__top">
<view
v-if="index > 0"
class="bind-steps__line bind-steps__line--left"
:class="{ 'bind-steps__line--active': current >= step.id - 1 }"
/>
<view
class="bind-steps__dot"
:class="{ 'bind-steps__dot--active': step.id <= current }"
>
<text class="bind-steps__num">{{ step.id }}</text>
</view>
<view
v-if="index < steps.length - 1"
class="bind-steps__line bind-steps__line--right"
:class="{ 'bind-steps__line--active': current >= step.id }"
/>
</view>
<text
class="bind-steps__label"
:class="{ 'bind-steps__label--active': step.id === current }"
>
{{ step.label }}
</text>
</view>
</view>
</template>
<script setup lang="ts">
import type { BindStepItem } from '../../add-device.types'
defineProps<{
steps: BindStepItem[]
current: number
}>()
</script>
<style lang="scss" scoped>
.bind-steps {
display: flex;
padding: 24rpx 16rpx 32rpx;
}
.bind-steps__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.bind-steps__top {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 48rpx;
margin-bottom: 12rpx;
}
.bind-steps__dot {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
}
.bind-steps__dot--active {
background: $color-primary;
}
.bind-steps__num {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
.bind-steps__dot--active .bind-steps__num {
color: #fff;
}
.bind-steps__line {
position: absolute;
top: 50%;
height: 4rpx;
background: #e8e8e8;
transform: translateY(-50%);
}
.bind-steps__line--left {
left: 0;
right: 50%;
margin-right: 24rpx;
}
.bind-steps__line--right {
left: 50%;
right: 0;
margin-left: 24rpx;
}
.bind-steps__line--active {
background: $color-primary;
}
.bind-steps__label {
font-size: 22rpx;
color: #999;
text-align: center;
line-height: 1.3;
padding: 0 4rpx;
}
.bind-steps__label--active {
color: #333;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<view class="add-device-page">
<app-navbar title="添加设备" />
<scroll-view class="add-device-page__scroll" scroll-y>
<view class="add-device-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<bind-bluetooth-hero />
<text class="add-device-page__title">需要开启蓝牙权限</text>
<text class="add-device-page__desc">
电子智能家需要使用蓝牙来发现和连接附近的智能设备,实现设备的快速配置和本地控制。
</text>
<bind-feature-list :items="BIND_FEATURES" />
<view class="add-device-page__footer">
<view class="add-device-page__btn" @tap="onOpenBluetooth">
<text class="add-device-page__btn-text">去开启蓝牙</text>
</view>
<view class="add-device-page__later-wrap" @tap="onBindLater">
<text class="add-device-page__later">稍后绑定</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import BindBluetoothHero from './components/bind-bluetooth-hero/index.vue'
import BindFeatureList from './components/bind-feature-list/index.vue'
import BindSteps from './components/bind-steps/index.vue'
import { BIND_FEATURES, BIND_STEPS, useAddDevice } from './use-add-device'
const { currentStep, onOpenBluetooth, onBindLater } = useAddDevice()
</script>
<style lang="scss" scoped>
@import './add-device.scss';
</style>

View File

@@ -0,0 +1,132 @@
<template>
<view class="pair-check-list">
<text class="pair-check-list__title">配对自检</text>
<view
v-for="(item, index) in items"
:key="item.id"
class="pair-check-list__row"
:class="{ 'pair-check-list__row--border': index < items.length - 1 }"
>
<view
class="pair-check-list__status"
:class="`pair-check-list__status--${item.status}`"
>
<text v-if="item.status === 'done'" class="pair-check-list__check">✓</text>
</view>
<text class="pair-check-list__label">{{ item.title }}</text>
<view
class="pair-check-list__tag"
:class="`pair-check-list__tag--${item.status}`"
>
<text class="pair-check-list__tag-text">{{ item.tag }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { PairCheckItem } from '../../pair.types'
defineProps<{
items: PairCheckItem[]
}>()
</script>
<style lang="scss" scoped>
.pair-check-list {
margin: 0 32rpx 24rpx;
padding: 28rpx 24rpx 8rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.pair-check-list__title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.pair-check-list__row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx 0;
}
.pair-check-list__row--border {
border-bottom: 1rpx solid #f0f0f0;
}
.pair-check-list__status {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.pair-check-list__status--done {
background: $color-primary;
}
.pair-check-list__status--syncing {
background: #ff9f43;
}
.pair-check-list__status--pending {
background: #e8eaed;
}
.pair-check-list__check {
font-size: 22rpx;
color: #fff;
font-weight: 700;
line-height: 1;
}
.pair-check-list__label {
flex: 1;
font-size: 28rpx;
color: $color-text;
}
.pair-check-list__tag {
padding: 8rpx 20rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.pair-check-list__tag--done {
background: #effbf4;
}
.pair-check-list__tag--syncing {
background: #fff4e8;
}
.pair-check-list__tag--pending {
background: #f5f6f8;
}
.pair-check-list__tag-text {
font-size: 24rpx;
font-weight: 500;
}
.pair-check-list__tag--done .pair-check-list__tag-text {
color: $color-primary;
}
.pair-check-list__tag--syncing .pair-check-list__tag-text {
color: #e67a2e;
}
.pair-check-list__tag--pending .pair-check-list__tag-text {
color: #98a2b3;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<view class="pair-device-summary">
<image class="pair-device-summary__thumb" :src="thumbSrc" mode="aspectFill" />
<view class="pair-device-summary__info">
<text class="pair-device-summary__name">{{ device.name }}</text>
<text class="pair-device-summary__serial">设备编号:{{ device.serialMasked }}</text>
</view>
<view class="pair-device-summary__status">
<view class="pair-device-summary__status-dot" />
<text class="pair-device-summary__status-text">连接中</text>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import type { PairDeviceInfo } from '../../pair.types'
withDefaults(
defineProps<{
device: PairDeviceInfo
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
)
</script>
<style lang="scss" scoped>
.pair-device-summary {
margin: 0 32rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 20rpx;
}
.pair-device-summary__thumb {
width: 88rpx;
height: 88rpx;
border-radius: 16rpx;
background: #f0f2f5;
flex-shrink: 0;
}
.pair-device-summary__info {
flex: 1;
min-width: 0;
}
.pair-device-summary__name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.pair-device-summary__serial {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
}
.pair-device-summary__status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
background: #effbf4;
border-radius: 999rpx;
flex-shrink: 0;
}
.pair-device-summary__status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
border: 3rpx solid $color-primary;
box-sizing: border-box;
}
.pair-device-summary__status-text {
font-size: 24rpx;
color: $color-primary;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<view class="pair-hero">
<view class="pair-hero__visual">
<view class="pair-hero__glow" aria-hidden="true" />
<view class="pair-hero__pulses" aria-hidden="true">
<view
v-for="i in 3"
:key="i"
class="pair-hero__pulse"
:style="{ animationDelay: `${(i - 1) * 0.9}s` }"
/>
</view>
<image class="pair-hero__avatar" :src="thumbSrc" mode="aspectFill" />
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
withDefaults(
defineProps<{
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
)
</script>
<style lang="scss" scoped>
.pair-hero {
display: flex;
justify-content: center;
padding: 8rpx 0 24rpx;
}
.pair-hero__visual {
position: relative;
width: 280rpx;
height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
}
.pair-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 220rpx;
height: 220rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.45) 0%,
rgba(201, 239, 217, 0.18) 42%,
transparent 72%
);
animation: pair-hero-glow 2.8s ease-in-out infinite;
}
.pair-hero__pulses {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.pair-hero__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 3rpx solid rgba(33, 194, 94, 0.28);
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, -50%) scale(1);
animation: pair-hero-pulse 2.7s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
}
.pair-hero__avatar {
position: relative;
z-index: 2;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
border: 4rpx solid #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
flex-shrink: 0;
background: #f0f2f5;
}
@keyframes pair-hero-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
70% {
opacity: 0.08;
}
100% {
transform: translate(-50%, -50%) scale(2.65);
opacity: 0;
}
}
@keyframes pair-hero-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.85;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<view class="pair-page">
<app-navbar title="设备配置" />
<scroll-view class="pair-page__scroll" scroll-y>
<view class="pair-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<pair-hero />
<view class="pair-page__status">
<text class="pair-page__title">配对中...</text>
<text class="pair-page__desc">正在与设备建立安全连接,请保持手机靠近</text>
</view>
<view class="pair-page__progress-block">
<view class="pair-page__progress-head">
<text class="pair-page__progress-label">正在同步设备配置...</text>
<text class="pair-page__progress-percent">{{ progress }}%</text>
</view>
<view class="pair-page__progress-track">
<view
class="pair-page__progress-fill"
:style="{ width: `${progress}%` }"
/>
</view>
<text class="pair-page__progress-tip">
{{ progress >= 100 ? '配置同步完成' : `预计还需约 ${etaSeconds} 秒,请勿离开当前页面` }}
</text>
</view>
<pair-check-list :items="checkItems" />
<pair-device-summary :device="device" />
</view>
</scroll-view>
<view class="pair-page__footer">
<view class="pair-page__cancel" @tap="onCancelPair">
<text class="pair-page__cancel-text">取消配对</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import PairCheckList from './components/pair-check-list/index.vue'
import PairDeviceSummary from './components/pair-device-summary/index.vue'
import PairHero from './components/pair-hero/index.vue'
import { usePairDevice } from './use-pair-device'
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } =
usePairDevice()
onLoad((query) => {
if (query?.name) {
device.value.name = decodeURIComponent(String(query.name))
}
if (query?.serial) {
device.value.serialMasked = decodeURIComponent(String(query.serial))
}
})
</script>
<style lang="scss" scoped>
@import './pair.scss';
</style>

View File

@@ -0,0 +1,109 @@
.pair-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.pair-page__scroll {
flex: 1;
height: 0;
}
.pair-page__body {
padding-bottom: 24rpx;
}
.pair-page__status {
padding: 0 40rpx;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.pair-page__title {
font-size: 40rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
text-align: center;
}
.pair-page__desc {
font-size: 26rpx;
color: #98a2b3;
line-height: 1.65;
text-align: center;
padding: 0 24rpx;
}
.pair-page__progress-block {
margin: 0 32rpx 24rpx;
padding: 28rpx 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.pair-page__progress-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.pair-page__progress-label {
font-size: 28rpx;
color: $color-text;
font-weight: 500;
}
.pair-page__progress-percent {
font-size: 28rpx;
font-weight: 600;
color: $color-primary;
}
.pair-page__progress-track {
height: 16rpx;
border-radius: 999rpx;
background: #eef1f4;
overflow: hidden;
margin-bottom: 16rpx;
}
.pair-page__progress-fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, #45d685 0%, #21c25e 100%);
transition: width 0.4s ease;
}
.pair-page__progress-tip {
font-size: 24rpx;
color: #98a2b3;
line-height: 1.5;
}
.pair-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
}
.pair-page__cancel {
height: 96rpx;
border-radius: 24rpx;
background: #fff;
border: 2rpx solid #e8eaed;
display: flex;
align-items: center;
justify-content: center;
}
.pair-page__cancel-text {
font-size: 32rpx;
color: #666;
font-weight: 500;
}

View File

@@ -0,0 +1,13 @@
export type PairCheckStatus = 'done' | 'syncing' | 'pending'
export interface PairCheckItem {
id: string
title: string
status: PairCheckStatus
tag: string
}
export interface PairDeviceInfo {
name: string
serialMasked: string
}

View File

@@ -0,0 +1,81 @@
import { onUnmounted, ref, watch } from 'vue'
import type { PairCheckItem, PairDeviceInfo } from './pair.types'
const DEFAULT_DEVICE: PairDeviceInfo = {
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
}
export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
const currentStep = 3
const device = ref<PairDeviceInfo>({
...DEFAULT_DEVICE,
...initial,
})
const progress = ref(65)
const etaSeconds = ref(15)
const checkItems = ref<PairCheckItem[]>([
{ id: 'signal', title: '蓝牙信号强度', status: 'done', tag: '良好' },
{ id: 'credential', title: '设备凭证校验', status: 'done', tag: '通过' },
{ id: 'network', title: '网络配置下发', status: 'syncing', tag: '同步中' },
])
let etaTick = 0
const tick = setInterval(() => {
if (progress.value < 100) {
progress.value = Math.min(100, progress.value + 1)
}
etaTick += 1
if (etaTick % 2 === 0 && etaSeconds.value > 0 && progress.value < 100) {
etaSeconds.value = Math.max(0, etaSeconds.value - 1)
}
if (progress.value >= 100) {
checkItems.value = checkItems.value.map((item) =>
item.id === 'network'
? { ...item, status: 'done', tag: '完成' }
: item,
)
}
}, 500)
let navigated = false
watch(progress, (value) => {
if (value < 100 || navigated) return
navigated = true
const d = device.value
const q = [
`name=${encodeURIComponent(d.name)}`,
`serial=${encodeURIComponent(d.serialMasked)}`,
].join('&')
setTimeout(() => {
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
}, 600)
})
onUnmounted(() => {
clearInterval(tick)
})
function onCancelPair() {
uni.showModal({
title: '取消配对',
content: '确定要取消当前设备的配对吗?',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
}
},
})
}
return {
currentStep,
device,
progress,
etaSeconds,
checkItems,
onCancelPair,
}
}

View File

@@ -0,0 +1,102 @@
<template>
<view
class="scan-device-item"
:class="{
'scan-device-item--selected': selected,
'scan-device-item--disabled': !device.canAdd,
}"
@tap="emit('select', device.id)"
>
<image
class="scan-device-item__thumb"
:src="thumbSrc"
mode="aspectFill"
/>
<view class="scan-device-item__info">
<text class="scan-device-item__name">{{ device.name }}</text>
<text class="scan-device-item__serial">设备编号:{{ device.serialMasked }}</text>
</view>
<image
class="scan-device-item__action"
:src="selected ? staticAssets.iconScanDeviceAction : staticAssets.iconScanDeviceActionDisabled"
mode="aspectFit"
@tap.stop="emit('action', device)"
/>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import type { ScannedDeviceItem } from '../../scan.types'
withDefaults(
defineProps<{
device: ScannedDeviceItem
selected: boolean
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
)
const emit = defineEmits<{
select: [id: string]
action: [device: ScannedDeviceItem]
}>()
</script>
<style lang="scss" scoped>
.scan-device-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 20rpx;
border: 2rpx solid transparent;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
.scan-device-item--selected {
border-color: $color-primary;
box-shadow: 0 4rpx 24rpx rgba(30, 198, 113, 0.12);
}
.scan-device-item--disabled {
opacity: 0.92;
}
.scan-device-item__thumb {
width: 96rpx;
height: 96rpx;
border-radius: 16rpx;
background: #f0f2f5;
flex-shrink: 0;
}
.scan-device-item__info {
flex: 1;
min-width: 0;
}
.scan-device-item__name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.scan-device-item__serial {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
}
.scan-device-item__action {
width: 64rpx;
height: 64rpx;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<view class="scan-help-tip">
<view class="scan-help-tip__icon-wrap">
<view class="scan-help-tip__bulb" />
</view>
<view class="scan-help-tip__content">
<text class="scan-help-tip__title">扫描不到设备?</text>
<text class="scan-help-tip__line">1. 确认目标设备已开机</text>
<text class="scan-help-tip__line">2. 确保设备处于配对/可发现模式</text>
<text class="scan-help-tip__line">3. 检查手机蓝牙已开启</text>
</view>
</view>
</template>
<style lang="scss" scoped>
.scan-help-tip {
display: flex;
gap: 20rpx;
margin: 8rpx 32rpx 0;
padding: 28rpx 24rpx;
background: #fff8f0;
border-radius: 20rpx;
box-sizing: border-box;
}
.scan-help-tip__icon-wrap {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 4rpx;
}
.scan-help-tip__bulb {
width: 36rpx;
height: 36rpx;
border-radius: 50% 50% 45% 45%;
background: #ffc940;
position: relative;
box-shadow: 0 0 0 6rpx rgba(255, 201, 64, 0.25);
&::after {
content: '';
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%);
width: 20rpx;
height: 8rpx;
background: #e8a82a;
border-radius: 0 0 4rpx 4rpx;
}
}
.scan-help-tip__content {
flex: 1;
min-width: 0;
}
.scan-help-tip__title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #e67a2e;
margin-bottom: 12rpx;
}
.scan-help-tip__line {
display: block;
font-size: 24rpx;
color: #c4845a;
line-height: 1.55;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<view class="scan-hero">
<view class="scan-hero__visual">
<view class="scan-hero__glow" aria-hidden="true" />
<view class="scan-hero__pulses" aria-hidden="true">
<view
v-for="i in 3"
:key="i"
class="scan-hero__pulse"
:style="{ animationDelay: `${(i - 1) * 0.9}s` }"
/>
</view>
<image
class="scan-hero__illus"
:src="staticAssets.iconScanDevice"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.scan-hero {
display: flex;
justify-content: center;
padding: 8rpx 0 28rpx;
}
.scan-hero__visual {
position: relative;
width: 320rpx;
height: 320rpx;
display: flex;
align-items: center;
justify-content: center;
}
.scan-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 240rpx;
height: 240rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.35) 0%,
rgba(201, 239, 217, 0.12) 45%,
transparent 70%
);
animation: scan-hero-glow 2.8s ease-in-out infinite;
}
.scan-hero__pulses {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.scan-hero__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 140rpx;
height: 140rpx;
border-radius: 50%;
border: 3rpx solid rgba(33, 194, 94, 0.28);
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, -50%) scale(1);
animation: scan-hero-pulse 2.7s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
}
.scan-hero__illus {
position: relative;
z-index: 2;
width: 240rpx;
height: 280rpx;
flex-shrink: 0;
}
@keyframes scan-hero-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
70% {
opacity: 0.08;
}
100% {
transform: translate(-50%, -50%) scale(2.65);
opacity: 0;
}
}
@keyframes scan-hero-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.85;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="scan-page">
<app-navbar title="扫描设备" />
<scroll-view class="scan-page__scroll" scroll-y>
<view class="scan-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<scan-hero />
<view class="scan-page__status">
<text class="scan-page__title">正在扫描附近设备...</text>
<view class="scan-page__hint">
<view class="scan-page__hint-dot" />
<text class="scan-page__hint-text">{{ statusHint }}</text>
</view>
</view>
<view class="scan-page__section-head">
<text class="scan-page__section-title">发现的设备</text>
<text class="scan-page__section-timer">
{{ scanning ? `${secondsLeft}秒后停止` : '扫描已停止' }}
</text>
</view>
<view class="scan-page__list">
<scan-device-item
v-for="item in devices"
:key="item.id"
:device="item"
:selected="selectedId === item.id"
@select="onSelectDevice"
@action="onDeviceAction"
/>
</view>
<scan-help-tip />
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import ScanDeviceItem from './components/scan-device-item/index.vue'
import ScanHelpTip from './components/scan-help-tip/index.vue'
import ScanHero from './components/scan-hero/index.vue'
import { useScanDevice } from './use-scan-device'
const {
currentStep,
devices,
selectedId,
secondsLeft,
scanning,
onSelectDevice,
onDeviceAction,
} = useScanDevice()
const statusHint = computed(() => {
const count = devices.value.length
if (scanning.value) {
return `已发现 ${count} 台设备,继续扫描中`
}
return `已发现 ${count} 台设备,扫描已停止`
})
</script>
<style lang="scss" scoped>
@import './scan.scss';
</style>

View File

@@ -0,0 +1,78 @@
.scan-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.scan-page__scroll {
flex: 1;
height: 0;
}
.scan-page__body {
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.scan-page__status {
padding: 0 40rpx;
margin-bottom: 36rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.scan-page__title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
line-height: 1.4;
text-align: center;
}
.scan-page__hint {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.scan-page__hint-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #4a9eff;
flex-shrink: 0;
}
.scan-page__hint-text {
font-size: 26rpx;
color: #98a2b3;
line-height: 1.5;
text-align: center;
}
.scan-page__section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx 16rpx;
}
.scan-page__section-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.scan-page__section-timer {
font-size: 24rpx;
color: #98a2b3;
}
.scan-page__list {
padding: 0 32rpx;
}

View File

@@ -0,0 +1,7 @@
export interface ScannedDeviceItem {
id: string
name: string
serialMasked: string
/** 是否可添加(未知设备为 false 时箭头置灰) */
canAdd: boolean
}

View File

@@ -0,0 +1,69 @@
import { onUnmounted, ref } from 'vue'
import type { ScannedDeviceItem } from './scan.types'
const MOCK_DEVICES: ScannedDeviceItem[] = [
{
id: 'z2-purifier',
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
canAdd: true,
},
{
id: 'unknown',
name: '未知设备',
serialMasked: '****7C91',
canAdd: false,
},
]
const SCAN_SECONDS = 20
export function useScanDevice() {
const currentStep = 2
const devices = ref<ScannedDeviceItem[]>(MOCK_DEVICES)
const selectedId = ref(MOCK_DEVICES[0]?.id ?? '')
const secondsLeft = ref(SCAN_SECONDS)
const scanning = ref(true)
const timer = setInterval(() => {
if (secondsLeft.value <= 0) {
scanning.value = false
return
}
secondsLeft.value -= 1
if (secondsLeft.value === 0) {
scanning.value = false
}
}, 1000)
onUnmounted(() => {
clearInterval(timer)
})
function onSelectDevice(id: string) {
selectedId.value = id
}
function onDeviceAction(device: ScannedDeviceItem) {
selectedId.value = device.id
if (!device.canAdd) {
uni.showToast({ title: '暂无法添加该设备', icon: 'none' })
return
}
const q = [
`name=${encodeURIComponent(device.name)}`,
`serial=${encodeURIComponent(device.serialMasked)}`,
].join('&')
uni.navigateTo({ url: `/pages/add-device/pair/index?${q}` })
}
return {
currentStep,
devices,
selectedId,
secondsLeft,
scanning,
onSelectDevice,
onDeviceAction,
}
}

View File

@@ -0,0 +1,48 @@
import { staticAssets } from '@/utils/static-assets'
import type { BindFeatureItem, BindStepItem } from './add-device.types'
export const BIND_STEPS: BindStepItem[] = [
{ id: 1, label: '开启蓝牙' },
{ id: 2, label: '扫描设备' },
{ id: 3, label: '配对连接' },
{ id: 4, label: '完成配置' },
]
export const BIND_FEATURES: BindFeatureItem[] = [
{
id: 'discover',
title: '发现附近设备',
desc: '通过蓝牙扫描发现您周围的电子智能设备',
icon: staticAssets.iconBindDiscover,
},
{
id: 'pair',
title: '快速配对连接',
desc: '一键完成设备配对,无需复杂的网络配置',
icon: staticAssets.iconBindPair,
},
{
id: 'secure',
title: '本地安全控制',
desc: '蓝牙直连控制,数据加密传输,保护您的隐私',
icon: staticAssets.iconBindSecure,
},
]
export function useAddDevice() {
const currentStep = 1
function onOpenBluetooth() {
uni.navigateTo({ url: '/pages/add-device/scan/index' })
}
function onBindLater() {
uni.navigateBack()
}
return {
currentStep,
onOpenBluetooth,
onBindLater,
}
}

View File

@@ -0,0 +1,11 @@
import { request } from '@/api/request'
import type { AgreementDetail, AgreementType } from './agreement.types'
/** GET /app/auth/agreement/detail — 协议详情 */
export function fetchAgreementDetail(type: AgreementType) {
return request<AgreementDetail>({
url: '/app/auth/agreement/detail',
method: 'POST',
data: { type },
})
}

View File

@@ -0,0 +1,10 @@
export type AgreementType = 'service' | 'privacy'
/** /app/auth/agreement/detail 返回 data 结构 */
export interface AgreementDetail {
agreementNo?: string
name?: string
versionName?: string
content?: string
publishTime?: string
}

View File

@@ -0,0 +1,52 @@
<template>
<view class="agreement-page">
<app-navbar :title="title" />
<scroll-view class="agreement-page__scroll" scroll-y>
<view class="agreement-page__content">
<view v-if="loading" class="agreement-page__loading">
<text>加载中...</text>
</view>
<text v-else class="agreement-page__text">{{ content }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { useAgreement } from './use-agreement'
const { title, content, loading } = useAgreement()
</script>
<style lang="scss" scoped>
.agreement-page {
min-height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
}
.agreement-page__scroll {
flex: 1;
height: calc(100vh - 88rpx);
}
.agreement-page__content {
padding: 32rpx $page-padding-x 64rpx;
min-height: 200rpx;
}
.agreement-page__loading {
padding: 48rpx 0;
text-align: center;
font-size: 28rpx;
color: $color-text-secondary;
}
.agreement-page__text {
font-size: 28rpx;
color: $color-text;
line-height: 1.8;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,51 @@
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getErrorMessage } from '@/utils/api-error'
import { fetchAgreementDetail } from './agreement.api'
import type { AgreementDetail, AgreementType } from './agreement.types'
const DEFAULT_TITLE: Record<AgreementType, string> = {
service: '用户服务协议',
privacy: '隐私政策',
}
function normalizeDetail(type: AgreementType, data: AgreementDetail) {
const title = data.name?.trim() || DEFAULT_TITLE[type]
const content = data.content?.trim() || ''
return { title, content }
}
export function useAgreement() {
const type = ref<AgreementType>('service')
const title = ref(DEFAULT_TITLE.service)
const content = ref('')
const loading = ref(false)
async function loadDetail(agreementType: AgreementType) {
loading.value = true
title.value = DEFAULT_TITLE[agreementType]
content.value = ''
try {
const data = await fetchAgreementDetail(agreementType)
const normalized = normalizeDetail(agreementType, data ?? {})
title.value = normalized.title
content.value = normalized.content
if (!content.value) {
uni.showToast({ title: '协议内容为空', icon: 'none' })
}
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '加载协议失败'), icon: 'none' })
} finally {
loading.value = false
}
}
onLoad((query) => {
const t = query?.type as AgreementType
const agreementType = t === 'privacy' || t === 'service' ? t : 'service'
type.value = agreementType
loadDetail(agreementType)
})
return { title, content, loading }
}

View File

@@ -0,0 +1,17 @@
import { request } from '@/api/request'
import type { ForgetPasswordResetBody } from '@/types/api'
import type { ChangePasswordForm } from './change-password.types'
/** POST /app/auth/forgetPassword/doReset — 凭重置令牌设置新密码 */
export function resetPasswordByToken(data: ChangePasswordForm) {
const body: ForgetPasswordResetBody = {
resetToken: data.resetToken,
newPassword: data.newPassword,
confirmPassword: data.confirmPassword,
}
return request<void>({
url: '/app/auth/forgetPassword/doReset',
method: 'POST',
data: body,
})
}

View File

@@ -0,0 +1,5 @@
export interface ChangePasswordForm {
newPassword: string
confirmPassword: string
resetToken: string
}

View File

@@ -0,0 +1,49 @@
<template>
<view class="auth-form-page">
<app-navbar title="修改密码" />
<view class="auth-form-page__body">
<text class="auth-form-page__heading">修改密码</text>
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
<base-input v-model="confirmPassword" icon="lock" type="password" placeholder="确认密码" />
<view class="auth-form-page__btn">
<primary-button text="确认修改" @click="submit" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useChangePassword } from './use-change-password'
const { password, confirmPassword, submit } = useChangePassword()
</script>
<style lang="scss" scoped>
.auth-form-page {
min-height: 100vh;
background: $color-bg-page;
}
.auth-form-page__body {
padding: 32rpx $page-padding-x 48rpx;
}
.auth-form-page__heading {
display: block;
font-size: 44rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 48rpx;
}
.auth-form-page__btn {
margin-top: 16rpx;
}
:deep(.base-input) {
border: none;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
</style>

View File

@@ -0,0 +1,55 @@
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getErrorMessage } from '@/utils/api-error'
import { isValidPassword } from '@/utils/validate'
import { resetPasswordByToken } from './change-password.api'
export function useChangePassword() {
const password = ref('')
const confirmPassword = ref('')
const resetToken = ref('')
const loading = ref(false)
onLoad((query) => {
const raw = (query?.resetToken as string) || ''
resetToken.value = raw ? decodeURIComponent(raw) : ''
if (!resetToken.value) {
uni.showToast({ title: '请先完成验证码校验', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
})
async function submit() {
if (!resetToken.value) {
uni.showToast({ title: '重置令牌无效', icon: 'none' })
return
}
if (!isValidPassword(password.value)) {
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
return
}
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码不一致', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
await resetPasswordByToken({
resetToken: resetToken.value,
newPassword: password.value,
confirmPassword: confirmPassword.value,
})
uni.showToast({ title: '修改成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/index' })
}, 800)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '修改失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
}
return { password, confirmPassword, submit }
}

View File

@@ -0,0 +1,118 @@
<template>
<view class="device-empty">
<view class="device-empty__state">
<view class="device-empty__halo">
<image
class="device-empty__icon"
:src="staticAssets.iconDeviceEmpty"
mode="aspectFit"
/>
</view>
<text class="device-empty__title">还没有添加设备</text>
</view>
<view class="device-empty__btn" @tap="$emit('bind')">
<text class="device-empty__btn-text">开始绑定</text>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
withDefaults(
defineProps<{
stepHint?: number
}>(),
{ stepHint: 0 },
)
defineEmits<{
bind: []
}>()
</script>
<style lang="scss" scoped>
.device-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 48rpx 56rpx;
width: 100%;
box-sizing: border-box;
}
/* 设计稿红框区域:图标 + 文案,固定高度 */
.device-empty__state {
width: 100%;
height: 480rpx;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 外圈浅绿光晕(设计稿 #C9EFD9) */
.device-empty__halo {
width: 220rpx;
height: 220rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
flex-shrink: 0;
background: radial-gradient(71% 71% at 50% 50%, #eaf7f0 0%, #f2faf6 62%, #f2faf600 74%);
}
.device-empty__icon {
width: 60rpx;
height: 60rpx;
}
.device-empty__title {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
line-height: 1.4;
}
.device-empty__btn {
width: 100%;
width: 335px;
height: 48px;
border-radius: 15rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0px 4px 14px 0px rgba(33, 194, 94, 0.28);
}
.device-empty__btn-text {
font-size: 32rpx;
font-weight: 500;
color: #fff;
}
.device-empty__badge {
position: absolute;
right: 32rpx;
width: 44rpx;
height: 44rpx;
background: #ffc940;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid #fff;
}
.device-empty__badge-num {
font-size: 24rpx;
font-weight: 700;
color: #fff;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<view class="device-header" :style="{ paddingTop: headerPaddingTop + 'px' }">
<view class="device-header__bar">
<view class="device-header__home" @tap="$emit('home')">
<image
class="device-header__home-icon"
:src="staticAssets.iconHomeSelector"
mode="aspectFit"
/>
<text class="device-header__home-text">{{ homeName }}</text>
<image
class="device-header__arrow"
:src="staticAssets.iconHomeArrow"
mode="aspectFit"
/>
</view>
<view class="device-header__notify-wrap" @tap="$emit('notify')">
<image
class="device-header__notify-icon"
:src="staticAssets.iconNotify"
mode="aspectFit"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
defineProps<{
homeName: string
}>()
defineEmits<{
home: []
notify: []
}>()
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight ?? 0
/** 状态栏下再留一段间距;H5 无状态栏时用固定上内边距 */
const headerPaddingTop =
statusBarHeight > 0
? statusBarHeight + uni.upx2px(24)
: uni.upx2px(48)
</script>
<style lang="scss" scoped>
.device-header {
padding-left: 32rpx;
padding-right: 32rpx;
padding-bottom: 16rpx;
}
.device-header__bar {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.device-header__home {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 28rpx 12rpx 20rpx;
background: #EFFBF4;
border-radius: 40rpx;
border: 1px solid #C9EFD9;
}
.device-header__home-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.device-header__home-text {
font-size: 30rpx;
font-weight: 600;
color: #1fa85c;
}
.device-header__arrow {
width: 18rpx;
height: 18rpx;
flex-shrink: 0;
}
.device-header__notify-icon {
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<view class="hot-devices">
<view class="hot-devices__head">
<text class="hot-devices__title">热门设备</text>
<text class="hot-devices__link" @tap="$emit('mall')">去商城看看 &gt;</text>
</view>
<scroll-view class="hot-devices__scroll" scroll-x :show-scrollbar="false">
<view class="hot-devices__list">
<view v-for="item in items" :key="item.id" class="hot-devices__card">
<view class="hot-devices__img" />
<text class="hot-devices__name">{{ item.name }}</text>
<text class="hot-devices__desc">{{ item.desc }}</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import type { HotDeviceItem } from '../../device.types'
defineProps<{
items: HotDeviceItem[]
}>()
defineEmits<{
mall: []
}>()
</script>
<style lang="scss" scoped>
.hot-devices {
padding: 0 32rpx 32rpx;
}
.hot-devices__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.hot-devices__title {
font-size: 32rpx;
font-weight: 600;
color: $color-text;
}
.hot-devices__link {
font-size: 26rpx;
color: $color-text-secondary;
}
.hot-devices__scroll {
width: 100%;
white-space: nowrap;
}
.hot-devices__list {
display: inline-flex;
gap: 24rpx;
padding-bottom: 8rpx;
}
.hot-devices__card {
display: inline-flex;
flex-direction: column;
width: 300rpx;
padding: 24rpx;
background: #fff;
border-radius: 24rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
vertical-align: top;
white-space: normal;
}
.hot-devices__img {
width: 100%;
height: 200rpx;
background: linear-gradient(145deg, #f0f2f5 0%, #e8eaed 100%);
border-radius: 16rpx;
margin-bottom: 20rpx;
}
.hot-devices__name {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.hot-devices__desc {
font-size: 22rpx;
color: $color-text-secondary;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<scroll-view class="room-tabs" scroll-x :show-scrollbar="false">
<view class="room-tabs__inner">
<view
v-for="room in rooms"
:key="room.id"
class="room-tabs__item"
:class="{ 'room-tabs__item--active': room.id === activeId }"
@tap="$emit('select', room.id)"
>
<text class="room-tabs__text">{{ room.name }}</text>
</view>
<view class="room-tabs__item room-tabs__item--add" @tap="$emit('add')">
<text class="room-tabs__text">+ 添加</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import type { RoomTab } from '../../device.types'
defineProps<{
rooms: RoomTab[]
activeId: string
}>()
defineEmits<{
select: [id: string]
add: []
}>()
</script>
<style lang="scss" scoped>
.room-tabs {
width: 100%;
white-space: nowrap;
padding: 8rpx 32rpx 24rpx;
}
.room-tabs__inner {
display: inline-flex;
align-items: center;
gap: 16rpx;
}
.room-tabs__item {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 16rpx 36rpx;
border-radius: 40rpx;
background: transparent;
}
.room-tabs__item--active {
background: $color-primary;
}
.room-tabs__item--active .room-tabs__text {
color: #fff;
font-weight: 500;
}
.room-tabs__item--add .room-tabs__text {
color: $color-text-secondary;
}
.room-tabs__text {
font-size: 28rpx;
color: $color-text;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,6 @@
.device-page {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}

View File

@@ -0,0 +1,10 @@
export interface RoomTab {
id: string
name: string
}
export interface HotDeviceItem {
id: string
name: string
desc: string
}

View File

@@ -0,0 +1,47 @@
<template>
<view class="device-page">
<device-header
:home-name="homeName"
@home="onHomePicker"
@notify="onNotify"
/>
<room-tabs
:rooms="rooms"
:active-id="activeRoomId"
@select="selectRoom"
@add="onAddRoom"
/>
<device-empty :step-hint="bindStepHint" @bind="onStartBind" />
<hot-devices :items="hotDevices" @mall="onGoMall" />
<app-tab-bar :current="tabCurrent" />
</view>
</template>
<script setup lang="ts">
import { useTabPage } from '@/hooks/use-tab-page'
import DeviceEmpty from './components/device-empty/index.vue'
import DeviceHeader from './components/device-header/index.vue'
import HotDevices from './components/hot-devices/index.vue'
import RoomTabs from './components/room-tabs/index.vue'
import { useDevice } from './use-device'
const { tabCurrent } = useTabPage('device')
const {
homeName,
rooms,
activeRoomId,
hotDevices,
bindStepHint,
selectRoom,
onAddRoom,
onStartBind,
onGoMall,
onNotify,
onHomePicker,
} = useDevice()
</script>
<style lang="scss" scoped>
@import './device.scss';
</style>

View File

@@ -0,0 +1,66 @@
import { ref } from 'vue'
import type { HotDeviceItem, RoomTab } from './device.types'
const DEFAULT_ROOMS: RoomTab[] = [
{ id: 'all', name: '全部' },
{ id: 'living', name: '客厅' },
]
const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
{
id: 'z2',
name: 'Z2 空气净化器',
desc: '高效除醛 · 智能检测',
},
{
id: 'lamp',
name: '智能护眼台灯',
desc: '无频闪 · 多场景模式',
},
]
export function useDevice() {
const homeName = ref('我的家')
const rooms = ref<RoomTab[]>(DEFAULT_ROOMS)
const activeRoomId = ref('all')
const hotDevices = ref<HotDeviceItem[]>(DEFAULT_HOT_DEVICES)
const bindStepHint = ref(3)
function selectRoom(id: string) {
activeRoomId.value = id
}
function onAddRoom() {
uni.showToast({ title: '添加房间功能开发中', icon: 'none' })
}
function onStartBind() {
uni.navigateTo({ url: '/pages/add-device/index' })
}
function onGoMall() {
uni.showToast({ title: '商城功能开发中', icon: 'none' })
}
function onNotify() {
uni.showToast({ title: '消息功能开发中', icon: 'none' })
}
function onHomePicker() {
uni.showToast({ title: '切换家庭功能开发中', icon: 'none' })
}
return {
homeName,
rooms,
activeRoomId,
hotDevices,
bindStepHint,
selectRoom,
onAddRoom,
onStartBind,
onGoMall,
onNotify,
onHomePicker,
}
}

View File

@@ -0,0 +1,16 @@
import { request } from '@/api/request'
import type { ForgetPasswordVerifyBody, ForgetPasswordVerifyResult } from '@/types/api'
import type { ForgotPasswordForm } from './forgot-password.types'
/** POST /app/auth/forgetPassword/verifyCode — 校验手机号与验证码,返回重置令牌 */
export function verifyForgetPasswordCode(data: ForgotPasswordForm) {
const body: ForgetPasswordVerifyBody = {
phonenumber: data.phone.trim(),
smsCode: data.code.trim(),
}
return request<ForgetPasswordVerifyResult>({
url: '/app/auth/forgetPassword/verifyCode',
method: 'POST',
data: body,
})
}

View File

@@ -0,0 +1,4 @@
export interface ForgotPasswordForm {
phone: string
code: string
}

View File

@@ -0,0 +1,48 @@
<template>
<view class="auth-form-page">
<app-navbar title="忘记密码" />
<view class="auth-form-page__body">
<text class="auth-form-page__heading">忘记密码</text>
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
<template #suffix>
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
</template>
</base-input>
<view class="auth-form-page__btn">
<primary-button text="下一步" @click="nextStep" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useForgotPassword } from './use-forgot-password'
const { phone, code, counting, seconds, onSendCode, nextStep } = useForgotPassword()
</script>
<style lang="scss" scoped>
.auth-form-page {
min-height: 100vh;
background: $color-bg-page;
}
.auth-form-page__body {
padding: 32rpx $page-padding-x 48rpx;
}
.auth-form-page__heading {
display: block;
font-size: 44rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 48rpx;
}
.auth-form-page__btn {
margin-top: 16rpx;
}
</style>

View File

@@ -0,0 +1,47 @@
import { ref } from 'vue'
import { useSmsCode } from '@/hooks/use-sms-code'
import { getErrorMessage } from '@/utils/api-error'
import { isValidPhone, isValidSmsCode } from '@/utils/validate'
import { SMS_CODE_SCENE } from '@/types/api'
import { verifyForgetPasswordCode } from './forgot-password.api'
export function useForgotPassword() {
const phone = ref('')
const code = ref('')
const loading = ref(false)
const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.reset)
function onSendCode() {
sendCode(phone.value)
}
async function nextStep() {
if (!isValidPhone(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!isValidSmsCode(code.value)) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
const res = await verifyForgetPasswordCode({ phone: phone.value, code: code.value })
if (!res.resetToken) {
uni.showToast({ title: '验证失败,未获取重置令牌', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/change-password/index?resetToken=${encodeURIComponent(res.resetToken)}`,
})
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '验证失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
}
return { phone, code, counting, seconds, onSendCode, nextStep }
}

View File

@@ -1,41 +0,0 @@
<template>
<view class="content">
<image class="logo" src="/static/logo.png" />
<view class="text-area">
<text class="title">{{ title }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const title = ref('Hello')
</script>
<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
}
.text-area {
display: flex;
justify-content: center;
}
.title {
font-size: 36rpx;
color: #8f8f94;
}
</style>

69
src/pages/login/index.vue Normal file
View File

@@ -0,0 +1,69 @@
<template>
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="login-page__hero">
<brand-logo />
<view class="login-page__welcome">
<text class="login-page__title">欢迎使用</text>
<text class="login-page__subtitle">智能硬件管理平台,让你的家更智能、更健康</text>
</view>
</view>
<template v-if="mode === 'code'">
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
<template #suffix>
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
</template>
</base-input>
<view class="login-page__links login-page__links--single">
<text class="login-page__link" @tap="switchMode('password')">账号密码登录</text>
</view>
</template>
<template v-else>
<base-input v-model="account" icon="phone" placeholder="请输入账号" />
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
<view class="login-page__links">
<text class="login-page__link" @tap="switchMode('code')">验证码登录</text>
<text class="login-page__link" @tap="goForgotPassword">忘记密码?</text>
</view>
</template>
<view class="login-page__footer">
<view class="login-page__agreement">
<agreement-checkbox v-model="agreed" />
</view>
<primary-button text="登录" @click="submit" />
<view class="login-page__register">
<text class="login-page__link" @tap="goRegister">还没有账号?立即注册</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useLogin } from './use-login'
const statusBarHeight = ref(uni.getSystemInfoSync().statusBarHeight ?? 0)
const {
mode,
agreed,
phone,
code,
account,
password,
counting,
seconds,
switchMode,
onSendCode,
submit,
goForgotPassword,
goRegister,
} = useLogin()
</script>
<style lang="scss" scoped>
@import './login.scss';
</style>

View File

@@ -0,0 +1,29 @@
import { request } from '@/api/request'
import type { AuthTokenResult, LoginPasswordBody, LoginSmsBody } from '@/types/api'
import type { LoginCodeForm, LoginPasswordForm } from './login.types'
/** POST /app/auth/login/sms — 短信验证码登录 */
export function loginBySms(data: LoginCodeForm) {
const body: LoginSmsBody = {
phonenumber: data.phone.trim(),
smsCode: data.code.trim(),
}
return request<AuthTokenResult>({
url: '/app/auth/login/sms',
method: 'POST',
data: body,
})
}
/** POST /app/auth/login/password — 账号密码登录(账号可为用户名或手机号) */
export function loginByPassword(data: LoginPasswordForm) {
const body: LoginPasswordBody = {
account: data.account.trim(),
password: data.password,
}
return request<AuthTokenResult>({
url: '/app/auth/login/password',
method: 'POST',
data: body,
})
}

View File

@@ -0,0 +1,59 @@
.login-page {
min-height: 100vh;
background: #fff;
padding: 0 $page-padding-x 48rpx;
}
.login-page__hero {
padding-top: 120rpx;
margin-bottom: 64rpx;
}
.login-page__welcome {
margin-top: 80rpx;
}
.login-page__title {
display: block;
font-size: 48rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
}
.login-page__subtitle {
display: block;
font-size: 26rpx;
color: $color-text-secondary;
line-height: 1.5;
}
.login-page__links {
display: flex;
justify-content: space-between;
align-items: center;
margin: 8rpx 0 48rpx;
}
.login-page__links--single {
justify-content: flex-start;
}
.login-page__link {
font-size: 28rpx;
color: $color-link;
}
.login-page__footer {
margin-top: auto;
padding-top: 48rpx;
}
.login-page__agreement {
margin-bottom: 32rpx;
}
.login-page__register {
margin-top: 32rpx;
text-align: center;
}

View File

@@ -0,0 +1,11 @@
export type LoginMode = 'code' | 'password'
export interface LoginCodeForm {
phone: string
code: string
}
export interface LoginPasswordForm {
account: string
password: string
}

View File

@@ -0,0 +1,96 @@
import { ref } from 'vue'
import { useSmsCode } from '@/hooks/use-sms-code'
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
import { saveAuthToken } from '@/api/auth'
import { getErrorMessage } from '@/utils/api-error'
import { SMS_CODE_SCENE } from '@/types/api'
import { loginByPassword, loginBySms } from './login.api'
import type { LoginMode } from './login.types'
export function useLogin() {
const mode = ref<LoginMode>('code')
const agreed = ref(true)
const phone = ref('')
const code = ref('')
const account = ref('')
const password = ref('')
const loading = ref(false)
const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.login)
function switchMode(next: LoginMode) {
mode.value = next
}
function onSendCode() {
sendCode(phone.value)
}
async function submit() {
if (!agreed.value) {
uni.showToast({ title: '请先阅读并同意协议', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
if (mode.value === 'code') {
if (!isValidPhone(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!isValidSmsCode(code.value)) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
const smsRes = await loginBySms({ phone: phone.value, code: code.value })
saveAuthToken(smsRes)
} else {
if (!account.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return
}
if (!isValidPassword(password.value)) {
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
return
}
const pwdRes = await loginByPassword({
account: account.value,
password: password.value,
})
saveAuthToken(pwdRes)
}
uni.showToast({ title: '登录成功', icon: 'success' })
uni.reLaunch({ url: '/pages/device/index' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请检查信息'), icon: 'none' })
} finally {
loading.value = false
}
}
function goRegister() {
uni.navigateTo({ url: '/pages/register/index' })
}
function goForgotPassword() {
uni.navigateTo({ url: '/pages/forgot-password/index' })
}
return {
mode,
agreed,
phone,
code,
account,
password,
counting,
seconds,
switchMode,
onSendCode,
submit,
goRegister,
goForgotPassword,
}
}

44
src/pages/mine/index.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<view class="mine-page">
<view class="mine-page__placeholder">
<text class="mine-page__title">我的</text>
<text class="mine-page__hint">个人中心页面开发中</text>
</view>
<app-tab-bar :current="tabCurrent" />
</view>
</template>
<script setup lang="ts">
import { useTabPage } from '@/hooks/use-tab-page'
const { tabCurrent } = useTabPage('mine')
</script>
<style lang="scss" scoped>
.mine-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.mine-page__placeholder {
text-align: center;
}
.mine-page__title {
display: block;
font-size: 40rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 16rpx;
}
.mine-page__hint {
font-size: 28rpx;
color: $color-text-secondary;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="auth-form-page">
<app-navbar title="注册" />
<view class="auth-form-page__body">
<text class="auth-form-page__heading">注册用户</text>
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
<template #suffix>
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
</template>
</base-input>
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
<base-input v-model="confirmPassword" icon="lock" type="password" placeholder="请确认密码" />
<view class="auth-form-page__btn">
<primary-button text="立即注册" @click="submit" />
</view>
<view class="auth-form-page__agreement">
<agreement-checkbox v-model="agreed" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useRegister } from './use-register'
const {
phone,
code,
password,
confirmPassword,
agreed,
counting,
seconds,
onSendCode,
submit,
} = useRegister()
</script>
<style lang="scss" scoped>
.auth-form-page {
min-height: 100vh;
background: $color-bg-page;
}
.auth-form-page__body {
padding: 32rpx $page-padding-x 48rpx;
}
.auth-form-page__heading {
display: block;
font-size: 44rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 48rpx;
}
.auth-form-page__btn {
margin-top: 16rpx;
margin-bottom: 40rpx;
}
.auth-form-page__agreement {
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,18 @@
import { request } from '@/api/request'
import type { RegisterBody } from '@/types/api'
import type { RegisterForm } from './register.types'
/** POST /app/auth/register */
export function registerUser(data: RegisterForm) {
const body: RegisterBody = {
phonenumber: data.phone.trim(),
smsCode: data.code.trim(),
password: data.password,
confirmPassword: data.confirmPassword,
}
return request<void>({
url: '/app/auth/register',
method: 'POST',
data: body,
})
}

View File

@@ -0,0 +1,6 @@
export interface RegisterForm {
phone: string
code: string
password: string
confirmPassword: string
}

View File

@@ -0,0 +1,73 @@
import { ref } from 'vue'
import { useSmsCode } from '@/hooks/use-sms-code'
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
import { SMS_CODE_SCENE } from '@/types/api'
import { registerUser } from './register.api'
export function useRegister() {
const phone = ref('')
const code = ref('')
const password = ref('')
const confirmPassword = ref('')
const agreed = ref(false)
const loading = ref(false)
const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.register)
function onSendCode() {
sendCode(phone.value)
}
async function submit() {
if (!agreed.value) {
uni.showToast({ title: '请先阅读并同意协议', icon: 'none' })
return
}
if (!isValidPhone(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!isValidSmsCode(code.value)) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
if (!isValidPassword(password.value)) {
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
return
}
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码不一致', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
await registerUser({
phone: phone.value,
code: code.value,
password: password.value,
confirmPassword: confirmPassword.value,
})
uni.showToast({ title: '注册成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 800)
} catch {
uni.showToast({ title: '注册失败,请稍后重试', icon: 'none' })
} finally {
loading.value = false
}
}
return {
phone,
code,
password,
confirmPassword,
agreed,
counting,
seconds,
onSendCode,
submit,
}
}

View File

@@ -0,0 +1,24 @@
import type { App, Component } from 'vue'
import AgreementCheckbox from '@/components/agreement-checkbox/index.vue'
import AppNavbar from '@/components/app-navbar/index.vue'
import BaseInput from '@/components/base-input/index.vue'
import BrandLogo from '@/components/brand-logo/index.vue'
import PrimaryButton from '@/components/primary-button/index.vue'
import SmsCodeBtn from '@/components/sms-code-btn/index.vue'
import AppTabBar from '@/components/app-tab-bar/index.vue'
const globalComponents: Record<string, Component> = {
'app-tab-bar': AppTabBar,
'agreement-checkbox': AgreementCheckbox,
'app-navbar': AppNavbar,
'base-input': BaseInput,
'brand-logo': BrandLogo,
'primary-button': PrimaryButton,
'sms-code-btn': SmsCodeBtn,
}
export function registerGlobalComponents(app: App) {
Object.entries(globalComponents).forEach(([name, component]) => {
app.component(name, component)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

BIN
src/static/icon-code@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

BIN
src/static/tab-device.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

BIN
src/static/tab-mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

BIN
src/static/图标 4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

BIN
src/static/图标 8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

BIN
src/static/图标 9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

BIN
src/static/组 27.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

BIN
src/static/组 281@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/static/组 286@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/static/组 291@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/static/组 41@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
src/static/组 468@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src/static/组 476@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/static/组 600@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/static/组 9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

BIN
src/static/蓝牙-F.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
src/static/路径.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

13
src/styles/index.scss Normal file
View File

@@ -0,0 +1,13 @@
@import './variables.scss';
page {
background-color: $color-bg-page;
color: $color-text;
font-size: 28rpx;
box-sizing: border-box;
}
view,
text {
box-sizing: border-box;
}

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