From e4a977532794ff226573ddbf53f2c92c2924eeb5 Mon Sep 17 00:00:00 2001 From: ozh Date: Fri, 17 Apr 2026 14:45:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + App.vue | 20 ++ api/http/client.js | 258 ++++++++++++++++++++++++ api/user.js | 30 +++ constants/auth.js | 13 ++ constants/bluetooth.js | 16 ++ constants/request.js | 6 + constants/routes.js | 16 ++ hooks/useBluetoothDiscovery.js | 186 ++++++++++++++++++ hooks/useCountdown.js | 49 +++++ index.html | 20 ++ main.js | 25 +++ manifest.json | 72 +++++++ pages.json | 67 +++++++ pages/device/add.vue | 37 ++++ pages/device/config.vue | 298 ++++++++++++++++++++++++++++ pages/event/index.vue | 46 +++++ pages/index/index.vue | 149 ++++++++++++++ pages/login/index.vue | 346 +++++++++++++++++++++++++++++++++ pages/mine/index.vue | 173 +++++++++++++++++ static/logo.png | Bin 0 -> 4023 bytes styles/common.css | 46 +++++ uni.promisify.adaptor.js | 13 ++ uni.scss | 76 ++++++++ utils/api/user.js | 6 + utils/auth.js | 210 ++++++++++++++++++++ utils/navigation.js | 18 ++ utils/request.js | 6 + utils/toast.js | 10 + utils/validators.js | 17 ++ 30 files changed, 2234 insertions(+) create mode 100644 .gitignore create mode 100644 App.vue create mode 100644 api/http/client.js create mode 100644 api/user.js create mode 100644 constants/auth.js create mode 100644 constants/bluetooth.js create mode 100644 constants/request.js create mode 100644 constants/routes.js create mode 100644 hooks/useBluetoothDiscovery.js create mode 100644 hooks/useCountdown.js create mode 100644 index.html create mode 100644 main.js create mode 100644 manifest.json create mode 100644 pages.json create mode 100644 pages/device/add.vue create mode 100644 pages/device/config.vue create mode 100644 pages/event/index.vue create mode 100644 pages/index/index.vue create mode 100644 pages/login/index.vue create mode 100644 pages/mine/index.vue create mode 100644 static/logo.png create mode 100644 styles/common.css create mode 100644 uni.promisify.adaptor.js create mode 100644 uni.scss create mode 100644 utils/api/user.js create mode 100644 utils/auth.js create mode 100644 utils/navigation.js create mode 100644 utils/request.js create mode 100644 utils/toast.js create mode 100644 utils/validators.js 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 0000000000000000000000000000000000000000..b5771e209bb677e2ebd5ff766ad5ee11790f305a GIT binary patch literal 4023 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 SYs}WS&%*SCNx89m_ { + 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)) +}