更新:登录功能
24
App.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
},
|
||||
onHide: function() {
|
||||
console.log('App Hide')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "@/static/tailwind.css";
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 DCloud
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# hello-i18n
|
||||
|
||||
# en
|
||||
|
||||
A demo project for uni-app globalization
|
||||
|
||||
This template include uni-framework, manifest.json, pages.json, tabbar, Page, Component, API
|
||||
|
||||
|
||||
# zh-hans
|
||||
|
||||
uni-app 国际化演示
|
||||
|
||||
包含 uni-framework、manifest.json、pages.json、tabbar、页面、组件、API
|
||||
|
||||
7
api/clients/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// api/clients/main.ts
|
||||
import { createRequestClient } from '../request';
|
||||
import { SERVICE_MAP } from '../config';
|
||||
|
||||
export const mainClient = createRequestClient({
|
||||
baseURL: SERVICE_MAP.MAIN,
|
||||
});
|
||||
11
api/clients/payment.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// api/clients/payment.ts
|
||||
/**
|
||||
* 支付专用接口实例
|
||||
*/
|
||||
import { createRequestClient } from '../request';
|
||||
import { SERVICE_MAP } from '../config';
|
||||
|
||||
export const paymentClient = createRequestClient({
|
||||
baseURL: SERVICE_MAP.PAYMENT,
|
||||
});
|
||||
|
||||
28
api/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// api/config.ts
|
||||
export const ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
/**
|
||||
* 开发/生产的 base url 组织成 SERVICE_MAP。
|
||||
* 根据实际域名替换下面的地址。
|
||||
*/
|
||||
const BASE_URL_MAP = {
|
||||
development: {
|
||||
MAIN: 'http://192.168.110.100:9300/pb/',
|
||||
// PAYMENT: 'https://dev-pay.example.com', // 暂时用不到
|
||||
// CDN: 'https://cdn-dev.example.com', // 暂时用不到
|
||||
},
|
||||
production: {
|
||||
MAIN: 'https://global.nuttyreading.com/',
|
||||
// PAYMENT: 'https://pay.example.com', // 暂时用不到
|
||||
// CDN: 'https://cdn.example.com', // 暂时用不到
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const APP_INFO = {
|
||||
TYPE: 'abroad', // APP 名称
|
||||
VERSION_CODE: '1.0.0', // APP 版本号,可能升级的时候会用,这里需要再确定?
|
||||
}
|
||||
|
||||
export const REQUEST_TIMEOUT = 15000;
|
||||
|
||||
export const SERVICE_MAP = (BASE_URL_MAP as any)[ENV] ?? BASE_URL_MAP.development;
|
||||
32
api/interceptors/request.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// 片段示例 - requestInterceptor 更稳健的写法
|
||||
import type { IRequestOptions } from '../types'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { APP_INFO } from '@/api/config'
|
||||
|
||||
export function requestInterceptor(options: IRequestOptions): IRequestOptions {
|
||||
const headers = { ...(options.headers || {}) }
|
||||
|
||||
// 更明确地调用 useUserStore
|
||||
let token = ''
|
||||
try {
|
||||
const userStore = typeof useUserStore === 'function' ? useUserStore() : null
|
||||
token = userStore?.token || uni.getStorageSync('token') || ''
|
||||
} catch (e) {
|
||||
token = uni.getStorageSync('token') || ''
|
||||
}
|
||||
|
||||
if (token) headers.token = token
|
||||
|
||||
// Content-Type:只有在没有 files 时才默认为 application/json
|
||||
if (!options.files && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json;charset=UTF-8'
|
||||
}
|
||||
|
||||
headers['appType'] = APP_INFO.TYPE
|
||||
headers['version_code'] = APP_INFO.VERSION_CODE || '1.0.0'
|
||||
|
||||
return {
|
||||
...options,
|
||||
header: headers,
|
||||
}
|
||||
}
|
||||
78
api/interceptors/response.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// api/interceptors/response.ts
|
||||
import type { IApiResponse } from '../types';
|
||||
|
||||
/**
|
||||
* 响应拦截器:严格兼容原项目返回约定
|
||||
*
|
||||
* 原项目要点回顾:
|
||||
* - 当 response.statusCode == 200 且 (body.success === true || body.code == 0) 时判定为成功;
|
||||
* - 部分错误码("401", 1000,1001,1100,402 等)表示需要重新登录/清除状态并跳转;
|
||||
* - 错误时会返回 Promise.reject({ statusCode: 0, errMsg: ... , data: body })
|
||||
*
|
||||
*/
|
||||
|
||||
function handleAuthExpired() {
|
||||
// 清空本地登录信息(保持与原项目一致)
|
||||
try {
|
||||
uni.removeStorageSync('userInfo');
|
||||
} catch (e) {}
|
||||
// 跳转 login,与原项目保持一致的路径
|
||||
// 在小程序/APP/H5 情况下原项目分别做了适配,简单通用处理如下:
|
||||
uni.showToast({ title: '登录失效,请重新登录', icon: 'none' });
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({ url: '/pages/login/login' });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
export function responseInterceptor(res: UniApp.RequestSuccessCallbackResult) {
|
||||
// 先处理非 200 的 http 状态
|
||||
if (res.statusCode && res.statusCode !== 200) {
|
||||
const msg = `网络错误(${res.statusCode})`;
|
||||
uni.showToast({ title: msg, icon: 'none' });
|
||||
return Promise.reject({ statusCode: res.statusCode, errMsg: msg, response: res });
|
||||
}
|
||||
|
||||
// 可能为字符串,尝试解析(原项目也做了类似处理)
|
||||
let httpData: IApiResponse | string = res.data as any;
|
||||
if (typeof httpData === 'string') {
|
||||
try {
|
||||
httpData = JSON.parse(httpData);
|
||||
} catch (e) {
|
||||
// 无法解析仍然返回原始 body
|
||||
}
|
||||
}
|
||||
|
||||
// 规范化 message 字段
|
||||
const message = (httpData as any).msg || (httpData as any).message || (httpData as any).errMsg || '';
|
||||
|
||||
// 成功判断:与原项目一致的条件
|
||||
const successFlag = (httpData as any).success === true || (httpData as any).code === 0;
|
||||
|
||||
if (successFlag) {
|
||||
// 返回原始 httpData(与原项目 dataFactory 返回 Promise.resolve(httpData) 保持一致)
|
||||
// 但大多数调用者更关心 data 字段,这里返回整个 httpData,调用者可取 .data
|
||||
return Promise.resolve(httpData);
|
||||
}
|
||||
|
||||
// 登录失效或需要强制登录的一些 code(与原项目一致)
|
||||
const code = (httpData as any).code;
|
||||
if (code === '401' || code === 401) {
|
||||
// 触发登出流程
|
||||
handleAuthExpired();
|
||||
return Promise.reject({ statusCode: 0, errMsg: '登录失效', data: httpData });
|
||||
}
|
||||
|
||||
// 原项目还将 1000,1001,1100,402 等视作需要强制登录
|
||||
if (code === '1000' || code === '1001' || code === 1000 || code === 1001 || code === 1100 || code === '402' || code === 402) {
|
||||
handleAuthExpired();
|
||||
return Promise.reject({ statusCode: 0, errMsg: message || '请登录', data: httpData });
|
||||
}
|
||||
|
||||
// 其他后端业务错误:toast 并 reject
|
||||
const errMsg = message || '请求异常';
|
||||
if (errMsg) {
|
||||
uni.showToast({ title: errMsg, icon: 'none' });
|
||||
}
|
||||
|
||||
return Promise.reject({ statusCode: 0, errMsg, data: httpData });
|
||||
}
|
||||
49
api/modules/auth.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// api/modules/auth.ts
|
||||
import { mainClient } from '@/api/clients/main'
|
||||
import type { IApiResponse } from '@/api/types'
|
||||
import type { IUserInfo, ILoginResponse } from '@/types/user'
|
||||
|
||||
/**
|
||||
* 验证码登录/注册
|
||||
* @param tel 邮箱地址
|
||||
* @param code 验证码
|
||||
*/
|
||||
export async function loginWithCode(tel: string, code: string) {
|
||||
const res = await mainClient.request<IApiResponse<ILoginResponse>>({
|
||||
url: 'book/user/registerOrLogin',
|
||||
method: 'GET',
|
||||
data: { tel, code }
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码登录
|
||||
* @param phone 邮箱地址
|
||||
* @param password 密码
|
||||
*/
|
||||
export async function loginWithPassword(phone: string, password: string) {
|
||||
const res = await mainClient.request<IApiResponse<ILoginResponse>>({
|
||||
url: 'book/user/login',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
data: { phone, password }
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param phone 邮箱地址
|
||||
* @param code 验证码
|
||||
* @param password 新密码
|
||||
*/
|
||||
export async function resetPassword(phone: string, code: string, password: string) {
|
||||
const res = await mainClient.request<IApiResponse>({
|
||||
url: 'book/user/setPassword',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
data: { phone, code, password }
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
31
api/modules/common.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// api/modules/common.ts
|
||||
import { mainClient } from '@/api/clients/main'
|
||||
import type { IApiResponse } from '@/api/types'
|
||||
import type { IAgreement } from '@/types/user'
|
||||
|
||||
export const commonApi = {
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
* @param email 邮箱地址
|
||||
*/
|
||||
sendMailCaptcha: async (email: string) => {
|
||||
const res = await mainClient.request<IApiResponse>({
|
||||
url: 'common/user/getMailCaptcha',
|
||||
method: 'GET',
|
||||
data: { email }
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
/**
|
||||
* 获取协议内容
|
||||
* @param id 协议 ID (111: 用户协议, 112: 隐私政策)
|
||||
*/
|
||||
getAgreement: async (id: number) => {
|
||||
const res = await mainClient.request<IApiResponse<IAgreement>>({
|
||||
url: 'sys/agreement/getAgreement',
|
||||
method: 'POST',
|
||||
data: { id }
|
||||
})
|
||||
return res.agreement
|
||||
}
|
||||
}
|
||||
45
api/request.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// api/request.ts
|
||||
import { requestInterceptor } from './interceptors/request';
|
||||
import { responseInterceptor } from './interceptors/response';
|
||||
import type { IRequestOptions, ICreateClientConfig } from './types';
|
||||
import { REQUEST_TIMEOUT } from './config';
|
||||
|
||||
export function createRequestClient(cfg: ICreateClientConfig) {
|
||||
const baseURL = cfg.baseURL;
|
||||
const timeout = cfg.timeout ?? REQUEST_TIMEOUT;
|
||||
|
||||
async function request<T = any>(options: IRequestOptions): Promise<T> {
|
||||
// 组装 final options
|
||||
const final: UniApp.RequestOptions = {
|
||||
...options,
|
||||
url: (options.url || '').startsWith('http') ? options.url : `${baseURL}${options.url}`,
|
||||
timeout,
|
||||
header: options.header || {},
|
||||
};
|
||||
|
||||
// run request interceptor to mutate headers, etc.
|
||||
const intercepted = requestInterceptor(final as IRequestOptions);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...intercepted,
|
||||
success(res: any) {
|
||||
// delegate to response interceptor
|
||||
responseInterceptor(res)
|
||||
.then((r) => {
|
||||
resolve(r as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
},
|
||||
fail(err: any) {
|
||||
uni.showToast({ title: '网络连接失败', icon: 'none' });
|
||||
reject(err);
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
}
|
||||
|
||||
return { request };
|
||||
}
|
||||
25
api/types.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// api/types.d.ts
|
||||
export interface IApiResponse<T = any> {
|
||||
/** 有些后端使用 code 字段,有些使用 success 字段 */
|
||||
code?: number | string;
|
||||
success?: boolean;
|
||||
data?: T;
|
||||
msg?: string;
|
||||
message?: string;
|
||||
errMsg?: string;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* createRequestClient 返回的 request 函数签名
|
||||
*/
|
||||
export interface IRequestOptions extends UniApp.RequestOptions {
|
||||
// 允许扩展额外字段(例如 FILE 上传专用等)
|
||||
maxSize?: number;
|
||||
files?: Array<{ name: string; uri: string; fileType?: string }>;
|
||||
}
|
||||
|
||||
export interface ICreateClientConfig {
|
||||
baseURL: string;
|
||||
timeout?: number;
|
||||
}
|
||||
72
auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
10
changelog.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1.0.3(2025-06-26)
|
||||
- index.html 模板中的 lang="en" 改成 lang="zh-CN"
|
||||
## 1.0.2(2022-06-30)
|
||||
- 新增 支持 ios 安全区
|
||||
## 1.0.1(2021-11-26)
|
||||
- 新增 schema 国际化 (HBuilderX 3.3+)
|
||||
- 修复 非 Android 平台切换语音无法实时变化的问题
|
||||
- 修复 设置某些语言下无法生效的问题 (HBuilderX 3.3+)
|
||||
## 1.0.0(2021-10-20)
|
||||
- 初始化
|
||||
16
hooks/usePageAuth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// hooks/usePageAuth.ts
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
export function usePageAuth(redirect = '/pages/login/login') {
|
||||
const store = useUserStore()
|
||||
|
||||
onShow(() => {
|
||||
if (!store.token) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: redirect })
|
||||
}, 800)
|
||||
}
|
||||
})
|
||||
}
|
||||
35
hooks/useRequest.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// hooks/useRequest.ts
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface IUseRequestOptions<T> {
|
||||
immediate?: boolean
|
||||
initialData?: T
|
||||
}
|
||||
|
||||
export function useRequest<T>(
|
||||
func: () => Promise<T>,
|
||||
options: IUseRequestOptions<T> = { immediate: false },
|
||||
) {
|
||||
const loading = ref(false)
|
||||
const error = ref<any>(null)
|
||||
const data = ref<T | undefined>(options.initialData)
|
||||
|
||||
const run = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await func()
|
||||
data.value = res
|
||||
return res
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
options.immediate && run()
|
||||
|
||||
return { loading, error, data, run }
|
||||
}
|
||||
41
hooks/useUpload.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// hooks/useUpload.ts
|
||||
import { ref } from 'vue'
|
||||
import { getAuthToken } from '@/utils/auth'
|
||||
|
||||
export function useUpload(uploadUrl: string) {
|
||||
const progress = ref(0)
|
||||
const uploading = ref(false)
|
||||
|
||||
const upload = (filePath: string) => {
|
||||
uploading.value = true
|
||||
return new Promise((resolve, reject) => {
|
||||
const uploadTask = uni.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: {
|
||||
Authorization: `Bearer ${getAuthToken()}`,
|
||||
},
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 200) resolve(data.data)
|
||||
else reject(data)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
complete: () => {
|
||||
uploading.value = false
|
||||
},
|
||||
})
|
||||
|
||||
uploadTask.onProgressUpdate((res) => {
|
||||
progress.value = res.progress
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { progress, uploading, upload }
|
||||
}
|
||||
20
index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
77
locale/en.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"locale": {
|
||||
"auto": "System",
|
||||
"en": "English",
|
||||
"zh-hans": "简体中文"
|
||||
},
|
||||
"global": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading",
|
||||
"tips": "Tips"
|
||||
},
|
||||
"tabar.course": "COURSE",
|
||||
"tabar.book": "EBOOK",
|
||||
"tabar.user": "My",
|
||||
"index": {
|
||||
"title": "Taimed International",
|
||||
"schema": "Schema",
|
||||
"demo": "uni-app globalization",
|
||||
"demo-description": "Include uni-framework, manifest.json, pages.json, tabbar, Page, Component, API, Schema",
|
||||
"detail": "Detail",
|
||||
"language": "Language",
|
||||
"language-info": "Settings",
|
||||
"system-language": "System language",
|
||||
"application-language": "Application language",
|
||||
"language-change-confirm": "Applying this setting will restart the app"
|
||||
},
|
||||
"api": {
|
||||
"message": "Message"
|
||||
},
|
||||
"schema": {
|
||||
"name": "Name",
|
||||
"add": "Add",
|
||||
"add-success": "Add success"
|
||||
},
|
||||
"login": {
|
||||
"title": "Hello! Welcome to Taimed International",
|
||||
"codeLogin": "Verification Code Login/Registration",
|
||||
"passwordLogin": "Password Login",
|
||||
"email": "email",
|
||||
"emailPlaceholder": "please enter your email address",
|
||||
"emailError": "please enter a valid email address",
|
||||
"code": "verification code",
|
||||
"codePlaceholder": "please enter the verification code",
|
||||
"sendCodeSuccess": "Verification code sent successfully",
|
||||
"password": "password",
|
||||
"passwordPlaceholder": "please input a password",
|
||||
"getCode": "Get Code",
|
||||
"agree": "I have agreed",
|
||||
"userAgreement": "User Agreement",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"goLogin": "Go Login",
|
||||
"switchToPassword": "Password Login",
|
||||
"switchToCode": "Verification Code Login",
|
||||
"forgotPassword": "forgot password?",
|
||||
"noLogin": "No login experience",
|
||||
"agreeFirst": "Please agree to the User Agreement and Privacy Policy first",
|
||||
"loginSuccess": "success",
|
||||
"loginFailed": "failed"
|
||||
},
|
||||
"forget": {
|
||||
"title": "Forgot Password",
|
||||
"password": "password",
|
||||
"passwordPlaceholder": "enter your password",
|
||||
"passwordAgain": "password again",
|
||||
"passwordAgainPlaceholder": "enter your password again",
|
||||
"passwordNotMatch": "Passwords do not match",
|
||||
"submit": "Submit",
|
||||
"getCode": "Get Code",
|
||||
"passwordStrengthStrong": "Strong password strength.",
|
||||
"passwordStrengthMedium": "Medium password strength.",
|
||||
"passwordStrengthWeak": "please use a password consisting of at least two types: uppercase and lowercase letters, numbers, and symbols, with a length of 8 characters.",
|
||||
"passwordChanged": "Password changed successfully"
|
||||
}
|
||||
}
|
||||
8
locale/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import en from './en.json'
|
||||
import zhHans from './zh-Hans.json'
|
||||
// import zhHant from './zh-Hant.json'
|
||||
// import ja from './ja.json'
|
||||
export default {
|
||||
en,
|
||||
'zh-Hans': zhHans
|
||||
}
|
||||
78
locale/zh-Hans.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"locale": {
|
||||
"auto": "系统",
|
||||
"en": "English",
|
||||
"zh-hans": "简体中文"
|
||||
},
|
||||
"global": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"loading": "加载中",
|
||||
"tips": "提示"
|
||||
},
|
||||
"tabar.course": "课程",
|
||||
"tabar.book": "图书",
|
||||
"tabar.user": "我的",
|
||||
"index": {
|
||||
"title": "太湖国际",
|
||||
"schema": "Schema",
|
||||
"demo": "uni-app 国际化演示",
|
||||
"demo-description": "包含 uni-framework、manifest.json、pages.json、tabbar、页面、组件、API、Schema",
|
||||
"detail": "详情",
|
||||
"language": "语言",
|
||||
"language-info": "语言信息",
|
||||
"system-language": "系统语言",
|
||||
"application-language": "应用语言",
|
||||
"language-change-confirm": "应用此设置将重启App"
|
||||
},
|
||||
"api": {
|
||||
"message": "提示"
|
||||
},
|
||||
"schema": {
|
||||
"name": "姓名",
|
||||
"add": "新增",
|
||||
"add-success": "新增成功"
|
||||
},
|
||||
"login": {
|
||||
"title": "你好!欢迎来到 太湖国际",
|
||||
"codeLogin": "验证码登录/注册",
|
||||
"passwordLogin": "密码登录",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "请输入您的邮箱地址",
|
||||
"emailError": "请输入正确的邮箱地址",
|
||||
"code": "验证码",
|
||||
"codePlaceholder": "请输入验证码",
|
||||
"sendCodeSuccess": "验证码发送成功",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "请输入密码",
|
||||
"getCode": "获取验证码",
|
||||
"agree": "我已同意",
|
||||
"userAgreement": "用户协议",
|
||||
"privacyPolicy": "隐私政策",
|
||||
"goLogin": "登录",
|
||||
"switchToPassword": "密码登录",
|
||||
"switchToCode": "验证码登录",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"noLogin": "游客体验",
|
||||
"agreeFirst": "请先同意用户协议和隐私政策",
|
||||
"loginSuccess": "登录成功",
|
||||
"loginFailed": "登录失败"
|
||||
},
|
||||
"forget": {
|
||||
"title": "忘记密码",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "请输入密码",
|
||||
"passwordAgain": "再次输入密码",
|
||||
"passwordAgainPlaceholder": "请再次输入密码",
|
||||
"passwordNotMatch": "两次密码不一致",
|
||||
"submit": "提交",
|
||||
"getCode": "获取验证码",
|
||||
"passwordStrengthStrong": "密码强度强",
|
||||
"passwordStrengthMedium": "密码强度中等",
|
||||
"passwordStrengthWeak": "请使用至少包含大小写字母、数字、符号中的两种类型,长度为8个字符的密码",
|
||||
"passwordChanged": "密码修改成功"
|
||||
}
|
||||
}
|
||||
38
main.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import App from './App'
|
||||
import messages from './locale/index'
|
||||
import '@/uni.scss';
|
||||
|
||||
let i18nConfig = {
|
||||
locale: uni.getLocale(),
|
||||
messages
|
||||
}
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n''
|
||||
Vue.use(VueI18n)
|
||||
export i18n = new VueI18n(i18nConfig)
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
const app = new Vue({
|
||||
i18n,
|
||||
...App
|
||||
})
|
||||
app.$mount()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createPinia } from 'pinia'
|
||||
export const i18n = createI18n(i18nConfig)
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
72
manifest.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name" : "EducationApp2",
|
||||
"appid" : "__UNI__1250B39",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.1",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
107
package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"id": "uni-taimed-international-app",
|
||||
"name": "TaimedInternationalApp",
|
||||
"displayName": "太湖国际",
|
||||
"version": "1.0.3",
|
||||
"description": "太湖国际",
|
||||
"keywords": [
|
||||
"太湖国际",
|
||||
"中医学 国学 心理学"
|
||||
],
|
||||
"scripts": {
|
||||
"tailwind-dev": "npx @tailwindcss/cli -i ./tailwind-input.css -o ./static/tailwind.css --watch",
|
||||
"tailwind-build": "npx @tailwindcss/cli -i ./tailwind-input.css -o ./static/tailwind.css"
|
||||
},
|
||||
"dcloudext": {
|
||||
"sale": {
|
||||
"regular": {
|
||||
"price": "0.00"
|
||||
},
|
||||
"sourcecode": {
|
||||
"price": "0.00"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"qq": ""
|
||||
},
|
||||
"declaration": {
|
||||
"ads": "无",
|
||||
"data": "无",
|
||||
"permissions": "无"
|
||||
},
|
||||
"npmurl": "",
|
||||
"type": "uniapp-template-project",
|
||||
"darkmode": "x",
|
||||
"i18n": "√",
|
||||
"widescreen": "x"
|
||||
},
|
||||
"repository": "",
|
||||
"uni_modules": {
|
||||
"platforms": {
|
||||
"cloud": {
|
||||
"tcb": "√",
|
||||
"aliyun": "√",
|
||||
"alipay": "x"
|
||||
},
|
||||
"client": {
|
||||
"uni-app": {
|
||||
"vue": {
|
||||
"vue2": "√",
|
||||
"vue3": "√"
|
||||
},
|
||||
"web": {
|
||||
"safari": "√",
|
||||
"chrome": "√"
|
||||
},
|
||||
"app": {
|
||||
"vue": "√",
|
||||
"nvue": "-",
|
||||
"android": "√",
|
||||
"ios": "√",
|
||||
"harmony": "-"
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "√",
|
||||
"alipay": "-",
|
||||
"toutiao": "-",
|
||||
"baidu": "-",
|
||||
"kuaishou": "-",
|
||||
"jd": "-",
|
||||
"harmony": "-",
|
||||
"qq": "-",
|
||||
"lark": "-"
|
||||
},
|
||||
"quickapp": {
|
||||
"huawei": "-",
|
||||
"union": "-"
|
||||
}
|
||||
},
|
||||
"uni-app-x": {
|
||||
"web": {
|
||||
"safari": "-",
|
||||
"chrome": "-"
|
||||
},
|
||||
"app": {
|
||||
"android": "-",
|
||||
"ios": "-",
|
||||
"harmony": "-"
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"HBuilderX": "^3.1.0",
|
||||
"uni-app": "^4.03",
|
||||
"uni-app-x": ""
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.16"
|
||||
}
|
||||
}
|
||||
78
pages.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "%index.title%"
|
||||
}
|
||||
}, {
|
||||
"path": "pages/component/component",
|
||||
"style": {
|
||||
"navigationBarTitleText": "%index.component%",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
}, {
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "Login",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
}, {
|
||||
"path": "pages/login/forget",
|
||||
"style": {
|
||||
"navigationBarTitleText": "Forgot Password",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
}
|
||||
],
|
||||
// "tabBar": {
|
||||
// "color": "#7A7E83",
|
||||
// "selectedColor": "#007AFF",
|
||||
// "borderStyle": "black",
|
||||
// "backgroundColor": "#F8F8F8",
|
||||
// "list": [{
|
||||
// "pagePath": "pages/index/index",
|
||||
// "text": "%index.home%"
|
||||
// },
|
||||
// {
|
||||
// "pagePath": "pages/component/component",
|
||||
// "text": "%index.component%"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
"tabBar": {
|
||||
"color": "#444444",
|
||||
"selectedColor": "#079307",
|
||||
"borderStyle": "black",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"iconPath": "static/tab/icon1_n.png",
|
||||
"selectedIconPath": "static/tab/icon1_y.png",
|
||||
"text": "%tabar.course%"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/book/index",
|
||||
"iconPath": "static/tab/icon3_n.png",
|
||||
"selectedIconPath": "static/tab/icon3_y.png",
|
||||
"text": "%tabar.book%"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/my/my",
|
||||
"iconPath": "static/tab/icon4_n.png",
|
||||
"selectedIconPath": "static/tab/icon4_y.png",
|
||||
"text": "%tabar.user%"
|
||||
}
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "疯子读书",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
22
pages/component/component.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<no-data></no-data>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
title: this.$t('')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
24
pages/index/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="title bg-[red] text-left text-[#fff]">这是一个等待开发的首页</view>
|
||||
<view class="description bg-[red]">首页的内容是在线课程</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
163
pages/login/README.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 登录功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本模块实现了从 nuttyreading-hw2 项目迁移的完整登录功能,包括:
|
||||
|
||||
- ✅ 验证码登录/注册
|
||||
- ✅ 密码登录
|
||||
- ✅ 忘记密码
|
||||
- ✅ 用户协议和隐私政策
|
||||
- ✅ 游客体验入口
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue3 Composition API
|
||||
- TypeScript
|
||||
- Pinia (状态管理)
|
||||
- WotUI (UI 组件库)
|
||||
- Tailwind CSS + SCSS
|
||||
- UniApp
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
pages/user/
|
||||
├── login.vue # 登录页面
|
||||
└── forget.vue # 忘记密码页面
|
||||
|
||||
api/modules/
|
||||
├── auth.ts # 认证相关 API
|
||||
└── common.ts # 通用 API
|
||||
|
||||
stores/
|
||||
└── user.ts # 用户状态管理
|
||||
|
||||
types/
|
||||
└── user.ts # 用户相关类型定义
|
||||
|
||||
utils/
|
||||
└── validator.ts # 表单验证工具
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 登录相关
|
||||
|
||||
1. **验证码登录/注册**
|
||||
- 接口:`GET book/user/registerOrLogin`
|
||||
- 参数:`{ tel: string, code: string }`
|
||||
|
||||
2. **密码登录**
|
||||
- 接口:`POST book/user/login`
|
||||
- 参数:`{ phone: string, password: string }`
|
||||
|
||||
3. **重置密码**
|
||||
- 接口:`POST book/user/setPassword`
|
||||
- 参数:`{ phone: string, code: string, password: string }`
|
||||
|
||||
### 通用接口
|
||||
|
||||
1. **发送邮箱验证码**
|
||||
- 接口:`GET common/user/getMailCaptcha`
|
||||
- 参数:`{ email: string }`
|
||||
|
||||
2. **获取协议内容**
|
||||
- 接口:`GET common/agreement/detail`
|
||||
- 参数:`{ id: number }` (111: 用户协议, 112: 隐私政策)
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 1. 登录页面
|
||||
|
||||
访问路径:`/pages/user/login`
|
||||
|
||||
**验证码登录**:
|
||||
1. 输入邮箱地址
|
||||
2. 点击"Get Code"获取验证码
|
||||
3. 输入收到的验证码
|
||||
4. 勾选用户协议
|
||||
5. 点击"Go Login"登录
|
||||
|
||||
**密码登录**:
|
||||
1. 点击"Password Login"切换到密码登录
|
||||
2. 输入邮箱地址和密码
|
||||
3. 勾选用户协议
|
||||
4. 点击"Go Login"登录
|
||||
|
||||
### 2. 忘记密码
|
||||
|
||||
访问路径:`/pages/user/forget`
|
||||
|
||||
1. 输入邮箱地址
|
||||
2. 点击"Get Code"获取验证码
|
||||
3. 输入验证码
|
||||
4. 输入新密码(需满足强度要求)
|
||||
5. 再次输入新密码确认
|
||||
6. 点击"Submit"提交
|
||||
|
||||
### 3. 密码强度要求
|
||||
|
||||
- **强密码**:8位以上,包含大小写字母、数字和特殊字符
|
||||
- **中等密码**:8位以上,包含大小写字母、数字、特殊字符中的两项
|
||||
- **弱密码**:8位以上
|
||||
- **最低要求**:6-20位,必须包含字母和数字
|
||||
|
||||
## 状态管理
|
||||
|
||||
使用 Pinia 管理用户状态:
|
||||
|
||||
```typescript
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 登录成功后设置用户信息
|
||||
userStore.setUserInfo(userInfo)
|
||||
|
||||
// 检查登录状态
|
||||
if (userStore.isLoggedIn) {
|
||||
// 已登录
|
||||
}
|
||||
|
||||
// 登出
|
||||
userStore.logout()
|
||||
```
|
||||
|
||||
## 国际化
|
||||
|
||||
支持中英文切换,翻译文件位于:
|
||||
- `locale/en.json` - 英文
|
||||
- `locale/zh-Hans.json` - 简体中文
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API 地址**:已配置为 `https://global.nuttyreading.com/`
|
||||
2. **请求头**:自动添加 `token`、`appType: 'abroad'`、`version_code`
|
||||
3. **Token 失效**:自动处理 401 错误,清除用户信息并跳转登录页
|
||||
4. **验证码倒计时**:60秒,防止重复发送
|
||||
5. **协议同意**:登录和获取验证码前必须同意用户协议和隐私政策
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. 测试验证码登录流程
|
||||
2. 测试密码登录流程
|
||||
3. 测试忘记密码流程
|
||||
4. 测试登录方式切换
|
||||
5. 测试表单验证
|
||||
6. 测试协议弹窗
|
||||
7. 测试多平台兼容性(H5、小程序、APP)
|
||||
|
||||
## 已知问题
|
||||
|
||||
- 游客页面 `/pages/visitor/visitor` 需要单独实现
|
||||
- 部分图标可能需要根据实际设计调整
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2025-11-02)
|
||||
- ✅ 完成登录功能迁移
|
||||
- ✅ 实现验证码登录和密码登录
|
||||
- ✅ 实现忘记密码功能
|
||||
- ✅ 添加用户协议和隐私政策
|
||||
- ✅ 支持中英文国际化
|
||||
364
pages/login/forget.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="title">{{ $t('forget.title') }}</view>
|
||||
|
||||
<!-- 邮箱输入 -->
|
||||
<view class="input-box">
|
||||
<text class="input-tit">{{ $t('login.email') }}</text>
|
||||
<input
|
||||
class="input-text"
|
||||
type="text"
|
||||
v-model="email"
|
||||
:placeholder="$t('login.emailPlaceholder')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<view class="input-box">
|
||||
<text class="input-tit">{{ $t('login.code') }}</text>
|
||||
<input
|
||||
class="input-text"
|
||||
type="number"
|
||||
v-model="code"
|
||||
:placeholder="$t('login.codePlaceholder')"
|
||||
/>
|
||||
<wd-button type="info" :class="['code-btn', { active: !readonly }]" @click="getCode">
|
||||
{{ t('login.getCode') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<view class="input-box">
|
||||
<text class="input-tit">{{ $t('forget.password') }}</text>
|
||||
<input
|
||||
class="input-text"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
v-model="password"
|
||||
:placeholder="$t('forget.passwordPlaceholder')"
|
||||
@input="inputMethod(password)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 密码强度提示 -->
|
||||
<view v-if="note !== ''" class="password-hint" style="font-size: 28rpx; color: #999;">
|
||||
<text style="line-height: 34rpx; padding-top: 15rpx; display: block;">
|
||||
{{ note }}
|
||||
</text>
|
||||
<text v-html="str2" style="margin-top: 10rpx; display: block;"></text>
|
||||
</view>
|
||||
|
||||
<!-- 确认密码输入 -->
|
||||
<view class="input-box">
|
||||
<text class="input-tit">{{ $t('forget.passwordAgain') }}</text>
|
||||
<input
|
||||
class="input-text"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
v-model="confirmPassword"
|
||||
:placeholder="$t('forget.passwordAgainPlaceholder')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<view class="btn-box">
|
||||
<button @click="onSubmit" class="submit-btn">{{ $t('forget.submit') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { commonApi } from '@/api/modules/common'
|
||||
import { resetPassword } from '@/api/modules/auth'
|
||||
import { validateEmail, checkPasswordStrength } from '@/utils/validator'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 表单数据
|
||||
const email = ref('')
|
||||
const code = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
// 验证码相关
|
||||
const codeText = ref('Get Code')
|
||||
const readonly = ref(false)
|
||||
|
||||
// 密码强度相关
|
||||
const passwordOk = ref(false)
|
||||
const note = ref('')
|
||||
const str2 = ref('')
|
||||
|
||||
let codeTimer: any = null
|
||||
|
||||
/**
|
||||
* 表单验证函数
|
||||
*/
|
||||
// 邮箱是否为空
|
||||
const isEmailEmpty = () => {
|
||||
if (!email.value) {
|
||||
uni.showToast({
|
||||
title: t('login.emailPlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 邮箱格式验证
|
||||
const isEmailVerified = (emailVal: string) => {
|
||||
if (!validateEmail(emailVal)) {
|
||||
uni.showToast({
|
||||
title: t('login.emailError'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 验证码是否为空
|
||||
const isCodeEmpty = () => {
|
||||
if (!code.value) {
|
||||
uni.showToast({
|
||||
title: t('login.codePlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 密码是否为空
|
||||
const isPasswordEmpty = () => {
|
||||
if (!password.value) {
|
||||
uni.showToast({
|
||||
title: t('forget.passwordPlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 确认密码是否为空
|
||||
const isConfirmPasswordEmpty = () => {
|
||||
if (!confirmPassword.value) {
|
||||
uni.showToast({
|
||||
title: t('forget.passwordAgainPlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 密码是否匹配
|
||||
const isPasswordMatch = () => {
|
||||
if (confirmPassword.value !== password.value) {
|
||||
uni.showToast({
|
||||
title: t('forget.passwordNotMatch'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 密码强度验证
|
||||
const isPasswordStrongEnough = () => {
|
||||
if (!passwordOk.value) {
|
||||
uni.showToast({
|
||||
title: note.value || t('forget.passwordStrengthWeak'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
const getCode = async () => {
|
||||
if (readonly.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmailEmpty()) return
|
||||
if (!isEmailVerified(email.value)) return
|
||||
|
||||
try {
|
||||
uni.showLoading()
|
||||
await commonApi.sendMailCaptcha(email.value)
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: t('login.sendCodeSuccess'),
|
||||
icon: 'none'
|
||||
})
|
||||
getCodeState()
|
||||
} catch (error) {
|
||||
uni.hideLoading()
|
||||
console.error('Send code error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码倒计时
|
||||
*/
|
||||
const getCodeState = () => {
|
||||
if (codeTimer) {
|
||||
clearInterval(codeTimer)
|
||||
}
|
||||
|
||||
readonly.value = true
|
||||
let countdown = 60
|
||||
codeText.value = `60S`
|
||||
|
||||
codeTimer = setInterval(() => {
|
||||
countdown--
|
||||
codeText.value = `${countdown}S`
|
||||
if (countdown <= 0) {
|
||||
clearInterval(codeTimer)
|
||||
codeText.value = t('login.getCode')
|
||||
readonly.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码强度验证
|
||||
*/
|
||||
const inputMethod = (value: string) => {
|
||||
passwordOk.value = false
|
||||
const strength = checkPasswordStrength(value)
|
||||
|
||||
if (strength === 'strong') {
|
||||
str2.value = `<span style='color:#18bc37'>${t('forget.passwordStrengthStrong')}</span>`
|
||||
note.value = ''
|
||||
passwordOk.value = true
|
||||
} else if (strength === 'medium') {
|
||||
note.value = t('forget.passwordStrengthWeak')
|
||||
str2.value = `<span style='color:#2979ff'>${t('forget.passwordStrengthMedium')}</span>`
|
||||
passwordOk.value = true
|
||||
} else if (strength === 'weak') {
|
||||
note.value = t('forget.passwordStrengthWeak')
|
||||
str2.value = ''
|
||||
} else {
|
||||
passwordOk.value = false
|
||||
note.value = t('forget.passwordStrengthWeak')
|
||||
str2.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交重置密码
|
||||
*/
|
||||
const onSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!isEmailEmpty()) return
|
||||
if (!isEmailVerified(email.value)) return
|
||||
if (!isCodeEmpty()) return
|
||||
if (!isPasswordEmpty()) return
|
||||
if (!isPasswordStrongEnough()) return
|
||||
if (!isConfirmPasswordEmpty()) return
|
||||
if (!isPasswordMatch()) return
|
||||
|
||||
try {
|
||||
uni.showLoading()
|
||||
await resetPassword(email.value, code.value, password.value)
|
||||
uni.hideLoading()
|
||||
|
||||
uni.showModal({
|
||||
title: t('global.tips'),
|
||||
content: t('forget.passwordChanged'),
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
uni.hideLoading()
|
||||
console.error('Reset password error:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
background-color: #ffffff;
|
||||
padding: 0 30rpx;
|
||||
min-height: 100vh;
|
||||
|
||||
.title {
|
||||
padding: 50rpx 0 50rpx 30rpx;
|
||||
font-size: 60rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 100rpx;
|
||||
padding-top: 30rpx;
|
||||
border-bottom: 1rpx solid #eeeeee;
|
||||
align-items: center;
|
||||
|
||||
.input-tit {
|
||||
font-size: 30rpx;
|
||||
line-height: 34rpx;
|
||||
width: 230rpx;
|
||||
text-align: right;
|
||||
padding-right: 25rpx;
|
||||
padding-bottom: 10rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-text {
|
||||
flex: 1;
|
||||
height: 70rpx;
|
||||
line-height: 70rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
height: 60rpx;
|
||||
background-color: #f8f9fb;
|
||||
font-size: 28rpx;
|
||||
padding: 0 14rpx;
|
||||
min-width: 0;
|
||||
border-radius: 10rpx;
|
||||
color: #999;
|
||||
line-height: 60rpx;
|
||||
margin-left: 20rpx;
|
||||
border: none;
|
||||
margin-bottom: 10rpx;
|
||||
|
||||
&.active {
|
||||
color: $app-theme-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
padding: 10rpx 0 10rpx 205rpx;
|
||||
}
|
||||
|
||||
.btn-box {
|
||||
margin-top: 70rpx;
|
||||
|
||||
.submit-btn {
|
||||
font-size: 32rpx;
|
||||
background: linear-gradient(90deg, #54a966 0%, #54a966 100%);
|
||||
color: #fff;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
border-radius: 50rpx;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
667
pages/login/login.vue
Normal file
@@ -0,0 +1,667 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- Logo 背景区域 -->
|
||||
<view class="logo-bg">
|
||||
<text class="welcome-text">Hello! Welcome to<br>Taimed International</text>
|
||||
<image src="@/static/icon/login_icon.png" mode="aspectFit" class="icon-hua-1"></image>
|
||||
<image src="@/static/icon/login_icon.png" mode="aspectFit" class="icon-hua-2"></image>
|
||||
</view>
|
||||
|
||||
<!-- 登录表单区域 -->
|
||||
<view class="form-box">
|
||||
<!-- 登录方式标题 -->
|
||||
<view class="login-method">
|
||||
<view class="title active">
|
||||
<template v-if="loginType === 2000">{{ $t('login.codeLogin') }}</template>
|
||||
<template v-if="loginType === 1000">{{ $t('login.passwordLogin') }}</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 验证码登录 -->
|
||||
<view v-if="loginType === 2000">
|
||||
<view class="input-tit">{{ $t('login.email') }}</view>
|
||||
<view class="input-box">
|
||||
<input
|
||||
v-model="email"
|
||||
:placeholder="$t('login.emailPlaceholder')"
|
||||
placeholder-class="grey"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="input-tit">{{ $t('login.code') }}</view>
|
||||
<view class="input-box">
|
||||
<input
|
||||
v-model="code"
|
||||
:placeholder="$t('login.codePlaceholder')"
|
||||
placeholder-class="grey"
|
||||
maxlength="6"
|
||||
@confirm="onSubmit"
|
||||
/>
|
||||
<wd-button type="info" :class="['code-btn', { active: !readonly }]" @click="onSetCode">
|
||||
{{ t('login.getCode') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<view v-if="loginType === 1000">
|
||||
<view class="input-tit">{{ $t('login.email') }}</view>
|
||||
<view class="input-box">
|
||||
<input
|
||||
v-model="phoneEmail"
|
||||
:placeholder="$t('login.emailPlaceholder')"
|
||||
placeholder-class="grey"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="input-tit">{{ $t('login.password') }}</view>
|
||||
<view class="input-box">
|
||||
<input
|
||||
class="input-item"
|
||||
v-model="password"
|
||||
:password="!isSee"
|
||||
:placeholder="$t('login.passwordPlaceholder')"
|
||||
placeholder-class="grey"
|
||||
@confirm="onSubmit"
|
||||
/>
|
||||
<image
|
||||
v-if="isSee"
|
||||
src="@/static/icon/ic_logon_display.png"
|
||||
mode="aspectFit"
|
||||
class="eye-icon"
|
||||
@click="isSee = false"
|
||||
></image>
|
||||
<image
|
||||
v-else
|
||||
src="@/static/icon/ic_logon_hide.png"
|
||||
mode="aspectFit"
|
||||
class="eye-icon"
|
||||
@click="isSee = true"
|
||||
></image>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 协议同意 -->
|
||||
<view class="protocol-box">
|
||||
<view class="select" :class="{ active: agree }" @click="agreeAgreements"></view>
|
||||
<view class="protocol-text">
|
||||
{{ $t('login.agree') }}
|
||||
<text class="highlight" @click="yhxy">《{{ $t('login.userAgreement') }}》</text>
|
||||
and
|
||||
<text class="highlight" @click="yszc">《{{ $t('login.privacyPolicy') }}》</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<view class="btn-box">
|
||||
<button @click="onSubmit" class="login-btn" :class="{ active: btnShow }">
|
||||
{{ $t('login.goLogin') }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 切换登录方式 -->
|
||||
<view class="qie-huan">
|
||||
<view v-if="loginType === 2000" @click="changeLoginType(1000)">
|
||||
{{ $t('login.switchToPassword') }}
|
||||
</view>
|
||||
<view v-if="loginType === 1000" class="switch-links">
|
||||
<text @click="changeLoginType(2000)">{{ $t('login.switchToCode') }}</text>
|
||||
<text @click="onPageJump('/pages/login/forget')">{{ $t('login.forgotPassword') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 游客体验 -->
|
||||
<view class="youke-l">
|
||||
<view @click="onPageJump('/pages/visitor/visitor')">
|
||||
{{ $t('login.noLogin') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户协议弹窗 -->
|
||||
<wd-popup v-model="yhxyShow" position="bottom">
|
||||
<view class="tanchu">
|
||||
<view class="dp-title" v-html="yhxyText.title"></view>
|
||||
<view class="dp-content" v-html="yhxyText.content"></view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 隐私政策弹窗 -->
|
||||
<wd-popup v-model="yszcShow" position="bottom">
|
||||
<view class="tanchu">
|
||||
<view class="dp-title" v-html="yszcText.title"></view>
|
||||
<view class="dp-content" v-html="yszcText.content"></view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { loginWithCode, loginWithPassword } from '@/api/modules/auth'
|
||||
import { commonApi } from '@/api/modules/common'
|
||||
import { validateEmail } from '@/utils/validator'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 登录类型:2000-验证码登录,1000-密码登录
|
||||
const loginType = ref<1000 | 2000>(2000)
|
||||
|
||||
// 表单数据
|
||||
const email = ref('')
|
||||
const code = ref('')
|
||||
const password = ref('')
|
||||
const phoneEmail = ref('')
|
||||
const agree = ref(false)
|
||||
const isSee = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const codeText = ref('Get Code')
|
||||
const readonly = ref(false)
|
||||
const btnShow = ref(true)
|
||||
|
||||
// 用户协议和隐私政策
|
||||
const yhxyShow = ref(false)
|
||||
const yszcShow = ref(false)
|
||||
const yhxyText = ref<any>({
|
||||
title: '',
|
||||
content: ''
|
||||
})
|
||||
const yszcText = ref<any>({
|
||||
title: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
let codeTimer: any = null
|
||||
|
||||
/**
|
||||
* 切换登录方式
|
||||
* @param type 1000-密码登录,2000-验证码登录
|
||||
*/
|
||||
const changeLoginType = (type: 1000 | 2000) => {
|
||||
loginType.value = type
|
||||
code.value = ''
|
||||
password.value = ''
|
||||
const temporaryEmail = email.value || phoneEmail.value
|
||||
email.value = temporaryEmail
|
||||
phoneEmail.value = temporaryEmail
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单验证
|
||||
*/
|
||||
// 是否同意协议
|
||||
const isAgree = () => {
|
||||
if (!agree.value) {
|
||||
uni.showToast({
|
||||
title: t('login.agreeFirst'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 是否填写邮箱
|
||||
const isEmailEmpty = () => {
|
||||
if (!email.value) {
|
||||
uni.showToast({
|
||||
title: t('login.emailPlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 是否填写验证码
|
||||
const isCodeEmpty = () => {
|
||||
if (!code.value) {
|
||||
uni.showToast({
|
||||
title: t('login.codePlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 邮箱格式验证
|
||||
const isEmailVerified = (email: string) => {
|
||||
console.log(email, validateEmail(email))
|
||||
if (!validateEmail(email)) {
|
||||
uni.showToast({
|
||||
title: t('login.emailError'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 是否填写手机号或邮箱
|
||||
const isPhoneEmailEmpty = () => {
|
||||
if (!phoneEmail.value) {
|
||||
uni.showToast({
|
||||
title: t('login.emailPlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 是否填写密码
|
||||
const isPasswordEmpty = () => {
|
||||
if (!password.value) {
|
||||
uni.showToast({
|
||||
title: t('login.passwordPlaceholder'),
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 提交登录
|
||||
*/
|
||||
// 验证码登录
|
||||
const verifyCodeLogin = async () => {
|
||||
if (!isEmailEmpty()) return false
|
||||
|
||||
if (!isEmailVerified(email.value)) return false
|
||||
|
||||
if (!isCodeEmpty()) return false
|
||||
|
||||
const res = await loginWithCode(email.value, code.value)
|
||||
|
||||
return res
|
||||
}
|
||||
// 密码登录
|
||||
const passwordLogin = async () => {
|
||||
if (!isPhoneEmailEmpty()) return false
|
||||
|
||||
if (!isEmailVerified(phoneEmail.value)) return false
|
||||
|
||||
if (!isPasswordEmpty) return false
|
||||
|
||||
const res = await loginWithPassword(phoneEmail.value, password.value)
|
||||
|
||||
return res
|
||||
}
|
||||
// 提交登录
|
||||
const onSubmit = async () => {
|
||||
if(!isAgree()) return false
|
||||
|
||||
let res = null
|
||||
|
||||
switch (loginType.value) {
|
||||
case 2000:
|
||||
res = await verifyCodeLogin()
|
||||
break
|
||||
case 1000:
|
||||
res = await passwordLogin()
|
||||
break
|
||||
}
|
||||
|
||||
console.log('res', res)
|
||||
|
||||
if (res && res.userInfo && res.token) {
|
||||
res.userInfo.token = res.token.token
|
||||
console.log('设置用户信息login', res.userInfo)
|
||||
userStore.setUserInfo(res.userInfo)
|
||||
uni.showToast({
|
||||
title: t('login.loginSuccess'),
|
||||
duration: 600,
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.switchTab({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}, 600)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
const onSetCode = async () => {
|
||||
if (readonly.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if(!isAgree()) return false
|
||||
|
||||
if (!isEmailEmpty()) return false
|
||||
|
||||
if (!isEmailVerified(email.value)) return false
|
||||
|
||||
try {
|
||||
uni.showLoading()
|
||||
await commonApi.sendMailCaptcha(email.value)
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: t('login.sendCodeSuccess'),
|
||||
icon: 'none'
|
||||
})
|
||||
getCodeState()
|
||||
} catch (error) {
|
||||
uni.hideLoading()
|
||||
console.error('Send code error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码倒计时
|
||||
*/
|
||||
const getCodeState = () => {
|
||||
if (codeTimer) {
|
||||
clearInterval(codeTimer)
|
||||
}
|
||||
|
||||
readonly.value = true
|
||||
let countdown = 60
|
||||
codeText.value = '60S'
|
||||
|
||||
codeTimer = setInterval(() => {
|
||||
countdown--
|
||||
codeText.value = `${countdown}S`
|
||||
if (countdown <= 0) {
|
||||
clearInterval(codeTimer)
|
||||
codeText.value = t('login.getCode')
|
||||
readonly.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示用户协议
|
||||
*/
|
||||
const yhxy = () => {
|
||||
yhxyShow.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示隐私政策
|
||||
*/
|
||||
const yszc = () => {
|
||||
yszcShow.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面跳转
|
||||
*/
|
||||
const onPageJump = (url: string) => {
|
||||
uni.navigateTo({
|
||||
url: url,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取协议内容
|
||||
*/
|
||||
const getAgreements = async (id: number) => {
|
||||
const res = await commonApi.getAgreement(id)
|
||||
if (!res.content) return false
|
||||
let content = res.content || ''
|
||||
content = content.replace(
|
||||
/<h5>/g,
|
||||
'<view style="font-weight: bold;font-size: 32rpx;margin-top: 20rpx;margin-bottom: 20rpx;">'
|
||||
)
|
||||
content = content.replace(/<\/h5>/g, '</view>')
|
||||
return {
|
||||
title: res.title,
|
||||
content: content
|
||||
}
|
||||
}
|
||||
const loadAgreements = async () => {
|
||||
// 获取用户协议
|
||||
const yhxyRes = await getAgreements(111)
|
||||
yhxyText.value = yhxyRes
|
||||
|
||||
// 获取隐私政策
|
||||
const yszcRes = await getAgreements(112)
|
||||
yszcText.value = yszcRes
|
||||
}
|
||||
|
||||
// 同意协议
|
||||
const agreeAgreements = () => {
|
||||
agree.value = !agree.value;
|
||||
uni.setStorageSync('Agreements_agreed', agree.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAgreements()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
background-color: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.logo-bg {
|
||||
background-image: url('@/static/icon/login_bg.png');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
height: 25vh;
|
||||
position: relative;
|
||||
|
||||
.welcome-text {
|
||||
font-size: 45rpx;
|
||||
line-height: 65rpx;
|
||||
position: absolute;
|
||||
top: 120rpx;
|
||||
left: 60rpx;
|
||||
color: #fff;
|
||||
letter-spacing: 6rpx;
|
||||
}
|
||||
|
||||
.icon-hua-1 {
|
||||
position: absolute;
|
||||
bottom: 60rpx;
|
||||
left: 245rpx;
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.icon-hua-2 {
|
||||
position: absolute;
|
||||
bottom: 10rpx;
|
||||
right: 30rpx;
|
||||
width: 250rpx;
|
||||
height: 250rpx;
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
.form-box {
|
||||
padding: calc(var(--status-bar-height) + 40rpx) 60rpx 50rpx 60rpx;
|
||||
background-color: #fff;
|
||||
min-height: 75vh;
|
||||
|
||||
.login-method {
|
||||
text-align: center;
|
||||
padding: 0 96rpx;
|
||||
|
||||
.title {
|
||||
margin: 0 auto;
|
||||
font-size: 40rpx;
|
||||
letter-spacing: 3rpx;
|
||||
color: #666;
|
||||
|
||||
&.active {
|
||||
position: relative;
|
||||
color: $app-theme-color;
|
||||
padding-bottom: 35rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-tit {
|
||||
margin-top: 20rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: bold;
|
||||
color: $app-theme-color;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8rpx;
|
||||
border-bottom: solid 2rpx #efeef4;
|
||||
margin: 30rpx 0;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
height: 70rpx;
|
||||
}
|
||||
|
||||
.input-item {
|
||||
font-size: 28rpx;
|
||||
flex: 1;
|
||||
height: 70rpx;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
height: 60rpx;
|
||||
background-color: #f8f9fb;
|
||||
font-size: 28rpx;
|
||||
padding: 0 14rpx;
|
||||
min-width: 0;
|
||||
border-radius: 10rpx;
|
||||
color: #999;
|
||||
line-height: 60rpx;
|
||||
margin-left: 20rpx;
|
||||
border: none;
|
||||
|
||||
&.active {
|
||||
color: $app-theme-color;
|
||||
}
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
width: 36rpx;
|
||||
height: 24rpx;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.grey {
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.protocol-box {
|
||||
line-height: 38rpx;
|
||||
margin-top: 40rpx;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
|
||||
.select {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background-image: url('@/static/icon/ic_gender_unselected.png');
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% auto;
|
||||
margin-right: 15rpx;
|
||||
margin-top: 2rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.active {
|
||||
background-image: url('@/static/icon/ic_agreed.png');
|
||||
}
|
||||
}
|
||||
|
||||
.protocol-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $app-theme-color;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-box {
|
||||
margin-top: 40rpx;
|
||||
|
||||
.login-btn {
|
||||
font-size: 32rpx;
|
||||
background-color: #e5e5e5;
|
||||
color: #fff;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
border-radius: 50rpx;
|
||||
border: none;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(90deg, #54a966 0%, #54a966 100%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qie-huan {
|
||||
font-size: 26rpx;
|
||||
margin: 20rpx 0 0 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
|
||||
.switch-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.youke-l {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 60rpx 0 0 0;
|
||||
font-size: 26rpx;
|
||||
color: $app-theme-color;
|
||||
|
||||
view {
|
||||
font-weight: bold;
|
||||
border: 1px solid $app-theme-color;
|
||||
border-radius: 10rpx;
|
||||
padding: 5rpx 15rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tanchu {
|
||||
padding: 40rpx 10rpx;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
|
||||
.dp-title {
|
||||
font-size: 36rpx;
|
||||
margin-bottom: 50rpx;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dp-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
line-height: 45rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
BIN
static/biaoqing.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/emojis/qq/0@2x.gif
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/emojis/qq/0@2x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
static/emojis/qq/100@2x.gif
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
static/emojis/qq/100@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/emojis/qq/101@2x.gif
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
static/emojis/qq/101@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/emojis/qq/102@2x.gif
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/emojis/qq/102@2x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
static/emojis/qq/103@2x.gif
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/emojis/qq/103@2x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/emojis/qq/104@2x.gif
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
static/emojis/qq/104@2x.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
static/emojis/qq/105@2x.gif
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
static/emojis/qq/105@2x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/emojis/qq/106@2x.gif
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
static/emojis/qq/106@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/emojis/qq/107@2x.gif
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
static/emojis/qq/107@2x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
static/emojis/qq/108@2x.gif
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
static/emojis/qq/108@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/emojis/qq/109@2x.gif
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/emojis/qq/109@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/emojis/qq/10@2x.gif
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
static/emojis/qq/10@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/emojis/qq/110@2x.gif
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/emojis/qq/110@2x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
static/emojis/qq/111@2x.gif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/emojis/qq/111@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
static/emojis/qq/112@2x.gif
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
static/emojis/qq/112@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/emojis/qq/113@2x.gif
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
static/emojis/qq/113@2x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/emojis/qq/114@2x.gif
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/emojis/qq/114@2x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/emojis/qq/115@2x.gif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/emojis/qq/115@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/emojis/qq/116@2x.gif
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/emojis/qq/116@2x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/emojis/qq/117@2x.gif
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
static/emojis/qq/117@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/emojis/qq/118@2x.gif
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
static/emojis/qq/118@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/emojis/qq/119@2x.gif
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
static/emojis/qq/119@2x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
static/emojis/qq/11@2x.gif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
static/emojis/qq/11@2x.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
static/emojis/qq/120@2x.gif
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/emojis/qq/120@2x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/emojis/qq/121@2x.gif
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/emojis/qq/121@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/emojis/qq/122@2x.gif
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
static/emojis/qq/122@2x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
static/emojis/qq/123@2x.gif
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
static/emojis/qq/123@2x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
static/emojis/qq/124@2x.gif
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/emojis/qq/124@2x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
static/emojis/qq/125@2x.gif
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/emojis/qq/125@2x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
static/emojis/qq/126@2x.gif
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
static/emojis/qq/126@2x.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
static/emojis/qq/127@2x.gif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
static/emojis/qq/127@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/emojis/qq/128@2x.gif
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
static/emojis/qq/128@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
static/emojis/qq/129@2x.gif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/emojis/qq/129@2x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
static/emojis/qq/12@2x.gif
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/emojis/qq/12@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/emojis/qq/130@2x.gif
Normal file
|
After Width: | Height: | Size: 7.9 KiB |