3.4号提交

This commit is contained in:
李远 2026-03-04 17:11:33 +08:00
parent ca5998e98e
commit 21173963f9
55 changed files with 7584 additions and 0 deletions

8
.editorconfig Normal file
View File

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

11
.env.development Normal file
View File

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

11
.env.production Normal file
View File

@ -0,0 +1,11 @@
# 生产环境标识Vite 内置变量,无需修改)
NODE_ENV = production
# 生产环境接口基础地址
VITE_API_BASE_URL = https://api.yourdomain.com
# 请求超时时间(毫秒)
VITE_REQUEST_TIMEOUT = 10000
# 可选:生产环境其他配置(如关闭调试)
VITE_DEBUG = false

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
#auto generated files
.eslintrc-auto-import.json

10
.oxlintrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

48
README.md Normal file
View File

@ -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
```

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

26
eslint.config.ts Normal file
View File

@ -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,
)

13
index.html Normal file
View File

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

55
package.json Normal file
View File

@ -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"
}
}

4084
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

12
src/App.vue Normal file
View File

@ -0,0 +1,12 @@
<template>
<div class="app-container">
<router-view/>
</div>
</template>
<script lang="ts" setup>
</script>
<style scoped lang="scss">
</style>

183
src/api/index.ts Normal file
View File

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

41
src/api/modules/menu.ts Normal file
View File

@ -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);
}

62
src/api/modules/role.ts Normal file
View File

@ -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 });
}

62
src/api/modules/user.ts Normal file
View File

@ -0,0 +1,62 @@
import { request } from '@/api';
import type {
LoginParams,
LoginResponse,
RegisterParams,
ForgotPasswordParams,
ChangePasswordParams,
} from '@/api/types/user';
/**
*
* @param params //
* @returns Promise<LoginResponse> token+
*/
export async function loginApi(params: LoginParams): Promise<LoginResponse> {
return request.post<LoginResponse>('/login', params);
}
/**
*
* @param params //
* @returns Promise<boolean> true
*/
export async function registerApi(params: RegisterParams) {
// 泛型指定为 boolean匹配后端返回的注册成功标识
return request.post<boolean>('/reg', params);
}
/**
*
* @param params /
* @returns Promise<string> ID
*/
export async function forgotPasswordApi(params: ForgotPasswordParams) {
return request.post<string>('/resetPassword', params);
}
/**
*
* @param params //
* @returns Promise<boolean> true
*/
export async function changePasswordApi(params: ChangePasswordParams) {
return request.post<boolean>('/user/editLoginPass', params);
}
/**
* 退
* @returns Promise<void>
*/
export async function logoutApi() {
return request.post<void>('/logout');
}
/**
*
* @param params /
* @returns Promise<string> ID
*/
export async function sendCodeApi(params: SendCodeParams) {
return request.post<string>('/sendCode', params);
}

20
src/api/types/index.d.ts vendored Normal file
View File

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

37
src/api/types/menu.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
import type { ApiResponse } from './index';
/** 菜单基础参数类型(新增/编辑通用) */
export interface MenuFormParams {
name: string; // 菜单名称
path: string; // 前端路由
api: string; // 后端接口路由
icon: string; // 菜单图标
parent_id: string; // 上级菜单IDstring类型匹配后端
sort: string; // 排序string类型
type: string; // 类型1菜单2按钮string类型
status?: string; // 状态1启用2禁用可选编辑时用
id?: string; // 菜单ID仅编辑/删除时传)
}
/** 删除菜单参数类型 */
export interface DeleteMenuParams {
id: string;
}
/** 菜单列表响应类型(单条菜单数据) */
export interface MenuItem {
id: string;
name: string;
path: string;
api: string;
icon: string;
parent_id: string;
sort: string;
type: string;
status: string;
create_time?: string;
update_time?: string;
}
/** 菜单列表响应类型(泛型约束) */
export type MenuListResponse = MenuItem[];

39
src/api/types/role.d.ts vendored Normal file
View File

@ -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[];

51
src/api/types/user.d.ts vendored Normal file
View File

@ -0,0 +1,51 @@
// 登录参数类型
export interface LoginParams {
email?: string
mobile?: string // 手机号(可选)
password: string
login_type: number // 1-邮箱登录 2-手机登录
code?: string // 验证码(可选)
}
// 登录响应类型
export interface LoginResponse {
user: UserInfo
menus_info: MenuItem[]
permissions_info: PermissionItem[]
isUserNotExist?: boolean // 自定义标识,标记“用户不存在”
}
// 注册参数类型
export interface RegisterParams {
email?: string // 邮箱(可选)
mobile?: string // 手机号(可选)
password: string
reg_type?: number // 1-邮箱注册 2-手机注册
invite_code?: string // 邀请码(可选)
code: string
}
//忘记密码参数类型
export interface ForgotPasswordParams {
mobile?: string // 手机号(可选)
email?: string // 邮箱(可选)
reg_type: number // 1-邮箱登录 2-手机登录
code: string
login_password1: string
login_password2: string
}
// 修改密码参数类型
export interface ChangePasswordParams {
mobile?: string // 手机号(可选)
email?: string // 邮箱(可选)
reg_type: number // 1-邮箱登录 2-手机登录
code: string
login_password1: string
login_password2: string
}
// 发送验证码参数类型
export interface SendCodeParams {
mobile?: string // 手机号(可选)
email?: string // 邮箱(可选)
reg_type: number // 1-邮箱登录 2-手机登录
type: number // 验证码类型
}

BIN
src/assets/images/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,43 @@
@import './user.scss';
*{margin: 0;padding: 0;box-sizing: border-box;}
html,body{
font-size: 16px;
color: black;
}
/* 过渡动画样式name="fade" 对应前缀 fade- */
/* 1. 进入/离开的初始状态 */
.user-enter-from,
.user-leave-to {
opacity: 0;
/* 初始透明 */
transform: translateX(20px);
/* 初始右移20px */
}
/* 2. 进入/离开的过渡过程 */
.user-enter-active,
.user-leave-active {
transition: all 0.5s ease;
/* 过渡时长、缓动效果 */
}
/* 3. 进入完成/离开开始的状态 */
.user-enter-to,
.user-leave-from {
opacity: 1;
/* 最终不透明 */
transform: translateX(0);
/* 最终回到原位 */
}
// 全局通知样式
.global-notification {
left: 50%;
transform: translateX(-50%);
// 2. 重写动画从顶部淡入取消右侧滑入
&.el-notification-fade-enter-active,
&.el-notification-fade-leave-active {
transition: all 0.3s ease-out !important;
transform: translate(-50%, -150%) !important;
}
}

104
src/assets/styles/user.scss Normal file
View File

@ -0,0 +1,104 @@
//user styles
.user-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
.user-card {
width: 100%;
max-width: 460px;
padding: 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.logo {
width: 115px;
height: 40px;
display: flex;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.user-form {
// .el-form-item {
// margin-bottom: 24px;
// }
.phone-item {
.el-select {
width: 120px;
margin-right: 10px;
.country-option {
width: 120px;
display: flex;
justify-content: space-between;
}
}
}
.form-submit {
margin-bottom: 0;
button {
width: 100%;
height: 40px;
}
}
.captcha-item {
button {
margin-left: 10px;
height: 40px;
}
}
}
}
}
// tab切换表单动画
.form-switch-enter-from {
opacity: 0;
transform: translateX(10px) scale(0.98);
}
.form-switch-enter-active {
transition: all 0.3s ease-out;
}
.form-switch-enter-to {
opacity: 1;
transform: translateX(0) scale(1);
}
.form-switch-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
.form-switch-leave-active {
transition: all 0.2s ease-in;
}
.form-switch-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.98);
}

322
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,322 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElLoading: typeof import('element-plus/es').ElLoading
const ElMessage: typeof import('element-plus/es').ElMessage
const ElNo: typeof import('element-plus/es').ElNo
const ElNotification: typeof import('element-plus/es').ElNotification
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const asyncComputed: typeof import('@vueuse/core').asyncComputed
const autoResetRef: typeof import('@vueuse/core').autoResetRef
const computed: typeof import('vue').computed
const computedAsync: typeof import('@vueuse/core').computedAsync
const computedEager: typeof import('@vueuse/core').computedEager
const computedInject: typeof import('@vueuse/core').computedInject
const computedWithControl: typeof import('@vueuse/core').computedWithControl
const controlledComputed: typeof import('@vueuse/core').controlledComputed
const controlledRef: typeof import('@vueuse/core').controlledRef
const createApp: typeof import('vue').createApp
const createEventHook: typeof import('@vueuse/core').createEventHook
const createGlobalState: typeof import('@vueuse/core').createGlobalState
const createInjectionState: typeof import('@vueuse/core').createInjectionState
const createPinia: typeof import('pinia').createPinia
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
const createRef: typeof import('@vueuse/core').createRef
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
const customRef: typeof import('vue').customRef
const debouncedRef: typeof import('@vueuse/core').debouncedRef
const debouncedWatch: typeof import('@vueuse/core').debouncedWatch
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const eagerComputed: typeof import('@vueuse/core').eagerComputed
const effectScope: typeof import('vue').effectScope
const extendRef: typeof import('@vueuse/core').extendRef
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
const inject: typeof import('vue').inject
const injectLocal: typeof import('@vueuse/core').injectLocal
const isDefined: typeof import('@vueuse/core').isDefined
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onClickOutside: typeof import('@vueuse/core').onClickOutside
const onDeactivated: typeof import('vue').onDeactivated
const onElementRemoval: typeof import('@vueuse/core').onElementRemoval
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onKeyStroke: typeof import('@vueuse/core').onKeyStroke
const onLongPress: typeof import('@vueuse/core').onLongPress
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onStartTyping: typeof import('@vueuse/core').onStartTyping
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const pausableWatch: typeof import('@vueuse/core').pausableWatch
const provide: typeof import('vue').provide
const provideLocal: typeof import('@vueuse/core').provideLocal
const reactify: typeof import('@vueuse/core').reactify
const reactifyObject: typeof import('@vueuse/core').reactifyObject
const reactive: typeof import('vue').reactive
const reactiveComputed: typeof import('@vueuse/core').reactiveComputed
const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
const reactivePick: typeof import('@vueuse/core').reactivePick
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const refAutoReset: typeof import('@vueuse/core').refAutoReset
const refDebounced: typeof import('@vueuse/core').refDebounced
const refDefault: typeof import('@vueuse/core').refDefault
const refManualReset: typeof import('@vueuse/core').refManualReset
const refThrottled: typeof import('@vueuse/core').refThrottled
const refWithControl: typeof import('@vueuse/core').refWithControl
const resolveComponent: typeof import('vue').resolveComponent
const resolveRef: typeof import('@vueuse/core').resolveRef
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const syncRef: typeof import('@vueuse/core').syncRef
const syncRefs: typeof import('@vueuse/core').syncRefs
const templateRef: typeof import('@vueuse/core').templateRef
const throttledRef: typeof import('@vueuse/core').throttledRef
const throttledWatch: typeof import('@vueuse/core').throttledWatch
const toRaw: typeof import('vue').toRaw
const toReactive: typeof import('@vueuse/core').toReactive
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
const tryOnScopeDispose: typeof import('@vueuse/core').tryOnScopeDispose
const tryOnUnmounted: typeof import('@vueuse/core').tryOnUnmounted
const unref: typeof import('vue').unref
const unrefElement: typeof import('@vueuse/core').unrefElement
const until: typeof import('@vueuse/core').until
const useActiveElement: typeof import('@vueuse/core').useActiveElement
const useAnimate: typeof import('@vueuse/core').useAnimate
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
const useArrayEvery: typeof import('@vueuse/core').useArrayEvery
const useArrayFilter: typeof import('@vueuse/core').useArrayFilter
const useArrayFind: typeof import('@vueuse/core').useArrayFind
const useArrayFindIndex: typeof import('@vueuse/core').useArrayFindIndex
const useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast
const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes
const useArrayJoin: typeof import('@vueuse/core').useArrayJoin
const useArrayMap: typeof import('@vueuse/core').useArrayMap
const useArrayReduce: typeof import('@vueuse/core').useArrayReduce
const useArraySome: typeof import('@vueuse/core').useArraySome
const useArrayUnique: typeof import('@vueuse/core').useArrayUnique
const useAsyncQueue: typeof import('@vueuse/core').useAsyncQueue
const useAsyncState: typeof import('@vueuse/core').useAsyncState
const useAttrs: typeof import('vue').useAttrs
const useBase64: typeof import('@vueuse/core').useBase64
const useBattery: typeof import('@vueuse/core').useBattery
const useBluetooth: typeof import('@vueuse/core').useBluetooth
const useBreakpoints: typeof import('@vueuse/core').useBreakpoints
const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel
const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation
const useCached: typeof import('@vueuse/core').useCached
const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned
const useColorMode: typeof import('@vueuse/core').useColorMode
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useCountdown: typeof import('@vueuse/core').useCountdown
const useCounter: typeof import('@vueuse/core').useCounter
const useCssModule: typeof import('vue').useCssModule
const useCssSupports: typeof import('@vueuse/core').useCssSupports
const useCssVar: typeof import('@vueuse/core').useCssVar
const useCssVars: typeof import('vue').useCssVars
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
const useCycleList: typeof import('@vueuse/core').useCycleList
const useDark: typeof import('@vueuse/core').useDark
const useDateFormat: typeof import('@vueuse/core').useDateFormat
const useDebounce: typeof import('@vueuse/core').useDebounce
const useDebounceFn: typeof import('@vueuse/core').useDebounceFn
const useDebouncedRefHistory: typeof import('@vueuse/core').useDebouncedRefHistory
const useDeviceMotion: typeof import('@vueuse/core').useDeviceMotion
const useDeviceOrientation: typeof import('@vueuse/core').useDeviceOrientation
const useDevicePixelRatio: typeof import('@vueuse/core').useDevicePixelRatio
const useDevicesList: typeof import('@vueuse/core').useDevicesList
const useDisplayMedia: typeof import('@vueuse/core').useDisplayMedia
const useDocumentVisibility: typeof import('@vueuse/core').useDocumentVisibility
const useDraggable: typeof import('@vueuse/core').useDraggable
const useDropZone: typeof import('@vueuse/core').useDropZone
const useElementBounding: typeof import('@vueuse/core').useElementBounding
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
const useElementHover: typeof import('@vueuse/core').useElementHover
const useElementSize: typeof import('@vueuse/core').useElementSize
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
const useEventBus: typeof import('@vueuse/core').useEventBus
const useEventListener: typeof import('@vueuse/core').useEventListener
const useEventSource: typeof import('@vueuse/core').useEventSource
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
const useFavicon: typeof import('@vueuse/core').useFavicon
const useFetch: typeof import('@vueuse/core').useFetch
const useFileDialog: typeof import('@vueuse/core').useFileDialog
const useFileSystemAccess: typeof import('@vueuse/core').useFileSystemAccess
const useFocus: typeof import('@vueuse/core').useFocus
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
const useFps: typeof import('@vueuse/core').useFps
const useFullscreen: typeof import('@vueuse/core').useFullscreen
const useGamepad: typeof import('@vueuse/core').useGamepad
const useGeolocation: typeof import('@vueuse/core').useGeolocation
const useId: typeof import('vue').useId
const useIdle: typeof import('@vueuse/core').useIdle
const useImage: typeof import('@vueuse/core').useImage
const useInfiniteScroll: typeof import('@vueuse/core').useInfiniteScroll
const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver
const useInterval: typeof import('@vueuse/core').useInterval
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router').useLink
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
const useMediaControls: typeof import('@vueuse/core').useMediaControls
const useMediaQuery: typeof import('@vueuse/core').useMediaQuery
const useMemoize: typeof import('@vueuse/core').useMemoize
const useMemory: typeof import('@vueuse/core').useMemory
const useModel: typeof import('vue').useModel
const useMounted: typeof import('@vueuse/core').useMounted
const useMouse: typeof import('@vueuse/core').useMouse
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
const useMousePressed: typeof import('@vueuse/core').useMousePressed
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
const useNetwork: typeof import('@vueuse/core').useNetwork
const useNow: typeof import('@vueuse/core').useNow
const useObjectUrl: typeof import('@vueuse/core').useObjectUrl
const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination
const useOnline: typeof import('@vueuse/core').useOnline
const usePageLeave: typeof import('@vueuse/core').usePageLeave
const useParallax: typeof import('@vueuse/core').useParallax
const useParentElement: typeof import('@vueuse/core').useParentElement
const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver
const usePermission: typeof import('@vueuse/core').usePermission
const usePointer: typeof import('@vueuse/core').usePointer
const usePointerLock: typeof import('@vueuse/core').usePointerLock
const usePointerSwipe: typeof import('@vueuse/core').usePointerSwipe
const usePreferredColorScheme: typeof import('@vueuse/core').usePreferredColorScheme
const usePreferredContrast: typeof import('@vueuse/core').usePreferredContrast
const usePreferredDark: typeof import('@vueuse/core').usePreferredDark
const usePreferredLanguages: typeof import('@vueuse/core').usePreferredLanguages
const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
const usePrevious: typeof import('@vueuse/core').usePrevious
const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
const useScriptTag: typeof import('@vueuse/core').useScriptTag
const useScroll: typeof import('@vueuse/core').useScroll
const useScrollLock: typeof import('@vueuse/core').useScrollLock
const useSessionStorage: typeof import('@vueuse/core').useSessionStorage
const useShare: typeof import('@vueuse/core').useShare
const useSlots: typeof import('vue').useSlots
const useSorted: typeof import('@vueuse/core').useSorted
const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition
const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis
const useStepper: typeof import('@vueuse/core').useStepper
const useStorage: typeof import('@vueuse/core').useStorage
const useStorageAsync: typeof import('@vueuse/core').useStorageAsync
const useStyleTag: typeof import('@vueuse/core').useStyleTag
const useSupported: typeof import('@vueuse/core').useSupported
const useSwipe: typeof import('@vueuse/core').useSwipe
const useTemplateRef: typeof import('vue').useTemplateRef
const useTemplateRefsList: typeof import('@vueuse/core').useTemplateRefsList
const useTextDirection: typeof import('@vueuse/core').useTextDirection
const useTextSelection: typeof import('@vueuse/core').useTextSelection
const useTextareaAutosize: typeof import('@vueuse/core').useTextareaAutosize
const useThrottle: typeof import('@vueuse/core').useThrottle
const useThrottleFn: typeof import('@vueuse/core').useThrottleFn
const useThrottledRefHistory: typeof import('@vueuse/core').useThrottledRefHistory
const useTimeAgo: typeof import('@vueuse/core').useTimeAgo
const useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
const useTimeout: typeof import('@vueuse/core').useTimeout
const useTimeoutFn: typeof import('@vueuse/core').useTimeoutFn
const useTimeoutPoll: typeof import('@vueuse/core').useTimeoutPoll
const useTimestamp: typeof import('@vueuse/core').useTimestamp
const useTitle: typeof import('@vueuse/core').useTitle
const useToNumber: typeof import('@vueuse/core').useToNumber
const useToString: typeof import('@vueuse/core').useToString
const useToggle: typeof import('@vueuse/core').useToggle
const useTransition: typeof import('@vueuse/core').useTransition
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
const useUserMedia: typeof import('@vueuse/core').useUserMedia
const useVModel: typeof import('@vueuse/core').useVModel
const useVModels: typeof import('@vueuse/core').useVModels
const useVibrate: typeof import('@vueuse/core').useVibrate
const useVirtualList: typeof import('@vueuse/core').useVirtualList
const useWakeLock: typeof import('@vueuse/core').useWakeLock
const useWebNotification: typeof import('@vueuse/core').useWebNotification
const useWebSocket: typeof import('@vueuse/core').useWebSocket
const useWebWorker: typeof import('@vueuse/core').useWebWorker
const useWebWorkerFn: typeof import('@vueuse/core').useWebWorkerFn
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
const useWindowSize: typeof import('@vueuse/core').useWindowSize
const watch: typeof import('vue').watch
const watchArray: typeof import('@vueuse/core').watchArray
const watchAtMost: typeof import('@vueuse/core').watchAtMost
const watchDebounced: typeof import('@vueuse/core').watchDebounced
const watchDeep: typeof import('@vueuse/core').watchDeep
const watchEffect: typeof import('vue').watchEffect
const watchIgnorable: typeof import('@vueuse/core').watchIgnorable
const watchImmediate: typeof import('@vueuse/core').watchImmediate
const watchOnce: typeof import('@vueuse/core').watchOnce
const watchPausable: typeof import('@vueuse/core').watchPausable
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
const watchThrottled: typeof import('@vueuse/core').watchThrottled
const watchTriggerable: typeof import('@vueuse/core').watchTriggerable
const watchWithFilter: typeof import('@vueuse/core').watchWithFilter
const whenever: typeof import('@vueuse/core').whenever
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

38
src/components.d.ts vendored Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Captcha: typeof import('./views/user/components/Captcha.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
Header: typeof import('./views/user/components/Header.vue')['default']
LanguageSelect: typeof import('./components/LanguageSelect/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Tabs: typeof import('./views/user/components/Tabs.vue')['default']
Text: typeof import('./views/user/components/Text.vue')['default']
}
}

View File

@ -0,0 +1,48 @@
<!-- src/components/LanguageSelect.vue -->
<template>
<!-- 独立的语言选择器 -->
<el-select v-model="innerValue" placeholder="Select" style="width: 120px" @change="handleChange">
<el-option v-for="item in languageOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
<script setup lang="ts">
// 1. TS
type LanguageType = 'zh-CN' | 'zh-HK' | 'en-US'
// 2. Propsv-model
const props = defineProps<{
modelValue: LanguageType // v-model prop
}>()
// 3. Emitsv-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>

View File

@ -0,0 +1 @@
export type LanguageType = 'zh-CN' | 'zh-HK' | 'en-US'

16
src/main.ts Normal file
View File

@ -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')

88
src/router/index.ts Normal file
View File

@ -0,0 +1,88 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from '@/stores/modules/user';
const routes = [
// 1. 补全根路径路由定义,避免通配符无限循环
{
path: '/',
name: 'Home',
component: () => import('@/views/Test/index.vue'), // 替换为你的首页组件
meta: { requiresAuth: true ,title: '首页' } // 首页需要登录
},
{
path: '/user',
name: 'User',
component: () => import('@/views/user/index.vue'),
redirect: '/user/login',
children: [
{
path: 'login',
name: 'Login',
component: () => import('@/views/user/Login.vue'),
meta: { isAuthPage: true, title: '登录' }
},
{
path: 'register',
name: 'Register',
component: () => import('@/views/user/Register.vue'),
meta: { isAuthPage: true, title: '注册' }
},
{
path: 'forgot-password',
name: 'ForgotPassword',
component: () => import('@/views/user/ForgotPassword.vue'),
meta: { isAuthPage: true, title: '忘记密码' }
},
{
path: 'change-password',
name: 'ChangePassword',
component: () => import('@/views/user/ChangePassword.vue'),
meta: { requiresAuth: true, title: '修改密码' }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/user/login' // 404 重定向到登录页,避免循环
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 全局前置守卫
router.beforeEach(async (to, from) => {
const userStore = useUserStore();
const isLoggedIn = !!userStore.token;
// 从修改密码页跳登录页时,直接放行(不跳首页)
const isFromChangePasswordToLogin = from.path === '/user/change-password' && to.path === '/user/login';
if (isFromChangePasswordToLogin) {
return true; // 放行,跳过后续的“已登录访问授权页跳首页”逻辑
}
// 1. 已登录用户访问授权页 → 重定向到首页
if (isLoggedIn && to.meta.isAuthPage) {
if (to.path === '/') return true;
return '/';
}
// 2. 未登录用户访问需要登录的页面(包括首页)→ 重定向到登录页
if (!isLoggedIn && to.meta.requiresAuth) {
if (to.path === '/user/login') return true;
return '/user/login';
}
// 3. 其他情况正常放行
return true;
});
// 可选:设置页面标题
router.afterEach((to) => {
if (to.meta.title) {
document.title = to.meta.title as string;
}
});
export default router;

View File

@ -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 }
})

142
src/stores/modules/user.ts Normal file
View File

@ -0,0 +1,142 @@
import { getStorage, setStorage, removeStorage } from '@/utils/storage';
import { loginApi, registerApi, logoutApi, forgotPasswordApi, changePasswordApi, sendCodeApi } from '@/api/modules/user';
import type { LoginParams, RegisterParams, ForgotPasswordParams, ChangePasswordParams, SendCodeParams } from '@/api/types/user';
import type { UserInfo, MenuItem, PermissionItem } from '@/stores/types/user';
// 定义用户 Store
export const useUserStore = defineStore('user', () => {
const router = useRouter();
// ========== 状态定义(与后端返回数据对应) ==========
const userInfo = ref<UserInfo | null>(null);
const menus = ref<MenuItem[]>([]);
const permissions = ref<PermissionItem[]>([]);
const token = ref<string>(getStorage('token') || '');
// ========== 计算属性 ==========
const isLoggedIn = computed(() => !!token.value);
const permissionKeys = computed(() => permissions.value.map(item => item.name));
// ========== 核心方法(全部改为箭头函数) ==========
/**
*
* @param params
*/
const login = async (params: LoginParams) => {
try {
const res = await loginApi(params);
if(res.isUserNotExist) {
// 用户不存在,跳转到注册页面
router.push('/user/register');
return;
}
token.value = res.user.token;
userInfo.value = res.user;
menus.value = res.menus_info;
permissions.value = res.permissions_info;
setStorage('token', res.user.token);
setStorage('userInfo', res.user);
// 跳转到首页
router.push('/');
} catch (error) {
console.error('登录失败', error);
throw error;
}
};
/**
*
* @param params
*/
const register = async (params: RegisterParams) => {
try {
const res = await registerApi(params);
//跳转登录页面
router.push('/user/login');
} catch (error) {
console.error('注册失败', error);
throw error;
}
};
/**
* 退
*/
const logout = async () => {
try {
await logoutApi();
} catch (error) {
console.error('退出登录接口调用失败', error);
} finally {
token.value = '';
userInfo.value = null;
menus.value = [];
permissions.value = [];
removeStorage('token');
removeStorage('userInfo');
// 跳转到登录页面
}
};
/**
*
*/
const forgotPassword = async (params: ForgotPasswordParams) => {
try {
const res = await forgotPasswordApi(params);
// 跳转登录页面
router.push('/user/login');
} catch (error) {
console.error('忘记密码失败', error);
throw error;
}
};
/**
*
*/
const changePassword = async (params: ChangePasswordParams) => {
try {
const res = await changePasswordApi(params);
// 跳转登录页面
removeStorage('token');
removeStorage('userInfo');
router.push('/user/login');
} catch (error) {
console.error('修改密码失败', error);
throw error;
}
};
/**
*
*/
const sendCode = async (params: SendCodeParams) => {
try {
const res = await sendCodeApi(params);
} catch (error) {
console.error('发送验证码失败', error);
throw error;
}
};
return {
// 状态
token,
userInfo,
menus,
permissions,
// 计算属性
isLoggedIn,
permissionKeys,
// 方法(箭头函数)
login,
register,
logout,
forgotPassword,
changePassword,
sendCode,
};
});

69
src/stores/types/user.d.ts vendored Normal file
View File

@ -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;

32
src/utils/crypto/index.ts Normal file
View File

@ -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}`)
// }

