commit e4a977532794ff226573ddbf53f2c92c2924eeb5 Author: ozh Date: Fri Apr 17 14:45:13 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..927507d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +unpackage/ +.hbuilderx/ +.DS_Store +.codex-tasks/ \ No newline at end of file diff --git a/App.vue b/App.vue new file mode 100644 index 0000000..9579b6d --- /dev/null +++ b/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/api/http/client.js b/api/http/client.js new file mode 100644 index 0000000..a76f708 --- /dev/null +++ b/api/http/client.js @@ -0,0 +1,258 @@ +import { DEFAULT_BASE_URL, DEFAULT_TIMEOUT, SUCCESS_CODE } from '@/constants/request' +import { clearAuth, getToken, isAuthEnabled, redirectToLogin } from '@/utils/auth' +import { showToast } from '@/utils/toast' + +let baseURL = DEFAULT_BASE_URL + +function buildUrl(url = '') { + if (!url) { + return baseURL + } + + if (/^https?:\/\//.test(url)) { + return url + } + + if (url.startsWith('/')) { + return `${baseURL}${url}` + } + + return `${baseURL}/${url}` +} + +/** + * 构造请求头。 + * @param {Record} customHeader 自定义请求头 + * @param {boolean} needAuth 是否需要携带 token + * @param {boolean} withJson 是否设置 JSON Content-Type + * @returns {Record} + */ +function buildHeaders(customHeader = {}, needAuth = true, withJson = true) { + const headers = { + ...(withJson ? { 'Content-Type': 'application/json' } : {}), + ...customHeader + } + + if (needAuth && isAuthEnabled()) { + const token = getToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + } + + return headers +} + +function handleUnauthorized() { + if (!isAuthEnabled()) { + return + } + + clearAuth() + redirectToLogin() +} + +/** + * 统一解析后端响应结构。 + * 约定:`code === 0` 为成功,最终返回 `data` 字段。 + */ +function resolveResponseData(responseData) { + const code = typeof responseData?.code === 'number' ? responseData.code : SUCCESS_CODE + + if (code !== SUCCESS_CODE) { + const error = new Error(responseData?.message || '业务处理失败') + error.code = code + throw error + } + + return responseData?.data !== undefined ? responseData.data : responseData +} + +/** + * 通用请求方法。 + * @param {object} options uni.request 参数扩展 + * @returns {Promise} + */ +function request(options = {}) { + const { + url, + method = 'GET', + data, + header, + timeout = DEFAULT_TIMEOUT, + needAuth = true, + showErrorToast = true, + showToast: showToastLegacy + } = options + + const shouldShowErrorToast = typeof showToastLegacy === 'boolean' + ? showToastLegacy + : showErrorToast + + return new Promise((resolve, reject) => { + uni.request({ + url: buildUrl(url), + method: String(method).toUpperCase(), + data, + timeout, + header: buildHeaders(header, needAuth, true), + success: (res) => { + const { statusCode = 0, data: responseData = {} } = res + + if (statusCode === 401) { + handleUnauthorized() + reject(new Error('登录状态已失效')) + return + } + + if (statusCode < 200 || statusCode >= 300) { + const message = responseData?.message || `网络错误(${statusCode})` + if (shouldShowErrorToast) { + showToast(message) + } + reject(new Error(message)) + return + } + + try { + const finalData = resolveResponseData(responseData) + resolve(finalData) + } catch (error) { + if (error.code === 401) { + handleUnauthorized() + } + if (shouldShowErrorToast) { + showToast(error.message) + } + reject(error) + } + }, + fail: (error) => { + if (shouldShowErrorToast) { + showToast('网络异常,请稍后重试') + } + reject(error) + } + }) + }) +} + +request.get = (url, params = {}, config = {}) => { + return request({ + url, + method: 'GET', + data: params, + ...config + }) +} + +request.post = (url, data = {}, config = {}) => { + return request({ + url, + method: 'POST', + data, + ...config + }) +} + +request.put = (url, data = {}, config = {}) => { + return request({ + url, + method: 'PUT', + data, + ...config + }) +} + +request.delete = (url, data = {}, config = {}) => { + return request({ + url, + method: 'DELETE', + data, + ...config + }) +} + +request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => { + const { + header = {}, + needAuth = true, + showErrorToast = true, + showToast: showToastLegacy + } = config + + const shouldShowErrorToast = typeof showToastLegacy === 'boolean' + ? showToastLegacy + : showErrorToast + + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: buildUrl(url), + filePath, + name, + formData, + header: buildHeaders(header, needAuth, false), + success: (res) => { + if (res.statusCode === 401) { + handleUnauthorized() + reject(new Error('登录状态已失效')) + return + } + + if (res.statusCode < 200 || res.statusCode >= 300) { + const message = `上传失败(${res.statusCode})` + if (shouldShowErrorToast) { + showToast(message) + } + reject(new Error(message)) + return + } + + let responseData = {} + try { + responseData = JSON.parse(res.data || '{}') + } catch (error) { + if (shouldShowErrorToast) { + showToast('上传返回数据格式错误') + } + reject(error) + return + } + + try { + const finalData = resolveResponseData(responseData) + resolve(finalData) + } catch (error) { + if (error.code === 401) { + handleUnauthorized() + } + if (shouldShowErrorToast) { + showToast(error.message) + } + reject(error) + } + }, + fail: (error) => { + if (shouldShowErrorToast) { + showToast('上传失败,请稍后重试') + } + reject(error) + } + }) + }) +} + +/** + * 动态设置 API 基础地址。 + */ +request.setBaseUrl = (url) => { + if (!url || typeof url !== 'string') { + return + } + + baseURL = url.replace(/\/$/, '') +} + +request.getBaseUrl = () => baseURL + +export default request diff --git a/api/user.js b/api/user.js new file mode 100644 index 0000000..6c07dc4 --- /dev/null +++ b/api/user.js @@ -0,0 +1,30 @@ +import request from '@/api/http/client' + +/** + * 发送短信验证码。 + * @param {string} mobile 手机号 + */ +export function sendSmsCode(mobile) { + return request.post('/auth/sms-code', { mobile }, { needAuth: false }) +} + +/** + * 手机号 + 验证码登录。 + */ +export function loginByMobile(mobile, code) { + return request.post('/auth/mobile-login', { mobile, code }, { needAuth: false }) +} + +/** + * 微信登录。 + */ +export function loginByWechat(loginCode) { + return request.post('/auth/wechat-login', { code: loginCode }, { needAuth: false }) +} + +/** + * 获取当前用户信息。 + */ +export function getUserProfile() { + return request.get('/user/profile') +} diff --git a/constants/auth.js b/constants/auth.js new file mode 100644 index 0000000..18f0aa8 --- /dev/null +++ b/constants/auth.js @@ -0,0 +1,13 @@ +/** + * 本地存储 key。 + */ +export const AUTH_STORAGE_KEYS = Object.freeze({ + TOKEN: 'smarthome_token', + USER: 'smarthome_user' +}) + +/** + * 登录开关:当前为 false,便于功能联调。 + * 恢复鉴权时改为 true 即可。 + */ +export const AUTH_ENABLED = false diff --git a/constants/bluetooth.js b/constants/bluetooth.js new file mode 100644 index 0000000..70a6977 --- /dev/null +++ b/constants/bluetooth.js @@ -0,0 +1,16 @@ +/** + * 目标服务 UUID(用于优先识别特定设备)。 + */ +export const TARGET_SERVICE_UUID = '0000F400-0000-1000-8000-00805F9B34FB' +export const TARGET_SHORT_UUID = '00F4' + +/** + * 蓝牙常见错误提示映射。 + */ +export const BLUETOOTH_ERROR_MESSAGES = Object.freeze({ + 10001: '蓝牙不可用,请先开启手机蓝牙', + 10003: '连接失败,请重试', + 10012: '操作超时,请重试' +}) + +export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败' diff --git a/constants/request.js b/constants/request.js new file mode 100644 index 0000000..2d54c9a --- /dev/null +++ b/constants/request.js @@ -0,0 +1,6 @@ +/** + * 接口请求默认配置。 + */ +export const SUCCESS_CODE = 0 +export const DEFAULT_TIMEOUT = 15000 +export const DEFAULT_BASE_URL = 'https://api.example.com' diff --git a/constants/routes.js b/constants/routes.js new file mode 100644 index 0000000..7fab84c --- /dev/null +++ b/constants/routes.js @@ -0,0 +1,16 @@ +/** + * 统一维护页面路由,避免页面内散落硬编码字符串。 + */ +export const ROUTES = Object.freeze({ + LOGIN: '/pages/login/index', + HOME: '/pages/index/index', + EVENT: '/pages/event/index', + MINE: '/pages/mine/index', + DEVICE_ADD: '/pages/device/add', + DEVICE_CONFIG: '/pages/device/config' +}) + +/** + * TabBar 页面集合,供登录跳转与路由守卫判断使用。 + */ +export const TAB_ROUTES = Object.freeze([ROUTES.HOME, ROUTES.EVENT, ROUTES.MINE]) diff --git a/hooks/useBluetoothDiscovery.js b/hooks/useBluetoothDiscovery.js new file mode 100644 index 0000000..3835eed --- /dev/null +++ b/hooks/useBluetoothDiscovery.js @@ -0,0 +1,186 @@ +import { + BLUETOOTH_ERROR_MESSAGES, + DEFAULT_BLUETOOTH_ERROR_MESSAGE, + TARGET_SERVICE_UUID, + TARGET_SHORT_UUID +} from '@/constants/bluetooth' +import { showToast } from '@/utils/toast' + +/** + * 蓝牙设备搜索逻辑封装。 + * 通过回调把状态同步给页面,减少页面文件复杂度。 + */ +export function createBluetoothDiscovery(options = {}) { + const { + onSearchingChange = () => {}, + onDeviceMapChange = () => {}, + onDeviceListChange = () => {} + } = options + + let isSearching = false + let deviceMap = {} + let deviceFoundHandler = null + + function setSearching(value) { + isSearching = Boolean(value) + onSearchingChange(isSearching) + } + + function setDeviceMap(nextMap) { + deviceMap = nextMap + onDeviceMapChange({ ...deviceMap }) + emitDeviceList() + } + + function emitDeviceList() { + const list = Object.values(deviceMap).sort((a, b) => { + if (a.hasTargetService !== b.hasTargetService) { + return a.hasTargetService ? -1 : 1 + } + + const aSignal = typeof a.RSSI === 'number' ? a.RSSI : -999 + const bSignal = typeof b.RSSI === 'number' ? b.RSSI : -999 + return bSignal - aSignal + }) + + onDeviceListChange(list) + } + + function normalizeUuid(uuid) { + return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase() + } + + function hasTargetService(device) { + if (!Array.isArray(device.advertisServiceUUIDs)) { + return false + } + + const targetUuid = normalizeUuid(TARGET_SERVICE_UUID) + return device.advertisServiceUUIDs.some((uuid) => { + const value = normalizeUuid(uuid) + return value === targetUuid || value.endsWith(TARGET_SHORT_UUID) + }) + } + + function openBluetoothAdapter() { + return new Promise((resolve, reject) => { + uni.openBluetoothAdapter({ + success: resolve, + fail: reject + }) + }) + } + + function startDiscoveryWithServices(services = []) { + return new Promise((resolve, reject) => { + const options = { + allowDuplicatesKey: true, + interval: 0, + success: resolve, + fail: reject + } + + if (services.length) { + options.services = services + } + + uni.startBluetoothDevicesDiscovery(options) + }) + } + + function upsertDevice(rawDevice) { + if (!rawDevice || !rawDevice.deviceId) { + return + } + + const existing = deviceMap[rawDevice.deviceId] || {} + const merged = { + ...existing, + ...rawDevice, + showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备', + hasTargetService: hasTargetService(rawDevice) || existing.hasTargetService + } + + setDeviceMap({ + ...deviceMap, + [rawDevice.deviceId]: merged + }) + } + + function registerDeviceFoundHandler() { + if (deviceFoundHandler) { + return + } + + deviceFoundHandler = (res) => { + const devices = Array.isArray(res.devices) ? res.devices : [res] + devices.forEach((device) => { + upsertDevice(device) + }) + } + + uni.onBluetoothDeviceFound(deviceFoundHandler) + } + + function stopSearch(showStopToast = true) { + return new Promise((resolve) => { + uni.stopBluetoothDevicesDiscovery({ + complete: () => { + setSearching(false) + if (showStopToast) { + showToast('已停止搜索') + } + resolve() + } + }) + }) + } + + function cleanupDiscovery() { + if (isSearching) { + uni.stopBluetoothDevicesDiscovery({ + complete: () => {} + }) + } + + setSearching(false) + + if (deviceFoundHandler && uni.offBluetoothDeviceFound) { + uni.offBluetoothDeviceFound(deviceFoundHandler) + } + + deviceFoundHandler = null + } + + function getBluetoothErrorText(error) { + const errCode = error && error.errCode + return BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE + } + + async function startSearch() { + setDeviceMap({}) + + try { + await openBluetoothAdapter() + registerDeviceFoundHandler() + + try { + await startDiscoveryWithServices([TARGET_SERVICE_UUID]) + } catch (error) { + await startDiscoveryWithServices([]) + } + + setSearching(true) + } catch (error) { + setSearching(false) + showToast(getBluetoothErrorText(error)) + throw error + } + } + + return { + startSearch, + stopSearch, + cleanupDiscovery + } +} diff --git a/hooks/useCountdown.js b/hooks/useCountdown.js new file mode 100644 index 0000000..5f7801e --- /dev/null +++ b/hooks/useCountdown.js @@ -0,0 +1,49 @@ +/** + * 创建一个简单倒计时控制器,适配 Vue2 Options API。 + * @param {object} options + * @param {number} options.duration 倒计时总秒数 + * @param {(value:number)=>void} options.onChange 每次变化回调 + */ +export function createCountdown(options = {}) { + const { + duration = 60, + onChange = () => {} + } = options + + let timer = null + let current = 0 + + function emit() { + onChange(current) + } + + function clear() { + if (timer) { + clearInterval(timer) + timer = null + } + } + + function start() { + clear() + current = duration + emit() + + timer = setInterval(() => { + current -= 1 + if (current <= 0) { + current = 0 + emit() + clear() + return + } + + emit() + }, 1000) + } + + return { + start, + clear + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..b5d330d --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + +
+ + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..c16539f --- /dev/null +++ b/main.js @@ -0,0 +1,25 @@ +import App from './App' +import { setupAuthInterceptors } from './utils/auth' + +setupAuthInterceptors() + +// #ifndef VUE3 +import Vue from 'vue' +import './uni.promisify.adaptor' +Vue.config.productionTip = false +App.mpType = 'app' +const app = new Vue({ + ...App +}) +app.$mount() +// #endif + +// #ifdef VUE3 +import { createSSRApp } from 'vue' +export function createApp() { + const app = createSSRApp(App) + return { + app + } +} +// #endif diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..8f594b0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,72 @@ +{ + "name" : "smarthome", + "appid" : "", + "description" : "", + "versionName" : "1.0.0", + "versionCode" : "100", + "transformPx" : false, + /* 5+App特有相关 */ + "app-plus" : { + "usingComponents" : true, + "nvueStyleCompiler" : "uni-app", + "compilerVersion" : 3, + "splashscreen" : { + "alwaysShowBeforeRender" : true, + "waiting" : true, + "autoclose" : true, + "delay" : 0 + }, + /* 模块配置 */ + "modules" : {}, + /* 应用发布信息 */ + "distribute" : { + /* android打包配置 */ + "android" : { + "permissions" : [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + /* ios打包配置 */ + "ios" : {}, + /* SDK配置 */ + "sdkConfigs" : {} + } + }, + /* 快应用特有相关 */ + "quickapp" : {}, + /* 小程序特有相关 */ + "mp-weixin" : { + "appid" : "wx7f272a672efe6c04", + "setting" : { + "urlCheck" : false + }, + "usingComponents" : true + }, + "mp-alipay" : { + "usingComponents" : true + }, + "mp-baidu" : { + "usingComponents" : true + }, + "mp-toutiao" : { + "usingComponents" : true + }, + "uniStatistics" : { + "enable" : false + }, + "vueVersion" : "3" +} diff --git a/pages.json b/pages.json new file mode 100644 index 0000000..d6d3b54 --- /dev/null +++ b/pages.json @@ -0,0 +1,67 @@ +{ + "pages": [ + { + "path": "pages/login/index", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/index/index", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/event/index", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/mine/index", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/device/add", + "style": { + "navigationBarTitleText": "添加设备" + } + }, + { + "path": "pages/device/config", + "style": { + "navigationStyle": "custom" + } + } + ], + "globalStyle": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "智慧养老", + "navigationBarBackgroundColor": "#F8F8F8", + "backgroundColor": "#F1F1F4" + }, + "tabBar": { + "color": "#666666", + "selectedColor": "#2CCB98", + "backgroundColor": "#FFFFFF", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页" + }, + { + "pagePath": "pages/event/index", + "text": "事件" + }, + { + "pagePath": "pages/mine/index", + "text": "我的" + } + ] + }, + "uniIdRouter": {} +} diff --git a/pages/device/add.vue b/pages/device/add.vue new file mode 100644 index 0000000..779595b --- /dev/null +++ b/pages/device/add.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/pages/device/config.vue b/pages/device/config.vue new file mode 100644 index 0000000..2dec480 --- /dev/null +++ b/pages/device/config.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/pages/event/index.vue b/pages/event/index.vue new file mode 100644 index 0000000..d66f992 --- /dev/null +++ b/pages/event/index.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/pages/index/index.vue b/pages/index/index.vue new file mode 100644 index 0000000..2c7cda2 --- /dev/null +++ b/pages/index/index.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/pages/login/index.vue b/pages/login/index.vue new file mode 100644 index 0000000..10cfbf0 --- /dev/null +++ b/pages/login/index.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/pages/mine/index.vue b/pages/mine/index.vue new file mode 100644 index 0000000..4545552 --- /dev/null +++ b/pages/mine/index.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..b5771e2 Binary files /dev/null and b/static/logo.png differ diff --git a/styles/common.css b/styles/common.css new file mode 100644 index 0000000..6b42b78 --- /dev/null +++ b/styles/common.css @@ -0,0 +1,46 @@ +.page-base { + min-height: 100vh; + background: #f1f1f4; +} + +.top-nav-base { + height: 120rpx; + padding: 24rpx 24rpx 0; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.top-nav-title-base { + font-size: 50rpx; + font-weight: 700; + color: #222222; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #a7a9ad; +} + +.empty-state-box { + width: 220rpx; + height: 180rpx; + border-radius: 12rpx; + background: linear-gradient(180deg, #eef0f4, #e3e6eb); + margin-bottom: 24rpx; +} + +.empty-state-text { + font-size: 52rpx; + font-weight: 600; +} + +.card-base { + background: #ffffff; + border-radius: 24rpx; +} diff --git a/uni.promisify.adaptor.js b/uni.promisify.adaptor.js new file mode 100644 index 0000000..5fec4f3 --- /dev/null +++ b/uni.promisify.adaptor.js @@ -0,0 +1,13 @@ +uni.addInterceptor({ + returnValue (res) { + if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) { + return res; + } + return new Promise((resolve, reject) => { + res.then((res) => { + if (!res) return resolve(res) + return res[0] ? reject(res[0]) : resolve(res[1]) + }); + }); + }, +}); \ No newline at end of file diff --git a/uni.scss b/uni.scss new file mode 100644 index 0000000..b9249e9 --- /dev/null +++ b/uni.scss @@ -0,0 +1,76 @@ +/** + * 这里是uni-app内置的常用样式变量 + * + * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量 + * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App + * + */ + +/** + * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能 + * + * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件 + */ + +/* 颜色变量 */ + +/* 行为相关颜色 */ +$uni-color-primary: #007aff; +$uni-color-success: #4cd964; +$uni-color-warning: #f0ad4e; +$uni-color-error: #dd524d; + +/* 文字基本颜色 */ +$uni-text-color:#333;//基本色 +$uni-text-color-inverse:#fff;//反色 +$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息 +$uni-text-color-placeholder: #808080; +$uni-text-color-disable:#c0c0c0; + +/* 背景颜色 */ +$uni-bg-color:#ffffff; +$uni-bg-color-grey:#f8f8f8; +$uni-bg-color-hover:#f1f1f1;//点击状态颜色 +$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色 + +/* 边框颜色 */ +$uni-border-color:#c8c7cc; + +/* 尺寸变量 */ + +/* 文字尺寸 */ +$uni-font-size-sm:12px; +$uni-font-size-base:14px; +$uni-font-size-lg:16px; + +/* 图片尺寸 */ +$uni-img-size-sm:20px; +$uni-img-size-base:26px; +$uni-img-size-lg:40px; + +/* Border Radius */ +$uni-border-radius-sm: 2px; +$uni-border-radius-base: 3px; +$uni-border-radius-lg: 6px; +$uni-border-radius-circle: 50%; + +/* 水平间距 */ +$uni-spacing-row-sm: 5px; +$uni-spacing-row-base: 10px; +$uni-spacing-row-lg: 15px; + +/* 垂直间距 */ +$uni-spacing-col-sm: 4px; +$uni-spacing-col-base: 8px; +$uni-spacing-col-lg: 12px; + +/* 透明度 */ +$uni-opacity-disabled: 0.3; // 组件禁用态的透明度 + +/* 文章场景相关 */ +$uni-color-title: #2C405A; // 文章标题颜色 +$uni-font-size-title:20px; +$uni-color-subtitle: #555555; // 二级标题颜色 +$uni-font-size-subtitle:26px; +$uni-color-paragraph: #3F536E; // 文章段落颜色 +$uni-font-size-paragraph:15px; diff --git a/utils/api/user.js b/utils/api/user.js new file mode 100644 index 0000000..77ed7b4 --- /dev/null +++ b/utils/api/user.js @@ -0,0 +1,6 @@ +export { + getUserProfile, + loginByMobile, + loginByWechat, + sendSmsCode +} from '@/api/user' diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..ebacdb5 --- /dev/null +++ b/utils/auth.js @@ -0,0 +1,210 @@ +import { AUTH_ENABLED, AUTH_STORAGE_KEYS } from '@/constants/auth' +import { ROUTES, TAB_ROUTES } from '@/constants/routes' + +export const LOGIN_PAGE = ROUTES.LOGIN +export const HOME_PAGE = ROUTES.HOME +export const TAB_PAGES = TAB_ROUTES + +let hasSetupInterceptors = false + +/** + * 规范化路由,保证以 `/` 开头。 + */ +function normalizeUrl(url = '') { + if (!url) { + return '' + } + + if (url.startsWith('/')) { + return url + } + + return `/${url}` +} + +/** + * 获取不带 query 的路径。 + */ +function getPath(url = '') { + return normalizeUrl(url).split('?')[0] +} + +function safeDecode(value = '') { + try { + return decodeURIComponent(value) + } catch (error) { + return value + } +} + +/** + * 把页面 options 组装成 query 字符串。 + */ +function buildQuery(options = {}) { + const keys = Object.keys(options) + if (!keys.length) { + return '' + } + + const query = keys + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(options[key] || '')}`) + .join('&') + + return query ? `?${query}` : '' +} + +/** + * 获取当前页面完整地址(path + query)。 + */ +function getCurrentPageUrl() { + const pages = getCurrentPages() + if (!pages.length) { + return '' + } + + const current = pages[pages.length - 1] + const currentPath = current.route ? `/${current.route}` : '' + const currentQuery = current.options ? buildQuery(current.options) : '' + + return `${currentPath}${currentQuery}` +} + +function isLoginPage(url = '') { + return getPath(url) === LOGIN_PAGE +} + +function needLogin(url = '') { + return !isLoginPage(url) +} + +export function isAuthEnabled() { + return AUTH_ENABLED +} + +export function isTabPage(url = '') { + return TAB_PAGES.includes(getPath(url)) +} + +export function getToken() { + return uni.getStorageSync(AUTH_STORAGE_KEYS.TOKEN) || '' +} + +export function isLoggedIn() { + if (!AUTH_ENABLED) { + return true + } + + return Boolean(getToken()) +} + +export function getUser() { + return uni.getStorageSync(AUTH_STORAGE_KEYS.USER) || null +} + +/** + * 保存登录态。 + */ +export function setAuth({ token, user } = {}) { + if (token) { + uni.setStorageSync(AUTH_STORAGE_KEYS.TOKEN, token) + } + + if (user) { + uni.setStorageSync(AUTH_STORAGE_KEYS.USER, user) + } +} + +export function clearAuth() { + uni.removeStorageSync(AUTH_STORAGE_KEYS.TOKEN) + uni.removeStorageSync(AUTH_STORAGE_KEYS.USER) +} + +/** + * 跳转到登录页并携带重定向地址。 + */ +export function redirectToLogin(targetUrl = '') { + if (!AUTH_ENABLED) { + return + } + + const redirect = normalizeUrl(targetUrl || getCurrentPageUrl()) + if (isLoginPage(redirect)) { + return + } + + const loginUrl = redirect + ? `${LOGIN_PAGE}?redirect=${encodeURIComponent(redirect)}` + : LOGIN_PAGE + + uni.reLaunch({ + url: loginUrl + }) +} + +/** + * 登录成功后按 redirect 返回目标页。 + */ +export function goAfterLogin(redirect = '') { + const target = normalizeUrl(safeDecode(redirect) || HOME_PAGE) + const targetPath = getPath(target) + + if (isLoginPage(targetPath)) { + uni.switchTab({ + url: HOME_PAGE + }) + return + } + + if (isTabPage(targetPath)) { + uni.switchTab({ + url: targetPath + }) + return + } + + uni.reLaunch({ + url: target + }) +} + +function guardPage(url = '') { + if (!AUTH_ENABLED) { + return true + } + + if (!needLogin(url) || isLoggedIn()) { + return true + } + + redirectToLogin(url) + return false +} + +/** + * 初始化 uni 路由拦截器,只注册一次。 + */ +export function setupAuthInterceptors() { + if (hasSetupInterceptors) { + return + } + + const methods = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab'] + methods.forEach((method) => { + uni.addInterceptor(method, { + invoke(args) { + return guardPage(args.url) + } + }) + }) + + hasSetupInterceptors = true +} + +export function ensureCurrentPageAuth() { + const currentUrl = getCurrentPageUrl() + if (!currentUrl) { + return + } + + guardPage(currentUrl) +} diff --git a/utils/navigation.js b/utils/navigation.js new file mode 100644 index 0000000..180e662 --- /dev/null +++ b/utils/navigation.js @@ -0,0 +1,18 @@ +/** + * 统一路由跳转封装,减少页面层重复样板代码。 + */ +export function navigateTo(url) { + if (!url) { + return + } + + uni.navigateTo({ + url + }) +} + +export function navigateBack(delta = 1) { + uni.navigateBack({ + delta + }) +} diff --git a/utils/request.js b/utils/request.js new file mode 100644 index 0000000..b8972a5 --- /dev/null +++ b/utils/request.js @@ -0,0 +1,6 @@ +import request from '@/api/http/client' + +/** + * 兼容层:保留旧导入路径 `@/utils/request`。 + */ +export default request diff --git a/utils/toast.js b/utils/toast.js new file mode 100644 index 0000000..f5a7ae1 --- /dev/null +++ b/utils/toast.js @@ -0,0 +1,10 @@ +/** + * 统一的轻提示封装。 + * @param {string} title 提示文案 + */ +export function showToast(title = '') { + uni.showToast({ + title, + icon: 'none' + }) +} diff --git a/utils/validators.js b/utils/validators.js new file mode 100644 index 0000000..4b9a763 --- /dev/null +++ b/utils/validators.js @@ -0,0 +1,17 @@ +/** + * 校验中国大陆手机号。 + * @param {string} value 手机号 + * @returns {boolean} + */ +export function isValidMobile(value = '') { + return /^1\d{10}$/.test(String(value)) +} + +/** + * 校验 6 位短信验证码。 + * @param {string} value 验证码 + * @returns {boolean} + */ +export function isValidSmsCode(value = '') { + return /^\d{6}$/.test(String(value)) +}