更新:登录功能

This commit is contained in:
2025-11-04 12:37:04 +08:00
commit a21fb92916
897 changed files with 51500 additions and 0 deletions

24
App.vue Normal file
View 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
View 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
View 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
View 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
View 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
View 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;

View 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,
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,10 @@
## 1.0.32025-06-26
- index.html 模板中的 lang="en" 改成 lang="zh-CN"
## 1.0.22022-06-30
- 新增 支持 ios 安全区
## 1.0.12021-11-26
- 新增 schema 国际化 (HBuilderX 3.3+)
- 修复 非 Android 平台切换语音无法实时变化的问题
- 修复 设置某些语言下无法生效的问题 (HBuilderX 3.3+)
## 1.0.02021-10-20
- 初始化

16
hooks/usePageAuth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
static/emojis/qq/0@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/emojis/qq/0@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
static/emojis/qq/100@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
static/emojis/qq/100@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/emojis/qq/101@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
static/emojis/qq/101@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/emojis/qq/102@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/emojis/qq/102@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
static/emojis/qq/103@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/emojis/qq/103@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
static/emojis/qq/104@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
static/emojis/qq/104@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
static/emojis/qq/105@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
static/emojis/qq/105@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
static/emojis/qq/106@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
static/emojis/qq/106@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/emojis/qq/107@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
static/emojis/qq/107@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
static/emojis/qq/108@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
static/emojis/qq/108@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
static/emojis/qq/109@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
static/emojis/qq/109@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/emojis/qq/10@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
static/emojis/qq/10@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
static/emojis/qq/110@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/emojis/qq/110@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
static/emojis/qq/111@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
static/emojis/qq/111@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/emojis/qq/112@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
static/emojis/qq/112@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
static/emojis/qq/113@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/emojis/qq/113@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
static/emojis/qq/114@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
static/emojis/qq/114@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
static/emojis/qq/115@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
static/emojis/qq/115@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
static/emojis/qq/116@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
static/emojis/qq/116@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
static/emojis/qq/117@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
static/emojis/qq/117@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/emojis/qq/118@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
static/emojis/qq/118@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
static/emojis/qq/119@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
static/emojis/qq/119@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/emojis/qq/11@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/emojis/qq/11@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
static/emojis/qq/120@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
static/emojis/qq/120@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
static/emojis/qq/121@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
static/emojis/qq/121@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
static/emojis/qq/122@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
static/emojis/qq/122@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/emojis/qq/123@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
static/emojis/qq/123@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/emojis/qq/124@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
static/emojis/qq/124@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
static/emojis/qq/125@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
static/emojis/qq/125@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
static/emojis/qq/126@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
static/emojis/qq/126@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
static/emojis/qq/127@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/emojis/qq/127@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/emojis/qq/128@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
static/emojis/qq/128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/emojis/qq/129@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
static/emojis/qq/129@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
static/emojis/qq/12@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
static/emojis/qq/12@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
static/emojis/qq/130@2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

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