View File

@ -0,0 +1,214 @@
import type { Ref } from 'vue';
import { setStorage, getStorage, removeStorage } from '@/utils/storage';
// 既支持指定的固定值,也支持任意字符串
export type CaptchaType = 'phone' | 'email' | string;
// 本地存储的状态类型
interface StoredCaptchaState {
type: CaptchaType; // 验证码类型
receiver: string; // 接收方(手机号/邮箱)
sendTime: number; // 发送时间戳(毫秒)
totalSeconds: number; // 总倒计时时长默认60
}
// 倒计时状态类型(供组件绑定)
export interface CaptchaCountdownState {
countdown: Ref<number>; // 剩余秒数
isCounting: Ref<boolean>; // 是否正在倒计时
timer: number | null; // 定时器标识
key: string; // 唯一标识(区分不同场景/类型的验证码register_phone
}
/**
*
* @param key register_phone/register_email/forgotPassword_phone
* @param totalSeconds 60
* @returns
*/
export const initCaptchaState = (key: string, totalSeconds: number = 60): CaptchaCountdownState => {
const state: CaptchaCountdownState = {
countdown: ref(0),
isCounting: ref(false),
timer: null,
key
};
// 页面刷新后从sessionStorage恢复状态
const stored = getStorage<StoredCaptchaState>(`captcha_${key}`, 'session');
if (stored) {
try {
const now = Date.now();
// 计算剩余时间 = 总时长 - (当前时间 - 发送时间戳)/1000
const remaining = stored.totalSeconds - Math.floor((now - stored.sendTime) / 1000);
// 剩余时间>0恢复倒计时
if (remaining > 0) {
state.countdown.value = remaining;
state.isCounting.value = true;
// 重启定时器
state.timer = window.setInterval(() => {
state.countdown.value--;
// 同步更新本地存储的剩余时间(可选,增强鲁棒性)
updateStoredCaptchaState(state.key, state.countdown.value);
if (state.countdown.value <= 0) {
clearCaptchaTimer(state); // 倒计时结束,清除状态
}
}, 1000);
} else {
// 剩余时间<=0清空本地存储
removeStorage(`captcha_${key}`, 'session');
}
} catch (e) {
console.error('恢复验证码倒计时状态失败:', e);
removeStorage(`captcha_${key}`, 'session');
}
}
return state;
};
/**
* 使
*/
const updateStoredCaptchaState = (key: string, remaining: number) => {
const stored = getStorage<StoredCaptchaState>(`captcha_${key}`, 'session');
if (stored) {
// 仅更新剩余时间(可选,核心是时间戳,剩余时间只是辅助)
setStorage(`captcha_${key}`, {
...stored,
// 可选:记录剩余时间,方便初始化时快速读取
remaining
}, 'session');
}
};
/**
* /
* @param type phone/email
* @param receiver /
* @param state initCaptchaState生成
* @param sendApi receiver
* @returns Promise<boolean>
*/
export const sendCaptcha = async (
type: CaptchaType,
receiver: string,
state: CaptchaCountdownState,
sendApi: (receiver: string) => Promise<any>
): Promise<boolean> => {
// ========== 核心:刷新后防重复发送的校验 ==========
const stored = getStorage<StoredCaptchaState>(`captcha_${state.key}`, 'session');
if (stored) {
const now = Date.now();
const elapsed = Math.floor((now - stored.sendTime) / 1000); // 已过去的秒数
// 未到冷却期60秒禁止发送
if (elapsed < stored.totalSeconds) {
const remaining = stored.totalSeconds - elapsed;
ElNotification({
message: `${remaining}秒后再发送验证码`,
type: 'warning',
duration: 2000,
customClass: 'global-notification'
});
// 恢复倒计时(防止内存状态和本地存储不一致)
state.countdown.value = remaining;
state.isCounting.value = true;
return false;
}
}
// ========== 原有校验 ==========
if (state.isCounting.value) {
ElNotification({
message: '验证码已发送,请勿重复点击',
type: 'warning',
duration: 2000,
customClass: 'global-notification'
});
return false;
}
if (!receiver) {
ElNotification({
message: `请输入${type === 'phone' ? '手机号' : '邮箱'}`,
type: 'warning',
duration: 2000,
customClass: 'global-notification'
});
return false;
}
try {
// 1. 调用真实发送接口
// await sendApi(receiver);
// 2. 发送成功提示
ElNotification({
message: `验证码已发送至${type === 'phone' ? '手机' : '邮箱'},请注意查收`,
type: 'success',
duration: 2000,
customClass: 'global-notification'
});
// 3. 记录发送状态到本地存储(核心:防刷新)
const storedState: StoredCaptchaState = {
type,
receiver,
sendTime: Date.now(), // 发送时间戳(毫秒)
totalSeconds: 60 // 总倒计时时长
};
setStorage(`captcha_${state.key}`, storedState, 'session');
// 4. 启动60秒倒计时
state.isCounting.value = true;
state.countdown.value = 60;
// 清除旧定时器(防止重复)
if (state.timer) clearInterval(state.timer);
// 5. 定时器逻辑(同步更新本地存储)
state.timer = window.setInterval(() => {
state.countdown.value--;
// 同步更新本地存储的剩余时间
updateStoredCaptchaState(state.key, state.countdown.value);
if (state.countdown.value <= 0) {
clearCaptchaTimer(state); // 倒计时结束,清除所有状态
}
}, 1000);
return true;
} catch (error) {
ElNotification({
message: `发送${type}验证码失败:${(error as Error).message || '未知错误'}`,
type: 'error',
duration: 2000,
customClass: 'global-notification'
});
console.error(`发送${type}验证码失败:`, error);
return false;
}
};
/**
* /+
* @param state
*/
export const clearCaptchaTimer = (state: CaptchaCountdownState): void => {
// 1. 清除定时器
if (state.timer) {
clearInterval(state.timer);
state.timer = null;
}
// 2. 重置内存状态
state.countdown.value = 0;
state.isCounting.value = false;
// 3. 清空本地存储(核心:倒计时结束后允许重新发送)
removeStorage(`captcha_${state.key}`, 'session');
};

