3.6号提交
This commit is contained in:
parent
eb77fb229d
commit
5e7344500a
|
|
@ -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,46 @@
|
|||
# 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
|
||||
|
||||
# Vue 自动生成文件
|
||||
.eslintrc-auto-import.json
|
||||
*.vue.js
|
||||
*.vue.d.ts
|
||||
src/auto-imports.d.ts
|
||||
src/components.d.ts
|
||||
|
|
@ -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,6 @@
|
|||
<template>
|
||||
<div class="app-container">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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,63 @@
|
|||
import { request } from '@/api';
|
||||
import type {
|
||||
LoginParams,
|
||||
LoginResponse,
|
||||
RegisterParams,
|
||||
ForgotPasswordParams,
|
||||
ChangePasswordParams,
|
||||
SendCodeParams
|
||||
} 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,36 @@
|
|||
import type { ApiResponse } from './index';
|
||||
|
||||
/** 菜单基础参数类型(新增/编辑通用) */
|
||||
export interface MenuFormParams {
|
||||
name: string; // 菜单名称
|
||||
path: string; // 前端路由
|
||||
api: string; // 后端接口路由
|
||||
icon: string; // 菜单图标
|
||||
parentId: number | string; // 上级菜单ID(number类型)
|
||||
sort: number; // 排序(number类型)
|
||||
type: number; // 类型:1菜单2按钮(number类型)
|
||||
status?: number; // 状态:1启用2禁用(可选,编辑时用)
|
||||
id?: number; // 菜单ID(仅编辑/删除时传)
|
||||
parent_id?: number | null; // 上级菜单ID(number类型)
|
||||
}
|
||||
|
||||
/** 删除菜单参数类型 */
|
||||
export interface DeleteMenuParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** 菜单列表响应类型(单条菜单数据) */
|
||||
export interface MenuItem {
|
||||
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 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: string // 验证码类型
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
|
|
@ -0,0 +1,83 @@
|
|||
@use './user.scss';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
/* 隐藏全局滚动条 */
|
||||
}
|
||||
|
||||
.app-container {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
display: flex;
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
aside {
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
border-right: solid 1px var(--el-menu-border-color);
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
main {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 全局通知样式
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面级过渡动画样式(name="fade" 对应前缀 fade-) */
|
||||
/* 1. 进入/离开的初始状态 */
|
||||
.global-enter-from,
|
||||
.global-leave-to {
|
||||
opacity: 0;
|
||||
/* 初始透明 */
|
||||
transform: translateX(20px);
|
||||
/* 初始右移20px */
|
||||
}
|
||||
|
||||
/* 2. 进入/离开的过渡过程 */
|
||||
.global-enter-active,
|
||||
.global-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
/* 过渡时长、缓动效果 */
|
||||
}
|
||||
|
||||
/* 3. 进入完成/离开开始的状态 */
|
||||
.global-enter-to,
|
||||
.global-leave-from {
|
||||
opacity: 1;
|
||||
/* 最终不透明 */
|
||||
transform: translateX(0);
|
||||
/* 最终回到原位 */
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
.user-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
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,57 @@
|
|||
<!-- src/components/LanguageSelect.vue -->
|
||||
<template>
|
||||
<!-- 独立的语言选择器 -->
|
||||
<el-select v-model="innerValue" placeholder="Select" @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. 定义语言类型
|
||||
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>
|
||||
<style scoped lang="scss">
|
||||
.el-select {
|
||||
width: 100px;
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type LanguageType = 'zh-CN' | 'zh-HK' | 'en-US'
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<header>
|
||||
<div class="logo"></div>
|
||||
<div class="user-info">
|
||||
<LanguageSelect v-model="languageStore.lang" @change="languageStore.changeLang" />
|
||||
<el-dropdown @command="handleCommand">
|
||||
<el-button class="user-button">
|
||||
{{ activeUserName }}<el-icon class="el-icon--right">
|
||||
<ArrowDownBold />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="item in dropdownItems" :key="item.command" :command="item.command">
|
||||
{{ item.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownBold } from '@element-plus/icons-vue'
|
||||
//引入语言选择组件和导入Pinia状态
|
||||
import LanguageSelect from '@/components/LanguageSelect/index.vue'
|
||||
import { useLanguageStore } from '@/stores/modules/language'
|
||||
|
||||
//引入store和storage
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { getStorage } from '@/utils/storage'
|
||||
|
||||
//定义路由/Store实例
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const languageStore = useLanguageStore()
|
||||
//获取用户信息
|
||||
const userInfo = getStorage<any>('userInfo')
|
||||
|
||||
//用户操作实例
|
||||
const activeUserName = ref(userInfo?.username || '')
|
||||
const activeItem = ref('currentUser')
|
||||
const dropdownItems = [
|
||||
{ label: '切换账户', command: 'account' },
|
||||
{ label: '修改密码', command: 'password' },
|
||||
{ label: '退出登录', command: 'logout' }
|
||||
]
|
||||
|
||||
|
||||
const handleAccount = async () => {
|
||||
userStore.switchAccount()
|
||||
await router.push('/user/login')
|
||||
}
|
||||
|
||||
const handleChangePassword = () => {
|
||||
router.push('/user/change-password')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
// 示例:退出登录逻辑
|
||||
userStore.logout()
|
||||
}
|
||||
|
||||
// 处理菜单项点击
|
||||
const handleCommand = (command: string) => {
|
||||
activeItem.value = command
|
||||
// 不同指令的业务逻辑
|
||||
switch (command) {
|
||||
case 'currentUser':
|
||||
// 点击当前用户:无操作(仅选中)
|
||||
break
|
||||
case 'account':
|
||||
// 这里添加切换账户的逻辑(如弹窗/路由跳转)
|
||||
handleAccount()
|
||||
break
|
||||
case 'password':
|
||||
// 示例:跳转到修改密码页
|
||||
handleChangePassword()
|
||||
break
|
||||
case 'logout':
|
||||
// 示例:退出登录逻辑
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
header {
|
||||
height: 60px;
|
||||
border-bottom: solid 1px var(--el-menu-border-color);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.user-info {
|
||||
.el-select {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.el-dropdown {
|
||||
.user-button {
|
||||
border: none;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!-- src/components/layout/Layout.vue -->
|
||||
<template class="layout">
|
||||
<section class="layout">
|
||||
<!-- 左侧菜单:Sidebar -->
|
||||
<Sidebar />
|
||||
|
||||
<!-- 标红区域:MainContent(主内容区) -->
|
||||
<section class="main-container">
|
||||
<!-- Header -->
|
||||
<Header />
|
||||
|
||||
<!-- 内容路由视图 -->
|
||||
<main>
|
||||
<router-view/>
|
||||
</main>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import Header from './Header.vue'
|
||||
</script>
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
<template>
|
||||
<aside>
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="@/assets/images/logo.webp" alt="logo" :router="true" />
|
||||
<span>Admin System</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 菜单区域 -->
|
||||
<el-menu class="menu" :unique-opened="true" default-active="dashboard" router>
|
||||
<template v-for="menu in staticMenuList" :key="menu.id">
|
||||
<!-- 无子菜单 -->
|
||||
<el-menu-item v-if="!menu.children || menu.children.length === 0" :index="menu.path">
|
||||
<el-icon>
|
||||
<component :is="iconMap[menu.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ menu.label }}</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu v-else :index="menu.path">
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<component :is="iconMap[menu.icon]" />
|
||||
</el-icon>
|
||||
<span>{{ menu.label }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="subMenu in menu.children" :key="subMenu.id" :index="subMenu.path">
|
||||
<el-icon v-if="subMenu.icon">
|
||||
<component :is="iconMap[subMenu.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ subMenu.label }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
</el-menu>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HomeFilled, List, UserFilled, Platform, BellFilled, GoodsFilled, WarningFilled, Tools } from '@element-plus/icons-vue'
|
||||
|
||||
//获取格式化后的菜单列表
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const menuList = ref<any[]>([])
|
||||
|
||||
// 组件挂载时异步加载菜单
|
||||
onMounted(async () => {
|
||||
menuList.value = userStore.sideMenu || []
|
||||
})
|
||||
|
||||
//图标映射表
|
||||
const iconMap: Record<string, any> = {
|
||||
HomeFilled,
|
||||
List,
|
||||
UserFilled,
|
||||
Platform,
|
||||
BellFilled,
|
||||
GoodsFilled,
|
||||
WarningFilled,
|
||||
Tools
|
||||
}
|
||||
|
||||
|
||||
// 菜单假数据,具体要根据实际菜单数据进行调整
|
||||
import type { ElMenuItem } from '@/utils/menuFormat';
|
||||
|
||||
// 抽离后的菜单数据(完全匹配模板中的结构和配置)
|
||||
const staticMenuList = ref<ElMenuItem[]>(
|
||||
[
|
||||
// 首页
|
||||
{
|
||||
id: 1,
|
||||
label: '首页',
|
||||
path: 'dashboard', // 对应 el-menu-item 的 index="dashboard"
|
||||
icon: 'HomeFilled' // 对应 <HomeFilled /> 图标
|
||||
},
|
||||
// 模板配置(子菜单)
|
||||
{
|
||||
id: 2,
|
||||
label: '模板配置',
|
||||
path: '2', // 对应 el-sub-menu 的 index="2"
|
||||
icon: 'List', // 对应 <List /> 图标
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
label: '头寸模板',
|
||||
path: '2-1', // 对应 el-menu-item 的 index="2-1"
|
||||
icon: ''
|
||||
// 二级菜单无图标(模板中未设置)
|
||||
}
|
||||
]
|
||||
},
|
||||
// 账户管理(子菜单)
|
||||
{
|
||||
id: 3,
|
||||
label: '账户管理',
|
||||
path: '3', // 对应 el-sub-menu 的 index="3"
|
||||
icon: 'UserFilled', // 对应 <UserFilled /> 图标
|
||||
children: [
|
||||
{
|
||||
id: 31,
|
||||
label: '客户管理',
|
||||
path: '3-1', // index="3-1"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
label: '代理管理',
|
||||
path: '3-2', // index="3-2"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 33,
|
||||
label: '做市商管理',
|
||||
path: '3-3', // index="3-3"
|
||||
icon: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
// 交易管理(子菜单)
|
||||
{
|
||||
id: 4,
|
||||
label: '交易管理',
|
||||
path: '4', // index="4"
|
||||
icon: 'Platform', // 对应 <Platform /> 图标
|
||||
children: [
|
||||
{
|
||||
id: 41,
|
||||
label: '品种',
|
||||
path: '4-1', // index="4-1"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
label: '交易组',
|
||||
path: '4-2', // index="4-2"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
label: '交易时间',
|
||||
path: '4-3', // index="4-3"
|
||||
icon: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
// 公告管理
|
||||
{
|
||||
id: 5,
|
||||
label: '公告管理',
|
||||
path: '5', // index="5"
|
||||
icon: 'BellFilled' // 对应 <BellFilled /> 图标
|
||||
},
|
||||
// 资金管理(子菜单)
|
||||
{
|
||||
id: 6,
|
||||
label: '资金管理',
|
||||
path: '6', // index="6"
|
||||
icon: 'GoodsFilled', // 对应 <GoodsFilled /> 图标
|
||||
children: [
|
||||
{
|
||||
id: 61,
|
||||
label: '存款审核',
|
||||
path: '6-1', // index="6-1"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 62,
|
||||
label: '取款审核',
|
||||
path: '6-2', // index="6-2"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 63,
|
||||
label: '取款明细',
|
||||
path: '6-3', // index="6-3"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 64,
|
||||
label: '存款明细',
|
||||
path: '6-4', // index="6-4"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 65,
|
||||
label: '转账明细',
|
||||
path: '6-5', // index="6-5"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 66,
|
||||
label: '资金流水',
|
||||
path: '6-6', // index="6-6"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 67,
|
||||
label: '收款账户列表',
|
||||
path: '6-7', // index="6-7"
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 68,
|
||||
label: '存款渠道',
|
||||
path: '6-8', // index="6-8"
|
||||
icon: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
// 系统管理(子菜单)
|
||||
{
|
||||
id: 7,
|
||||
label: '系统管理',
|
||||
path: 'system', // index="7"
|
||||
icon: 'Tools', // 对应 <Tools /> 图标
|
||||
children: [
|
||||
{
|
||||
id: 71,
|
||||
label: '菜单管理',
|
||||
path: '/system/menu', // index="6-1"(模板中此处 index 写的是 6-1,保留原配置)
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
id: 72,
|
||||
label: '角色管理',
|
||||
path: '/system/role', // index="6-2"(模板中此处 index 写的是 6-2,保留原配置)
|
||||
icon: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
// 风控管理
|
||||
{
|
||||
id: 8,
|
||||
label: '风控管理',
|
||||
path: '8', // index="8"
|
||||
icon: 'WarningFilled' // 对应 <WarningFilled /> 图标
|
||||
}
|
||||
]
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
aside {
|
||||
.sidebar-header {
|
||||
height: 60px;
|
||||
border-bottom: solid 1px var(--el-menu-border-color);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
height: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: var(--el-menu-hover-bg-color)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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,122 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
import Layout from '@/components/layout/Layout.vue'
|
||||
|
||||
const routes = [
|
||||
// user路由
|
||||
{
|
||||
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: '/',
|
||||
component: Layout, // 全局布局(Sidebar + Header + MainContent)
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true }, // 需要登录
|
||||
children: [
|
||||
// 首页
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: {
|
||||
title: '首页', // 左侧菜单显示的标题
|
||||
}
|
||||
},
|
||||
// 系统管理
|
||||
{
|
||||
path: 'system',
|
||||
name: 'System',
|
||||
meta: {
|
||||
title: '系统管理', // 左侧菜单显示的标题
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'menu',
|
||||
name: 'Menu',
|
||||
component: () => import('@/views/system/menuManagement/index.vue'),
|
||||
meta: {
|
||||
title: '菜单管理', // 左侧菜单显示的标题
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'role',
|
||||
name: 'Role',
|
||||
component: () => import('@/views/system/roleManagement/index.vue'),
|
||||
meta: {
|
||||
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;
|
||||
|
||||
// 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,181 @@
|
|||
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';
|
||||
import { formatMenuToElMenu, type ElMenuItem } from '@/utils/menuFormat';
|
||||
|
||||
// 定义用户 Store
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const router = useRouter();
|
||||
|
||||
// ========== 状态定义(与后端返回数据对应) ==========
|
||||
// 用户/菜单和权限状态
|
||||
const userInfo = ref<UserInfo | null>(getStorage('userInfo') || null);
|
||||
const token = ref<string>(getStorage('token') || '');
|
||||
const menusInfo = ref<MenuItem[]>(getStorage('menusInfo') || []);
|
||||
const permissionsInfo = ref<PermissionItem[]>(getStorage('permissionsInfo') || []);
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
const isLoggedIn = computed(() => !!token.value);
|
||||
// const permissionKeys = computed(() => permissions.value.map(item => item.name));
|
||||
// 格式化后的 el-menu 数据(自动更新)
|
||||
const sideMenu = computed<ElMenuItem[]>(() => formatMenuToElMenu(menusInfo.value));
|
||||
/**
|
||||
* 设置用户信息
|
||||
* 作用:根据后端返回数据设置用户状态 + 本地存储
|
||||
*/
|
||||
const setUserInfo = (set: any) => {
|
||||
token.value = set.user.token;
|
||||
userInfo.value = set.user;
|
||||
menusInfo.value = set.menus_info;
|
||||
permissionsInfo.value = set.permissions_info;
|
||||
|
||||
// 保存到本地存储
|
||||
setStorage('token', set.user.token);
|
||||
setStorage('userInfo', set.user);
|
||||
setStorage('menusInfo', set.menus_info);
|
||||
setStorage('permissionsInfo', set.permissions_info);
|
||||
|
||||
console.log('用户信息保存成功');
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空用户信息(核心复用方法)
|
||||
* 作用:重置所有用户相关状态 + 清除本地存储
|
||||
*/
|
||||
const clearUserInfo = () => {
|
||||
// 重置 Pinia 中的状态
|
||||
token.value = '';
|
||||
userInfo.value = null;
|
||||
menusInfo.value = [];
|
||||
permissionsInfo.value = [];
|
||||
|
||||
// 清除本地存储的用户数据
|
||||
removeStorage('token');
|
||||
removeStorage('userInfo');
|
||||
removeStorage('menusInfo');
|
||||
removeStorage('permissionsInfo');
|
||||
|
||||
console.log('用户信息已清空');
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录方法
|
||||
* @param params 登录参数
|
||||
*/
|
||||
const login = async (params: LoginParams) => {
|
||||
try {
|
||||
const res = await loginApi(params);
|
||||
if (res.isUserNotExist) {
|
||||
// 用户不存在,跳转到注册页面
|
||||
router.push('/user/register');
|
||||
return;
|
||||
}
|
||||
setUserInfo(res);
|
||||
// 跳转到首页
|
||||
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 {
|
||||
clearUserInfo();
|
||||
|
||||
// 跳转到登录页面
|
||||
router.push('/user/login');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 忘记密码
|
||||
*/
|
||||
const forgotPassword = async (params: ForgotPasswordParams) => {
|
||||
try {
|
||||
await forgotPasswordApi(params);
|
||||
// 跳转登录页面
|
||||
router.push('/user/login');
|
||||
} catch (error) {
|
||||
console.error('重置密码失败', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
const changePassword = async (params: ChangePasswordParams) => {
|
||||
try {
|
||||
await changePasswordApi(params);
|
||||
// 跳转登录页面
|
||||
clearUserInfo();
|
||||
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;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 切换账户
|
||||
*/
|
||||
const switchAccount = async () => {
|
||||
clearUserInfo();
|
||||
router.push('/user/login');
|
||||
};
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
userInfo,
|
||||
menusInfo,
|
||||
permissionsInfo,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
sideMenu,
|
||||
// 方法(箭头函数)
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
forgotPassword,
|
||||
changePassword,
|
||||
sendCode,
|
||||
switchAccount
|
||||
};
|
||||
});
|
||||
|
|
@ -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,66 @@
|
|||
// 定义 el-menu 所需的标准类型
|
||||
export interface ElMenuItem {
|
||||
id: number;
|
||||
label: string; // 菜单显示名称
|
||||
path: string; // 路由路径(对应 el-menu 的 index)
|
||||
icon: string; // 图标名称(Element Plus 图标)
|
||||
children?: ElMenuItem[];
|
||||
}
|
||||
|
||||
// 🌟 核心修改:基于后端 path 字段做图标映射(仅一级菜单)
|
||||
// key = 后端返回的 path 值,value = Element Plus 图标名称
|
||||
const pathToIconMap: Record<string, string> = {
|
||||
'/#': 'Tools', // 系统管理 path=/# → Setting 图标
|
||||
"/menu-11": 'WarningFilled'
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化后端菜单数据为 el-menu 标准格式
|
||||
* - 路由路径:优先用后端 path 字段,其次用 api 字段
|
||||
* - 图标映射:仅一级菜单基于 path 字段映射,二级菜单无图标
|
||||
* @param rawMenuList 后端返回的 menus_info 数组
|
||||
* @returns 适配 el-menu 的菜单数据
|
||||
*/
|
||||
export const formatMenuToElMenu = (rawMenuList: any[]): ElMenuItem[] => {
|
||||
// 递归处理菜单,增加 isFirstLevel 标记是否为一级菜单
|
||||
const formatSubMenu = (menu: any, isFirstLevel: boolean): ElMenuItem => {
|
||||
// 1. 处理路由路径(逻辑保留,优先用后端 path 字段)
|
||||
let path = '';
|
||||
if (menu.path && menu.path !== '/') {
|
||||
path = menu.path;
|
||||
} else if (menu.api && menu.api !== '/#') {
|
||||
path = menu.api;
|
||||
} else {
|
||||
path = `/menu-${menu.id}`;
|
||||
if (menu.name === '首页') {
|
||||
path = '/dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理图标:仅一级菜单基于 path 映射,二级菜单无图标
|
||||
let icon: string = '';
|
||||
if (isFirstLevel) {
|
||||
// 一级菜单:根据后端 path 字段映射图标
|
||||
icon = pathToIconMap[menu.path] || pathToIconMap['default'] || '';
|
||||
}
|
||||
// 二级菜单:不赋值 icon → 自动无图标
|
||||
|
||||
// 3. 基础菜单项
|
||||
const elMenuItem: ElMenuItem = {
|
||||
id: menu.id,
|
||||
label: menu.name,
|
||||
path: path,
|
||||
icon: icon // 一级菜单有值,二级菜单 undefined(无图标)
|
||||
};
|
||||
|
||||
// 4. 处理子菜单(son 数组),标记为非一级菜单
|
||||
if (menu.son && menu.son.length > 0) {
|
||||
elMenuItem.children = menu.son.map((subMenu: any) => formatSubMenu(subMenu, false));
|
||||
}
|
||||
|
||||
return elMenuItem;
|
||||
};
|
||||
|
||||
// 遍历一级菜单,标记 isFirstLevel = true
|
||||
return rawMenuList.map(menu => formatSubMenu(menu, true));
|
||||
};
|
||||
|
|
@ -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,
|
||||
sendCodeApi: (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 sendCodeApi(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,13 @@
|
|||
<template>
|
||||
<div class="not-found">
|
||||
<h1>404 - 页面不存在</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>代理商管理</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>客户管理</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>做市商管理</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<!-- views/dashboard/index.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<h1>欢迎来到管理系统</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
<template>
|
||||
<div class="menu-list">
|
||||
<!-- 缺少通配搜索组件 -->
|
||||
|
||||
<div class="menu-action">
|
||||
<h4>菜单列表</h4>
|
||||
<el-button type="primary" :icon="Plus" @click="handleAddMenu">新增菜单</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<el-table :data="menuTree" row-key="id" :tree-props="{ children: 'son' }" border highlight-current-row>
|
||||
<el-table-column prop="name" label="菜单名称" />
|
||||
<el-table-column prop="path" label="路由路径" />
|
||||
<el-table-column prop="icon" label="图标">
|
||||
<template #default="{ row }">
|
||||
<el-icon v-if="row.icon && iconMap[row.icon]">
|
||||
<component :is="iconMap[row.icon]" />
|
||||
</el-icon>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型">
|
||||
<template #default="{ row }">
|
||||
{{ row.type === 1 ? '目录' : '按钮' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 新增:状态列 -->
|
||||
<el-table-column prop="status" label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEditMenu(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteMenu(row)">
|
||||
删除
|
||||
</el-button>
|
||||
<el-button link size="small" :type="row.status === 1 ? 'warning' : 'success'"
|
||||
@click="handleToggleStatus(row)">
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增菜单' : '编辑菜单'" width="600px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="菜单名称">
|
||||
<el-input v-model="menuForm.name" placeholder="请输入菜单名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级菜单">
|
||||
<el-select v-model="menuForm.parentId" placeholder="请选择父级菜单" style="width:100%">
|
||||
<el-option label="一级菜单" value="0" />
|
||||
<el-option v-for="item in menuTree" :key="item.id" :label="item.name" :value="item.id + ''" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="路由路径">
|
||||
<el-input v-model="menuForm.path" placeholder="例如:/system/menuList" />
|
||||
</el-form-item>
|
||||
<el-form-item label="后端路由">
|
||||
<el-input v-model="menuForm.api" placeholder="例如:/api/system/menu/list(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标名称">
|
||||
<el-input v-model="menuForm.icon" placeholder="例如:Tools、List" />
|
||||
<div style="margin-top:8px;font-size:12px;color:#909399;">
|
||||
支持的图标:Tools、List、HomeFilled、UserFilled、Platform、BellFilled、GoodsFilled、WarningFilled
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="menuForm.sort" :min="0" placeholder="数字越小越靠前(默认1)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型">
|
||||
<el-radio-group v-model="menuForm.type">
|
||||
<el-radio :value="1">菜单(显示在侧边栏)</el-radio>
|
||||
<el-radio :value="2">按钮(权限控制)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmitMenu">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tools, List, HomeFilled, UserFilled, Platform, BellFilled, GoodsFilled, WarningFilled, Plus } from '@element-plus/icons-vue'
|
||||
|
||||
import { getMenuApi, addMenuApi, deleteMenuApi, editMenuApi } from '@/api/modules/menu'
|
||||
|
||||
//menu数据
|
||||
const menuTree = ref<any[]>([])
|
||||
|
||||
// 定义菜单表单数据
|
||||
const menuForm = ref({
|
||||
name: '', // 菜单名称
|
||||
parentId: 0, // 默认一级菜单
|
||||
path: '', // 路由路径
|
||||
api: '', // 后端路由
|
||||
icon: '', // 图标名称
|
||||
sort: 1, // 排序(默认1)
|
||||
type: 1 // 默认是菜单类型
|
||||
})
|
||||
|
||||
// 定义图标映射表(和侧边栏逻辑一致)
|
||||
const iconMap: Record<string, any> = {
|
||||
Tools,
|
||||
List,
|
||||
HomeFilled,
|
||||
UserFilled,
|
||||
Platform,
|
||||
BellFilled,
|
||||
GoodsFilled,
|
||||
WarningFilled
|
||||
}
|
||||
// 定义弹窗显示状态
|
||||
const dialogVisible = ref(false)
|
||||
// 新增:区分新增/编辑模式
|
||||
const dialogType = ref<'add' | 'edit'>('add')
|
||||
// 编辑菜单ID
|
||||
const currentEditId = ref(null)
|
||||
|
||||
//所有的方法
|
||||
|
||||
// 获取菜单列表
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getMenuApi()
|
||||
menuTree.value = filterEnabledMenus(res)
|
||||
} catch (err) {
|
||||
ElMessage.error('获取菜单列表失败')
|
||||
}
|
||||
})
|
||||
|
||||
// 处理菜单状态,只保留 status=1 的菜单
|
||||
const filterEnabledMenus = (menuList: any[]) => {
|
||||
const enabledMenus = menuList.filter(menu => menu.status === 1)
|
||||
enabledMenus.forEach(menu => {
|
||||
if (menu.son && menu.son.length) {
|
||||
menu.son = filterEnabledMenus(menu.son)
|
||||
}
|
||||
})
|
||||
return enabledMenus
|
||||
}
|
||||
|
||||
// 打开新增弹窗
|
||||
const handleAddMenu = () => {
|
||||
// 清空表单
|
||||
menuForm.value = {
|
||||
name: '',
|
||||
parentId: '',
|
||||
path: '',
|
||||
api: '',
|
||||
icon: '',
|
||||
sort: 1,
|
||||
type: 1
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
//打开编辑弹窗
|
||||
const handleEditMenu = (row: any) => {
|
||||
dialogType.value = 'edit'
|
||||
currentEditId.value = row.id
|
||||
|
||||
menuForm.value = {
|
||||
name: row.name,
|
||||
parentId: row.parentId,
|
||||
path: row.path,
|
||||
api: row.api,
|
||||
icon: row.icon,
|
||||
sort: row.sort,
|
||||
type: row.type
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单(新增/编辑)
|
||||
const handleSubmitMenu = async () => {
|
||||
//添加校验,校验通过后提交表单
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
await addMenuApi({
|
||||
...menuForm.value,
|
||||
parent_id: menuForm.value.parentId
|
||||
})
|
||||
} else {
|
||||
await editMenuApi({
|
||||
id: currentEditId.value || 0,
|
||||
status: menuForm.value.type === 2 ? 0 : 1, // 按钮默认禁用
|
||||
parent_id: menuForm.value.parentId,
|
||||
...menuForm.value
|
||||
})
|
||||
}
|
||||
dialogVisible.value = false
|
||||
} catch (err) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
//删除菜单
|
||||
const handleDeleteMenu = async (row: any) => {
|
||||
try {
|
||||
await deleteMenuApi({ id: row.id })
|
||||
} catch (err) {
|
||||
console.log('删除菜单失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 切换状态
|
||||
const handleToggleStatus = async (row: any) => {
|
||||
row.status = row.status === 1 ? 0 : 1
|
||||
ElMessage.success(`已${row.status === 1 ? '启用' : '禁用'}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu-list {
|
||||
.menu-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div class="role-list">
|
||||
<h1>角色列表</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<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')
|
||||
const router = useRouter()
|
||||
|
||||
//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 sendCodeApi = async () => {
|
||||
// 发送验证码参数
|
||||
const sendParams = {
|
||||
email: changePasswordForm.value.email,
|
||||
mobile: changePasswordForm.value.phone,
|
||||
reg_type: activeTab.value === 'email' ? 1 : 2,
|
||||
type:'resetPass_code'
|
||||
}
|
||||
return userStore.sendCode(sendParams)
|
||||
}
|
||||
|
||||
//发送验证码
|
||||
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;
|
||||
|
||||
// 第三步:调用封装的发送函数(传正确的函数,而非字符串)
|
||||
await sendCaptcha(
|
||||
type,
|
||||
receiver,
|
||||
currentCaptchaState, // 只更新当前类型的倒计时状态
|
||||
() => sendCodeApi() // 传入返回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 = () => {
|
||||
router.back()
|
||||
};
|
||||
|
||||
// 组件卸载时清除定时器(关键:防止内存泄漏)
|
||||
onUnmounted(() => {
|
||||
clearCaptchaTimer(emailCaptchaState);
|
||||
clearCaptchaTimer(phoneCaptchaState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
.form-submit {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
<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 sendCodeApi = async () => {
|
||||
// 发送验证码参数
|
||||
const sendParams = {
|
||||
email: forgotPasswordForm.value.email,
|
||||
mobile: forgotPasswordForm.value.phone,
|
||||
reg_type: activeTab.value === 'email' ? 1 : 2,
|
||||
type:'transfer_code'
|
||||
}
|
||||
return userStore.sendCode(sendParams)
|
||||
}
|
||||
|
||||
//发送验证码
|
||||
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, // 只更新当前类型的倒计时状态
|
||||
() => sendCodeApi() // 传入返回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,214 @@
|
|||
<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: 'test@163.com',
|
||||
countryCode: '+86',
|
||||
phone: '',
|
||||
password: '123456',
|
||||
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';
|
||||
|
||||
if (currentTab === 'phone' && loginForm.value.email !== '') {
|
||||
loginForm.value.email = ''
|
||||
}
|
||||
}
|
||||
// 验证密码+验证码 → 执行登录
|
||||
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,207 @@
|
|||
<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 sendCodeApi = async () => {
|
||||
// 发送验证码参数
|
||||
const sendParams = {
|
||||
email: registerForm.value.email,
|
||||
mobile: registerForm.value.phone,
|
||||
reg_type: activeTab.value === 'email' ? 1 : 2,
|
||||
type:'resetPass_code'
|
||||
}
|
||||
return userStore.sendCode(sendParams)
|
||||
}
|
||||
//发送验证码
|
||||
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, // 只更新当前类型的倒计时状态
|
||||
() => sendCodeApi() // 传入返回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