diff --git a/.cursor/rules/src-directory-structure.mdc b/.cursor/rules/src-directory-structure.mdc new file mode 100644 index 0000000..1d452c5 --- /dev/null +++ b/.cursor/rules/src-directory-structure.mdc @@ -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//`) + +可就近存放该页专属内容: + +| 文件 | 说明 | +|------|------| +| `index.vue` | 页面入口 | +| `.types.ts` | 仅本页使用的类型 | +| `use-.ts` | 仅本页使用的 hooks | +| `.api.ts` | 仅本页的接口函数(调用 `src/api/request.ts`,不重复写拦截) | +| `.scss` | 本页独立样式(可选) | +| `components/` | 页面私有子组件;子组件目录内可有 `types.ts` | + +命名示例(页面 `home`):`home.types.ts`、`use-home.ts`、`home.api.ts`。 + +## 局部 vs 全局 + +- 仅单页使用 → 放在 `pages//` 下就近管理 +- 多页复用 → `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 +``` + +新建与重构时:先判断是否多页复用,再选页面内就近或顶层全局。 diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..45953e7 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=http://192.168.0.222:8082 +VITE_API_PROXY_TARGET=http://192.168.0.222:8082 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9264453 --- /dev/null +++ b/.env.example @@ -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 diff --git a/package-lock.json b/package-lock.json index a6a6b12..15c4913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003", "@vue/runtime-core": "^3.4.21", "@vue/tsconfig": "^0.1.3", + "sass": "^1.105.1", "typescript": "^4.9.4", "vite": "5.2.8", "vue-tsc": "^1.0.24" @@ -4664,6 +4665,312 @@ "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": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", @@ -7075,6 +7382,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": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz", @@ -8123,6 +8440,13 @@ ], "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": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz", @@ -11027,6 +11351,13 @@ "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": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", @@ -12134,6 +12465,57 @@ "dev": true, "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": { "version": "1.6.1", "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", diff --git a/package.json b/package.json index 248b25d..2871fdc 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,9 @@ "@dcloudio/uni-cli-shared": "3.0.0-5020420260813003", "@dcloudio/uni-stacktracey": "3.0.0-5020420260813003", "@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003", - "@vue/tsconfig": "^0.1.3", "@vue/runtime-core": "^3.4.21", + "@vue/tsconfig": "^0.1.3", + "sass": "^1.105.1", "typescript": "^4.9.4", "vite": "5.2.8", "vue-tsc": "^1.0.24" diff --git a/src/App.vue b/src/App.vue index 6086575..c366687 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,4 +10,6 @@ onHide(() => { console.log("App Hide"); }); - + diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..ad573a7 --- /dev/null +++ b/src/api/auth.ts @@ -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({ + 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 +} diff --git a/src/api/request.ts b/src/api/request.ts new file mode 100644 index 0000000..d80c520 --- /dev/null +++ b/src/api/request.ts @@ -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 { + url: string +} + +export function request(options: RequestOptions): Promise { + 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 & Record + if (!isApiSuccess(body)) { + reject( + new ApiError(body.msg || body.message || '请求失败', body.code), + ) + return + } + + resolve(unwrapApiData(body)) + }, + fail: (err) => { + reject(new ApiError(err.errMsg || '网络异常')) + }, + }) + }) +} diff --git a/src/components/agreement-checkbox/index.vue b/src/components/agreement-checkbox/index.vue new file mode 100644 index 0000000..4189bf3 --- /dev/null +++ b/src/components/agreement-checkbox/index.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/app-navbar/index.vue b/src/components/app-navbar/index.vue new file mode 100644 index 0000000..75daa61 --- /dev/null +++ b/src/components/app-navbar/index.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/components/app-tab-bar/index.vue b/src/components/app-tab-bar/index.vue new file mode 100644 index 0000000..6401b3b --- /dev/null +++ b/src/components/app-tab-bar/index.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/src/components/base-input/index.vue b/src/components/base-input/index.vue new file mode 100644 index 0000000..c7f860c --- /dev/null +++ b/src/components/base-input/index.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/src/components/brand-logo/index.vue b/src/components/brand-logo/index.vue new file mode 100644 index 0000000..ff4632e --- /dev/null +++ b/src/components/brand-logo/index.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/components/primary-button/index.vue b/src/components/primary-button/index.vue new file mode 100644 index 0000000..9478057 --- /dev/null +++ b/src/components/primary-button/index.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/components/sms-code-btn/index.vue b/src/components/sms-code-btn/index.vue new file mode 100644 index 0000000..aa3572a --- /dev/null +++ b/src/components/sms-code-btn/index.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/hooks/use-countdown.ts b/src/hooks/use-countdown.ts new file mode 100644 index 0000000..180a335 --- /dev/null +++ b/src/hooks/use-countdown.ts @@ -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 | 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 } +} diff --git a/src/hooks/use-sms-code.ts b/src/hooks/use-sms-code.ts new file mode 100644 index 0000000..6adc925 --- /dev/null +++ b/src/hooks/use-sms-code.ts @@ -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 } +} diff --git a/src/hooks/use-tab-page.ts b/src/hooks/use-tab-page.ts new file mode 100644 index 0000000..aef0ab3 --- /dev/null +++ b/src/hooks/use-tab-page.ts @@ -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(tab) + + onShow(() => { + tabCurrent.value = tab + }) + + return { tabCurrent } +} diff --git a/src/main.ts b/src/main.ts index b12c1ee..5c4de56 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,10 @@ import { createSSRApp } from "vue"; import App from "./App.vue"; +import { registerGlobalComponents } from "./plugins/register-global-components"; + export function createApp() { const app = createSSRApp(App); + registerGlobalComponents(app); return { app, }; diff --git a/src/pages.json b/src/pages.json index 1aea851..ab1ff0e 100644 --- a/src/pages.json +++ b/src/pages.json @@ -1,16 +1,93 @@ { - "pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages - { - "path": "pages/index/index", - "style": { - "navigationBarTitleText": "uni-app" - } - } - ], - "globalStyle": { - "navigationBarTextStyle": "black", - "navigationBarTitleText": "uni-app", - "navigationBarBackgroundColor": "#F8F8F8", - "backgroundColor": "#F8F8F8" - } + "easycom": { + "autoscan": true, + "custom": { + "^([a-z][a-z0-9]*(?:-[a-z0-9]+)+)$": "@/components/$1/index.vue" + } + }, + "pages": [ + { + "path": "pages/login/index", + "style": { + "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": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "虫子智家", + "navigationBarBackgroundColor": "#F7F8FA", + "backgroundColor": "#F7F8FA" + } } diff --git a/src/pages/add-device/add-device.scss b/src/pages/add-device/add-device.scss new file mode 100644 index 0000000..4d638c0 --- /dev/null +++ b/src/pages/add-device/add-device.scss @@ -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; +} diff --git a/src/pages/add-device/add-device.types.ts b/src/pages/add-device/add-device.types.ts new file mode 100644 index 0000000..f967504 --- /dev/null +++ b/src/pages/add-device/add-device.types.ts @@ -0,0 +1,11 @@ +export interface BindStepItem { + id: number + label: string +} + +export interface BindFeatureItem { + id: string + title: string + desc: string + icon: string +} diff --git a/src/pages/add-device/complete/complete.scss b/src/pages/add-device/complete/complete.scss new file mode 100644 index 0000000..45837e1 --- /dev/null +++ b/src/pages/add-device/complete/complete.scss @@ -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; +} diff --git a/src/pages/add-device/complete/components/complete-success-hero/index.vue b/src/pages/add-device/complete/components/complete-success-hero/index.vue new file mode 100644 index 0000000..dbd92f2 --- /dev/null +++ b/src/pages/add-device/complete/components/complete-success-hero/index.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/pages/add-device/complete/index.vue b/src/pages/add-device/complete/index.vue new file mode 100644 index 0000000..7b1a961 --- /dev/null +++ b/src/pages/add-device/complete/index.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/pages/add-device/complete/use-complete-device.ts b/src/pages/add-device/complete/use-complete-device.ts new file mode 100644 index 0000000..83929ac --- /dev/null +++ b/src/pages/add-device/complete/use-complete-device.ts @@ -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, + } +} diff --git a/src/pages/add-device/components/bind-bluetooth-hero/index.vue b/src/pages/add-device/components/bind-bluetooth-hero/index.vue new file mode 100644 index 0000000..c37b81d --- /dev/null +++ b/src/pages/add-device/components/bind-bluetooth-hero/index.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/pages/add-device/components/bind-feature-list/index.vue b/src/pages/add-device/components/bind-feature-list/index.vue new file mode 100644 index 0000000..1f686b1 --- /dev/null +++ b/src/pages/add-device/components/bind-feature-list/index.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/pages/add-device/components/bind-steps/index.vue b/src/pages/add-device/components/bind-steps/index.vue new file mode 100644 index 0000000..45d38d6 --- /dev/null +++ b/src/pages/add-device/components/bind-steps/index.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/pages/add-device/index.vue b/src/pages/add-device/index.vue new file mode 100644 index 0000000..490080d --- /dev/null +++ b/src/pages/add-device/index.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/src/pages/add-device/pair/components/pair-check-list/index.vue b/src/pages/add-device/pair/components/pair-check-list/index.vue new file mode 100644 index 0000000..0b5826a --- /dev/null +++ b/src/pages/add-device/pair/components/pair-check-list/index.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/pages/add-device/pair/components/pair-device-summary/index.vue b/src/pages/add-device/pair/components/pair-device-summary/index.vue new file mode 100644 index 0000000..de8ebcf --- /dev/null +++ b/src/pages/add-device/pair/components/pair-device-summary/index.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/pages/add-device/pair/components/pair-hero/index.vue b/src/pages/add-device/pair/components/pair-hero/index.vue new file mode 100644 index 0000000..b6159b0 --- /dev/null +++ b/src/pages/add-device/pair/components/pair-hero/index.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/pages/add-device/pair/index.vue b/src/pages/add-device/pair/index.vue new file mode 100644 index 0000000..d2edd43 --- /dev/null +++ b/src/pages/add-device/pair/index.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/pages/add-device/pair/pair.scss b/src/pages/add-device/pair/pair.scss new file mode 100644 index 0000000..2088b85 --- /dev/null +++ b/src/pages/add-device/pair/pair.scss @@ -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; +} diff --git a/src/pages/add-device/pair/pair.types.ts b/src/pages/add-device/pair/pair.types.ts new file mode 100644 index 0000000..2d1880e --- /dev/null +++ b/src/pages/add-device/pair/pair.types.ts @@ -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 +} diff --git a/src/pages/add-device/pair/use-pair-device.ts b/src/pages/add-device/pair/use-pair-device.ts new file mode 100644 index 0000000..92a3728 --- /dev/null +++ b/src/pages/add-device/pair/use-pair-device.ts @@ -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) { + const currentStep = 3 + const device = ref({ + ...DEFAULT_DEVICE, + ...initial, + }) + + const progress = ref(65) + const etaSeconds = ref(15) + const checkItems = ref([ + { 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, + } +} diff --git a/src/pages/add-device/scan/components/scan-device-item/index.vue b/src/pages/add-device/scan/components/scan-device-item/index.vue new file mode 100644 index 0000000..7689a58 --- /dev/null +++ b/src/pages/add-device/scan/components/scan-device-item/index.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/pages/add-device/scan/components/scan-help-tip/index.vue b/src/pages/add-device/scan/components/scan-help-tip/index.vue new file mode 100644 index 0000000..10032a3 --- /dev/null +++ b/src/pages/add-device/scan/components/scan-help-tip/index.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/pages/add-device/scan/components/scan-hero/index.vue b/src/pages/add-device/scan/components/scan-hero/index.vue new file mode 100644 index 0000000..0224a84 --- /dev/null +++ b/src/pages/add-device/scan/components/scan-hero/index.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/pages/add-device/scan/index.vue b/src/pages/add-device/scan/index.vue new file mode 100644 index 0000000..0a2307b --- /dev/null +++ b/src/pages/add-device/scan/index.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/pages/add-device/scan/scan.scss b/src/pages/add-device/scan/scan.scss new file mode 100644 index 0000000..e93dce2 --- /dev/null +++ b/src/pages/add-device/scan/scan.scss @@ -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; +} diff --git a/src/pages/add-device/scan/scan.types.ts b/src/pages/add-device/scan/scan.types.ts new file mode 100644 index 0000000..20b5dea --- /dev/null +++ b/src/pages/add-device/scan/scan.types.ts @@ -0,0 +1,7 @@ +export interface ScannedDeviceItem { + id: string + name: string + serialMasked: string + /** 是否可添加(未知设备为 false 时箭头置灰) */ + canAdd: boolean +} diff --git a/src/pages/add-device/scan/use-scan-device.ts b/src/pages/add-device/scan/use-scan-device.ts new file mode 100644 index 0000000..fafe361 --- /dev/null +++ b/src/pages/add-device/scan/use-scan-device.ts @@ -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(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, + } +} diff --git a/src/pages/add-device/use-add-device.ts b/src/pages/add-device/use-add-device.ts new file mode 100644 index 0000000..56f0a18 --- /dev/null +++ b/src/pages/add-device/use-add-device.ts @@ -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, + } +} diff --git a/src/pages/agreement/agreement.api.ts b/src/pages/agreement/agreement.api.ts new file mode 100644 index 0000000..2cc1a89 --- /dev/null +++ b/src/pages/agreement/agreement.api.ts @@ -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({ + url: '/app/auth/agreement/detail', + method: 'POST', + data: { type }, + }) +} diff --git a/src/pages/agreement/agreement.types.ts b/src/pages/agreement/agreement.types.ts new file mode 100644 index 0000000..a5e6917 --- /dev/null +++ b/src/pages/agreement/agreement.types.ts @@ -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 +} diff --git a/src/pages/agreement/index.vue b/src/pages/agreement/index.vue new file mode 100644 index 0000000..b1f2008 --- /dev/null +++ b/src/pages/agreement/index.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/pages/agreement/use-agreement.ts b/src/pages/agreement/use-agreement.ts new file mode 100644 index 0000000..ad3b7a0 --- /dev/null +++ b/src/pages/agreement/use-agreement.ts @@ -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 = { + 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('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 } +} diff --git a/src/pages/change-password/change-password.api.ts b/src/pages/change-password/change-password.api.ts new file mode 100644 index 0000000..f3a7477 --- /dev/null +++ b/src/pages/change-password/change-password.api.ts @@ -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({ + url: '/app/auth/forgetPassword/doReset', + method: 'POST', + data: body, + }) +} diff --git a/src/pages/change-password/change-password.types.ts b/src/pages/change-password/change-password.types.ts new file mode 100644 index 0000000..3c5c091 --- /dev/null +++ b/src/pages/change-password/change-password.types.ts @@ -0,0 +1,5 @@ +export interface ChangePasswordForm { + newPassword: string + confirmPassword: string + resetToken: string +} diff --git a/src/pages/change-password/index.vue b/src/pages/change-password/index.vue new file mode 100644 index 0000000..99b1cda --- /dev/null +++ b/src/pages/change-password/index.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/pages/change-password/use-change-password.ts b/src/pages/change-password/use-change-password.ts new file mode 100644 index 0000000..2b34937 --- /dev/null +++ b/src/pages/change-password/use-change-password.ts @@ -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 } +} diff --git a/src/pages/device/components/device-empty/index.vue b/src/pages/device/components/device-empty/index.vue new file mode 100644 index 0000000..348e71b --- /dev/null +++ b/src/pages/device/components/device-empty/index.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/pages/device/components/device-header/index.vue b/src/pages/device/components/device-header/index.vue new file mode 100644 index 0000000..88991e3 --- /dev/null +++ b/src/pages/device/components/device-header/index.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/pages/device/components/hot-devices/index.vue b/src/pages/device/components/hot-devices/index.vue new file mode 100644 index 0000000..f1bffe6 --- /dev/null +++ b/src/pages/device/components/hot-devices/index.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/pages/device/components/room-tabs/index.vue b/src/pages/device/components/room-tabs/index.vue new file mode 100644 index 0000000..868c929 --- /dev/null +++ b/src/pages/device/components/room-tabs/index.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/pages/device/device.scss b/src/pages/device/device.scss new file mode 100644 index 0000000..1671998 --- /dev/null +++ b/src/pages/device/device.scss @@ -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; +} diff --git a/src/pages/device/device.types.ts b/src/pages/device/device.types.ts new file mode 100644 index 0000000..ee5b96d --- /dev/null +++ b/src/pages/device/device.types.ts @@ -0,0 +1,10 @@ +export interface RoomTab { + id: string + name: string +} + +export interface HotDeviceItem { + id: string + name: string + desc: string +} diff --git a/src/pages/device/index.vue b/src/pages/device/index.vue new file mode 100644 index 0000000..c169a3b --- /dev/null +++ b/src/pages/device/index.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/pages/device/use-device.ts b/src/pages/device/use-device.ts new file mode 100644 index 0000000..38e9e03 --- /dev/null +++ b/src/pages/device/use-device.ts @@ -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(DEFAULT_ROOMS) + const activeRoomId = ref('all') + const hotDevices = ref(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, + } +} diff --git a/src/pages/forgot-password/forgot-password.api.ts b/src/pages/forgot-password/forgot-password.api.ts new file mode 100644 index 0000000..94eda0e --- /dev/null +++ b/src/pages/forgot-password/forgot-password.api.ts @@ -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({ + url: '/app/auth/forgetPassword/verifyCode', + method: 'POST', + data: body, + }) +} diff --git a/src/pages/forgot-password/forgot-password.types.ts b/src/pages/forgot-password/forgot-password.types.ts new file mode 100644 index 0000000..191b7e8 --- /dev/null +++ b/src/pages/forgot-password/forgot-password.types.ts @@ -0,0 +1,4 @@ +export interface ForgotPasswordForm { + phone: string + code: string +} diff --git a/src/pages/forgot-password/index.vue b/src/pages/forgot-password/index.vue new file mode 100644 index 0000000..1ae80ab --- /dev/null +++ b/src/pages/forgot-password/index.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/pages/forgot-password/use-forgot-password.ts b/src/pages/forgot-password/use-forgot-password.ts new file mode 100644 index 0000000..7ba232c --- /dev/null +++ b/src/pages/forgot-password/use-forgot-password.ts @@ -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 } +} diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index ecac4f5..e69de29 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -1,41 +0,0 @@ - - - - - diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue new file mode 100644 index 0000000..26673fd --- /dev/null +++ b/src/pages/login/index.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/pages/login/login.api.ts b/src/pages/login/login.api.ts new file mode 100644 index 0000000..664a852 --- /dev/null +++ b/src/pages/login/login.api.ts @@ -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({ + 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({ + url: '/app/auth/login/password', + method: 'POST', + data: body, + }) +} diff --git a/src/pages/login/login.scss b/src/pages/login/login.scss new file mode 100644 index 0000000..0b4183f --- /dev/null +++ b/src/pages/login/login.scss @@ -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; +} diff --git a/src/pages/login/login.types.ts b/src/pages/login/login.types.ts new file mode 100644 index 0000000..31d4958 --- /dev/null +++ b/src/pages/login/login.types.ts @@ -0,0 +1,11 @@ +export type LoginMode = 'code' | 'password' + +export interface LoginCodeForm { + phone: string + code: string +} + +export interface LoginPasswordForm { + account: string + password: string +} diff --git a/src/pages/login/use-login.ts b/src/pages/login/use-login.ts new file mode 100644 index 0000000..94e0f9e --- /dev/null +++ b/src/pages/login/use-login.ts @@ -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('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, + } +} diff --git a/src/pages/mine/index.vue b/src/pages/mine/index.vue new file mode 100644 index 0000000..4df17d3 --- /dev/null +++ b/src/pages/mine/index.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/pages/register/index.vue b/src/pages/register/index.vue new file mode 100644 index 0000000..126db0e --- /dev/null +++ b/src/pages/register/index.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/pages/register/register.api.ts b/src/pages/register/register.api.ts new file mode 100644 index 0000000..748d800 --- /dev/null +++ b/src/pages/register/register.api.ts @@ -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({ + url: '/app/auth/register', + method: 'POST', + data: body, + }) +} diff --git a/src/pages/register/register.types.ts b/src/pages/register/register.types.ts new file mode 100644 index 0000000..fbebe2b --- /dev/null +++ b/src/pages/register/register.types.ts @@ -0,0 +1,6 @@ +export interface RegisterForm { + phone: string + code: string + password: string + confirmPassword: string +} diff --git a/src/pages/register/use-register.ts b/src/pages/register/use-register.ts new file mode 100644 index 0000000..4ad26d9 --- /dev/null +++ b/src/pages/register/use-register.ts @@ -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, + } +} diff --git a/src/plugins/register-global-components.ts b/src/plugins/register-global-components.ts new file mode 100644 index 0000000..90b48cb --- /dev/null +++ b/src/plugins/register-global-components.ts @@ -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 = { + '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) + }) +} diff --git a/src/static/icon-checked@2x.png b/src/static/icon-checked@2x.png new file mode 100644 index 0000000..5f87dd5 Binary files /dev/null and b/src/static/icon-checked@2x.png differ diff --git a/src/static/icon-code@2x.png b/src/static/icon-code@2x.png new file mode 100644 index 0000000..7e3b8a7 Binary files /dev/null and b/src/static/icon-code@2x.png differ diff --git a/src/static/icon-phone@2x.png b/src/static/icon-phone@2x.png new file mode 100644 index 0000000..9b24eaf Binary files /dev/null and b/src/static/icon-phone@2x.png differ diff --git a/src/static/logo-center@2x.png b/src/static/logo-center@2x.png new file mode 100644 index 0000000..e4ab7f0 Binary files /dev/null and b/src/static/logo-center@2x.png differ diff --git a/src/static/tab-device-active.png b/src/static/tab-device-active.png new file mode 100644 index 0000000..987e478 Binary files /dev/null and b/src/static/tab-device-active.png differ diff --git a/src/static/tab-device.png b/src/static/tab-device.png new file mode 100644 index 0000000..fa88452 Binary files /dev/null and b/src/static/tab-device.png differ diff --git a/src/static/tab-mine-active.png b/src/static/tab-mine-active.png new file mode 100644 index 0000000..60ef271 Binary files /dev/null and b/src/static/tab-mine-active.png differ diff --git a/src/static/tab-mine.png b/src/static/tab-mine.png new file mode 100644 index 0000000..4d62eaa Binary files /dev/null and b/src/static/tab-mine.png differ diff --git a/src/static/图标 4.png b/src/static/图标 4.png new file mode 100644 index 0000000..b20a45a Binary files /dev/null and b/src/static/图标 4.png differ diff --git a/src/static/图标 8.png b/src/static/图标 8.png new file mode 100644 index 0000000..11cc2f2 Binary files /dev/null and b/src/static/图标 8.png differ diff --git a/src/static/图标 9.png b/src/static/图标 9.png new file mode 100644 index 0000000..880de4e Binary files /dev/null and b/src/static/图标 9.png differ diff --git a/src/static/组 27.png b/src/static/组 27.png new file mode 100644 index 0000000..7b3c1b3 Binary files /dev/null and b/src/static/组 27.png differ diff --git a/src/static/组 281@2x.png b/src/static/组 281@2x.png new file mode 100644 index 0000000..fa6f479 Binary files /dev/null and b/src/static/组 281@2x.png differ diff --git a/src/static/组 286@2x.png b/src/static/组 286@2x.png new file mode 100644 index 0000000..7783e42 Binary files /dev/null and b/src/static/组 286@2x.png differ diff --git a/src/static/组 291@2x.png b/src/static/组 291@2x.png new file mode 100644 index 0000000..c9c59d5 Binary files /dev/null and b/src/static/组 291@2x.png differ diff --git a/src/static/组 41@2x.png b/src/static/组 41@2x.png new file mode 100644 index 0000000..b100b24 Binary files /dev/null and b/src/static/组 41@2x.png differ diff --git a/src/static/组 468@2x.png b/src/static/组 468@2x.png new file mode 100644 index 0000000..947ac17 Binary files /dev/null and b/src/static/组 468@2x.png differ diff --git a/src/static/组 476@2x.png b/src/static/组 476@2x.png new file mode 100644 index 0000000..358436c Binary files /dev/null and b/src/static/组 476@2x.png differ diff --git a/src/static/组 600@2x.png b/src/static/组 600@2x.png new file mode 100644 index 0000000..dccec49 Binary files /dev/null and b/src/static/组 600@2x.png differ diff --git a/src/static/组 9.png b/src/static/组 9.png new file mode 100644 index 0000000..5d66af5 Binary files /dev/null and b/src/static/组 9.png differ diff --git a/src/static/蓝牙-F.png b/src/static/蓝牙-F.png new file mode 100644 index 0000000..2ac9b5c Binary files /dev/null and b/src/static/蓝牙-F.png differ diff --git a/src/static/路径.png b/src/static/路径.png new file mode 100644 index 0000000..365f0d1 Binary files /dev/null and b/src/static/路径.png differ diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 0000000..b1b34bc --- /dev/null +++ b/src/styles/index.scss @@ -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; +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000..42cf8c0 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,14 @@ +$color-primary: #1ec671; +$color-primary-dark: #18a85d; +$color-text: #1a1a1a; +$color-text-secondary: #666666; +$color-text-placeholder: #c0c0c0; +$color-border: #e8e8e8; +$color-bg-page: #f7f8fa; +$color-bg-input: #ffffff; +$color-link: #1ec671; + +$page-padding-x: 48rpx; +$radius-input: 48rpx; +$radius-button: 24rpx; +$shadow-button: 0 8rpx 24rpx rgba(30, 198, 113, 0.35); diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..18ea324 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,62 @@ +/** 发送验证码场景(与后端 SmsCodeSceneConstants 一致) */ +export const SMS_CODE_SCENE = { + register: 'register', + login: 'login', + reset: 'reset', + changePhoneOld: 'change_phone_old', + changePhoneNew: 'change_phone_new', +} as const + +export type SmsCodeScene = (typeof SMS_CODE_SCENE)[keyof typeof SMS_CODE_SCENE] + +/** 常见后端统一响应(如 RuoYi:code 200 为成功) */ +export interface ApiResponse { + code?: number + msg?: string + message?: string + data?: T +} + +export interface SendSmsCodeBody { + phonenumber: string + scene: SmsCodeScene +} + +export interface LoginSmsBody { + phonenumber: string + smsCode: string +} + +export interface LoginPasswordBody { + account: string + password: string +} + +export interface RegisterBody { + phonenumber: string + smsCode: string + password: string + confirmPassword: string +} + +export interface AuthTokenResult { + token?: string + accessToken?: string +} + +/** POST /app/auth/forgetPassword/verifyCode */ +export interface ForgetPasswordVerifyBody { + phonenumber: string + smsCode: string +} + +export interface ForgetPasswordVerifyResult { + resetToken: string +} + +/** POST /app/auth/forgetPassword/doReset */ +export interface ForgetPasswordResetBody { + resetToken: string + newPassword: string + confirmPassword: string +} diff --git a/src/utils/api-error.ts b/src/utils/api-error.ts new file mode 100644 index 0000000..fdffdfc --- /dev/null +++ b/src/utils/api-error.ts @@ -0,0 +1,34 @@ +import type { ApiResponse } from '@/types/api' + +export class ApiError extends Error { + code?: number + + constructor(message: string, code?: number) { + super(message) + this.name = 'ApiError' + this.code = code + } +} + +export function isApiSuccess(body: ApiResponse): boolean { + if (body.code === undefined) return true + return body.code === 200 || body.code === 0 +} + +export function unwrapApiData(body: ApiResponse & Record): T { + if (body.data !== undefined && body.data !== null) { + return body.data as T + } + return body as T +} + +export function getErrorMessage(err: unknown, fallback = '请求失败'): string { + if (err instanceof ApiError) return err.message + if (err instanceof Error && err.message) return err.message + if (err && typeof err === 'object' && 'data' in err) { + const data = (err as { data?: ApiResponse }).data + if (data?.msg) return data.msg + if (data?.message) return data.message + } + return fallback +} diff --git a/src/utils/static-assets.ts b/src/utils/static-assets.ts new file mode 100644 index 0000000..2d60f56 --- /dev/null +++ b/src/utils/static-assets.ts @@ -0,0 +1,66 @@ +/** 设计稿静态图标(src/static) */ +import logoCenter from '@/static/logo-center@2x.png' +import iconPhone from '@/static/icon-phone@2x.png' +import iconCode from '@/static/icon-code@2x.png' +import iconChecked from '@/static/icon-checked@2x.png' +import iconPassword from '@/static/路径.png' +import iconHomeSelector from '@/static/图标 4.png' +import iconHomeArrow from '@/static/组 9.png' +import iconNotify from '@/static/组 27.png' +import iconDeviceEmpty from '@/static/图标 8.png' +import iconDeviceNameEdit from '@/static/图标 9.png' +import iconBindComplete from '@/static/组 41@2x.png' +import iconBluetooth from '@/static/蓝牙-F.png' +import iconBindDiscover from '@/static/组 281@2x.png' +import iconBindPair from '@/static/组 286@2x.png' +import iconBindSecure from '@/static/组 291@2x.png' +import iconScanDevice from '@/static/组 600@2x.png' +import iconScanDeviceAction from '@/static/组 468@2x.png' +import iconScanDeviceActionDisabled from '@/static/组 476@2x.png' +import tabDevice from '@/static/tab-device-active.png' +import tabDeviceActive from '@/static/tab-device.png' +import tabMine from '@/static/tab-mine-active.png' +import tabMineActive from '@/static/tab-mine.png' + +export const staticAssets = { + /** 组 15106@2x.png — 登录区中心大图标 */ + logo: logoCenter, + /** 手机号@2x.png */ + iconPhone, + /** 验证码@2x.png */ + iconCode, + /** 选中@2x.png */ + iconChecked, + /** 路径.png — 密码框 */ + iconPassword, + /** 图标 4.png — 首页「我的家」 */ + iconHomeSelector, + /** 组 9.png — 「我的家」下拉箭头 */ + iconHomeArrow, + /** 组 27.png — 设备页右上角通知 */ + iconNotify, + /** 图标 8.png — 设备空状态中心图标 */ + iconDeviceEmpty, + /** 图标 9.png — 完成配置「设备名称」编辑 */ + iconDeviceNameEdit, + /** 组 41@2x.png — 完成配置成功示意图 */ + iconBindComplete, + /** 蓝牙-F.png — 添加设备页蓝牙示意图 */ + iconBluetooth, + /** 组 281@2x.png — 添加设备「发现附近设备」 */ + iconBindDiscover, + /** 组 286@2x.png — 添加设备「快速配对连接」 */ + iconBindPair, + /** 组 291@2x.png — 添加设备「本地安全控制」 */ + iconBindSecure, + /** 组 600@2x.png — 扫描设备页示意图 */ + iconScanDevice, + /** 组 468@2x.png — 发现设备列表「可添加」箭头 */ + iconScanDeviceAction, + /** 组 476@2x.png — 发现设备列表「不可添加」箭头 */ + iconScanDeviceActionDisabled, + tabDevice, + tabDeviceActive, + tabMine, + tabMineActive, +} as const diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 0000000..3e82020 --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,11 @@ +export function isValidPhone(phone: string): boolean { + return /^1\d{10}$/.test(phone.trim()) +} + +export function isValidSmsCode(code: string): boolean { + return /^\d{4,6}$/.test(code.trim()) +} + +export function isValidPassword(password: string): boolean { + return password.length >= 6 && password.length <= 32 +} diff --git a/vite.config.ts b/vite.config.ts index 46e36fe..85cabba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,28 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import uni from "@dcloudio/vite-plugin-uni"; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const proxyTarget = + env.VITE_API_PROXY_TARGET || "http://192.168.0.222:8082"; + + return { plugins: [uni()], + server: { + proxy: { + "/app": { + target: proxyTarget, + changeOrigin: true, + }, + }, + }, + css: { + preprocessorOptions: { + scss: { + additionalData: `@import "@/styles/variables.scss";`, + }, + }, + }, +}; });