114
src/utils/storage/index.ts Normal file
View File

@ -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
src/views/404/index.vue Normal file
View File

8
src/views/Test/index.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
<div class="test-card">
<h1>测试页</h1>
</div>
</template>
<script lang="ts" setup>
</script>

View File

@ -0,0 +1,206 @@
<template>
<div class="user-card">
<UserHeader />
<UserText :user-text="userText" @back="handleBack" />
<UserTabs v-model="activeTab" :tab-list="tabList" />
<el-form class="user-form" ref="changePasswordFormRef" :model="changePasswordForm" :rules="changePasswordRules">
<UserCaptcha v-model="changePasswordForm.code" @sendCode="handleSendCode" :show-send-button="showSendButton"
:send-state="sendState" />
<el-form-item prop="password">
<el-input v-model="changePasswordForm.password" placeholder="请输入新密码" type="password" show-password clearable
size="large" />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="changePasswordForm.confirmPassword" placeholder="请确认新密码" type="password" show-password
clearable size="large" />
</el-form-item>
<el-form-item class="form-submit">
<el-button type="primary" @click="handleChangePassword">重新登录</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
// type
import type { FormInstance, FormRules } from 'element-plus'
//
import UserHeader from './components/Header.vue'
import UserText from './components/Text.vue'
import UserTabs from './components/Tabs.vue'
import UserCaptcha from './components/Captcha.vue'
///store/
import { useUserStore } from '@/stores/modules/user'
import { getStorage } from '@/utils/storage'
import { md5Encrypt } from '@/utils/crypto'
import { sendCaptcha, initCaptchaState, clearCaptchaTimer } from '@/utils/sendCaptcha'
//
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
//storeuser
const userStore = useUserStore()
const userInfo = getStorage<any>('userInfo')
//userText
const userText = ref({
backText: '返回',
account: userInfo?.username || ''
})
//tabs
const tabList = ref([
{ label: '邮箱验证', name: 'email' },
{ label: '手机验证', name: 'phone' },
])
const activeTab = ref<string>(getStorage('loginType', 'session') || 'email')
// ChangePassword
const changePasswordFormRef = ref<FormInstance>()
const changePasswordForm = ref({
email: userInfo?.email || '',
countryCode: '+86',
phone: userInfo?.mobile || '',
password: '',
confirmPassword: '',
code: '',
})
console.log('changePasswordForm.value', changePasswordForm.value);
const changePasswordRules = ref<FormRules>({
email: emailRules().email,
phone: phoneRules(changePasswordForm.value).phone,
password: passwordRules(changePasswordForm.value).password,
confirmPassword: passwordRules(changePasswordForm.value).confirmPassword,
code: codeRules().code,
})
//
const showSendButton = ref<boolean>(true)
const sendState = ref<object>({
isCounting: false,
countdown: 0,
})
//
const emailCaptchaState = initCaptchaState('email'); //
const phoneCaptchaState = initCaptchaState('phone'); //
//
const { isCounting: emailIsCounting, countdown: emailCountdown } = emailCaptchaState
//
const { isCounting: phoneIsCounting, countdown: phoneCountdown } = phoneCaptchaState
//
// sendState
const updateSendState = (type: string) => {
if (type === 'email') {
sendState.value = {
isCounting: emailIsCounting,
countdown: emailCountdown
}
} else {
sendState.value = {
isCounting: phoneIsCounting,
countdown: phoneCountdown
}
}
}
// activeTabsendState
const initSendState = () => {
const currentType = activeTab.value === 'email' ? 'email' : 'phone'
updateSendState(currentType)
}
onMounted(() => {
//
initSendState()
})
// activeTabsendState
watch(
activeTab,
(newTab, oldTab) => {
activeTab.value = newTab;
console.log('activeTab.value', activeTab.value);
// taboldTaboldTabundefined
if (oldTab === undefined) return
if (newTab === 'email' || newTab === 'phone') {
// /
updateSendState(newTab)
} else if (newTab === 'code') {
//
const currentType: string = getStorage('forgotType', 'session') || 'email'
updateSendState(currentType)
}
},
{ immediate: false } //
)
//
const handleSendCode = async () => {
const currentType = activeTab.value
//
const type: string = currentType || 'email'
const receiver = type === 'phone' ? changePasswordForm.value.phone : changePasswordForm.value.email;
const currentCaptchaState = type === 'phone' ? phoneCaptchaState : emailCaptchaState;
const sendFunc = type === 'phone' ? "sendPhoneCaptcha" : "sendEmailCaptcha";
//
await sendCaptcha(
type,
receiver,
currentCaptchaState, //
sendFunc // Promise
);
//
updateSendState(type)
}
//
const handleChangePassword = async () => {
if (!changePasswordFormRef.value) return;
try {
//
await changePasswordFormRef.value.validate();
//
const changePasswordParams = {
mobile: changePasswordForm.value.phone,
email: changePasswordForm.value.email,
reg_type: activeTab.value === 'email' ? 1 : 2,
code: changePasswordForm.value.code,
login_password1: md5Encrypt(changePasswordForm.value.password),
login_password2: md5Encrypt(changePasswordForm.value.confirmPassword),
};
console.log('changePasswordParams', changePasswordParams);
await userStore.changePassword(changePasswordParams);
changePasswordForm.value = {
email: userInfo?.email || '',
countryCode: '+86',
phone: userInfo?.mobile || '',
password: '',
confirmPassword: '',
code: ''
};
} catch (error) {
console.error('表单校验失败:', error);
}
}
//
const handleBack = () => {
};
</script>
<style scoped lang="scss">
.user-text {
margin-bottom: 0;
}
.user-form {
.form-submit {
margin-top: 24px;
}
}
</style>

View File

@ -0,0 +1,344 @@
<template>
<div class="user-card">
<UserHeader />
<UserText :user-text="userText" @back="handleBack" />
<UserTabs v-model="activeTab" :hide-tab="hideTab" :tab-list="tabList" />
<el-form class="user-form" ref="forgotPasswordFormRef" :model="forgotPasswordForm" :rules="forgotPasswordRules">
<Transition name="form-switch" mode="out-in">
<el-form-item v-if="activeTab === 'email'" key="form-email-item" prop="email">
<el-input v-model="forgotPasswordForm.email" placeholder="请输入邮箱" clearable size="large" />
</el-form-item>
<el-form-item class="phone-item" prop="phone" v-else-if="activeTab === 'phone'" key="form-phone-item">
<el-select v-model="forgotPasswordForm.countryCode" :teleported="false" value-key="code" placeholder="国家"
size="large">
<el-option v-for="item in countryOptions" :key="item.code" :label="item.code" :value="item.code">
<div class="country-option">
<span class="country-name">{{ item.name }}</span>
<span class="country-code">{{ item.code }}</span>
</div>
</el-option>
</el-select>
<el-input v-model="forgotPasswordForm.phone" placeholder="请输入手机号" clearable size="large" />
</el-form-item>
<UserCaptcha v-model="forgotPasswordForm.code" v-else-if="activeTab === 'code'" key="form-code-item"
@sendCode="handleSendCode" :show-send-button="showSendButton" :send-state="sendState" />
<div v-else-if="activeTab === 'password'" key="form-password-group">
<el-form-item prop="password">
<el-input v-model="forgotPasswordForm.password" placeholder="请输入新密码" type="password" show-password clearable
size="large" />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="forgotPasswordForm.confirmPassword" placeholder="请确认新密码" type="password" show-password
clearable size="large" />
</el-form-item>
</div>
</Transition>
<div class="forgot-next">
<div :class="{ active: activeTab === 'email' || activeTab === 'phone' }"></div>
<div :class="{ active: activeTab === 'code' }"></div>
<div :class="{ active: activeTab === 'password' }"></div>
</div>
<el-form-item class="form-submit">
<el-button type="primary" @click="handleForgotPassword">{{ activeTab === 'password' ? '去登录' : '继续'
}}</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
// type
import type { FormInstance, FormRules } from 'element-plus'
//
import UserHeader from './components/Header.vue'
import UserText from './components/Text.vue'
import UserTabs from './components/Tabs.vue'
import UserCaptcha from './components/Captcha.vue'
///store/
import { useUserStore } from '@/stores/modules/user'
import { setStorage, getStorage, removeStorage } from '@/utils/storage'
import { md5Encrypt } from '@/utils/crypto'
import { sendCaptcha, initCaptchaState, clearCaptchaTimer } from '@/utils/sendCaptcha'
//
import { countryOptions } from '@/views/user/config/mock'
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
//storerouter
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
}
}
}
// activeTabsendState
watch(activeTab, (newTab) => {
if (newTab === 'email' || newTab === 'phone') {
// /sendState
sendState.value = { isCounting: false, countdown: 0 }
} else if (newTab === 'code') {
//
const currentType: string = getStorage('forgotType', 'session') || 'email'
updateSendState(currentType)
}
})
//
const handleSendCode = async () => {
//
const type: string = getStorage('forgotType', 'session') || 'email'
const receiver = type === 'phone' ? forgotPasswordForm.value.phone : forgotPasswordForm.value.email;
const currentCaptchaState = type === 'phone' ? phoneCaptchaState : emailCaptchaState;
const sendFunc = type === 'phone' ? "sendPhoneCaptcha" : "sendEmailCaptcha";
//
await sendCaptcha(
type,
receiver,
currentCaptchaState, //
sendFunc // Promise
);
//
updateSendState(type)
}
//
const handleBack = () => {
//
if (activeTab.value === 'password') {
activeTab.value = 'code'
userText.value = {
...userText.value,
title: '双重验证',
text: '请输入发送的验证码',
account: getStorage('forgot', 'session') || ''
}
//
forgotPasswordForm.value.password = ''
forgotPasswordForm.value.confirmPassword = ''
forgotPasswordFormRef.value?.clearValidate(['password', 'confirmPassword'])
}
// /
else if (activeTab.value === 'code') {
const getForgotType: string = getStorage('forgotType', 'session') || 'email'
activeTab.value = getForgotType
hideTab.value = false
userText.value = {
...userText.value,
title: '恢复密码',
text: '请输入您注册时使用的邮箱或手机号',
account: ''
}
//
forgotPasswordForm.value.code = ''
forgotPasswordFormRef.value?.clearValidate(['code'])
}
// /
else {
activeTab.value = 'email'
forgotPasswordForm.value.email = ''
forgotPasswordForm.value.phone = ''
forgotPasswordFormRef.value?.clearValidate(['email', 'phone'])
router.push('/user/login')
}
}
//
const handleForgotPassword = async () => {
if (!forgotPasswordFormRef.value) return
try {
const currentTab = activeTab.value;
const getForgotType: string = getStorage('forgotType', 'session') || 'email'
// 1/
if (currentTab === 'email' || currentTab === 'phone') {
//
await forgotPasswordFormRef.value.validateField(currentTab)
//
setStorage('forgotType', currentTab, 'session')
setStorage('forgot', currentTab === 'email' ? forgotPasswordForm.value.email : forgotPasswordForm.value.phone, 'session')
//
hideTab.value = true
activeTab.value = 'code'
userText.value = {
...userText.value,
title: '双重验证',
text: '请输入发送的验证码',
account: currentTab === 'email' ? forgotPasswordForm.value.email : forgotPasswordForm.value.phone
}
updateSendState(getForgotType)
}
// 2
else if (currentTab === 'code') {
//
await forgotPasswordFormRef.value.validateField(currentTab)
//
activeTab.value = 'password'
userText.value = {
...userText.value,
title: '设置新密码',
text: '请设置您的新登录密码',
account: getStorage('forgot', 'session') || ''
}
}
// 3
else if (currentTab === 'password') {
// +
await forgotPasswordFormRef.value.validateField(['password', 'confirmPassword'])
//
const getForgotType = getStorage('forgotType', 'session') || 'email'
const forgotParams = {
mobile: forgotPasswordForm.value.phone,
email: forgotPasswordForm.value.email,
reg_type: getForgotType === 'email' ? 1 : 2,
code: forgotPasswordForm.value.code,
login_password1: md5Encrypt(forgotPasswordForm.value.password),
login_password2: md5Encrypt(forgotPasswordForm.value.confirmPassword),
}
//
await userStore.forgotPassword(forgotParams)
//
removeStorage('forgotType', 'session')
removeStorage('forgot', 'session')
forgotPasswordForm.value = {
email: '',
countryCode: '+86',
phone: '',
password: '',
confirmPassword: '',
code: ''
}
}
} catch (error) {
console.error('表单提交失败:', error)
}
}
// 4.
onUnmounted(() => {
clearCaptchaTimer(emailCaptchaState);
clearCaptchaTimer(phoneCaptchaState);
});
</script>
<style lang="scss" scoped>
.user-form {
.phone-item {
:deep(.el-form-item__content) {
flex-wrap: nowrap;
}
}
.forgot-next {
display: flex;
justify-content: center;
width: 100%;
margin-bottom: 20px;
margin-top: 60px;
div {
position: relative;
height: 4px;
width: 40px;
background: rgb(209 213 219 / var(--tw-bg-opacity, 1));
border-radius: 999px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0;
background-color: var(--el-color-primary);
transition: all 0.3s ease-out;
border-radius: 999px;
}
&:nth-child(2) {
margin: 0 10px;
}
}
.active {
&::before {
width: 100%;
}
}
}
}
</style>

210
src/views/user/Login.vue Normal file
View File

@ -0,0 +1,210 @@
<template>
<div class="user-card">
<UserHeader />
<UserText :user-text="userText" @back="handleBack" />
<UserTabs v-model="activeTab" :hide-tab="hideTab" :tab-list="tabList" />
<el-form class="user-form" ref="loginFormRef" :model="loginForm" :rules="loginRules">
<Transition name="form-switch" mode="out-in">
<el-form-item v-if="activeTab === 'email'" key="form-email-item" prop="email">
<el-input v-model="loginForm.email" placeholder="请输入邮箱" clearable size="large" />
</el-form-item>
<el-form-item class="phone-item" prop="phone" v-else-if="activeTab === 'phone'" key="form-phone-item">
<el-select v-model="loginForm.countryCode" :teleported="false" value-key="code" placeholder="国家" size="large">
<el-option v-for="item in countryOptions" :key="item.code" :label="item.code" :value="item.code">
<div class="country-option">
<span class="country-name">{{ item.name }}</span>
<span class="country-code">{{ item.code }}</span>
</div>
</el-option>
</el-select>
<el-input v-model="loginForm.phone" placeholder="请输入手机号" clearable size="large" />
</el-form-item>
<el-form-item v-else-if="activeTab === 'password'" key="form-password-item" prop="password">
<el-input v-model="loginForm.password" placeholder="请输入密码" type="password" show-password clearable
size="large" />
<router-link to="/user/forgot-password" class="forgot-password">忘记密码</router-link>
</el-form-item>
</Transition>
<el-form-item class="form-submit">
<el-button type="primary" @click="handleLogin">{{ activeTab === 'password' ? '登录' : '注册/登录' }}</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
// type
import type { FormInstance, FormRules } from 'element-plus'
//
import UserHeader from './components/Header.vue'
import UserText from './components/Text.vue'
import UserTabs from './components/Tabs.vue'
///store/
import { useUserStore } from '@/stores/modules/user'
import { setStorage, getStorage } from '@/utils/storage'
import { md5Encrypt } from '@/utils/crypto'
//
import { countryOptions } from '@/views/user/config/mock'
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
//userText
const userText = ref({
backText: '',
title: '',
text: '初次登录时,您的电子邮箱或手机号码将被用于注册。',
account: '',
})
//tabs
const tabList = ref([
{ label: '邮箱', name: 'email' },
{ label: '手机', name: 'phone' },
])
const activeTab = ref<string>('email')
const hideTab = ref<boolean>(false)
// login form
const userStore = useUserStore();
const loginFormRef = ref<FormInstance>()
const loginForm = ref({
email: '',
countryCode: '+86',
phone: '',
password: '',
code: '123456',
})
const loginRules = ref<FormRules>({
email: emailRules().email,
phone: phoneRules(loginForm.value).phone,
code: codeRules().code,
password: passwordRules(loginForm.value).password,
})
//
//
const handleLogin = async () => {
if (!loginFormRef.value) return;
try {
const currentTab = activeTab.value;
///
if (currentTab === 'email' || currentTab === 'phone') {
//email/phone
await loginFormRef.value.validateField(currentTab);
//便
const loginValue = loginForm.value[currentTab];
setStorage('loginType', currentTab, 'session'); // email/phone
setStorage('login', loginValue, 'session'); //
//
hideTab.value = true;
userText.value = {
backText: '返回',
title: '欢迎回来!',
text: '请输入您的登录密码',
account: loginValue
};
activeTab.value = 'password';
}
// +
else if (currentTab === 'password') {
// +
await loginFormRef.value.validateField(['password', 'code']);
//
const loginParams = {
email: loginForm.value.email,
mobile: loginForm.value.phone,
login_type: getStorage('loginType', 'session') === 'email' ? 1 : 2,
code: loginForm.value.code,
password: md5Encrypt(loginForm.value.password)
};
await userStore.login(loginParams);
activeTab.value = 'email'; // tab
hideTab.value = false;
loginForm.value = { //
email: '',
countryCode: '+86',
phone: '',
password: '',
code: ''
};
}
} catch (error) {
console.error('表单校验失败:', error);
}
}
//
const handleBack = () => {
if (activeTab.value === 'password') {
// sessionStoragepasswordkey
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 {
// passwordemailtab
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>

196
src/views/user/Register.vue Normal file
View File

@ -0,0 +1,196 @@
<template>
<div class="user-card">
<UserHeader />
<UserText :user-text="userText" />
<UserTabs v-model="activeTab" :tab-list="tabList" />
<el-form class="user-form" ref="registerFormRef" :model="registerForm" :rules="registerRules">
<Transition name="form-switch" mode="out-in">
<el-form-item class="captcha-item" v-if="activeTab === 'email'" key="form-email-item" prop="email">
<el-input v-model="registerForm.email" placeholder="请输入邮箱" clearable size="large" />
<el-button type="primary" class="captcha-btn" @click="handleSendCode" :disabled="emailIsCounting">{{
emailIsCounting ? `${emailCountdown}s后重新获取` : '发送验证码' }}</el-button>
</el-form-item>
<el-form-item class="phone-item captcha-item" prop="phone" v-else-if="activeTab === 'phone'"
key="form-phone-item">
<el-select v-model="registerForm.countryCode" :teleported="false" value-key="code" placeholder="国家"
size="large">
<el-option v-for="item in countryOptions" :key="item.code" :label="item.code" :value="item.code">
<div class="country-option">
<span class="country-name">{{ item.name }}</span>
<span class="country-code">{{ item.code }}</span>
</div>
</el-option>
</el-select>
<el-input v-model="registerForm.phone" placeholder="请输入手机号" clearable size="large" />
<el-button type="primary" class="captcha-btn" @click="handleSendCode" :disabled="phoneIsCounting">{{
phoneIsCounting ? `${phoneCountdown}s后重新获取` : '发送验证码' }}</el-button>
</el-form-item>
</Transition>
<UserCaptcha v-model="registerForm.code" />
<el-form-item label="设置密码(6-12位密码包含特殊符号以及大小写)" label-position="top" prop="password">
<el-input placeholder="请输入密码" clearable show-password size="large" type="password"
v-model="registerForm.password" />
</el-form-item>
<el-form-item label="邀请链接/邀请码(选填)" label-position="top" prop="inviteCode">
<el-input placeholder="请输入邀请链接/邀请码" clearable size="large" type="text" v-model="registerForm.inviteCode" />
</el-form-item>
<el-form-item>
<el-checkbox label="同意注册协议">我已阅读并同意 隐私条例 退款政策 AMLCTF政策<br> 执行政策 网站条款和条件</el-checkbox>
</el-form-item>
<el-form-item class="form-submit">
<el-button type="primary" class="form-btn" @click="handleRegister">注册</el-button>
</el-form-item>
<router-link to="/user/login" class="to-login">已有账号立即登录</router-link>
</el-form>
</div>
</template>
<script setup lang="ts">
// type
import type { FormInstance, FormRules } from 'element-plus'
//
import UserHeader from './components/Header.vue'
import UserText from './components/Text.vue'
import UserTabs from './components/Tabs.vue'
import UserCaptcha from './components/Captcha.vue'
///store/
import { useUserStore } from '@/stores/modules/user'
import { md5Encrypt } from '@/utils/crypto'
import { sendCaptcha, initCaptchaState, clearCaptchaTimer } from '@/utils/sendCaptcha'
//
import { countryOptions } from '@/views/user/config/mock'
import { passwordRules, emailRules, phoneRules, codeRules } from './config/rules'
//store
const userStore = useUserStore()
// UserText
const userText = ref({
backText: '',
title: '注册',
text: '',
});
//tabs
const tabList = ref([
{ label: '邮箱', name: 'email' },
{ label: '手机', name: 'phone' },
])
const activeTab = ref<string>('email')
//
const registerFormRef = ref<FormInstance>()
const registerForm = ref({
email: '',
countryCode: '+86',
phone: '',
password: '',
code: '',
inviteCode: ''
})
const registerRules = ref<FormRules>({
email: emailRules().email,
phone: phoneRules(registerForm.value).phone,
password: passwordRules(registerForm.value).password,
code: codeRules().code,
})
//
const emailCaptchaState = initCaptchaState('email'); //
const phoneCaptchaState = initCaptchaState('phone'); //
//
const { isCounting: emailIsCounting, countdown: emailCountdown } = emailCaptchaState
//
const { isCounting: phoneIsCounting, countdown: phoneCountdown } = phoneCaptchaState
//
//
const handleSendCode = async () => {
if (!registerFormRef.value) return
try {
await registerFormRef.value.validateField(activeTab.value)
} catch (error) {
console.error('字段校验失败:', error)
return
}
//
const type: string = activeTab.value === 'phone' ? 'phone' : 'email';
const receiver = type === 'phone' ? registerForm.value.phone : registerForm.value.email;
const currentCaptchaState = type === 'phone' ? phoneCaptchaState : emailCaptchaState;
const sendFunc = type === 'phone' ? "sendPhoneCaptcha" : "sendEmailCaptcha";
//
await sendCaptcha(
type,
receiver,
currentCaptchaState, //
sendFunc // Promise
);
}
//
const handleRegister = async () => {
if (!registerFormRef.value) return;
try {
//
await registerFormRef.value.validate();
//
const registerParams = {
email: registerForm.value.email,
mobile: registerForm.value.phone,
reg_type: activeTab.value === 'email' ? 1 : 2,
invite_code: registerForm.value.inviteCode,
code: registerForm.value.code,
password: md5Encrypt(registerForm.value.password)
};
//
await userStore.register(registerParams);
} catch (error) {
console.error('表单校验失败:', error);
}
}
//
onUnmounted(() => {
clearCaptchaTimer(emailCaptchaState);
clearCaptchaTimer(phoneCaptchaState);
});
</script>
<style scoped lang="scss">
.user-form {
.phone-item,
.captcha-item {
:deep(.el-form-item__content) {
flex-wrap: nowrap;
}
:deep(.el-select-dropdown__item) {
padding: 0 16px 0 16px;
}
.el-select {
width: 180px !important;
}
}
.to-login {
display: flex;
justify-content: center;
text-decoration: none;
color: #409eff;
margin-top: 24px;
}
}
</style>

View File

@ -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' // ruleskey
},
})
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>

View File

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

View File

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

View File

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

View File

@ -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' }
])

View File

@ -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']
}
]
};
};

9
src/views/user/index.vue Normal file
View File

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

12
tsconfig.app.json Normal file
View File

@ -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/*"]
}
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View File

@ -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"]
}
}

80
vite.config.ts Normal file
View File

@ -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, // 彻底剔除所有注释
// },
// } : {},
},
}
})