3.4号提交
This commit is contained in:
parent
ca5998e98e
commit
21173963f9
|
|
@ -0,0 +1,8 @@
|
|||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# 开发环境标识(Vite 内置变量,无需修改)
|
||||
NODE_ENV = development
|
||||
|
||||
# 开发环境接口基础地址(对应你配置的 192.168.10.10:8085/api)
|
||||
VITE_API_BASE_URL = http://192.168.10.10:8085/api
|
||||
|
||||
# 请求超时时间(毫秒)
|
||||
VITE_REQUEST_TIMEOUT = 10000
|
||||
|
||||
# 可选:开发环境其他配置(如是否开启调试)
|
||||
VITE_DEBUG = true
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# 生产环境标识(Vite 内置变量,无需修改)
|
||||
NODE_ENV = production
|
||||
|
||||
# 生产环境接口基础地址
|
||||
VITE_API_BASE_URL = https://api.yourdomain.com
|
||||
|
||||
# 请求超时时间(毫秒)
|
||||
VITE_REQUEST_TIMEOUT = 10000
|
||||
|
||||
# 可选:生产环境其他配置(如关闭调试)
|
||||
VITE_DEBUG = false
|
||||
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
|
||||
#auto generated files
|
||||
.eslintrc-auto-import.json
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# admin-system
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "admin-system",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@casl/vue": "^2.2.6",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"element-plus": "^2.13.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/node": "^24.10.13",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.50.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.50.0",
|
||||
"prettier": "3.8.1",
|
||||
"sass": "^1.97.3",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div class="app-container">
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import axios from 'axios'
|
||||
import type { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { ElLoading, ElNotification } from 'element-plus'
|
||||
import { getStorage, setStorage } from '@/utils/storage'
|
||||
|
||||
// 定义loading实例类型,用于控制loading的显示和关闭
|
||||
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null
|
||||
// 记录当前请求数量,防止多个请求重复显示/关闭loading
|
||||
let requestCount = 0
|
||||
|
||||
// 显示loading的方法
|
||||
const showLoading = () => {
|
||||
if (requestCount === 0 && !loadingInstance) {
|
||||
loadingInstance = ElLoading.service({
|
||||
lock: true, // 锁定屏幕滚动
|
||||
fullscreen: true // 全屏显示
|
||||
})
|
||||
}
|
||||
requestCount++
|
||||
}
|
||||
|
||||
// 关闭loading的方法
|
||||
const hideLoading = () => {
|
||||
requestCount--
|
||||
if (requestCount <= 0) {
|
||||
if (loadingInstance) {
|
||||
loadingInstance.close()
|
||||
loadingInstance = null
|
||||
}
|
||||
requestCount = 0 // 重置计数,防止负数
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 环境自动识别 + 配置读取(核心修改:适配Vite环境变量)
|
||||
const env = import.meta.env
|
||||
const baseURL = env.VITE_API_BASE_URL || ''
|
||||
const timeout = Number(env.VITE_REQUEST_TIMEOUT) || 10000
|
||||
|
||||
// 2. 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL,
|
||||
timeout,
|
||||
headers: { 'Content-Type': 'application/json;charset=utf-8' },
|
||||
})
|
||||
|
||||
// 3. 请求拦截器:直接使用 axios 内置类型
|
||||
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||||
console.log('请求配置', config);
|
||||
|
||||
// 显示loading(可通过config配置项控制是否显示,比如某些请求不需要loading)
|
||||
if (config.headers?.showLoading !== false) {
|
||||
showLoading()
|
||||
}
|
||||
// 添加token到请求头
|
||||
const token = getStorage('token')
|
||||
if (token) {
|
||||
config.headers.token = token
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
const requestErrorInterceptor = (error: AxiosError) => {
|
||||
// 请求拦截器出错时也要关闭loading
|
||||
ElNotification({
|
||||
message: error.message || '未知的请求错误!',
|
||||
type: 'error',
|
||||
duration: 2000,
|
||||
})
|
||||
hideLoading()
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
service.interceptors.request.use(requestInterceptor, requestErrorInterceptor)
|
||||
|
||||
// 4. 响应拦截器
|
||||
const responseInterceptor = <T = unknown>(response: AxiosResponse<ApiResponse<T>>) => {
|
||||
//关闭loading(无论成功失败都要关)
|
||||
hideLoading()
|
||||
if (response.headers['new_token'] !== undefined) {
|
||||
setStorage('token', response.headers['new_token'] as string)
|
||||
}
|
||||
console.log('响应response', response);
|
||||
const { data } = response
|
||||
console.log('响应数据data', data);
|
||||
// 判断业务状态码
|
||||
if (data.code == 200 || data.code == 0) {
|
||||
// 仅当业务成功时,才弹成功提示
|
||||
ElNotification({
|
||||
message: data.msg || '操作成功!',
|
||||
type: 'success',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
})
|
||||
return data.data
|
||||
} else {
|
||||
if (data.code === 1 && data.msg === '用户不存在') {
|
||||
// 不弹错误提示,返回带特殊标识的结果
|
||||
// 其他业务失败:弹错误提示并拒绝Promise
|
||||
ElNotification({
|
||||
message: data.msg || '未知的错误!',
|
||||
type: 'error',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
})
|
||||
const result = {
|
||||
isUserNotExist: true, // 自定义标识,标记“用户不存在”
|
||||
} as T
|
||||
return result
|
||||
}
|
||||
|
||||
// 其他业务失败:弹错误提示并拒绝Promise
|
||||
ElNotification({
|
||||
message: data.msg || '未知的错误!',
|
||||
type: 'error',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
})
|
||||
return Promise.reject(data)
|
||||
}
|
||||
}
|
||||
|
||||
const responseErrorInterceptor = (error: AxiosError) => {
|
||||
// 响应失败,关闭loading
|
||||
hideLoading()
|
||||
|
||||
// 分类处理不同类型的网络错误
|
||||
let errMsg = ''
|
||||
if (error.message === 'Network Error') {
|
||||
errMsg = '网络异常,请检查您的网络连接!'
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
errMsg = `请求超时!请求耗时超过 ${timeout / 1000} 秒`
|
||||
} else if (error.response) {
|
||||
// 有响应状态码的错误
|
||||
const status = error.response.status
|
||||
switch (status) {
|
||||
case 401:
|
||||
errMsg = '登录状态过期,请重新登录!'
|
||||
// 可在此处添加跳转到登录页的逻辑
|
||||
break
|
||||
case 403:
|
||||
errMsg = '您没有权限访问该资源!'
|
||||
break
|
||||
case 404:
|
||||
errMsg = '请求的资源不存在!'
|
||||
break
|
||||
case 500:
|
||||
errMsg = '服务器内部错误,请稍后重试!'
|
||||
break
|
||||
default:
|
||||
errMsg = (error.response?.data as ApiResponse)?.msg || `请求失败:${status}`
|
||||
}
|
||||
} else {
|
||||
errMsg = error.message || '服务器错误'
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
message: errMsg,
|
||||
type: 'error',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
service.interceptors.response.use(responseInterceptor, responseErrorInterceptor)
|
||||
|
||||
// 5. 封装通用请求方法
|
||||
export const request = {
|
||||
get<T = unknown>(url: string, params?: unknown, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
return service.get<ApiResponse<T>>(url, { params, ...config }).then((res) => {
|
||||
return res as T;
|
||||
});
|
||||
},
|
||||
post<T = unknown>(url: string, data?: unknown, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
return service.post<ApiResponse<T>>(url, data, config).then((res) => {
|
||||
return res as T;
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
export default service
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { request } from '@/api';
|
||||
import type {
|
||||
MenuFormParams,
|
||||
DeleteMenuParams,
|
||||
MenuListResponse
|
||||
} from '@/api/types/menu';
|
||||
|
||||
/**
|
||||
* 获取菜单列表
|
||||
* @returns Promise<MenuListResponse> 菜单列表数据
|
||||
*/
|
||||
export async function getMenuApi(): Promise<MenuListResponse> {
|
||||
return request.get<MenuListResponse>('/menus/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加菜单
|
||||
* @param params 菜单表单参数(form-data格式)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function addMenuApi(params: MenuFormParams): Promise<void> {
|
||||
return request.post<void>('/menus/create', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑菜单
|
||||
* @param params 菜单表单参数(含id)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function editMenuApi(params: MenuFormParams): Promise<void> {
|
||||
return request.post<void>('/menus/edit', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除菜单
|
||||
* @param params 仅需菜单id
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function deleteMenuApi(params: DeleteMenuParams): Promise<void> {
|
||||
return request.post<void>('/menus/del', params);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { request } from '@/api';
|
||||
import type {
|
||||
RoleFormParams,
|
||||
DeleteRoleParams,
|
||||
SetRolePermissionParams,
|
||||
GetRolePermissionParams,
|
||||
RoleListResponse,
|
||||
PermissionIdsResponse
|
||||
} from '@/api/types/role';
|
||||
|
||||
/**
|
||||
* 获取角色列表
|
||||
* @returns Promise<RoleListResponse> 角色列表数据
|
||||
*/
|
||||
export async function getRoleList(): Promise<RoleListResponse> {
|
||||
return request.get<RoleListResponse>('/roles/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* @param params 角色名称、标题
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function createRole(params: RoleFormParams): Promise<void> {
|
||||
return request.post<void>('/roles/create', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑角色
|
||||
* @param params 角色ID、名称、标题
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function editRole(params: RoleFormParams): Promise<void> {
|
||||
return request.post<void>('/roles/edit', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
* @param params 角色ID
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function deleteRole(params: DeleteRoleParams): Promise<void> {
|
||||
return request.post<void>('/roles/del', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置角色权限
|
||||
* @param params 角色ID + 权限ID列表(逗号分隔字符串)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function setRolePermission(params: SetRolePermissionParams): Promise<void> {
|
||||
return request.post<void>('/roles/setPermission', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色权限
|
||||
* @param params 角色ID
|
||||
* @returns Promise<PermissionIdsResponse> 权限ID数组
|
||||
*/
|
||||
export async function getRolePermission(params: GetRolePermissionParams): Promise<PermissionIdsResponse> {
|
||||
return request.get<PermissionIdsResponse>('/roles/getPermission', { id: params.id });
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { request } from '@/api';
|
||||
import type {
|
||||
LoginParams,
|
||||
LoginResponse,
|
||||
RegisterParams,
|
||||
ForgotPasswordParams,
|
||||
ChangePasswordParams,
|
||||
} from '@/api/types/user';
|
||||
|
||||
/**
|
||||
* 登录接口
|
||||
* @param params 登录参数(邮箱/密码/登录类型等)
|
||||
* @returns Promise<LoginResponse> 登录成功返回token+用户信息
|
||||
*/
|
||||
export async function loginApi(params: LoginParams): Promise<LoginResponse> {
|
||||
return request.post<LoginResponse>('/login', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册接口
|
||||
* @param params 注册参数(邮箱/密码/验证码等)
|
||||
* @returns Promise<boolean> 注册成功返回true,失败抛错
|
||||
*/
|
||||
export async function registerApi(params: RegisterParams) {
|
||||
// 泛型指定为 boolean,匹配后端返回的注册成功标识
|
||||
return request.post<boolean>('/reg', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 忘记密码
|
||||
* @param params 忘记密码参数(邮箱/手机号等)
|
||||
* @returns Promise<string> 返回验证码ID(用于验证)
|
||||
*/
|
||||
export async function forgotPasswordApi(params: ForgotPasswordParams) {
|
||||
return request.post<string>('/resetPassword', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
* @param params 改密参数(验证码/新密码/邮箱等)
|
||||
* @returns Promise<boolean> 改密成功返回true
|
||||
*/
|
||||
export async function changePasswordApi(params: ChangePasswordParams) {
|
||||
return request.post<boolean>('/user/editLoginPass', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* @returns Promise<void> 无返回值
|
||||
*/
|
||||
export async function logoutApi() {
|
||||
return request.post<void>('/logout');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* @param params 发送验证码参数(邮箱/手机号等)
|
||||
* @returns Promise<string> 返回验证码ID(用于验证)
|
||||
*/
|
||||
export async function sendCodeApi(params: SendCodeParams) {
|
||||
return request.post<string>('/sendCode', params);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// src/utils/request.types.ts
|
||||
import type {
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
AxiosError,
|
||||
InternalAxiosRequestConfig // 导入内部类型
|
||||
} from 'axios'
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
isUserNotExist?: boolean
|
||||
}
|
||||
|
||||
// 修正拦截器类型,使用 InternalAxiosRequestConfig
|
||||
export type RequestInterceptor = (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>
|
||||
export type RequestErrorInterceptor = (error: AxiosError) => Promise<AxiosError>
|
||||
export type ResponseInterceptor = <T = unknown>(response: AxiosResponse<ApiResponse<T>>) => T | Promise<T>
|
||||
export type ResponseErrorInterceptor = (error: AxiosError) => Promise<AxiosError>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { ApiResponse } from './index';
|
||||
|
||||
/** 菜单基础参数类型(新增/编辑通用) */
|
||||
export interface MenuFormParams {
|
||||
name: string; // 菜单名称
|
||||
path: string; // 前端路由
|
||||
api: string; // 后端接口路由
|
||||
icon: string; // 菜单图标
|
||||
parent_id: string; // 上级菜单ID(string类型,匹配后端)
|
||||
sort: string; // 排序(string类型)
|
||||
type: string; // 类型:1菜单2按钮(string类型)
|
||||
status?: string; // 状态:1启用2禁用(可选,编辑时用)
|
||||
id?: string; // 菜单ID(仅编辑/删除时传)
|
||||
}
|
||||
|
||||
/** 删除菜单参数类型 */
|
||||
export interface DeleteMenuParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** 菜单列表响应类型(单条菜单数据) */
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
api: string;
|
||||
icon: string;
|
||||
parent_id: string;
|
||||
sort: string;
|
||||
type: string;
|
||||
status: string;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
/** 菜单列表响应类型(泛型约束) */
|
||||
export type MenuListResponse = MenuItem[];
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { ApiResponse } from './index';
|
||||
|
||||
// 角色基础参数(创建/编辑通用)
|
||||
export interface RoleFormParams {
|
||||
name: string; // 角色名称(唯一)
|
||||
title: string; // 角色标题
|
||||
id?: string; // 仅编辑/删除/权限操作时需要
|
||||
}
|
||||
|
||||
// 删除角色参数
|
||||
export interface DeleteRoleParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 设置角色权限参数
|
||||
export interface SetRolePermissionParams {
|
||||
id: string;
|
||||
permission_ids: string; // 权限ID,逗号分隔的字符串,如 "1,2,3,4"
|
||||
}
|
||||
|
||||
// 获取角色权限参数
|
||||
export interface GetRolePermissionParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 角色列表单条数据类型
|
||||
export interface RoleItem {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
// 角色列表响应类型
|
||||
export type RoleListResponse = RoleItem[];
|
||||
|
||||
// 权限ID列表响应类型
|
||||
export type PermissionIdsResponse = string[];
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// 登录参数类型
|
||||
export interface LoginParams {
|
||||
email?: string
|
||||
mobile?: string // 手机号(可选)
|
||||
password: string
|
||||
login_type: number // 1-邮箱登录 2-手机登录
|
||||
code?: string // 验证码(可选)
|
||||
}
|
||||
// 登录响应类型
|
||||
export interface LoginResponse {
|
||||
user: UserInfo
|
||||
menus_info: MenuItem[]
|
||||
permissions_info: PermissionItem[]
|
||||
isUserNotExist?: boolean // 自定义标识,标记“用户不存在”
|
||||
}
|
||||
// 注册参数类型
|
||||
export interface RegisterParams {
|
||||
email?: string // 邮箱(可选)
|
||||
mobile?: string // 手机号(可选)
|
||||
password: string
|
||||
reg_type?: number // 1-邮箱注册 2-手机注册
|
||||
invite_code?: string // 邀请码(可选)
|
||||
code: string
|
||||
|
||||
}
|
||||
|
||||
//忘记密码参数类型
|
||||
export interface ForgotPasswordParams {
|
||||
mobile?: string // 手机号(可选)
|
||||
email?: string // 邮箱(可选)
|
||||
reg_type: number // 1-邮箱登录 2-手机登录
|
||||
code: string
|
||||
login_password1: string
|
||||
login_password2: string
|
||||
}
|
||||
// 修改密码参数类型
|
||||
export interface ChangePasswordParams {
|
||||
mobile?: string // 手机号(可选)
|
||||
email?: string // 邮箱(可选)
|
||||
reg_type: number // 1-邮箱登录 2-手机登录
|
||||
code: string
|
||||
login_password1: string
|
||||
login_password2: string
|
||||
}
|
||||
// 发送验证码参数类型
|
||||
export interface SendCodeParams {
|
||||
mobile?: string // 手机号(可选)
|
||||
email?: string // 邮箱(可选)
|
||||
reg_type: number // 1-邮箱登录 2-手机登录
|
||||
type: number // 验证码类型
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
|
|
@ -0,0 +1,43 @@
|
|||
@import './user.scss';
|
||||
*{margin: 0;padding: 0;box-sizing: border-box;}
|
||||
html,body{
|
||||
font-size: 16px;
|
||||
color: black;
|
||||
}
|
||||
/* 过渡动画样式(name="fade" 对应前缀 fade-) */
|
||||
/* 1. 进入/离开的初始状态 */
|
||||
.user-enter-from,
|
||||
.user-leave-to {
|
||||
opacity: 0;
|
||||
/* 初始透明 */
|
||||
transform: translateX(20px);
|
||||
/* 初始右移20px */
|
||||
}
|
||||
|
||||
/* 2. 进入/离开的过渡过程 */
|
||||
.user-enter-active,
|
||||
.user-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
/* 过渡时长、缓动效果 */
|
||||
}
|
||||
|
||||
/* 3. 进入完成/离开开始的状态 */
|
||||
.user-enter-to,
|
||||
.user-leave-from {
|
||||
opacity: 1;
|
||||
/* 最终不透明 */
|
||||
transform: translateX(0);
|
||||
/* 最终回到原位 */
|
||||
}
|
||||
|
||||
// 全局通知样式
|
||||
.global-notification {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
// 2. 重写动画:从顶部淡入(取消右侧滑入)
|
||||
&.el-notification-fade-enter-active,
|
||||
&.el-notification-fade-leave-active {
|
||||
transition: all 0.3s ease-out !important;
|
||||
transform: translate(-50%, -150%) !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//user styles
|
||||
.user-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
|
||||
.user-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.logo {
|
||||
width: 115px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-form {
|
||||
// .el-form-item {
|
||||
// margin-bottom: 24px;
|
||||
// }
|
||||
|
||||
.phone-item {
|
||||
|
||||
.el-select {
|
||||
width: 120px;
|
||||
margin-right: 10px;
|
||||
|
||||
.country-option {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
margin-bottom: 0;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-item {
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// tab切换表单动画
|
||||
.form-switch-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(10px) scale(0.98);
|
||||
}
|
||||
|
||||
.form-switch-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.form-switch-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.form-switch-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.form-switch-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
|
||||
.form-switch-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.98);
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
/* 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 ElLoading: typeof import('element-plus/es').ElLoading
|
||||
const ElMessage: typeof import('element-plus/es').ElMessage
|
||||
const ElNo: typeof import('element-plus/es').ElNo
|
||||
const ElNotification: typeof import('element-plus/es').ElNotification
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const asyncComputed: typeof import('@vueuse/core').asyncComputed
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
const computedEager: typeof import('@vueuse/core').computedEager
|
||||
const computedInject: typeof import('@vueuse/core').computedInject
|
||||
const computedWithControl: typeof import('@vueuse/core').computedWithControl
|
||||
const controlledComputed: typeof import('@vueuse/core').controlledComputed
|
||||
const controlledRef: typeof import('@vueuse/core').controlledRef
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createEventHook: typeof import('@vueuse/core').createEventHook
|
||||
const createGlobalState: typeof import('@vueuse/core').createGlobalState
|
||||
const createInjectionState: typeof import('@vueuse/core').createInjectionState
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
|
||||
const createRef: typeof import('@vueuse/core').createRef
|
||||
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
|
||||
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
|
||||
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
|
||||
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
|
||||
const customRef: typeof import('vue').customRef
|
||||
const debouncedRef: typeof import('@vueuse/core').debouncedRef
|
||||
const debouncedWatch: typeof import('@vueuse/core').debouncedWatch
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const defineStore: typeof import('pinia').defineStore
|
||||
const eagerComputed: typeof import('@vueuse/core').eagerComputed
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const extendRef: typeof import('@vueuse/core').extendRef
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const h: typeof import('vue').h
|
||||
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
|
||||
const inject: typeof import('vue').inject
|
||||
const injectLocal: typeof import('@vueuse/core').injectLocal
|
||||
const isDefined: typeof import('@vueuse/core').isDefined
|
||||
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 isShallow: typeof import('vue').isShallow
|
||||
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
||||
const mapActions: typeof import('pinia').mapActions
|
||||
const mapGetters: typeof import('pinia').mapGetters
|
||||
const mapState: typeof import('pinia').mapState
|
||||
const mapStores: typeof import('pinia').mapStores
|
||||
const mapWritableState: typeof import('pinia').mapWritableState
|
||||
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 onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onClickOutside: typeof import('@vueuse/core').onClickOutside
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onElementRemoval: typeof import('@vueuse/core').onElementRemoval
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onKeyStroke: typeof import('@vueuse/core').onKeyStroke
|
||||
const onLongPress: typeof import('@vueuse/core').onLongPress
|
||||
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 onStartTyping: typeof import('@vueuse/core').onStartTyping
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const provide: typeof import('vue').provide
|
||||
const provideLocal: typeof import('@vueuse/core').provideLocal
|
||||
const reactify: typeof import('@vueuse/core').reactify
|
||||
const reactifyObject: typeof import('@vueuse/core').reactifyObject
|
||||
const reactive: typeof import('vue').reactive
|
||||
const reactiveComputed: typeof import('@vueuse/core').reactiveComputed
|
||||
const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
|
||||
const reactivePick: typeof import('@vueuse/core').reactivePick
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const refAutoReset: typeof import('@vueuse/core').refAutoReset
|
||||
const refDebounced: typeof import('@vueuse/core').refDebounced
|
||||
const refDefault: typeof import('@vueuse/core').refDefault
|
||||
const refManualReset: typeof import('@vueuse/core').refManualReset
|
||||
const refThrottled: typeof import('@vueuse/core').refThrottled
|
||||
const refWithControl: typeof import('@vueuse/core').refWithControl
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const resolveRef: typeof import('@vueuse/core').resolveRef
|
||||
const setActivePinia: typeof import('pinia').setActivePinia
|
||||
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const storeToRefs: typeof import('pinia').storeToRefs
|
||||
const syncRef: typeof import('@vueuse/core').syncRef
|
||||
const syncRefs: typeof import('@vueuse/core').syncRefs
|
||||
const templateRef: typeof import('@vueuse/core').templateRef
|
||||
const throttledRef: typeof import('@vueuse/core').throttledRef
|
||||
const throttledWatch: typeof import('@vueuse/core').throttledWatch
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toReactive: typeof import('@vueuse/core').toReactive
|
||||
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 tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
|
||||
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core').tryOnScopeDispose
|
||||
const tryOnUnmounted: typeof import('@vueuse/core').tryOnUnmounted
|
||||
const unref: typeof import('vue').unref
|
||||
const unrefElement: typeof import('@vueuse/core').unrefElement
|
||||
const until: typeof import('@vueuse/core').until
|
||||
const useActiveElement: typeof import('@vueuse/core').useActiveElement
|
||||
const useAnimate: typeof import('@vueuse/core').useAnimate
|
||||
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
|
||||
const useArrayEvery: typeof import('@vueuse/core').useArrayEvery
|
||||
const useArrayFilter: typeof import('@vueuse/core').useArrayFilter
|
||||
const useArrayFind: typeof import('@vueuse/core').useArrayFind
|
||||
const useArrayFindIndex: typeof import('@vueuse/core').useArrayFindIndex
|
||||
const useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast
|
||||
const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes
|
||||
const useArrayJoin: typeof import('@vueuse/core').useArrayJoin
|
||||
const useArrayMap: typeof import('@vueuse/core').useArrayMap
|
||||
const useArrayReduce: typeof import('@vueuse/core').useArrayReduce
|
||||
const useArraySome: typeof import('@vueuse/core').useArraySome
|
||||
const useArrayUnique: typeof import('@vueuse/core').useArrayUnique
|
||||
const useAsyncQueue: typeof import('@vueuse/core').useAsyncQueue
|
||||
const useAsyncState: typeof import('@vueuse/core').useAsyncState
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useBase64: typeof import('@vueuse/core').useBase64
|
||||
const useBattery: typeof import('@vueuse/core').useBattery
|
||||
const useBluetooth: typeof import('@vueuse/core').useBluetooth
|
||||
const useBreakpoints: typeof import('@vueuse/core').useBreakpoints
|
||||
const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel
|
||||
const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation
|
||||
const useCached: typeof import('@vueuse/core').useCached
|
||||
const useClipboard: typeof import('@vueuse/core').useClipboard
|
||||
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||
const useCloned: typeof import('@vueuse/core').useCloned
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useCountdown: typeof import('@vueuse/core').useCountdown
|
||||
const useCounter: typeof import('@vueuse/core').useCounter
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssSupports: typeof import('@vueuse/core').useCssSupports
|
||||
const useCssVar: typeof import('@vueuse/core').useCssVar
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
|
||||
const useCycleList: typeof import('@vueuse/core').useCycleList
|
||||
const useDark: typeof import('@vueuse/core').useDark
|
||||
const useDateFormat: typeof import('@vueuse/core').useDateFormat
|
||||
const useDebounce: typeof import('@vueuse/core').useDebounce
|
||||
const useDebounceFn: typeof import('@vueuse/core').useDebounceFn
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core').useDebouncedRefHistory
|
||||
const useDeviceMotion: typeof import('@vueuse/core').useDeviceMotion
|
||||
const useDeviceOrientation: typeof import('@vueuse/core').useDeviceOrientation
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core').useDevicePixelRatio
|
||||
const useDevicesList: typeof import('@vueuse/core').useDevicesList
|
||||
const useDisplayMedia: typeof import('@vueuse/core').useDisplayMedia
|
||||
const useDocumentVisibility: typeof import('@vueuse/core').useDocumentVisibility
|
||||
const useDraggable: typeof import('@vueuse/core').useDraggable
|
||||
const useDropZone: typeof import('@vueuse/core').useDropZone
|
||||
const useElementBounding: typeof import('@vueuse/core').useElementBounding
|
||||
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
|
||||
const useElementHover: typeof import('@vueuse/core').useElementHover
|
||||
const useElementSize: typeof import('@vueuse/core').useElementSize
|
||||
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
|
||||
const useEventBus: typeof import('@vueuse/core').useEventBus
|
||||
const useEventListener: typeof import('@vueuse/core').useEventListener
|
||||
const useEventSource: typeof import('@vueuse/core').useEventSource
|
||||
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
|
||||
const useFavicon: typeof import('@vueuse/core').useFavicon
|
||||
const useFetch: typeof import('@vueuse/core').useFetch
|
||||
const useFileDialog: typeof import('@vueuse/core').useFileDialog
|
||||
const useFileSystemAccess: typeof import('@vueuse/core').useFileSystemAccess
|
||||
const useFocus: typeof import('@vueuse/core').useFocus
|
||||
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
|
||||
const useFps: typeof import('@vueuse/core').useFps
|
||||
const useFullscreen: typeof import('@vueuse/core').useFullscreen
|
||||
const useGamepad: typeof import('@vueuse/core').useGamepad
|
||||
const useGeolocation: typeof import('@vueuse/core').useGeolocation
|
||||
const useId: typeof import('vue').useId
|
||||
const useIdle: typeof import('@vueuse/core').useIdle
|
||||
const useImage: typeof import('@vueuse/core').useImage
|
||||
const useInfiniteScroll: typeof import('@vueuse/core').useInfiniteScroll
|
||||
const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver
|
||||
const useInterval: typeof import('@vueuse/core').useInterval
|
||||
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
|
||||
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLink: typeof import('vue-router').useLink
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
|
||||
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
|
||||
const useMediaControls: typeof import('@vueuse/core').useMediaControls
|
||||
const useMediaQuery: typeof import('@vueuse/core').useMediaQuery
|
||||
const useMemoize: typeof import('@vueuse/core').useMemoize
|
||||
const useMemory: typeof import('@vueuse/core').useMemory
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useMounted: typeof import('@vueuse/core').useMounted
|
||||
const useMouse: typeof import('@vueuse/core').useMouse
|
||||
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
|
||||
const useMousePressed: typeof import('@vueuse/core').useMousePressed
|
||||
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
|
||||
const useNetwork: typeof import('@vueuse/core').useNetwork
|
||||
const useNow: typeof import('@vueuse/core').useNow
|
||||
const useObjectUrl: typeof import('@vueuse/core').useObjectUrl
|
||||
const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination
|
||||
const useOnline: typeof import('@vueuse/core').useOnline
|
||||
const usePageLeave: typeof import('@vueuse/core').usePageLeave
|
||||
const useParallax: typeof import('@vueuse/core').useParallax
|
||||
const useParentElement: typeof import('@vueuse/core').useParentElement
|
||||
const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver
|
||||
const usePermission: typeof import('@vueuse/core').usePermission
|
||||
const usePointer: typeof import('@vueuse/core').usePointer
|
||||
const usePointerLock: typeof import('@vueuse/core').usePointerLock
|
||||
const usePointerSwipe: typeof import('@vueuse/core').usePointerSwipe
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core').usePreferredColorScheme
|
||||
const usePreferredContrast: typeof import('@vueuse/core').usePreferredContrast
|
||||
const usePreferredDark: typeof import('@vueuse/core').usePreferredDark
|
||||
const usePreferredLanguages: typeof import('@vueuse/core').usePreferredLanguages
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
|
||||
const usePrevious: typeof import('@vueuse/core').usePrevious
|
||||
const useRafFn: typeof import('@vueuse/core').useRafFn
|
||||
const useRefHistory: typeof import('@vueuse/core').useRefHistory
|
||||
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
|
||||
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
|
||||
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
|
||||
const useScriptTag: typeof import('@vueuse/core').useScriptTag
|
||||
const useScroll: typeof import('@vueuse/core').useScroll
|
||||
const useScrollLock: typeof import('@vueuse/core').useScrollLock
|
||||
const useSessionStorage: typeof import('@vueuse/core').useSessionStorage
|
||||
const useShare: typeof import('@vueuse/core').useShare
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useSorted: typeof import('@vueuse/core').useSorted
|
||||
const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis
|
||||
const useStepper: typeof import('@vueuse/core').useStepper
|
||||
const useStorage: typeof import('@vueuse/core').useStorage
|
||||
const useStorageAsync: typeof import('@vueuse/core').useStorageAsync
|
||||
const useStyleTag: typeof import('@vueuse/core').useStyleTag
|
||||
const useSupported: typeof import('@vueuse/core').useSupported
|
||||
const useSwipe: typeof import('@vueuse/core').useSwipe
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const useTemplateRefsList: typeof import('@vueuse/core').useTemplateRefsList
|
||||
const useTextDirection: typeof import('@vueuse/core').useTextDirection
|
||||
const useTextSelection: typeof import('@vueuse/core').useTextSelection
|
||||
const useTextareaAutosize: typeof import('@vueuse/core').useTextareaAutosize
|
||||
const useThrottle: typeof import('@vueuse/core').useThrottle
|
||||
const useThrottleFn: typeof import('@vueuse/core').useThrottleFn
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core').useThrottledRefHistory
|
||||
const useTimeAgo: typeof import('@vueuse/core').useTimeAgo
|
||||
const useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
|
||||
const useTimeout: typeof import('@vueuse/core').useTimeout
|
||||
const useTimeoutFn: typeof import('@vueuse/core').useTimeoutFn
|
||||
const useTimeoutPoll: typeof import('@vueuse/core').useTimeoutPoll
|
||||
const useTimestamp: typeof import('@vueuse/core').useTimestamp
|
||||
const useTitle: typeof import('@vueuse/core').useTitle
|
||||
const useToNumber: typeof import('@vueuse/core').useToNumber
|
||||
const useToString: typeof import('@vueuse/core').useToString
|
||||
const useToggle: typeof import('@vueuse/core').useToggle
|
||||
const useTransition: typeof import('@vueuse/core').useTransition
|
||||
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
|
||||
const useUserMedia: typeof import('@vueuse/core').useUserMedia
|
||||
const useVModel: typeof import('@vueuse/core').useVModel
|
||||
const useVModels: typeof import('@vueuse/core').useVModels
|
||||
const useVibrate: typeof import('@vueuse/core').useVibrate
|
||||
const useVirtualList: typeof import('@vueuse/core').useVirtualList
|
||||
const useWakeLock: typeof import('@vueuse/core').useWakeLock
|
||||
const useWebNotification: typeof import('@vueuse/core').useWebNotification
|
||||
const useWebSocket: typeof import('@vueuse/core').useWebSocket
|
||||
const useWebWorker: typeof import('@vueuse/core').useWebWorker
|
||||
const useWebWorkerFn: typeof import('@vueuse/core').useWebWorkerFn
|
||||
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
|
||||
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
|
||||
const useWindowSize: typeof import('@vueuse/core').useWindowSize
|
||||
const watch: typeof import('vue').watch
|
||||
const watchArray: typeof import('@vueuse/core').watchArray
|
||||
const watchAtMost: typeof import('@vueuse/core').watchAtMost
|
||||
const watchDebounced: typeof import('@vueuse/core').watchDebounced
|
||||
const watchDeep: typeof import('@vueuse/core').watchDeep
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchIgnorable: typeof import('@vueuse/core').watchIgnorable
|
||||
const watchImmediate: typeof import('@vueuse/core').watchImmediate
|
||||
const watchOnce: typeof import('@vueuse/core').watchOnce
|
||||
const watchPausable: typeof import('@vueuse/core').watchPausable
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
const watchThrottled: typeof import('@vueuse/core').watchThrottled
|
||||
const watchTriggerable: typeof import('@vueuse/core').watchTriggerable
|
||||
const watchWithFilter: typeof import('@vueuse/core').watchWithFilter
|
||||
const whenever: typeof import('@vueuse/core').whenever
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Captcha: typeof import('./views/user/components/Captcha.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
Header: typeof import('./views/user/components/Header.vue')['default']
|
||||
LanguageSelect: typeof import('./components/LanguageSelect/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Tabs: typeof import('./views/user/components/Tabs.vue')['default']
|
||||
Text: typeof import('./views/user/components/Text.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<!-- src/components/LanguageSelect.vue -->
|
||||
<template>
|
||||
<!-- 独立的语言选择器 -->
|
||||
<el-select v-model="innerValue" placeholder="Select" style="width: 120px" @change="handleChange">
|
||||
<el-option v-for="item in languageOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 1. 定义语言类型(TS约束,和原有逻辑一致)
|
||||
type LanguageType = 'zh-CN' | 'zh-HK' | 'en-US'
|
||||
|
||||
// 2. 定义Props:接收父组件传入的选中值(支持v-model)
|
||||
const props = defineProps<{
|
||||
modelValue: LanguageType // v-model 绑定的核心prop
|
||||
}>()
|
||||
|
||||
// 3. 定义Emits:触发父组件更新值(实现v-model双向绑定)
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: LanguageType): void // v-model 必备emit
|
||||
(e: 'change', value: LanguageType): void // 可选:暴露change事件给父组件
|
||||
}>()
|
||||
|
||||
// 4. 内部响应式值(关联父组件的modelValue)
|
||||
const innerValue = ref<LanguageType>(props.modelValue)
|
||||
|
||||
// 5. 监听父组件值变化,同步到内部(避免单向数据流问题)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
innerValue.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 6. 语言选项(组件内封装,也可通过props传入增强灵活性)
|
||||
const languageOptions = [
|
||||
{ value: 'zh-CN' as LanguageType, label: '简体中文' },
|
||||
{ value: 'zh-HK' as LanguageType, label: '繁体中文' },
|
||||
{ value: 'en-US' as LanguageType, label: 'English' }
|
||||
]
|
||||
|
||||
// 7. 选择变化时触发emit,同步给父组件
|
||||
const handleChange = (value: LanguageType) => {
|
||||
emit('update:modelValue', value) // 实现v-model双向绑定
|
||||
emit('change', value) // 可选:额外暴露change事件
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type LanguageType = 'zh-CN' | 'zh-HK' | 'en-US'
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// css和scss
|
||||
import './assets/styles/global.scss'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
|
||||
const routes = [
|
||||
// 1. 补全根路径路由定义,避免通配符无限循环
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Test/index.vue'), // 替换为你的首页组件
|
||||
meta: { requiresAuth: true ,title: '首页' } // 首页需要登录
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'User',
|
||||
component: () => import('@/views/user/index.vue'),
|
||||
redirect: '/user/login',
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/user/Login.vue'),
|
||||
meta: { isAuthPage: true, title: '登录' }
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/user/Register.vue'),
|
||||
meta: { isAuthPage: true, title: '注册' }
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/views/user/ForgotPassword.vue'),
|
||||
meta: { isAuthPage: true, title: '忘记密码' }
|
||||
},
|
||||
{
|
||||
path: 'change-password',
|
||||
name: 'ChangePassword',
|
||||
component: () => import('@/views/user/ChangePassword.vue'),
|
||||
meta: { requiresAuth: true, title: '修改密码' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/user/login' // 404 重定向到登录页,避免循环
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from) => {
|
||||
const userStore = useUserStore();
|
||||
const isLoggedIn = !!userStore.token;
|
||||
|
||||
// 从修改密码页跳登录页时,直接放行(不跳首页)
|
||||
const isFromChangePasswordToLogin = from.path === '/user/change-password' && to.path === '/user/login';
|
||||
if (isFromChangePasswordToLogin) {
|
||||
return true; // 放行,跳过后续的“已登录访问授权页跳首页”逻辑
|
||||
}
|
||||
// 1. 已登录用户访问授权页 → 重定向到首页
|
||||
if (isLoggedIn && to.meta.isAuthPage) {
|
||||
if (to.path === '/') return true;
|
||||
return '/';
|
||||
}
|
||||
|
||||
// 2. 未登录用户访问需要登录的页面(包括首页)→ 重定向到登录页
|
||||
if (!isLoggedIn && to.meta.requiresAuth) {
|
||||
if (to.path === '/user/login') return true;
|
||||
return '/user/login';
|
||||
}
|
||||
|
||||
// 3. 其他情况正常放行
|
||||
return true;
|
||||
});
|
||||
|
||||
// 可选:设置页面标题
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title as string;
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { getStorage, setStorage } from '@/utils/storage'
|
||||
|
||||
export type LanguageType = 'zh-CN' | 'zh-HK' | 'en-US'
|
||||
const langMap: Record<LanguageType, string> = {
|
||||
'zh-CN': 'zh-CN', // 简体中文(中国大陆)
|
||||
'zh-HK': 'zh-HK', // 繁体中文(中国香港)
|
||||
'en-US': 'en-US' // 英语(美国)
|
||||
}
|
||||
|
||||
export const useLanguageStore = defineStore('language', () => {
|
||||
const initLang = getStorage('lang') as LanguageType || 'zh-CN'
|
||||
const lang = ref<LanguageType>(initLang)
|
||||
|
||||
if (!getStorage('lang')) {
|
||||
setStorage('lang', initLang)
|
||||
}
|
||||
// const setHtmlLang = (currentLang: LanguageType) => {
|
||||
// document.documentElement.lang = langMap[currentLang]
|
||||
// }
|
||||
|
||||
// setHtmlLang(lang.value)
|
||||
|
||||
const changeLang = (newLang: LanguageType) => {
|
||||
lang.value = newLang
|
||||
setStorage('lang', newLang)
|
||||
// setHtmlLang(newLang)
|
||||
}
|
||||
|
||||
return { lang, changeLang }
|
||||
})
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { getStorage, setStorage, removeStorage } from '@/utils/storage';
|
||||
import { loginApi, registerApi, logoutApi, forgotPasswordApi, changePasswordApi, sendCodeApi } from '@/api/modules/user';
|
||||
import type { LoginParams, RegisterParams, ForgotPasswordParams, ChangePasswordParams, SendCodeParams } from '@/api/types/user';
|
||||
import type { UserInfo, MenuItem, PermissionItem } from '@/stores/types/user';
|
||||
|
||||
// 定义用户 Store
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const router = useRouter();
|
||||
|
||||
// ========== 状态定义(与后端返回数据对应) ==========
|
||||
const userInfo = ref<UserInfo | null>(null);
|
||||
const menus = ref<MenuItem[]>([]);
|
||||
const permissions = ref<PermissionItem[]>([]);
|
||||
const token = ref<string>(getStorage('token') || '');
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
const isLoggedIn = computed(() => !!token.value);
|
||||
const permissionKeys = computed(() => permissions.value.map(item => item.name));
|
||||
|
||||
// ========== 核心方法(全部改为箭头函数) ==========
|
||||
/**
|
||||
* 登录方法
|
||||
* @param params 登录参数
|
||||
*/
|
||||
const login = async (params: LoginParams) => {
|
||||
try {
|
||||
const res = await loginApi(params);
|
||||
if(res.isUserNotExist) {
|
||||
// 用户不存在,跳转到注册页面
|
||||
router.push('/user/register');
|
||||
return;
|
||||
}
|
||||
token.value = res.user.token;
|
||||
userInfo.value = res.user;
|
||||
menus.value = res.menus_info;
|
||||
permissions.value = res.permissions_info;
|
||||
|
||||
setStorage('token', res.user.token);
|
||||
setStorage('userInfo', res.user);
|
||||
// 跳转到首页
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('登录失败', error);
|
||||
|
||||
throw error;
|
||||
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 注册方法
|
||||
* @param params 登录参数
|
||||
*/
|
||||
|
||||
const register = async (params: RegisterParams) => {
|
||||
try {
|
||||
const res = await registerApi(params);
|
||||
//跳转登录页面
|
||||
router.push('/user/login');
|
||||
} catch (error) {
|
||||
console.error('注册失败', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutApi();
|
||||
} catch (error) {
|
||||
console.error('退出登录接口调用失败', error);
|
||||
} finally {
|
||||
token.value = '';
|
||||
userInfo.value = null;
|
||||
menus.value = [];
|
||||
permissions.value = [];
|
||||
|
||||
removeStorage('token');
|
||||
removeStorage('userInfo');
|
||||
|
||||
// 跳转到登录页面
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 忘记密码
|
||||
*/
|
||||
const forgotPassword = async (params: ForgotPasswordParams) => {
|
||||
try {
|
||||
const res = await forgotPasswordApi(params);
|
||||
// 跳转登录页面
|
||||
router.push('/user/login');
|
||||
} catch (error) {
|
||||
console.error('忘记密码失败', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
const changePassword = async (params: ChangePasswordParams) => {
|
||||
try {
|
||||
const res = await changePasswordApi(params);
|
||||
// 跳转登录页面
|
||||
removeStorage('token');
|
||||
removeStorage('userInfo');
|
||||
router.push('/user/login');
|
||||
} catch (error) {
|
||||
console.error('修改密码失败', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
const sendCode = async (params: SendCodeParams) => {
|
||||
try {
|
||||
const res = await sendCodeApi(params);
|
||||
} catch (error) {
|
||||
console.error('发送验证码失败', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
userInfo,
|
||||
menus,
|
||||
permissions,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
permissionKeys,
|
||||
// 方法(箭头函数)
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
forgotPassword,
|
||||
changePassword,
|
||||
sendCode,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { ApiResponse } from './index';
|
||||
|
||||
// 用户信息子类型
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
role_id: number;
|
||||
pass_admin: string;
|
||||
salt_code_admin: string;
|
||||
real_check: number;
|
||||
par_uid: number;
|
||||
path: string;
|
||||
lang_type: number;
|
||||
reg_type: number;
|
||||
status: number;
|
||||
invite_code: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// 菜单子项类型
|
||||
export interface MenuChildItem {
|
||||
id: number;
|
||||
name: string;
|
||||
api: string;
|
||||
path: string | null;
|
||||
icon: string | null;
|
||||
parent_id: number;
|
||||
sort: number;
|
||||
type: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
// 菜单主项类型
|
||||
export interface MenuItem {
|
||||
id: number;
|
||||
name: string;
|
||||
api: string;
|
||||
path: string | null;
|
||||
icon: string | null;
|
||||
parent_id: number;
|
||||
sort: number;
|
||||
type: number;
|
||||
status: number;
|
||||
son: MenuChildItem[];
|
||||
}
|
||||
|
||||
// 权限信息类型
|
||||
export interface PermissionItem {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string;
|
||||
module: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
// 登录响应数据类型
|
||||
export interface LoginResponseData {
|
||||
user: UserInfo;
|
||||
menus_info: MenuItem[];
|
||||
permissions_info: PermissionItem[];
|
||||
}
|
||||
|
||||
// 登录接口响应类型(适配 ApiResponse 泛型)
|
||||
export type LoginResponse = LoginResponseData;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import CryptoJS from 'crypto-js'
|
||||
|
||||
/**
|
||||
* MD5 加密函数
|
||||
* @param str 要加密的字符串
|
||||
* @returns 加密后的 32 位小写字符串
|
||||
*/
|
||||
export const md5Encrypt = (str: string): string => {
|
||||
if (!str) return ''
|
||||
// 核心:调用 crypto-js 的 MD5 方法,转换为小写字符串
|
||||
return CryptoJS.MD5(str).toString(CryptoJS.enc.Hex).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* MD5 加密(大写)
|
||||
* @param str 要加密的字符串
|
||||
* @returns 加密后的 32 位大写字符串
|
||||
*/
|
||||
// export const md5EncryptUpper = (str: string): string => {
|
||||
// return md5Encrypt(str).toUpperCase()
|
||||
// }
|
||||
|
||||
/**
|
||||
* MD5 加盐加密(提升安全性)
|
||||
* @param str 要加密的字符串
|
||||
* @param salt 盐值(自定义,如项目唯一标识)
|
||||
* @returns 加盐后的 MD5 字符串
|
||||
*/
|
||||
// export const md5EncryptWithSalt = (str: string, salt: string = 'your-custom-salt'): string => {
|
||||
// // 盐值拼接方式可自定义,如 str + salt 或 salt + str
|
||||
// return md5Encrypt(`${str}${salt}`)
|
||||
// }
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import type { Ref } from 'vue';
|
||||
import { setStorage, getStorage, removeStorage } from '@/utils/storage';
|
||||
// 既支持指定的固定值,也支持任意字符串
|
||||
export type CaptchaType = 'phone' | 'email' | string;
|
||||
|
||||
// 本地存储的状态类型
|
||||
interface StoredCaptchaState {
|
||||
type: CaptchaType; // 验证码类型
|
||||
receiver: string; // 接收方(手机号/邮箱)
|
||||
sendTime: number; // 发送时间戳(毫秒)
|
||||
totalSeconds: number; // 总倒计时时长(默认60)
|
||||
}
|
||||
|
||||
// 倒计时状态类型(供组件绑定)
|
||||
export interface CaptchaCountdownState {
|
||||
countdown: Ref<number>; // 剩余秒数
|
||||
isCounting: Ref<boolean>; // 是否正在倒计时
|
||||
timer: number | null; // 定时器标识
|
||||
key: string; // 唯一标识(区分不同场景/类型的验证码,如:register_phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化验证码倒计时状态(支持刷新恢复)
|
||||
* @param key 唯一标识(必填,如:register_phone/register_email/forgotPassword_phone)
|
||||
* @param totalSeconds 总倒计时时长(默认60秒)
|
||||
* @returns 倒计时状态对象
|
||||
*/
|
||||
export const initCaptchaState = (key: string, totalSeconds: number = 60): CaptchaCountdownState => {
|
||||
const state: CaptchaCountdownState = {
|
||||
countdown: ref(0),
|
||||
isCounting: ref(false),
|
||||
timer: null,
|
||||
key
|
||||
};
|
||||
|
||||
// 页面刷新后,从sessionStorage恢复状态
|
||||
const stored = getStorage<StoredCaptchaState>(`captcha_${key}`, 'session');
|
||||
if (stored) {
|
||||
try {
|
||||
const now = Date.now();
|
||||
// 计算剩余时间 = 总时长 - (当前时间 - 发送时间戳)/1000
|
||||
const remaining = stored.totalSeconds - Math.floor((now - stored.sendTime) / 1000);
|
||||
|
||||
// 剩余时间>0,恢复倒计时
|
||||
if (remaining > 0) {
|
||||
state.countdown.value = remaining;
|
||||
state.isCounting.value = true;
|
||||
|
||||
// 重启定时器
|
||||
state.timer = window.setInterval(() => {
|
||||
state.countdown.value--;
|
||||
// 同步更新本地存储的剩余时间(可选,增强鲁棒性)
|
||||
updateStoredCaptchaState(state.key, state.countdown.value);
|
||||
|
||||
if (state.countdown.value <= 0) {
|
||||
clearCaptchaTimer(state); // 倒计时结束,清除状态
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
// 剩余时间<=0,清空本地存储
|
||||
removeStorage(`captcha_${key}`, 'session');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复验证码倒计时状态失败:', e);
|
||||
removeStorage(`captcha_${key}`, 'session');
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新本地存储的验证码状态(内部使用)
|
||||
*/
|
||||
const updateStoredCaptchaState = (key: string, remaining: number) => {
|
||||
const stored = getStorage<StoredCaptchaState>(`captcha_${key}`, 'session');
|
||||
if (stored) {
|
||||
// 仅更新剩余时间(可选,核心是时间戳,剩余时间只是辅助)
|
||||
setStorage(`captcha_${key}`, {
|
||||
...stored,
|
||||
// 可选:记录剩余时间,方便初始化时快速读取
|
||||
remaining
|
||||
}, 'session');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用发送验证码函数(支持手机/邮箱,防刷新重复发送)
|
||||
* @param type 验证码类型:phone/email
|
||||
* @param receiver 接收方:手机号/邮箱地址
|
||||
* @param state 倒计时状态(由initCaptchaState生成)
|
||||
* @param sendApi 实际发送验证码的接口函数(需自行实现,入参为receiver)
|
||||
* @returns Promise<boolean> 发送是否成功
|
||||
*/
|
||||
export const sendCaptcha = async (
|
||||
type: CaptchaType,
|
||||
receiver: string,
|
||||
state: CaptchaCountdownState,
|
||||
sendApi: (receiver: string) => Promise<any>
|
||||
): Promise<boolean> => {
|
||||
// ========== 核心:刷新后防重复发送的校验 ==========
|
||||
const stored = getStorage<StoredCaptchaState>(`captcha_${state.key}`, 'session');
|
||||
if (stored) {
|
||||
const now = Date.now();
|
||||
const elapsed = Math.floor((now - stored.sendTime) / 1000); // 已过去的秒数
|
||||
|
||||
// 未到冷却期(60秒),禁止发送
|
||||
if (elapsed < stored.totalSeconds) {
|
||||
const remaining = stored.totalSeconds - elapsed;
|
||||
ElNotification({
|
||||
message: `请${remaining}秒后再发送验证码`,
|
||||
type: 'warning',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
});
|
||||
// 恢复倒计时(防止内存状态和本地存储不一致)
|
||||
state.countdown.value = remaining;
|
||||
state.isCounting.value = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 原有校验 ==========
|
||||
if (state.isCounting.value) {
|
||||
ElNotification({
|
||||
message: '验证码已发送,请勿重复点击',
|
||||
type: 'warning',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!receiver) {
|
||||
ElNotification({
|
||||
message: `请输入${type === 'phone' ? '手机号' : '邮箱'}`,
|
||||
type: 'warning',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 调用真实发送接口
|
||||
// await sendApi(receiver);
|
||||
|
||||
// 2. 发送成功提示
|
||||
ElNotification({
|
||||
message: `验证码已发送至${type === 'phone' ? '手机' : '邮箱'},请注意查收`,
|
||||
type: 'success',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
});
|
||||
|
||||
// 3. 记录发送状态到本地存储(核心:防刷新)
|
||||
const storedState: StoredCaptchaState = {
|
||||
type,
|
||||
receiver,
|
||||
sendTime: Date.now(), // 发送时间戳(毫秒)
|
||||
totalSeconds: 60 // 总倒计时时长
|
||||
};
|
||||
setStorage(`captcha_${state.key}`, storedState, 'session');
|
||||
|
||||
|
||||
|
||||
// 4. 启动60秒倒计时
|
||||
state.isCounting.value = true;
|
||||
state.countdown.value = 60;
|
||||
|
||||
// 清除旧定时器(防止重复)
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
|
||||
// 5. 定时器逻辑(同步更新本地存储)
|
||||
state.timer = window.setInterval(() => {
|
||||
state.countdown.value--;
|
||||
// 同步更新本地存储的剩余时间
|
||||
updateStoredCaptchaState(state.key, state.countdown.value);
|
||||
|
||||
if (state.countdown.value <= 0) {
|
||||
clearCaptchaTimer(state); // 倒计时结束,清除所有状态
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
ElNotification({
|
||||
message: `发送${type}验证码失败:${(error as Error).message || '未知错误'}`,
|
||||
type: 'error',
|
||||
duration: 2000,
|
||||
customClass: 'global-notification'
|
||||
});
|
||||
console.error(`发送${type}验证码失败:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除验证码倒计时(组件卸载/倒计时结束时调用,防止内存泄漏+清空本地存储)
|
||||
* @param state 倒计时状态
|
||||
*/
|
||||
export const clearCaptchaTimer = (state: CaptchaCountdownState): void => {
|
||||
// 1. 清除定时器
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
|
||||
// 2. 重置内存状态
|
||||
state.countdown.value = 0;
|
||||
state.isCounting.value = false;
|
||||
|
||||
// 3. 清空本地存储(核心:倒计时结束后允许重新发送)
|
||||
removeStorage(`captcha_${state.key}`, 'session');
|
||||
};
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
// src/utils/storage.ts
|
||||
import type { Ref } from 'vue'
|
||||
import { useStorage as useVueUseStorage } from '@vueuse/core'
|
||||
|
||||
// 项目前缀(避免存储键冲突)
|
||||
const PROJECT_PREFIX = 'foreign_'
|
||||
type StorageType = 'local' | 'session'
|
||||
|
||||
/**
|
||||
* 给key添加项目前缀
|
||||
*/
|
||||
const addPrefix = (key: string): string => `${PROJECT_PREFIX}${key}`
|
||||
|
||||
/**
|
||||
* 获取对应的 Storage 实例
|
||||
*/
|
||||
const getStorageInstance = (type: StorageType = 'local') => {
|
||||
return type === 'local' ? localStorage : sessionStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用方法:自动反序列化存储值
|
||||
*/
|
||||
const autoParse = (value: string | null) => {
|
||||
if (value === null) return undefined
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增/改:设置存储值(默认localStorage,自动加前缀)
|
||||
*/
|
||||
export const setStorage = <T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
type: StorageType = 'local'
|
||||
): void => {
|
||||
const prefixedKey = addPrefix(key)
|
||||
const storage = getStorageInstance(type)
|
||||
let storedValue: string
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
storedValue = JSON.stringify(value)
|
||||
} else {
|
||||
storedValue = String(value)
|
||||
}
|
||||
storage.setItem(prefixedKey, storedValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查:获取存储值(默认localStorage,自动加前缀,无需默认值)
|
||||
*/
|
||||
export const getStorage = <T = unknown>(
|
||||
key: string,
|
||||
type: StorageType = 'local',
|
||||
defaultValue?: T
|
||||
): T | undefined => {
|
||||
const prefixedKey = addPrefix(key)
|
||||
const storage = getStorageInstance(type)
|
||||
const rawValue = storage.getItem(prefixedKey)
|
||||
return rawValue === null ? defaultValue : (autoParse(rawValue) as T)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删:删除指定键
|
||||
*/
|
||||
export const removeStorage = (
|
||||
key: string,
|
||||
type: StorageType = 'local'
|
||||
): void => {
|
||||
const prefixedKey = addPrefix(key)
|
||||
getStorageInstance(type).removeItem(prefixedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空:只清空当前项目前缀的存储
|
||||
*/
|
||||
export const clearStorage = (type: StorageType = 'local'): void => {
|
||||
const storage = getStorageInstance(type)
|
||||
Object.keys(storage).forEach(key => {
|
||||
if (key.startsWith(PROJECT_PREFIX)) {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式存储引用(兼容自动反序列化)
|
||||
*/
|
||||
export const useStorage = <T = unknown>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
type: StorageType = 'local'
|
||||
): Ref<T> => {
|
||||
const prefixedKey = addPrefix(key)
|
||||
return useVueUseStorage(
|
||||
prefixedKey,
|
||||
defaultValue,
|
||||
getStorageInstance(type),
|
||||
{
|
||||
serializer: {
|
||||
read: autoParse,
|
||||
write: (v: unknown) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
) as Ref<T>
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<div class="test-card">
|
||||
<h1>测试页</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div class="user-card">
|
||||
<UserHeader />
|
||||
<UserText :user-text="userText" @back="handleBack" />
|
||||
<UserTabs v-model="activeTab" :tab-list="tabList" />
|
||||
|
||||
<el-form class="user-form" ref="changePasswordFormRef" :model="changePasswordForm" :rules="changePasswordRules">
|
||||
<UserCaptcha v-model="changePasswordForm.code" @sendCode="handleSendCode" :show-send-button="showSendButton"
|
||||
:send-state="sendState" />
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="changePasswordForm.password" placeholder="请输入新密码" type="password" show-password clearable
|
||||
size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input v-model="changePasswordForm.confirmPassword" placeholder="请确认新密码" type="password" show-password
|
||||
clearable size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item class="form-submit">
|
||||
<el-button type="primary" @click="handleChangePassword">重新登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// type类型
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
// 组件
|
||||
import UserHeader from './components/Header.vue'
|
||||
import UserText from './components/Text.vue'
|
||||
import UserTabs from './components/Tabs.vue'
|
||||
import UserCaptcha from './components/Captcha.vue'
|
||||
|
||||
//公共方法/store/配置文件
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { getStorage } from '@/utils/storage'
|
||||
import { md5Encrypt } from '@/utils/crypto'
|
||||
import { sendCaptcha, initCaptchaState, clearCaptchaTimer } from '@/utils/sendCaptcha'
|
||||
|
||||
//用户校验规则
|
||||
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
|
||||
//store实例和获取user信息
|
||||
const userStore = useUserStore()
|
||||
const userInfo = getStorage<any>('userInfo')
|
||||
|
||||
//userText 实例
|
||||
const userText = ref({
|
||||
backText: '返回',
|
||||
account: userInfo?.username || ''
|
||||
})
|
||||
|
||||
//tabs 实例
|
||||
const tabList = ref([
|
||||
{ label: '邮箱验证', name: 'email' },
|
||||
{ label: '手机验证', name: 'phone' },
|
||||
])
|
||||
const activeTab = ref<string>(getStorage('loginType', 'session') || 'email')
|
||||
|
||||
// ChangePassword 表单实例
|
||||
const changePasswordFormRef = ref<FormInstance>()
|
||||
const changePasswordForm = ref({
|
||||
email: userInfo?.email || '',
|
||||
countryCode: '+86',
|
||||
phone: userInfo?.mobile || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
code: '',
|
||||
})
|
||||
console.log('changePasswordForm.value', changePasswordForm.value);
|
||||
const changePasswordRules = ref<FormRules>({
|
||||
email: emailRules().email,
|
||||
phone: phoneRules(changePasswordForm.value).phone,
|
||||
password: passwordRules(changePasswordForm.value).password,
|
||||
confirmPassword: passwordRules(changePasswordForm.value).confirmPassword,
|
||||
code: codeRules().code,
|
||||
})
|
||||
|
||||
// 验证码实例
|
||||
const showSendButton = ref<boolean>(true)
|
||||
const sendState = ref<object>({
|
||||
isCounting: false,
|
||||
countdown: 0,
|
||||
})
|
||||
//初始化验证码倒计时状态
|
||||
const emailCaptchaState = initCaptchaState('email'); // 邮箱专属
|
||||
const phoneCaptchaState = initCaptchaState('phone'); // 手机专属
|
||||
// 邮箱验证码状态
|
||||
const { isCounting: emailIsCounting, countdown: emailCountdown } = emailCaptchaState
|
||||
// 手机验证码状态
|
||||
const { isCounting: phoneIsCounting, countdown: phoneCountdown } = phoneCaptchaState
|
||||
|
||||
//方法
|
||||
// 统一更新sendState的方法
|
||||
const updateSendState = (type: string) => {
|
||||
if (type === 'email') {
|
||||
sendState.value = {
|
||||
isCounting: emailIsCounting,
|
||||
countdown: emailCountdown
|
||||
}
|
||||
} else {
|
||||
sendState.value = {
|
||||
isCounting: phoneIsCounting,
|
||||
countdown: phoneCountdown
|
||||
}
|
||||
}
|
||||
}
|
||||
// 初始化时根据当前activeTab更新sendState(避免刷新后丢失)
|
||||
const initSendState = () => {
|
||||
const currentType = activeTab.value === 'email' ? 'email' : 'phone'
|
||||
updateSendState(currentType)
|
||||
}
|
||||
onMounted(() => {
|
||||
// 页面加载时立即执行,恢复倒计时
|
||||
initSendState()
|
||||
})
|
||||
// 监听activeTab变化,同步sendState
|
||||
watch(
|
||||
activeTab,
|
||||
(newTab, oldTab) => {
|
||||
activeTab.value = newTab;
|
||||
console.log('activeTab.value', activeTab.value);
|
||||
// 只有“主动切换tab”(oldTab有值)时才处理,初始化时(oldTab为undefined)不触发
|
||||
if (oldTab === undefined) return
|
||||
|
||||
if (newTab === 'email' || newTab === 'phone') {
|
||||
// 主动切换到邮箱/手机页时,同步当前类型的倒计时状态(不再无脑重置)
|
||||
updateSendState(newTab)
|
||||
} else if (newTab === 'code') {
|
||||
// 切换到验证码页时,根据存储的类型更新状态
|
||||
const currentType: string = getStorage('forgotType', 'session') || 'email'
|
||||
updateSendState(currentType)
|
||||
}
|
||||
},
|
||||
{ immediate: false } // 关键:关闭立即执行,避免刷新时触发
|
||||
)
|
||||
|
||||
//发送验证码
|
||||
const handleSendCode = async () => {
|
||||
const currentType = activeTab.value
|
||||
// 第二步:确定类型、接收方、对应的倒计时状态和发送函数
|
||||
const type: string = currentType || 'email'
|
||||
const receiver = type === 'phone' ? changePasswordForm.value.phone : changePasswordForm.value.email;
|
||||
const currentCaptchaState = type === 'phone' ? phoneCaptchaState : emailCaptchaState;
|
||||
const sendFunc = type === 'phone' ? "sendPhoneCaptcha" : "sendEmailCaptcha";
|
||||
|
||||
// 第三步:调用封装的发送函数(传正确的函数,而非字符串)
|
||||
await sendCaptcha(
|
||||
type,
|
||||
receiver,
|
||||
currentCaptchaState, // 只更新当前类型的倒计时状态
|
||||
sendFunc // 传入返回Promise的函数
|
||||
);
|
||||
|
||||
//把状态同步给子组件
|
||||
updateSendState(type)
|
||||
}
|
||||
|
||||
// 登录
|
||||
const handleChangePassword = async () => {
|
||||
if (!changePasswordFormRef.value) return;
|
||||
|
||||
try {
|
||||
//信息校验
|
||||
await changePasswordFormRef.value.validate();
|
||||
|
||||
// 重置密码参数
|
||||
const changePasswordParams = {
|
||||
mobile: changePasswordForm.value.phone,
|
||||
email: changePasswordForm.value.email,
|
||||
reg_type: activeTab.value === 'email' ? 1 : 2,
|
||||
code: changePasswordForm.value.code,
|
||||
login_password1: md5Encrypt(changePasswordForm.value.password),
|
||||
login_password2: md5Encrypt(changePasswordForm.value.confirmPassword),
|
||||
};
|
||||
console.log('changePasswordParams', changePasswordParams);
|
||||
await userStore.changePassword(changePasswordParams);
|
||||
changePasswordForm.value = {
|
||||
email: userInfo?.email || '',
|
||||
countryCode: '+86',
|
||||
phone: userInfo?.mobile || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
code: ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('表单校验失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
//返回登录页
|
||||
const handleBack = () => {
|
||||
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
.form-submit {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
<template>
|
||||
<div class="user-card">
|
||||
<UserHeader />
|
||||
<UserText :user-text="userText" @back="handleBack" />
|
||||
<UserTabs v-model="activeTab" :hide-tab="hideTab" :tab-list="tabList" />
|
||||
|
||||
<el-form class="user-form" ref="forgotPasswordFormRef" :model="forgotPasswordForm" :rules="forgotPasswordRules">
|
||||
<Transition name="form-switch" mode="out-in">
|
||||
<el-form-item v-if="activeTab === 'email'" key="form-email-item" prop="email">
|
||||
<el-input v-model="forgotPasswordForm.email" placeholder="请输入邮箱" clearable size="large" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="phone-item" prop="phone" v-else-if="activeTab === 'phone'" key="form-phone-item">
|
||||
<el-select v-model="forgotPasswordForm.countryCode" :teleported="false" value-key="code" placeholder="国家"
|
||||
size="large">
|
||||
<el-option v-for="item in countryOptions" :key="item.code" :label="item.code" :value="item.code">
|
||||
<div class="country-option">
|
||||
<span class="country-name">{{ item.name }}</span>
|
||||
<span class="country-code">{{ item.code }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-input v-model="forgotPasswordForm.phone" placeholder="请输入手机号" clearable size="large" />
|
||||
</el-form-item>
|
||||
<UserCaptcha v-model="forgotPasswordForm.code" v-else-if="activeTab === 'code'" key="form-code-item"
|
||||
@sendCode="handleSendCode" :show-send-button="showSendButton" :send-state="sendState" />
|
||||
<div v-else-if="activeTab === 'password'" key="form-password-group">
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="forgotPasswordForm.password" placeholder="请输入新密码" type="password" show-password clearable
|
||||
size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input v-model="forgotPasswordForm.confirmPassword" placeholder="请确认新密码" type="password" show-password
|
||||
clearable size="large" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="forgot-next">
|
||||
<div :class="{ active: activeTab === 'email' || activeTab === 'phone' }"></div>
|
||||
<div :class="{ active: activeTab === 'code' }"></div>
|
||||
<div :class="{ active: activeTab === 'password' }"></div>
|
||||
</div>
|
||||
<el-form-item class="form-submit">
|
||||
<el-button type="primary" @click="handleForgotPassword">{{ activeTab === 'password' ? '去登录' : '继续'
|
||||
}}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// type类型
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
// 组件
|
||||
import UserHeader from './components/Header.vue'
|
||||
import UserText from './components/Text.vue'
|
||||
import UserTabs from './components/Tabs.vue'
|
||||
import UserCaptcha from './components/Captcha.vue'
|
||||
|
||||
//公共方法/store/配置文件
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { setStorage, getStorage, removeStorage } from '@/utils/storage'
|
||||
import { md5Encrypt } from '@/utils/crypto'
|
||||
import { sendCaptcha, initCaptchaState, clearCaptchaTimer } from '@/utils/sendCaptcha'
|
||||
|
||||
//用户校验规则
|
||||
import { countryOptions } from '@/views/user/config/mock'
|
||||
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
|
||||
|
||||
//用户store和router实例
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
//userText 实例
|
||||
const userText = ref({
|
||||
backText: '返回',
|
||||
title: '恢复密码',
|
||||
text: '请输入您注册时使用的邮箱或手机号',
|
||||
account: '',
|
||||
|
||||
})
|
||||
|
||||
//tabs实例
|
||||
const tabList = ref([
|
||||
{ label: '邮箱', name: 'email' },
|
||||
{ label: '手机', name: 'phone' },
|
||||
])
|
||||
const activeTab = ref<string>('email')
|
||||
const hideTab = ref<boolean>(false)
|
||||
|
||||
// 表单实例
|
||||
const forgotPasswordFormRef = ref<FormInstance>()
|
||||
const forgotPasswordForm = ref({
|
||||
email: '',
|
||||
countryCode: '+86',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
code: ''
|
||||
})
|
||||
const forgotPasswordRules = ref<FormRules>({
|
||||
email: emailRules().email,
|
||||
phone: phoneRules(forgotPasswordForm.value).phone,
|
||||
password: passwordRules(forgotPasswordForm.value).password,
|
||||
confirmPassword: passwordRules(forgotPasswordForm.value).confirmPassword,
|
||||
code: codeRules().code
|
||||
})
|
||||
|
||||
// 验证码实例
|
||||
const showSendButton = ref<boolean>(true)
|
||||
const sendState = ref<object>({
|
||||
isCounting: false,
|
||||
countdown: 0,
|
||||
})
|
||||
//初始化验证码倒计时状态
|
||||
const emailCaptchaState = initCaptchaState('email'); // 邮箱专属
|
||||
const phoneCaptchaState = initCaptchaState('phone'); // 手机专属
|
||||
// 邮箱验证码状态
|
||||
const { isCounting: emailIsCounting, countdown: emailCountdown } = emailCaptchaState
|
||||
// 手机验证码状态
|
||||
const { isCounting: phoneIsCounting, countdown: phoneCountdown } = phoneCaptchaState
|
||||
|
||||
//方法
|
||||
|
||||
|
||||
// 统一更新sendState的方法
|
||||
const updateSendState = (type: string) => {
|
||||
if (type === 'email') {
|
||||
sendState.value = {
|
||||
isCounting: emailIsCounting,
|
||||
countdown: emailCountdown
|
||||
}
|
||||
} else {
|
||||
sendState.value = {
|
||||
isCounting: phoneIsCounting,
|
||||
countdown: phoneCountdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听activeTab变化,同步sendState
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'email' || newTab === 'phone') {
|
||||
// 切换回邮箱/手机页时,重置sendState
|
||||
sendState.value = { isCounting: false, countdown: 0 }
|
||||
} else if (newTab === 'code') {
|
||||
// 切换到验证码页时,根据当前存储的类型更新状态
|
||||
const currentType: string = getStorage('forgotType', 'session') || 'email'
|
||||
updateSendState(currentType)
|
||||
}
|
||||
})
|
||||
|
||||
//发送验证码
|
||||
const handleSendCode = async () => {
|
||||
// 第二步:确定类型、接收方、对应的倒计时状态和发送函数
|
||||
const type: string = getStorage('forgotType', 'session') || 'email'
|
||||
const receiver = type === 'phone' ? forgotPasswordForm.value.phone : forgotPasswordForm.value.email;
|
||||
const currentCaptchaState = type === 'phone' ? phoneCaptchaState : emailCaptchaState;
|
||||
const sendFunc = type === 'phone' ? "sendPhoneCaptcha" : "sendEmailCaptcha";
|
||||
|
||||
// 第三步:调用封装的发送函数(传正确的函数,而非字符串)
|
||||
await sendCaptcha(
|
||||
type,
|
||||
receiver,
|
||||
currentCaptchaState, // 只更新当前类型的倒计时状态
|
||||
sendFunc // 传入返回Promise的函数
|
||||
);
|
||||
|
||||
//把状态同步给子组件
|
||||
updateSendState(type)
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
// 从密码页返回 → 验证码页
|
||||
if (activeTab.value === 'password') {
|
||||
activeTab.value = 'code'
|
||||
userText.value = {
|
||||
...userText.value,
|
||||
title: '双重验证',
|
||||
text: '请输入发送的验证码',
|
||||
account: getStorage('forgot', 'session') || ''
|
||||
}
|
||||
// 清空密码字段
|
||||
forgotPasswordForm.value.password = ''
|
||||
forgotPasswordForm.value.confirmPassword = ''
|
||||
forgotPasswordFormRef.value?.clearValidate(['password', 'confirmPassword'])
|
||||
}
|
||||
|
||||
// 从验证码页返回 → 邮箱/手机页
|
||||
else if (activeTab.value === 'code') {
|
||||
const getForgotType: string = getStorage('forgotType', 'session') || 'email'
|
||||
activeTab.value = getForgotType
|
||||
hideTab.value = false
|
||||
userText.value = {
|
||||
...userText.value,
|
||||
title: '恢复密码',
|
||||
text: '请输入您注册时使用的邮箱或手机号',
|
||||
account: ''
|
||||
}
|
||||
// 清空验证码字段
|
||||
forgotPasswordForm.value.code = ''
|
||||
forgotPasswordFormRef.value?.clearValidate(['code'])
|
||||
}
|
||||
|
||||
// 从邮箱/手机页返回 → 重置为初始状态
|
||||
else {
|
||||
activeTab.value = 'email'
|
||||
forgotPasswordForm.value.email = ''
|
||||
forgotPasswordForm.value.phone = ''
|
||||
forgotPasswordFormRef.value?.clearValidate(['email', 'phone'])
|
||||
router.push('/user/login')
|
||||
}
|
||||
}
|
||||
|
||||
//重置密码
|
||||
const handleForgotPassword = async () => {
|
||||
if (!forgotPasswordFormRef.value) return
|
||||
|
||||
try {
|
||||
const currentTab = activeTab.value;
|
||||
const getForgotType: string = getStorage('forgotType', 'session') || 'email'
|
||||
// 步骤1:校验邮箱/手机号 → 切换到验证码页
|
||||
if (currentTab === 'email' || currentTab === 'phone') {
|
||||
// 校验当前字段
|
||||
await forgotPasswordFormRef.value.validateField(currentTab)
|
||||
// 存储账号类型和值
|
||||
setStorage('forgotType', currentTab, 'session')
|
||||
setStorage('forgot', currentTab === 'email' ? forgotPasswordForm.value.email : forgotPasswordForm.value.phone, 'session')
|
||||
// 更新状态
|
||||
hideTab.value = true
|
||||
activeTab.value = 'code'
|
||||
userText.value = {
|
||||
...userText.value,
|
||||
title: '双重验证',
|
||||
text: '请输入发送的验证码',
|
||||
account: currentTab === 'email' ? forgotPasswordForm.value.email : forgotPasswordForm.value.phone
|
||||
}
|
||||
updateSendState(getForgotType)
|
||||
}
|
||||
|
||||
// 步骤2:校验验证码 → 切换到密码页
|
||||
else if (currentTab === 'code') {
|
||||
// 校验验证码
|
||||
await forgotPasswordFormRef.value.validateField(currentTab)
|
||||
// 更新状态
|
||||
activeTab.value = 'password'
|
||||
userText.value = {
|
||||
...userText.value,
|
||||
title: '设置新密码',
|
||||
text: '请设置您的新登录密码',
|
||||
account: getStorage('forgot', 'session') || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:校验密码 → 重置密码
|
||||
else if (currentTab === 'password') {
|
||||
// 校验密码+确认密码
|
||||
await forgotPasswordFormRef.value.validateField(['password', 'confirmPassword'])
|
||||
|
||||
// 重置密码参数
|
||||
const getForgotType = getStorage('forgotType', 'session') || 'email'
|
||||
const forgotParams = {
|
||||
mobile: forgotPasswordForm.value.phone,
|
||||
email: forgotPasswordForm.value.email,
|
||||
reg_type: getForgotType === 'email' ? 1 : 2,
|
||||
code: forgotPasswordForm.value.code,
|
||||
login_password1: md5Encrypt(forgotPasswordForm.value.password),
|
||||
login_password2: md5Encrypt(forgotPasswordForm.value.confirmPassword),
|
||||
}
|
||||
|
||||
// 调用重置密码接口
|
||||
await userStore.forgotPassword(forgotParams)
|
||||
|
||||
// 清空存储和表单
|
||||
removeStorage('forgotType', 'session')
|
||||
removeStorage('forgot', 'session')
|
||||
forgotPasswordForm.value = {
|
||||
email: '',
|
||||
countryCode: '+86',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
code: ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单提交失败:', error)
|
||||
}
|
||||
}
|
||||
// 4. 组件卸载时清除定时器(关键:防止内存泄漏)
|
||||
onUnmounted(() => {
|
||||
clearCaptchaTimer(emailCaptchaState);
|
||||
clearCaptchaTimer(phoneCaptchaState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-form {
|
||||
.phone-item {
|
||||
:deep(.el-form-item__content) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-next {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 60px;
|
||||
|
||||
div {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
width: 40px;
|
||||
background: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
border-radius: 999px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background-color: var(--el-color-primary);
|
||||
transition: all 0.3s ease-out;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
&::before {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
<template>
|
||||
<div class="user-card">
|
||||
<UserHeader />
|
||||
<UserText :user-text="userText" @back="handleBack" />
|
||||
<UserTabs v-model="activeTab" :hide-tab="hideTab" :tab-list="tabList" />
|
||||
|
||||
<el-form class="user-form" ref="loginFormRef" :model="loginForm" :rules="loginRules">
|
||||
<Transition name="form-switch" mode="out-in">
|
||||
<el-form-item v-if="activeTab === 'email'" key="form-email-item" prop="email">
|
||||
<el-input v-model="loginForm.email" placeholder="请输入邮箱" clearable size="large" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="phone-item" prop="phone" v-else-if="activeTab === 'phone'" key="form-phone-item">
|
||||
<el-select v-model="loginForm.countryCode" :teleported="false" value-key="code" placeholder="国家" size="large">
|
||||
<el-option v-for="item in countryOptions" :key="item.code" :label="item.code" :value="item.code">
|
||||
<div class="country-option">
|
||||
<span class="country-name">{{ item.name }}</span>
|
||||
<span class="country-code">{{ item.code }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-input v-model="loginForm.phone" placeholder="请输入手机号" clearable size="large" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-else-if="activeTab === 'password'" key="form-password-item" prop="password">
|
||||
<el-input v-model="loginForm.password" placeholder="请输入密码" type="password" show-password clearable
|
||||
size="large" />
|
||||
<router-link to="/user/forgot-password" class="forgot-password">忘记密码?</router-link>
|
||||
</el-form-item>
|
||||
</Transition>
|
||||
<el-form-item class="form-submit">
|
||||
<el-button type="primary" @click="handleLogin">{{ activeTab === 'password' ? '登录' : '注册/登录' }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// type类型
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
// 组件
|
||||
import UserHeader from './components/Header.vue'
|
||||
import UserText from './components/Text.vue'
|
||||
import UserTabs from './components/Tabs.vue'
|
||||
|
||||
//公共方法/store/配置文件
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { setStorage, getStorage } from '@/utils/storage'
|
||||
import { md5Encrypt } from '@/utils/crypto'
|
||||
|
||||
//用户校验规则
|
||||
import { countryOptions } from '@/views/user/config/mock'
|
||||
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
|
||||
|
||||
//userText 实例
|
||||
const userText = ref({
|
||||
backText: '',
|
||||
title: '',
|
||||
text: '初次登录时,您的电子邮箱或手机号码将被用于注册。',
|
||||
account: '',
|
||||
|
||||
})
|
||||
|
||||
//tabs 实例
|
||||
const tabList = ref([
|
||||
{ label: '邮箱', name: 'email' },
|
||||
{ label: '手机', name: 'phone' },
|
||||
])
|
||||
const activeTab = ref<string>('email')
|
||||
const hideTab = ref<boolean>(false)
|
||||
|
||||
// login form 实例
|
||||
const userStore = useUserStore();
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loginForm = ref({
|
||||
email: '',
|
||||
countryCode: '+86',
|
||||
phone: '',
|
||||
password: '',
|
||||
code: '123456',
|
||||
})
|
||||
const loginRules = ref<FormRules>({
|
||||
email: emailRules().email,
|
||||
phone: phoneRules(loginForm.value).phone,
|
||||
code: codeRules().code,
|
||||
password: passwordRules(loginForm.value).password,
|
||||
})
|
||||
|
||||
//方法
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return;
|
||||
|
||||
try {
|
||||
const currentTab = activeTab.value;
|
||||
//验证邮箱/手机号 → 切换到密码页
|
||||
if (currentTab === 'email' || currentTab === 'phone') {
|
||||
//校验当前账号字段(email/phone)
|
||||
await loginFormRef.value.validateField(currentTab);
|
||||
//存储账号类型和账号值(方便后续登录取值)
|
||||
const loginValue = loginForm.value[currentTab];
|
||||
setStorage('loginType', currentTab, 'session'); // 存储账号类型(email/phone)
|
||||
setStorage('login', loginValue, 'session'); // 存储账号值
|
||||
//切换到密码页,更新提示
|
||||
hideTab.value = true;
|
||||
userText.value = {
|
||||
backText: '返回',
|
||||
title: '欢迎回来!',
|
||||
text: '请输入您的登录密码',
|
||||
account: loginValue
|
||||
};
|
||||
activeTab.value = 'password';
|
||||
}
|
||||
// 验证密码+验证码 → 执行登录
|
||||
else if (currentTab === 'password') {
|
||||
// 校验密码+验证码
|
||||
await loginFormRef.value.validateField(['password', 'code']);
|
||||
// 登录参数
|
||||
const loginParams = {
|
||||
email: loginForm.value.email,
|
||||
mobile: loginForm.value.phone,
|
||||
login_type: getStorage('loginType', 'session') === 'email' ? 1 : 2,
|
||||
code: loginForm.value.code,
|
||||
password: md5Encrypt(loginForm.value.password)
|
||||
};
|
||||
await userStore.login(loginParams);
|
||||
activeTab.value = 'email'; // 重置tab
|
||||
hideTab.value = false;
|
||||
loginForm.value = { // 清空表单
|
||||
email: '',
|
||||
countryCode: '+86',
|
||||
phone: '',
|
||||
password: '',
|
||||
code: ''
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单校验失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
//返回登录页
|
||||
const handleBack = () => {
|
||||
if (activeTab.value === 'password') {
|
||||
// 从sessionStorage取账号类型(而非用password作为key)
|
||||
const loginType = getStorage('loginType', 'session') || 'email';
|
||||
// 清空密码、验证码字段,避免残留
|
||||
loginForm.value.password = '';
|
||||
loginForm.value.code = '';
|
||||
// 清空表单校验提示
|
||||
loginFormRef.value?.clearValidate(['password', 'code']);
|
||||
// 重置文本和tab状态
|
||||
hideTab.value = false;
|
||||
userText.value = {
|
||||
backText: '',
|
||||
title: '',
|
||||
text: '初次登录时,您的电子邮箱或手机号码将被用于注册。',
|
||||
account: ''
|
||||
};
|
||||
activeTab.value = loginType as string;
|
||||
} else {
|
||||
// 非password页:仅重置为email(可选,也可保留当前tab)
|
||||
activeTab.value = 'email';
|
||||
// 清空当前账号字段和校验提示
|
||||
loginForm.value.email = '';
|
||||
loginForm.value.phone = '';
|
||||
loginFormRef.value?.clearValidate(['email', 'phone']);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-form {
|
||||
.phone-item {
|
||||
:deep(.el-form-item__content) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
width: 120px;
|
||||
margin-right: 10px;
|
||||
|
||||
.country-option {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown__item) {
|
||||
padding: 0 16px 0 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
font-size: 14px;
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
margin-top: 18px;
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<template>
|
||||
<div class="user-card">
|
||||
<UserHeader />
|
||||
<UserText :user-text="userText" />
|
||||
<UserTabs v-model="activeTab" :tab-list="tabList" />
|
||||
|
||||
<el-form class="user-form" ref="registerFormRef" :model="registerForm" :rules="registerRules">
|
||||
|
||||
<Transition name="form-switch" mode="out-in">
|
||||
<el-form-item class="captcha-item" v-if="activeTab === 'email'" key="form-email-item" prop="email">
|
||||
<el-input v-model="registerForm.email" placeholder="请输入邮箱" clearable size="large" />
|
||||
<el-button type="primary" class="captcha-btn" @click="handleSendCode" :disabled="emailIsCounting">{{
|
||||
emailIsCounting ? `${emailCountdown}s后重新获取` : '发送验证码' }}</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="phone-item captcha-item" prop="phone" v-else-if="activeTab === 'phone'"
|
||||
key="form-phone-item">
|
||||
<el-select v-model="registerForm.countryCode" :teleported="false" value-key="code" placeholder="国家"
|
||||
size="large">
|
||||
<el-option v-for="item in countryOptions" :key="item.code" :label="item.code" :value="item.code">
|
||||
<div class="country-option">
|
||||
<span class="country-name">{{ item.name }}</span>
|
||||
<span class="country-code">{{ item.code }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-input v-model="registerForm.phone" placeholder="请输入手机号" clearable size="large" />
|
||||
<el-button type="primary" class="captcha-btn" @click="handleSendCode" :disabled="phoneIsCounting">{{
|
||||
phoneIsCounting ? `${phoneCountdown}s后重新获取` : '发送验证码' }}</el-button>
|
||||
</el-form-item>
|
||||
</Transition>
|
||||
|
||||
<UserCaptcha v-model="registerForm.code" />
|
||||
<el-form-item label="设置密码(6-12位密码,包含特殊符号以及大小写)" label-position="top" prop="password">
|
||||
<el-input placeholder="请输入密码" clearable show-password size="large" type="password"
|
||||
v-model="registerForm.password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邀请链接/邀请码(选填)" label-position="top" prop="inviteCode">
|
||||
<el-input placeholder="请输入邀请链接/邀请码" clearable size="large" type="text" v-model="registerForm.inviteCode" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox label="同意注册协议">我已阅读并同意 隐私条例、 退款政策、 AML&CTF政策、<br> 执行政策 和 网站条款和条件</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item class="form-submit">
|
||||
<el-button type="primary" class="form-btn" @click="handleRegister">注册</el-button>
|
||||
</el-form-item>
|
||||
<router-link to="/user/login" class="to-login">已有账号?立即登录</router-link>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// type类型
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
// 组件
|
||||
import UserHeader from './components/Header.vue'
|
||||
import UserText from './components/Text.vue'
|
||||
import UserTabs from './components/Tabs.vue'
|
||||
import UserCaptcha from './components/Captcha.vue'
|
||||
|
||||
//公共方法/store/配置文件
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { md5Encrypt } from '@/utils/crypto'
|
||||
import { sendCaptcha, initCaptchaState, clearCaptchaTimer } from '@/utils/sendCaptcha'
|
||||
|
||||
//用户校验规则
|
||||
import { countryOptions } from '@/views/user/config/mock'
|
||||
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
|
||||
|
||||
//store实例
|
||||
const userStore = useUserStore()
|
||||
|
||||
// UserText 实例
|
||||
const userText = ref({
|
||||
backText: '',
|
||||
title: '注册',
|
||||
text: '',
|
||||
});
|
||||
|
||||
//tabs 实例
|
||||
const tabList = ref([
|
||||
{ label: '邮箱', name: 'email' },
|
||||
{ label: '手机', name: 'phone' },
|
||||
])
|
||||
const activeTab = ref<string>('email')
|
||||
|
||||
// 注册表单实例
|
||||
const registerFormRef = ref<FormInstance>()
|
||||
const registerForm = ref({
|
||||
email: '',
|
||||
countryCode: '+86',
|
||||
phone: '',
|
||||
password: '',
|
||||
code: '',
|
||||
inviteCode: ''
|
||||
})
|
||||
const registerRules = ref<FormRules>({
|
||||
email: emailRules().email,
|
||||
phone: phoneRules(registerForm.value).phone,
|
||||
password: passwordRules(registerForm.value).password,
|
||||
code: codeRules().code,
|
||||
})
|
||||
|
||||
//初始化验证码倒计时状态
|
||||
const emailCaptchaState = initCaptchaState('email'); // 邮箱专属
|
||||
const phoneCaptchaState = initCaptchaState('phone'); // 手机专属
|
||||
// 邮箱验证码状态
|
||||
const { isCounting: emailIsCounting, countdown: emailCountdown } = emailCaptchaState
|
||||
// 手机验证码状态
|
||||
const { isCounting: phoneIsCounting, countdown: phoneCountdown } = phoneCaptchaState
|
||||
|
||||
|
||||
//方法
|
||||
|
||||
//发送验证码
|
||||
const handleSendCode = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
try {
|
||||
await registerFormRef.value.validateField(activeTab.value)
|
||||
} catch (error) {
|
||||
console.error('字段校验失败:', error)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定类型、接收方、对应的倒计时状态和发送函数
|
||||
const type: string = activeTab.value === 'phone' ? 'phone' : 'email';
|
||||
const receiver = type === 'phone' ? registerForm.value.phone : registerForm.value.email;
|
||||
const currentCaptchaState = type === 'phone' ? phoneCaptchaState : emailCaptchaState;
|
||||
const sendFunc = type === 'phone' ? "sendPhoneCaptcha" : "sendEmailCaptcha";
|
||||
|
||||
// 调用封装的发送函数(传正确的函数,而非字符串)
|
||||
await sendCaptcha(
|
||||
type,
|
||||
receiver,
|
||||
currentCaptchaState, // 只更新当前类型的倒计时状态
|
||||
sendFunc // 传入返回Promise的函数
|
||||
);
|
||||
}
|
||||
|
||||
//注册
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return;
|
||||
try {
|
||||
// 提交时再次验证(显示错误提示)
|
||||
await registerFormRef.value.validate();
|
||||
|
||||
// 注册参数
|
||||
const registerParams = {
|
||||
email: registerForm.value.email,
|
||||
mobile: registerForm.value.phone,
|
||||
reg_type: activeTab.value === 'email' ? 1 : 2,
|
||||
invite_code: registerForm.value.inviteCode,
|
||||
code: registerForm.value.code,
|
||||
password: md5Encrypt(registerForm.value.password)
|
||||
};
|
||||
|
||||
// 注册逻辑
|
||||
await userStore.register(registerParams);
|
||||
} catch (error) {
|
||||
console.error('表单校验失败:', error);
|
||||
}
|
||||
}
|
||||
// 组件卸载时清除定时器(关键:防止内存泄漏)
|
||||
onUnmounted(() => {
|
||||
clearCaptchaTimer(emailCaptchaState);
|
||||
clearCaptchaTimer(phoneCaptchaState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-form {
|
||||
|
||||
.phone-item,
|
||||
.captcha-item {
|
||||
:deep(.el-form-item__content) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown__item) {
|
||||
padding: 0 16px 0 16px;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
width: 180px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.to-login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: #409eff;
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<el-form-item class="captcha-container" label="请输入验证码" label-position="top" :prop="prop">
|
||||
<el-input v-for="(item, index) in inputList" :key="index" v-model="inputList[index]" class="captcha-input-item"
|
||||
:maxlength="1" @input="handleInput(index, $event)" @focus="handleFocus(index)" @keyup.delete="handleDelete(index)"
|
||||
ref="inputRefs" type="text" />
|
||||
<el-button type="primary" class="form-btn" v-if="showSendButton" @click="sendCode" :disabled="sendState.isCounting">{{
|
||||
sendState.isCounting ? `${sendState.countdown}s后重新获取` : '发送验证码' }}</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
showSendButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
sendState: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
isCounting: false,
|
||||
countdown: 0,
|
||||
})
|
||||
},
|
||||
prop: {
|
||||
type: String,
|
||||
default: 'code' // 和父组件rules中的key一致
|
||||
},
|
||||
})
|
||||
const { showSendButton } = toRefs(props)
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'finish', 'sendCode'])
|
||||
|
||||
const inputRefs = ref<Array<HTMLElement | null>>([]) // 补充 TS 类型
|
||||
const inputList = reactive<string[]>(new Array(props.length).fill(''))
|
||||
|
||||
// 修复:优化 modelValue 监听逻辑,避免首次清空输入
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal && newVal.length === props.length && newVal !== inputList.join('')) {
|
||||
for (let i = 0; i < props.length; i++) {
|
||||
inputList[i] = newVal[i] || '';
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 修复:精准同步输入内容到 modelValue
|
||||
watch(
|
||||
() => [...inputList], // 深度监听数组变化
|
||||
(newList) => {
|
||||
const newVal = newList.join('');
|
||||
emit('update:modelValue', newVal);
|
||||
emit('change', newVal);
|
||||
if (newVal.length === props.length) {
|
||||
emit('finish', newVal);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 修复:输入处理逻辑,确保字符正确赋值
|
||||
const handleInput = (index: number, val: string) => {
|
||||
// 只保留数字,且只取第一个字符
|
||||
const cleanVal = val.replace(/\D/g, '').slice(0, 1);
|
||||
inputList[index] = cleanVal; // 确保赋值有效
|
||||
|
||||
// 自动跳转下一个输入框
|
||||
if (cleanVal && index < props.length - 1) {
|
||||
nextTick(() => {
|
||||
inputRefs.value[index + 1]?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = (index: number) => {
|
||||
// 聚焦时清空当前输入框(可选:如果不需要清空可注释)
|
||||
inputList[index] = '';
|
||||
}
|
||||
|
||||
// 修复:删除键逻辑,确保删除后同步值
|
||||
const handleDelete = (index: number) => {
|
||||
if (inputList[index] === '') {
|
||||
if (index > 0) {
|
||||
nextTick(() => {
|
||||
inputRefs.value[index - 1]?.focus();
|
||||
inputList[index - 1] = ''; // 清空上一个输入框
|
||||
});
|
||||
}
|
||||
} else {
|
||||
inputList[index] = '';
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
inputList.fill('');
|
||||
emit('update:modelValue', '');
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({ clear })
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = () => {
|
||||
emit('sendCode');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.captcha-container {
|
||||
:deep(.el-form-item__content) {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.captcha-input-item {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-btn {
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<!-- src/components/Header.vue -->
|
||||
<template>
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="@/assets/images/logo.webp" alt="" />
|
||||
</div>
|
||||
<LanguageSelect v-model="languageStore.lang" @change="languageStore.changeLang" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 导入UI组件
|
||||
import LanguageSelect from '@/components/LanguageSelect/index.vue'
|
||||
|
||||
// 导入Pinia状态
|
||||
import { useLanguageStore } from '@/stores/modules/language'
|
||||
|
||||
// 创建Pinia状态实例
|
||||
const languageStore = useLanguageStore()
|
||||
</script>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<el-tabs v-model="innerTabValue" class="user-tabs" @tab-change="handleTabChange"
|
||||
:class="hideTab ? 'hide-active' : ''">
|
||||
<el-tab-pane v-for="item in tabList" :key="item.name" :label="item.label" :name="item.name">
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TabPaneName } from 'element-plus'
|
||||
|
||||
// 1. TabItem 类型定义
|
||||
export interface TabItem {
|
||||
label: string;
|
||||
name: TabPaneName;
|
||||
}
|
||||
// 2. props 定义
|
||||
const props = defineProps<{
|
||||
tabList: TabItem[]; // 自定义选项卡列表
|
||||
modelValue: TabPaneName; // 父组件传递的选中值(必传)
|
||||
hideTab?: boolean; // 是否隐藏密码和验证码选项卡
|
||||
}>()
|
||||
|
||||
// 3. 定义 Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TabPaneName): void;
|
||||
(e: 'tab-change', value: TabPaneName): void;
|
||||
}>()
|
||||
|
||||
// 4. 内部响应式值 + 监听父组件值变化
|
||||
const innerTabValue = ref<TabPaneName>(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
innerTabValue.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 5. Tab 切换通知父组件
|
||||
const handleTabChange = (newVal: TabPaneName) => {
|
||||
emit('update:modelValue', newVal)
|
||||
emit('tab-change', newVal)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-tabs {
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.hide-active {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div class="user-text">
|
||||
<el-button type="primary" :icon="ArrowLeft" class="back" v-if="userText.backText" @click="handleBack">{{ userText.backText
|
||||
}}</el-button>
|
||||
<h2 v-if="userText.title">{{ userText.title }}</h2>
|
||||
<p class="text" v-if="userText.text">{{ userText.text }}</p>
|
||||
<p class="account" v-if="userText.account">{{ userText.account }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
interface UserText {
|
||||
backText: string
|
||||
title: string
|
||||
text: string
|
||||
account: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
userText?: Partial<UserText>
|
||||
}>(),
|
||||
{
|
||||
userText: () => ({
|
||||
backText: '',
|
||||
title: '',
|
||||
text: '',
|
||||
account: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['back'])
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-text {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.back {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #000;
|
||||
margin-bottom: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
//国家码选项:用于手机号登录
|
||||
interface CountryItem {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export const countryOptions = ref<CountryItem[]>([
|
||||
{ name: '中国', code: '+86' },
|
||||
{ name: '中国台湾', code: '+886' },
|
||||
{ name: '中国香港', code: '+852' },
|
||||
{ name: '中国澳门', code: '+853' },
|
||||
{ name: '韩国', code: '+82' },
|
||||
{ name: '美国', code: '+1' },
|
||||
{ name: '新加坡', code: '+65' }
|
||||
])
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import type { FormRules } from 'element-plus';
|
||||
|
||||
/**
|
||||
* 邮箱验证规则
|
||||
* @returns 邮箱校验规则
|
||||
*/
|
||||
export const emailRules = (): FormRules => {
|
||||
return {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: ['blur', 'change'] },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] }
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 手机号验证规则(支持动态传入表单数据,适配国家码校验)
|
||||
* @param loginForm 表单数据(包含countryCode和phone)
|
||||
* @returns 手机号校验规则
|
||||
*/
|
||||
export const phoneRules = (loginForm: { countryCode: string; phone: string }): FormRules => {
|
||||
return {
|
||||
phone: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
// 验证国家码必填
|
||||
if (!loginForm.countryCode) {
|
||||
return callback(new Error('请选择国家/地区码'));
|
||||
}
|
||||
// 验证手机号必填
|
||||
if (!value) {
|
||||
return callback(new Error('请输入手机号码'));
|
||||
}
|
||||
// 中国大陆手机号格式验证(仅+86时生效)
|
||||
if (loginForm.countryCode === '+86' && !/^1[3-9]\d{9}$/.test(value)) {
|
||||
return callback(new Error('请输入正确的中国大陆手机号'));
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 密码验证规则(通用,后续可扩展)
|
||||
* @returns 密码校验规则
|
||||
*/
|
||||
// 传参改为:表单对象(响应式) + 密码字段名(默认password)
|
||||
export const passwordRules = (form: any, passwordField = 'password'): FormRules => {
|
||||
return {
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: ['blur', 'change'] },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: ['blur', 'change'] }
|
||||
],
|
||||
confirmPassword: [{
|
||||
required: true,
|
||||
message: '请确认密码',
|
||||
trigger: 'blur'
|
||||
}, {
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请确认密码'));
|
||||
return;
|
||||
}
|
||||
// 实时读取表单对象的最新密码值(核心:form是响应式引用)
|
||||
if (value !== form[passwordField]) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}]
|
||||
};
|
||||
};
|
||||
|
||||
export const codeRules = (length: number = 6): FormRules => {
|
||||
return {
|
||||
code: [
|
||||
// 1. 必填校验
|
||||
{
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
// 2. 长度校验(匹配组件的length属性)
|
||||
{
|
||||
min: length,
|
||||
max: length,
|
||||
message: `验证码必须输入${length}位`,
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
// 3. 仅数字校验
|
||||
{
|
||||
pattern: /^\d+$/,
|
||||
message: '验证码只能输入数字',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<div class="user-container">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="user" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// 自动导入插件
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
// Element Plus 组件自动导入
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
//自动导入Vue/路由/Pinia等API(无需手动import)
|
||||
AutoImport({
|
||||
// 适配Element Plus的自动导入
|
||||
resolvers: [
|
||||
ElementPlusResolver(),
|
||||
],
|
||||
// 指定要自动导入的库
|
||||
imports: [
|
||||
'vue', // 自动导入ref、reactive、watch等Vue API
|
||||
'vue-router', // 自动导入useRoute、useRouter等路由API
|
||||
'pinia', // 自动导入defineStore等Pinia API
|
||||
'@vueuse/core', // 自动导入@vueuse的hooks
|
||||
],
|
||||
// 生成类型声明文件(TypeScript必备,解决类型提示问题)
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
// 自动生成eslint配置(解决eslint报"未定义变量"的问题)
|
||||
eslintrc: {
|
||||
enabled: true, // 开启后会生成 .eslintrc-auto-import.json
|
||||
},
|
||||
}),
|
||||
// 2. 自动导入组件(包括Element Plus和本地组件)
|
||||
Components({
|
||||
// 自动解析Element Plus组件
|
||||
resolvers: [
|
||||
ElementPlusResolver(),
|
||||
],
|
||||
// 生成组件类型声明文件
|
||||
dts: 'src/components.d.ts',
|
||||
// 指定要自动导入的本地组件目录(src/components下的组件无需手动注册)
|
||||
dirs: ['src/components'],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
|
||||
// 构建配置
|
||||
build: {
|
||||
// 输出目录
|
||||
outDir: 'dist',
|
||||
// 静态资源目录
|
||||
assetsDir: 'assets',
|
||||
// 生产环境移除console和debugger
|
||||
minify: 'esbuild',
|
||||
esbuild: {
|
||||
drop: mode === 'production' ? ['console', 'debugger'] : [],
|
||||
// 剔除 JS 中的所有注释(包括注释掉的代码)
|
||||
minifyIdentifiers: true, // 压缩变量名
|
||||
minifySyntax: true, // 压缩语法(含剔除普通注释)
|
||||
minifyWhitespace: true, // 剔除空格
|
||||
},
|
||||
// 可选:进一步压缩打包产物
|
||||
// terserOptions: mode === 'production' ? {
|
||||
// format: {
|
||||
// comments: false, // 彻底剔除所有注释
|
||||
// },
|
||||
// } : {},
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue