feat: 适配5.x后端,重构前端架构
主要变更: 1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范 2. 优化布局组件,提升用户界面体验 3. 增强国际化支持,完善多语言配置 4. 新增权限控制功能,支持前端路由权限管理 5. 优化认证流程,支持JWT认证机制 6. 新增中间件和插件,提升系统扩展性 7. 优化状态管理,采用更清晰的模块化设计 8. 新增搜索框、广告等组件,丰富页面功能 9. 更改包管理工具从npm到pnpm,提升依赖管理效率 10. 完善配置系统,支持多环境配置 适配5.x后端特性: - 采用新的API接口规范 - 支持JWT认证机制 - 适配后端权限控制体系 - 支持后端国际化接口 - 优化数据交互方式
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
# API 基础路径
|
||||||
|
VITE_APP_BASE_URL=/api
|
||||||
|
|
||||||
|
# 请求头配置
|
||||||
|
VITE_REQUEST_HEADER_CHANNEL_KEY=pc
|
||||||
|
|
||||||
|
VITE_REQUEST_HEADER_TOKEN_KEY=Authorization
|
||||||
|
|
||||||
|
# 默认语言
|
||||||
|
VITE_APP_DEFAULT_LANG=zh-CN
|
||||||
|
|
||||||
|
# 禁用 OXC 原生绑定(解决 Windows 安全策略问题)
|
||||||
|
OXC_DISABLE_NATIVE=1
|
||||||
@@ -8,3 +8,5 @@ legacy-peer-deps=true
|
|||||||
node-linker=hoisted
|
node-linker=hoisted
|
||||||
verify-store-integrity=false
|
verify-store-integrity=false
|
||||||
auto-install-peers=true
|
auto-install-peers=true
|
||||||
|
# Skip oxc native bindings due to security policy
|
||||||
|
public-hoist-pattern[]=@oxc-*
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# AI 架构上下文
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
|
这是一个基于 Nuxt 4 和 Element Plus 的现代化管理系统前端项目,使用 TypeScript 开发,旨在提供高效、美观、可扩展的管理界面。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **前端框架**:Nuxt 4
|
||||||
|
- **UI 库**:Element Plus
|
||||||
|
- **开发语言**:TypeScript
|
||||||
|
- **状态管理**:Pinia
|
||||||
|
- **国际化**:Vue I18n
|
||||||
|
- **样式方案**:SCSS + UnoCSS
|
||||||
|
- **图标库**:Nuxt Icons
|
||||||
|
- **构建工具**:Vite
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── app/ # 主应用目录
|
||||||
|
│ ├── api/ # API 接口定义
|
||||||
|
│ ├── apps/ # 功能模块目录
|
||||||
|
│ │ └── cms/ # CMS 功能模块
|
||||||
|
│ ├── assets/ # 静态资源
|
||||||
|
│ │ ├── icons/ # 图标文件
|
||||||
|
│ │ ├── images/ # 图片文件
|
||||||
|
│ │ └── scss/ # SCSS 样式文件
|
||||||
|
│ ├── components/ # Vue 组件
|
||||||
|
│ ├── composables/ # 组合式函数
|
||||||
|
│ ├── lang/ # 国际化语言文件
|
||||||
|
│ ├── layouts/ # 布局组件
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ ├── plugins/ # 插件
|
||||||
|
│ ├── stores/ # Pinia 状态管理
|
||||||
|
│ ├── types/ # TypeScript 类型定义
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── app.vue # 根组件
|
||||||
|
│ └── router.options.ts # 路由配置
|
||||||
|
├── public/ # 公共静态资源
|
||||||
|
├── .gitignore # Git 忽略文件
|
||||||
|
├── nuxt.config.ts # Nuxt 配置
|
||||||
|
├── package.json # 项目依赖和脚本
|
||||||
|
├── tsconfig.json # TypeScript 配置
|
||||||
|
└── uno.config.ts # UnoCSS 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 认证系统
|
||||||
|
- **登录/注册**:完整的用户认证流程
|
||||||
|
- **验证码**:集成短信验证码和图形验证码
|
||||||
|
- **路由守卫**:保护需要认证的路由
|
||||||
|
|
||||||
|
### 会员中心
|
||||||
|
- **个人资料**:用户信息管理和编辑
|
||||||
|
- **专属布局**:独立的会员中心布局
|
||||||
|
|
||||||
|
### 国际化
|
||||||
|
- **多语言支持**:内置中文和英文
|
||||||
|
- **动态切换**:实时语言切换功能
|
||||||
|
|
||||||
|
### 主题系统
|
||||||
|
- **深色模式**:支持浅色/深色主题切换
|
||||||
|
- **主题定制**:基于 SCSS 的灵活主题配置
|
||||||
|
|
||||||
|
### 路由系统
|
||||||
|
- **动态路由**:从功能模块自动加载路由
|
||||||
|
- **布局路由**:基于路由元信息的智能布局切换
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### 前端架构
|
||||||
|
- **MVVM 模式**:基于 Vue 3 的响应式数据绑定
|
||||||
|
- **组件化开发**:模块化、可复用的组件设计
|
||||||
|
- **组合式 API**:使用 Vue 3 的组合式 API 进行状态管理和逻辑复用
|
||||||
|
- **路由管理**:基于 Vue Router 的完整路由系统
|
||||||
|
- **状态管理**:使用 Pinia 进行高效的全局状态管理
|
||||||
|
|
||||||
|
### 构建系统
|
||||||
|
- **Vite**:现代化的开发服务器和构建工具
|
||||||
|
- **ESLint**:确保代码质量和一致性
|
||||||
|
- **TypeScript**:提供类型安全保障
|
||||||
|
- **SCSS**:强大的 CSS 预处理器
|
||||||
|
- **UnoCSS**:高性能的原子化 CSS 框架
|
||||||
|
|
||||||
|
### 部署方案
|
||||||
|
- **Docker**:支持容器化部署
|
||||||
|
- **Nitro**:Nuxt 的服务器引擎,支持 SSR/SSG 等多种渲染模式
|
||||||
|
|
||||||
|
## 核心 API 和服务
|
||||||
|
|
||||||
|
### API 接口
|
||||||
|
- **认证接口**:`app/api/auth.ts` - 处理登录、注册等认证相关请求
|
||||||
|
- **会员接口**:`app/api/member.ts` - 处理会员相关请求
|
||||||
|
- **系统接口**:`app/api/system.ts` - 处理系统相关请求
|
||||||
|
|
||||||
|
### 工具函数
|
||||||
|
- **请求工具**:`app/utils/request.ts` - 封装 HTTP 请求
|
||||||
|
- **存储工具**:`app/utils/storage.ts` - 封装本地存储
|
||||||
|
- **路由工具**:`app/utils/router.ts` - 路由相关工具函数
|
||||||
|
- **验证工具**:`app/utils/validate.ts` - 表单验证工具
|
||||||
|
- **语言工具**:`app/utils/language.ts` - 语言相关工具函数
|
||||||
|
- **通用工具**:`app/utils/common.ts` - 通用工具函数
|
||||||
|
- **深色模式工具**:`app/utils/dark.ts` - 深色模式相关工具函数
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- **应用状态**:`app/stores/app.ts` - 应用全局状态
|
||||||
|
- **配置状态**:`app/stores/config.ts` - 系统配置状态
|
||||||
|
- **全局状态**:`app/stores/globals.ts` - 全局共享状态
|
||||||
|
- **会员状态**:`app/stores/member.ts` - 会员相关状态
|
||||||
|
- **个人中心状态**:`app/stores/personal-center.ts` - 个人中心相关状态
|
||||||
|
- **系统状态**:`app/stores/system.ts` - 系统相关状态
|
||||||
|
|
||||||
|
## 配置信息
|
||||||
|
|
||||||
|
### Nuxt 配置
|
||||||
|
- **模块集成**:Element Plus、Pinia、VueUse、UnoCSS 等
|
||||||
|
- **CSS 配置**:SCSS 和 UnoCSS
|
||||||
|
- **路由配置**:自定义路由,支持动态加载功能模块路由
|
||||||
|
- **服务器代理**:配置 API 代理,解决跨域问题
|
||||||
|
|
||||||
|
### 依赖管理
|
||||||
|
- **包管理工具**:pnpm
|
||||||
|
- **核心依赖**:Element Plus、Vue I18n、Pinia、NProgress 等
|
||||||
|
- **开发依赖**:TypeScript、ESLint、Sass 等
|
||||||
|
|
||||||
|
## 关键文件和模块
|
||||||
|
|
||||||
|
### 路由配置
|
||||||
|
- `app/router.options.ts`:自定义路由配置,支持从功能模块自动加载路由
|
||||||
|
- `app/pages/routes.ts`:主应用路由定义
|
||||||
|
- `app/apps/cms/pages/routes.ts`:CMS 功能模块路由定义
|
||||||
|
|
||||||
|
### 布局组件
|
||||||
|
- `app/layouts/default.vue`:默认布局
|
||||||
|
- `app/layouts/member.vue`:会员中心布局
|
||||||
|
- `app/layouts/container.vue`:容器布局
|
||||||
|
|
||||||
|
### 页面组件
|
||||||
|
- `app/pages/index.vue`:首页
|
||||||
|
- `app/pages/member/profile.vue`:会员个人资料页
|
||||||
|
- `app/apps/cms/pages/index.vue`:CMS 首页
|
||||||
|
|
||||||
|
### 核心组件
|
||||||
|
- `app/components/login-dialog/`:登录/注册对话框
|
||||||
|
- `app/components/sms-code/`:短信验证码组件
|
||||||
|
- `app/components/icon.vue`:图标组件
|
||||||
|
|
||||||
|
### 组合式函数
|
||||||
|
- `app/composables/login.ts`:登录相关逻辑
|
||||||
|
- `app/composables/captcha.ts`:验证码相关逻辑
|
||||||
|
- `app/composables/lang.ts`:语言相关逻辑
|
||||||
|
- `app/composables/send-sms.ts`:发送短信相关逻辑
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
### 代码风格
|
||||||
|
- 使用 TypeScript 进行类型检查
|
||||||
|
- 使用 ESLint 进行代码质量检查
|
||||||
|
- 遵循 Vue 3 的最佳实践
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
- **组件名**:使用 PascalCase(如 `LoginDialog`)
|
||||||
|
- **文件名**:使用 kebab-case(如 `login-dialog.vue`)
|
||||||
|
- **变量名**:使用 camelCase(如 `userInfo`)
|
||||||
|
- **常量名**:使用 UPPER_CASE(如 `API_BASE_URL`)
|
||||||
|
|
||||||
|
### 目录结构规范
|
||||||
|
- 功能模块放在 `app/apps/` 目录下
|
||||||
|
- 通用组件放在 `app/components/` 目录下
|
||||||
|
- 页面组件放在 `app/pages/` 目录下
|
||||||
|
- 工具函数放在 `app/utils/` 目录下
|
||||||
|
|
||||||
|
## 未来扩展
|
||||||
|
|
||||||
|
### 功能扩展
|
||||||
|
- 支持更多功能模块的集成
|
||||||
|
- 增强国际化支持,添加更多语言
|
||||||
|
- 提供更多主题选项和自定义能力
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
- 实现更细粒度的代码分割和懒加载
|
||||||
|
- 优化静态资源加载
|
||||||
|
- 进一步优化构建流程
|
||||||
|
|
||||||
|
### 安全增强
|
||||||
|
- 加强认证和授权机制
|
||||||
|
- 防止 XSS 和 CSRF 攻击
|
||||||
|
- 实现敏感数据加密
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
本项目是一个现代化的前端应用,基于 Nuxt 4 和 Element Plus 构建,使用 TypeScript 确保类型安全,采用组件化、模块化的开发方式。项目支持国际化、深色模式等现代前端特性,结构清晰,代码组织合理,具有良好的可扩展性和可维护性。
|
||||||
|
|
||||||
|
通过使用 Nuxt 4 的强大功能和 Element Plus 的丰富组件,项目能够快速构建出美观、高效的管理系统界面,同时为未来的功能扩展和性能优化提供了坚实的基础。项目使用 pnpm 作为包管理工具,提高了依赖安装和管理的效率。
|
||||||
+77
-21
@@ -1,36 +1,92 @@
|
|||||||
# Madong-Nuxt
|
# Madong-Nuxt
|
||||||
|
|
||||||
#### Description
|
## Project Introduction
|
||||||
MadongAdmin的Nuxt-Web工程,Nuxt4.x+TypeScript+Vite+Pinia+Element Plus
|
|
||||||
|
|
||||||
#### Software Architecture
|
MadongAdmin's Nuxt-Web project, a modern admin system frontend built with Nuxt 4.x, TypeScript, Vite, Pinia, and Element Plus.
|
||||||
Software architecture description
|
|
||||||
|
|
||||||
#### Installation
|
## Tech Stack
|
||||||
|
|
||||||
1. xxxx
|
- **Frontend Framework**: Nuxt 4
|
||||||
2. xxxx
|
- **UI Library**: Element Plus
|
||||||
3. xxxx
|
- **Development Language**: TypeScript
|
||||||
|
- **State Management**: Pinia
|
||||||
|
- **Internationalization**: Vue I18n
|
||||||
|
- **Styling Solution**: SCSS + UnoCSS
|
||||||
|
- **Icon Library**: Nuxt Icons
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
|
||||||
#### Instructions
|
## Core Features
|
||||||
|
|
||||||
1. xxxx
|
- **Authentication System**: Login/Register, CAPTCHA, Route Guard
|
||||||
2. xxxx
|
- **Member Center**: Personal profile management
|
||||||
3. xxxx
|
- **Internationalization**: Multi-language support (Chinese/English)
|
||||||
|
- **Theme System**: Light/Dark mode switching
|
||||||
|
- **Routing System**: Dynamic routes, Layout routes
|
||||||
|
|
||||||
#### Contribution
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── app/ # Main application directory
|
||||||
|
│ ├── api/ # API interface definitions
|
||||||
|
│ ├── apps/ # Feature modules directory
|
||||||
|
│ ├── assets/ # Static assets
|
||||||
|
│ ├── components/ # Vue components
|
||||||
|
│ ├── composables/ # Composables
|
||||||
|
│ ├── lang/ # Internationalization language files
|
||||||
|
│ ├── layouts/ # Layout components
|
||||||
|
│ ├── pages/ # Page components
|
||||||
|
│ ├── plugins/ # Plugins
|
||||||
|
│ ├── stores/ # Pinia state management
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── app.vue # Root component
|
||||||
|
│ └── router.options.ts # Route configuration
|
||||||
|
├── public/ # Public static resources
|
||||||
|
├── .gitignore # Git ignore file
|
||||||
|
├── nuxt.config.ts # Nuxt configuration
|
||||||
|
├── package.json # Project dependencies and scripts
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
└── uno.config.ts # UnoCSS configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd web
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start development server
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build for production
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Development mode: `pnpm run dev` - Start development server with hot reload
|
||||||
|
2. Build for production: `pnpm run build` - Build production version
|
||||||
|
3. Preview production: `pnpm run preview` - Preview production build
|
||||||
|
4. Code linting: `pnpm run lint` - Run ESLint to check code quality
|
||||||
|
|
||||||
|
## Contribution
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create Feat_xxx branch
|
2. Create Feat_xxx branch
|
||||||
3. Commit your code
|
3. Commit your code
|
||||||
4. Create Pull Request
|
4. Create Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
#### Gitee Feature
|
[MIT](LICENSE)
|
||||||
|
|
||||||
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
|
|
||||||
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
|
|
||||||
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
|
|
||||||
4. The most valuable open source project [GVP](https://gitee.com/gvp)
|
|
||||||
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
|
|
||||||
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
|
|
||||||
|
|||||||
@@ -1,37 +1,92 @@
|
|||||||
# Madong-Nuxt
|
# Madong-Nuxt
|
||||||
|
|
||||||
#### 介绍
|
## 项目介绍
|
||||||
MadongAdmin的Nuxt-Web工程,Nuxt4.x+TypeScript+Vite+Pinia+Element Plus
|
|
||||||
|
|
||||||
#### 软件架构
|
MadongAdmin的Nuxt-Web工程,基于Nuxt 4.x、TypeScript、Vite、Pinia和Element Plus构建的现代化管理系统前端。
|
||||||
软件架构说明
|
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
#### 安装教程
|
- **前端框架**:Nuxt 4
|
||||||
|
- **UI 库**:Element Plus
|
||||||
|
- **开发语言**:TypeScript
|
||||||
|
- **状态管理**:Pinia
|
||||||
|
- **国际化**:Vue I18n
|
||||||
|
- **样式方案**:SCSS + UnoCSS
|
||||||
|
- **图标库**:Nuxt Icons
|
||||||
|
- **构建工具**:Vite
|
||||||
|
|
||||||
1. xxxx
|
## 核心功能
|
||||||
2. xxxx
|
|
||||||
3. xxxx
|
|
||||||
|
|
||||||
#### 使用说明
|
- **认证系统**:登录/注册、验证码、路由守卫
|
||||||
|
- **会员中心**:个人资料管理
|
||||||
|
- **国际化**:多语言支持(中文/英文)
|
||||||
|
- **主题系统**:深色/浅色模式切换
|
||||||
|
- **路由系统**:动态路由、布局路由
|
||||||
|
|
||||||
1. xxxx
|
## 目录结构
|
||||||
2. xxxx
|
|
||||||
3. xxxx
|
|
||||||
|
|
||||||
#### 参与贡献
|
```
|
||||||
|
web/
|
||||||
|
├── app/ # 主应用目录
|
||||||
|
│ ├── api/ # API 接口定义
|
||||||
|
│ ├── apps/ # 功能模块目录
|
||||||
|
│ ├── assets/ # 静态资源
|
||||||
|
│ ├── components/ # Vue 组件
|
||||||
|
│ ├── composables/ # 组合式函数
|
||||||
|
│ ├── lang/ # 国际化语言文件
|
||||||
|
│ ├── layouts/ # 布局组件
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ ├── plugins/ # 插件
|
||||||
|
│ ├── stores/ # Pinia 状态管理
|
||||||
|
│ ├── types/ # TypeScript 类型定义
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── app.vue # 根组件
|
||||||
|
│ └── router.options.ts # 路由配置
|
||||||
|
├── public/ # 公共静态资源
|
||||||
|
├── .gitignore # Git 忽略文件
|
||||||
|
├── nuxt.config.ts # Nuxt 配置
|
||||||
|
├── package.json # 项目依赖和脚本
|
||||||
|
├── tsconfig.json # TypeScript 配置
|
||||||
|
└── uno.config.ts # UnoCSS 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装教程
|
||||||
|
|
||||||
|
1. 克隆仓库
|
||||||
|
```bash
|
||||||
|
git clone <仓库地址>
|
||||||
|
cd web
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安装依赖
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动开发服务器
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 构建生产版本
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
1. 开发模式:`pnpm run dev` - 启动开发服务器,支持热更新
|
||||||
|
2. 构建生产:`pnpm run build` - 构建生产版本
|
||||||
|
3. 预览生产:`pnpm run preview` - 预览生产构建结果
|
||||||
|
4. 代码检查:`pnpm run lint` - 运行 ESLint 检查代码质量
|
||||||
|
|
||||||
|
## 参与贡献
|
||||||
|
|
||||||
1. Fork 本仓库
|
1. Fork 本仓库
|
||||||
2. 新建 Feat_xxx 分支
|
2. 新建 Feat_xxx 分支
|
||||||
3. 提交代码
|
3. 提交代码
|
||||||
4. 新建 Pull Request
|
4. 新建 Pull Request
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
#### 特技
|
[MIT](LICENSE)
|
||||||
|
|
||||||
1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
|
|
||||||
2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
|
|
||||||
3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
|
|
||||||
4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
|
|
||||||
5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
|
|
||||||
6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
|
|
||||||
/**
|
|
||||||
* 用户登录认证
|
|
||||||
*/
|
|
||||||
export function authenticateUser(data: AnyObject) {
|
|
||||||
return request.post('/auth/login', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 手机验证码登录
|
|
||||||
*/
|
|
||||||
export function authenticateWithMobile(data: AnyObject) {
|
|
||||||
return request.post('/auth/login/mobile', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取登录配置信息
|
|
||||||
*/
|
|
||||||
export function getLoginConfig() {
|
|
||||||
return request.get('/auth/config')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户退出登录
|
|
||||||
*/
|
|
||||||
export function logoutUser() {
|
|
||||||
return request.post('/auth/logout')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户注册
|
|
||||||
*/
|
|
||||||
export function registerUser(data: AnyObject) {
|
|
||||||
const url = data.pid ? `/auth/register?pid=${data.pid}` : '/auth/register'
|
|
||||||
return request.post(url, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 手机号注册
|
|
||||||
*/
|
|
||||||
export function registerWithMobile(data: AnyObject) {
|
|
||||||
const url = data.pid ? `/auth/register/mobile?pid=${data.pid}` : '/auth/register/mobile'
|
|
||||||
return request.post(url, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 微信授权登录
|
|
||||||
*/
|
|
||||||
export function authenticateWithWechat(data: AnyObject) {
|
|
||||||
return request.post('/auth/wechat', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 微信小程序登录
|
|
||||||
*/
|
|
||||||
export function authenticateWithWeapp(data: AnyObject) {
|
|
||||||
return request.post('/auth/weapp', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定手机号
|
|
||||||
*/
|
|
||||||
export function bindMobile(data: AnyObject) {
|
|
||||||
const url = data.pid ? `/auth/bind?pid=${data.pid}` : '/auth/bind'
|
|
||||||
return request.post(url, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成微信扫码登录二维码
|
|
||||||
*/
|
|
||||||
export function generateWechatQrCode() {
|
|
||||||
return request.post('/auth/wechat/scan')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查微信扫码状态
|
|
||||||
*/
|
|
||||||
export function checkWechatScanStatus(data: AnyObject) {
|
|
||||||
return request.get('/auth/wechat/scan/status', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查微信登录可用性
|
|
||||||
*/
|
|
||||||
export function checkWechatAvailability() {
|
|
||||||
return request.get('/auth/wechat/availability')
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import request from '~/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录认证
|
||||||
|
*/
|
||||||
|
export function authenticateUser(data: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}): Promise<any> {
|
||||||
|
return request.post('/auth/login', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机验证码登录
|
||||||
|
*/
|
||||||
|
export function authenticateWithMobile(data: {
|
||||||
|
mobile: string
|
||||||
|
code: string
|
||||||
|
key: string
|
||||||
|
}): Promise<any> {
|
||||||
|
return request.post('/auth/login/mobile', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户退出登录
|
||||||
|
*/
|
||||||
|
export function logoutUser(): Promise<boolean> {
|
||||||
|
return request.post('/auth/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*/
|
||||||
|
export function registerUser(data: {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
confirm_password: string
|
||||||
|
pid?: string
|
||||||
|
}): Promise<any> {
|
||||||
|
const url = data.pid ? `/auth/register?pid=${data.pid}` : '/auth/register'
|
||||||
|
return request.post(url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号注册
|
||||||
|
*/
|
||||||
|
export function registerWithMobile(data: {
|
||||||
|
mobile: string
|
||||||
|
code: string
|
||||||
|
key: string
|
||||||
|
pid?: string
|
||||||
|
}): Promise<any> {
|
||||||
|
const url = data.pid ? `/auth/register/mobile?pid=${data.pid}` : '/auth/register/mobile'
|
||||||
|
return request.post(url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信授权登录
|
||||||
|
*/
|
||||||
|
export function authenticateWithWechat(data: any): Promise<any> {
|
||||||
|
return request.post('/auth/wechat', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序登录
|
||||||
|
*/
|
||||||
|
export function authenticateWithWeapp(data: any): Promise<any> {
|
||||||
|
return request.post('/auth/weapp', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定手机号
|
||||||
|
*/
|
||||||
|
export function bindMobile(data: {
|
||||||
|
mobile: string
|
||||||
|
code: string
|
||||||
|
pid?: string
|
||||||
|
}): Promise<any> {
|
||||||
|
const url = data.pid ? `/auth/bind?pid=${data.pid}` : '/auth/bind'
|
||||||
|
return request.post(url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成微信扫码登录二维码
|
||||||
|
*/
|
||||||
|
export function generateWechatQrCode(): Promise<any> {
|
||||||
|
return request.post('/auth/wechat/scan')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查微信扫码状态
|
||||||
|
*/
|
||||||
|
export function checkWechatScanStatus(data: { scene_id: string }): Promise<any> {
|
||||||
|
return request.get('/auth/wechat/scan/status', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查微信登录可用性
|
||||||
|
*/
|
||||||
|
export function checkWechatAvailability(): Promise<boolean> {
|
||||||
|
return request.get('/auth/wechat/availability')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取第三方绑定列表
|
||||||
|
*/
|
||||||
|
export function getThirdPartyList(): Promise<any[]> {
|
||||||
|
return request.get('/auth/third-party/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定第三方账号
|
||||||
|
*/
|
||||||
|
export function bindThirdParty(data: { platform: number }): Promise<boolean> {
|
||||||
|
return request.post('/auth/third-party/bind', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑第三方账号
|
||||||
|
*/
|
||||||
|
export function unbindThirdParty(platform: number): Promise<boolean> {
|
||||||
|
return request.delete(`/auth/third-party/unbind/${platform}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成第三方绑定二维码
|
||||||
|
*/
|
||||||
|
export function generateBindQrCode(platform: number): Promise<any> {
|
||||||
|
return request.post('/auth/third-party/qr-code', { platform })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查绑定二维码状态
|
||||||
|
*/
|
||||||
|
export function checkBindQrStatus(sceneId: string): Promise<any> {
|
||||||
|
return request.get('/auth/third-party/qr-status', { scene_id: sceneId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱(第一步:验证邮箱和验证码)
|
||||||
|
*/
|
||||||
|
export function verifyEmail(data: {
|
||||||
|
email: string
|
||||||
|
code: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return request.post('/auth/verify-email', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码
|
||||||
|
*/
|
||||||
|
export function sendEmailCode(data: {
|
||||||
|
email: string
|
||||||
|
captcha_key: string
|
||||||
|
captcha_code: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return request.post('/auth/send-email-code', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忘记密码 - 重置密码(第二步:设置新密码)
|
||||||
|
*/
|
||||||
|
export function forgetPassword(data: {
|
||||||
|
email: string
|
||||||
|
code: string
|
||||||
|
new_password: string
|
||||||
|
confirm_password: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return request.post('/auth/forget-password', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 Token
|
||||||
|
*/
|
||||||
|
export function refreshToken(refresh_token: string): Promise<{
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data?: {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
return request.post('/auth/refresh', { refresh_token }, {
|
||||||
|
// 不带 token 请求,避免 401 循环
|
||||||
|
headers: {}
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 认证模块相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录数据
|
||||||
|
*/
|
||||||
|
export interface LoginData {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
phone?: string
|
||||||
|
verify_code?: string
|
||||||
|
pid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册数据
|
||||||
|
*/
|
||||||
|
export interface RegisterData {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
phone?: string
|
||||||
|
verify_code?: string
|
||||||
|
pid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定手机号数据
|
||||||
|
*/
|
||||||
|
export interface BindMobileData {
|
||||||
|
mobile: string
|
||||||
|
code: string
|
||||||
|
pid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息(登录/注册后返回)
|
||||||
|
*/
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname?: string
|
||||||
|
avatar?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录配置
|
||||||
|
*/
|
||||||
|
export interface LoginConfig {
|
||||||
|
enable_username_login: boolean
|
||||||
|
enable_mobile_login: boolean
|
||||||
|
enable_wechat_login: boolean
|
||||||
|
enable_weapp_login: boolean
|
||||||
|
enable_register: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码状态
|
||||||
|
*/
|
||||||
|
export enum ScanStatus {
|
||||||
|
WAITING = 'waiting',
|
||||||
|
SCANNED = 'scanned',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
CANCELED = 'canceled'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第三方平台类型
|
||||||
|
*/
|
||||||
|
export enum ThirdPartyPlatform {
|
||||||
|
WECHAT = 1,
|
||||||
|
QQ = 2,
|
||||||
|
ALIPAY = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第三方绑定信息
|
||||||
|
*/
|
||||||
|
export interface ThirdPartyBinding {
|
||||||
|
platform: number
|
||||||
|
platform_name: string
|
||||||
|
nickname?: string
|
||||||
|
avatar?: string
|
||||||
|
bind_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码登录信息
|
||||||
|
*/
|
||||||
|
export interface QrCodeLoginInfo {
|
||||||
|
scene_id: string
|
||||||
|
qrcode_url: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* 获取会员基本信息
|
|
||||||
*/
|
|
||||||
export function getMemberProfile() {
|
|
||||||
return request.get('/member/profile')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新会员信息
|
|
||||||
*/
|
|
||||||
export function updateMemberInfo(data: AnyObject) {
|
|
||||||
return request.put(`/member/profile/${data.field}`, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取积分流水记录
|
|
||||||
*/
|
|
||||||
export function getPointTransactions(data: AnyObject) {
|
|
||||||
return request.get('/member/points/transactions', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取会员积分总额
|
|
||||||
*/
|
|
||||||
export function getMemberPointsTotal() {
|
|
||||||
return request.get('/member/points/total')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取余额流水记录
|
|
||||||
*/
|
|
||||||
export function getBalanceTransactions(data: AnyObject) {
|
|
||||||
return request.get('/member/balance/transactions', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有余额流水记录
|
|
||||||
*/
|
|
||||||
export function getAllBalanceTransactions(data: AnyObject) {
|
|
||||||
return request.get('/member/balance/transactions/all', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定会员手机号
|
|
||||||
*/
|
|
||||||
export function bindMemberMobile(data: AnyObject) {
|
|
||||||
return request.put('/member/mobile', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取会员等级信息
|
|
||||||
*/
|
|
||||||
export function getMemberLevel() {
|
|
||||||
return request.get('/member/level')
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import request from '~/utils/request'
|
||||||
|
import type {
|
||||||
|
MemberInfo,
|
||||||
|
Address,
|
||||||
|
PointTransactionsResponse,
|
||||||
|
BalanceTransactionsResponse,
|
||||||
|
SignCalendar,
|
||||||
|
SignStatistics,
|
||||||
|
MemberLevel,
|
||||||
|
SignStatus
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会员基本信息
|
||||||
|
*/
|
||||||
|
export function getMemberProfile(): Promise<MemberInfo> {
|
||||||
|
return request.get('/member/user-info')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会员信息
|
||||||
|
*/
|
||||||
|
export function updateMemberInfo(data: Partial<MemberInfo>): Promise<boolean> {
|
||||||
|
return request.put('/member/user/update', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取积分流水记录
|
||||||
|
*/
|
||||||
|
export function getPointTransactions(data: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PointTransactionsResponse> {
|
||||||
|
return request.get('/member/points/record', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会员积分总额
|
||||||
|
*/
|
||||||
|
export function getMemberPointsTotal(): Promise<number> {
|
||||||
|
return request.get('/member/points/total')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取余额流水记录
|
||||||
|
*/
|
||||||
|
export function getBalanceTransactions(data: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<BalanceTransactionsResponse> {
|
||||||
|
return request.get('/member/balance/record', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有余额流水记录
|
||||||
|
*/
|
||||||
|
export function getAllBalanceTransactions(data?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<BalanceTransactionsResponse> {
|
||||||
|
return request.get('/member/balance/all', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会员等级信息
|
||||||
|
*/
|
||||||
|
export function getMemberLevel(): Promise<MemberLevel> {
|
||||||
|
return request.get('/member/points/level')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员签到
|
||||||
|
*/
|
||||||
|
export function memberSign(): Promise<number> {
|
||||||
|
return request.post('/member/points/sign')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行签到
|
||||||
|
*/
|
||||||
|
export function signIn(): Promise<boolean> {
|
||||||
|
return request.post('/member/sign')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签到状态
|
||||||
|
*/
|
||||||
|
export function getSignStatus(): Promise<SignStatus> {
|
||||||
|
return request.get('/member/sign/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签到日历
|
||||||
|
*/
|
||||||
|
export function getSignCalendar(year: number, month: number): Promise<SignCalendar> {
|
||||||
|
return request.get('/member/sign/calendar', { year, month })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签到统计
|
||||||
|
*/
|
||||||
|
export function getSignStatistics(type: string = 'month'): Promise<SignStatistics> {
|
||||||
|
return request.get('/member/sign/statistics', { type })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
export function changePassword(data: {
|
||||||
|
old_password: string
|
||||||
|
new_password: string
|
||||||
|
confirm_password: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return request.put('/member/user/change-password', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取地址列表
|
||||||
|
*/
|
||||||
|
export function getAddressList(): Promise<Address[]> {
|
||||||
|
return request.get('/member/user/address/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建地址
|
||||||
|
*/
|
||||||
|
export function createAddress(data: Partial<Address>): Promise<Address> {
|
||||||
|
return request.post('/member/user/address/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新地址
|
||||||
|
*/
|
||||||
|
export function updateAddress(id: number, data: Partial<Address>): Promise<boolean> {
|
||||||
|
return request.put(`/member/user/address/update/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除地址
|
||||||
|
*/
|
||||||
|
export function deleteAddress(id: number): Promise<boolean> {
|
||||||
|
return request.delete(`/member/user/address/delete/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证码
|
||||||
|
*/
|
||||||
|
export function getVerificationCode(data: { type: string; phone: string }): Promise<boolean> {
|
||||||
|
return request.post('/member/user/send-code', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改手机号码
|
||||||
|
*/
|
||||||
|
export function updateMemberMobile(data: {
|
||||||
|
oldMobile: string
|
||||||
|
newMobile: string
|
||||||
|
code: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return request.put('/member/user/update-phone', {
|
||||||
|
old_phone: data.oldMobile,
|
||||||
|
new_phone: data.newMobile,
|
||||||
|
verify_code: data.code
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改邮箱
|
||||||
|
*/
|
||||||
|
export function updateMemberEmail(data: { email: string; code: string }): Promise<boolean> {
|
||||||
|
return request.put('/member/user/update-email', {
|
||||||
|
email: data.email,
|
||||||
|
verify_code: data.code
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传头像
|
||||||
|
*/
|
||||||
|
export function uploadAvatar(file: File): Promise<string> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return request.post('/member/user/avatar', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
} as any
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 会员模块相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员基本信息
|
||||||
|
*/
|
||||||
|
export interface MemberInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname?: string
|
||||||
|
avatar?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
points: number
|
||||||
|
balance: number
|
||||||
|
level: number
|
||||||
|
level_name?: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地址
|
||||||
|
*/
|
||||||
|
export interface Address {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
consignee: string
|
||||||
|
phone: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
address: string
|
||||||
|
postal_code?: string
|
||||||
|
is_default: number
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分流水记录
|
||||||
|
*/
|
||||||
|
export interface PointTransaction {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
type: string
|
||||||
|
amount: number
|
||||||
|
balance: number
|
||||||
|
description: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分流水响应
|
||||||
|
*/
|
||||||
|
export interface PointTransactionsResponse {
|
||||||
|
total: number
|
||||||
|
list: PointTransaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 余额流水记录
|
||||||
|
*/
|
||||||
|
export interface BalanceTransaction {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
type: string
|
||||||
|
amount: number
|
||||||
|
balance: number
|
||||||
|
description: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 余额流水响应
|
||||||
|
*/
|
||||||
|
export interface BalanceTransactionsResponse {
|
||||||
|
total: number
|
||||||
|
list: BalanceTransaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签到日历
|
||||||
|
*/
|
||||||
|
export interface SignCalendar {
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
signed_dates: number[]
|
||||||
|
total_signed: number
|
||||||
|
total_can_sign: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签到统计
|
||||||
|
*/
|
||||||
|
export interface SignStatistics {
|
||||||
|
total_signed: number
|
||||||
|
continuous_signed: number
|
||||||
|
this_month_signed: number
|
||||||
|
today_signed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员等级信息
|
||||||
|
*/
|
||||||
|
export interface MemberLevel {
|
||||||
|
level: number
|
||||||
|
level_name: string
|
||||||
|
points: number
|
||||||
|
next_level_points?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签到状态
|
||||||
|
*/
|
||||||
|
export interface SignStatus {
|
||||||
|
today_signed: boolean
|
||||||
|
continuous_days: number
|
||||||
|
can_sign: boolean
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import request from '~/utils/request'
|
||||||
|
import type { LinkItem, AdvertisementItem, NavigationItem, SiteData } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取友情链接列表
|
||||||
|
*/
|
||||||
|
export function getLinks(params?: { type?: string }): Promise<LinkItem[]> {
|
||||||
|
return request.get('/site/link', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有友情链接
|
||||||
|
*/
|
||||||
|
export function getAllLinks(): Promise<LinkItem[]> {
|
||||||
|
return request.get('/site/link/all')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取广告位信息
|
||||||
|
*/
|
||||||
|
export function getAdvertisements(params?: { type?: string }): Promise<AdvertisementItem[]> {
|
||||||
|
return request.get('/site/ads', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导航菜单列表
|
||||||
|
*/
|
||||||
|
export function getNavigationList(): Promise<NavigationItem[]> {
|
||||||
|
return request.get('/site/nav')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取站点首页数据(广告、导航、友情链接等)
|
||||||
|
*/
|
||||||
|
export function getSiteData(): Promise<SiteData> {
|
||||||
|
return request.get('/site/site-data')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Web 分组所有配置
|
||||||
|
* 一次性获取前台站点设置、搜索配置、统计分析配置等
|
||||||
|
*/
|
||||||
|
export function getWebGroupConfigs(): Promise<Record<string, any>> {
|
||||||
|
return request.get('/system/config/group/web')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 站点首页模块相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 友情链接项
|
||||||
|
*/
|
||||||
|
export interface LinkItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
logo?: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
sort: number
|
||||||
|
target?: string
|
||||||
|
click_count?: number
|
||||||
|
enabled: number
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
deleted_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广告项
|
||||||
|
*/
|
||||||
|
export interface AdvertisementItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
link: string
|
||||||
|
image: string
|
||||||
|
description?: string
|
||||||
|
sort: number
|
||||||
|
enabled: number
|
||||||
|
start_time: number
|
||||||
|
end_time: number
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
deleted_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航菜单项
|
||||||
|
*/
|
||||||
|
export interface NavigationItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
code: string | null
|
||||||
|
sort: number
|
||||||
|
enabled: number
|
||||||
|
is_show: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点首页数据
|
||||||
|
*/
|
||||||
|
export interface SiteData {
|
||||||
|
advertisements: AdvertisementItem[]
|
||||||
|
navigation: NavigationItem[]
|
||||||
|
links: LinkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索配置项
|
||||||
|
*/
|
||||||
|
export interface SearchConfig {
|
||||||
|
keyword: string // 搜索引擎关键字
|
||||||
|
keyword_enabled: number // 是否启用关键字搜索 (0/1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计分析配置项
|
||||||
|
*/
|
||||||
|
export interface AnalyticsConfig {
|
||||||
|
baidu_tongji_code: string // 百度统计代码
|
||||||
|
baidu_tongji_enabled: number // 是否启用百度统计 (0/1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* 获取验证码图片
|
|
||||||
*/
|
|
||||||
export function getCaptchaImage() {
|
|
||||||
return request.get('/system/captcha', { time: Date.now() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取微信授权码
|
|
||||||
*/
|
|
||||||
export function getWechatAuthCode(data: AnyObject) {
|
|
||||||
return request.get('/system/wechat/auth-code', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步微信用户信息
|
|
||||||
*/
|
|
||||||
export function syncWechatUserInfo(data: AnyObject) {
|
|
||||||
return request.get('/system/wechat/sync', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取协议内容
|
|
||||||
*/
|
|
||||||
export function getAgreementContent(key: string) {
|
|
||||||
return request.get(`/system/agreement/${key}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置用户密码
|
|
||||||
*/
|
|
||||||
export function resetUserPassword(data: AnyObject) {
|
|
||||||
return request.post('/system/password/reset', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送短信验证码
|
|
||||||
*/
|
|
||||||
export function sendSmsVerificationCode(data: AnyObject) {
|
|
||||||
return request.post(`/system/sms/${data.type}`, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取微信 JSSDK 配置
|
|
||||||
*/
|
|
||||||
export function getWechatJssdkConfig(data: AnyObject) {
|
|
||||||
return request.get('/system/wechat/jssdk-config', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取远程图片
|
|
||||||
*/
|
|
||||||
export function fetchRemoteImage(data: AnyObject) {
|
|
||||||
return request.post('/system/image/fetch', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Base64 格式图片
|
|
||||||
*/
|
|
||||||
export function fetchBase64Image(data: AnyObject) {
|
|
||||||
return request.post('/system/image/base64', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取版权信息
|
|
||||||
*/
|
|
||||||
export function getCopyrightInfo() {
|
|
||||||
return request.get('/system/copyright')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取站点信息
|
|
||||||
*/
|
|
||||||
export function getSiteInformation() {
|
|
||||||
return request.get('/system/site')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取广告位信息
|
|
||||||
*/
|
|
||||||
export function getAdvertisementInfo(params: Record<string, any>) {
|
|
||||||
return request.get('/system/web/advertisements', params, { showErrorMessage: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取导航菜单列表
|
|
||||||
*/
|
|
||||||
export function getNavigationList() {
|
|
||||||
return request.get('/system/web/navigation')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取友情链接列表
|
|
||||||
*/
|
|
||||||
export function getFriendlyLinks() {
|
|
||||||
return request.get('/system/web/friendly-links')
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import request from '~/utils/request'
|
||||||
|
import type { SiteConfig, AgreementType, SmsType } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按分组获取配置
|
||||||
|
*/
|
||||||
|
export function getConfigByGroup(groupCode: string, options?: {
|
||||||
|
enabled_only?: boolean
|
||||||
|
with_metadata?: boolean
|
||||||
|
}) {
|
||||||
|
return request.get(`/system/config/group/${groupCode}`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 code 获取配置
|
||||||
|
*/
|
||||||
|
export function getConfigByCode(code: string, options?: { group_code?: string}) {
|
||||||
|
return request.get(`/system/config/code/${code}`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取协议内容
|
||||||
|
*/
|
||||||
|
export function getAgreementContent(key: AgreementType): Promise<string> {
|
||||||
|
return request.get(`/agreement/${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信验证码
|
||||||
|
*/
|
||||||
|
export function sendSmsVerificationCode(data: {
|
||||||
|
type: SmsType
|
||||||
|
phone: string
|
||||||
|
[key: string]: any
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return request.post(`/sms/${data.type}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证码图片
|
||||||
|
*/
|
||||||
|
export function getCaptchaImage() {
|
||||||
|
return request.get('/captcha', { time: Date.now() }, {
|
||||||
|
showSuccessMessage: false,
|
||||||
|
returnFullResponse: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取远程图片
|
||||||
|
*/
|
||||||
|
export function fetchRemoteImage(data: { url: string }): Promise<string> {
|
||||||
|
return request.post('/image/fetch', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Base64 格式图片
|
||||||
|
*/
|
||||||
|
export function fetchBase64Image(data: { url: string }): Promise<string> {
|
||||||
|
return request.post('/image/base64', data)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 系统配置相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置项
|
||||||
|
*/
|
||||||
|
export interface ConfigItem {
|
||||||
|
id: number
|
||||||
|
group_code: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
content: Record<string, any>
|
||||||
|
is_sys: number
|
||||||
|
enabled: number
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点设置配置
|
||||||
|
*/
|
||||||
|
export interface SiteConfig {
|
||||||
|
copyright: string
|
||||||
|
icp: string
|
||||||
|
icp_url: string
|
||||||
|
network_security: string
|
||||||
|
network_security_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议类型
|
||||||
|
*/
|
||||||
|
export type AgreementType = 'user' | 'privacy' | 'service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短信/邮箱验证码类型
|
||||||
|
*/
|
||||||
|
export type SmsType = 'register' | 'login' | 'reset_password' | 'reset_password_email' | 'change_phone' | 'change_email'
|
||||||
|
|
||||||
+35
-4
@@ -1,26 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- SEO 组件 -->
|
||||||
|
<Seo />
|
||||||
|
|
||||||
<el-config-provider :value-on-clear="() => null" :locale="locale">
|
<el-config-provider :value-on-clear="() => null" :locale="locale">
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
|
|
||||||
|
<!-- 百度统计组件 -->
|
||||||
|
<BaiduAnalytics />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
import en from 'element-plus/dist/locale/en.mjs'
|
import en from 'element-plus/dist/locale/en.mjs'
|
||||||
import type { Language } from 'element-plus/es/locale'
|
import type { Language } from 'element-plus/es/locale'
|
||||||
|
import { onMounted, computed } from 'vue'
|
||||||
|
import { initDark, getDark } from './utils/dark'
|
||||||
|
import { useHead } from 'nuxt/app'
|
||||||
|
|
||||||
// modules import mark, Please do not remove.
|
// modules import mark, Please do not remove.
|
||||||
|
import { useSystemStore } from './stores/system'
|
||||||
|
import { useMemberStore } from './stores/member'
|
||||||
|
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const locale = computed(() => (systemStore.lang === 'zh-cn' ? zhCn : en))
|
const locale = computed(() => (systemStore.lang === 'zh-cn' ? zhCn : en))
|
||||||
|
|
||||||
|
// 在服务器端渲染时就设置正确的 dark 类
|
||||||
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
class: getDark() ? 'dark' : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 仅在客户端执行,避免 SSR 时清空持久化的 token
|
||||||
|
if (import.meta.client) {
|
||||||
|
const memberStore = useMemberStore()
|
||||||
|
// 如果有 token 但没有 refreshToken(比如旧数据),清除它
|
||||||
|
if (memberStore.token && !memberStore.refreshToken) {
|
||||||
|
memberStore.token = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await systemStore.getSiteInfoFn()
|
||||||
|
// 初始化语言设置
|
||||||
|
systemStore.initLanguage()
|
||||||
|
// 初始化暗黑模式(确保客户端与服务器端保持一致)
|
||||||
|
initDark()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
/*
|
||||||
|
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||||
|
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||||
|
2. [UnoCSS]: allow to override the default border color with css var `--un-default-border-color`
|
||||||
|
*/
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box; /* 1 */
|
||||||
|
border-width: 0; /* 2 */
|
||||||
|
border-style: solid; /* 2 */
|
||||||
|
border-color: var(--un-default-border-color, #e5e7eb); /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
--un-content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Use a consistent sensible line-height in all browsers.
|
||||||
|
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
|
3. Use a more readable tab size.
|
||||||
|
4. Use the user's configured `sans` font-family by default.
|
||||||
|
5. Use the user's configured `sans` font-feature-settings by default.
|
||||||
|
6. Use the user's configured `sans` font-variation-settings by default.
|
||||||
|
7. Disable tap highlights on iOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html,
|
||||||
|
:host {
|
||||||
|
line-height: 1.5; /* 1 */
|
||||||
|
-webkit-text-size-adjust: 100%; /* 2 */
|
||||||
|
-moz-tab-size: 4; /* 3 */
|
||||||
|
tab-size: 4; /* 3 */
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */
|
||||||
|
font-feature-settings: normal; /* 5 */
|
||||||
|
font-variation-settings: normal; /* 6 */
|
||||||
|
-webkit-tap-highlight-color: transparent; /* 7 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Remove the margin in all browsers.
|
||||||
|
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0; /* 1 */
|
||||||
|
line-height: inherit; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Add the correct height in Firefox.
|
||||||
|
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||||
|
3. Ensure horizontal rules are visible by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 0; /* 1 */
|
||||||
|
color: inherit; /* 2 */
|
||||||
|
border-top-width: 1px; /* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
abbr:where([title]) {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the default font size and weight for headings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reset links to optimize for opt-in styling instead of opt-out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct font weight in Edge and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Use the user's configured `mono` font-family by default.
|
||||||
|
2. Use the user's configured `mono` font-feature-settings by default.
|
||||||
|
3. Use the user's configured `mono` font-variation-settings by default.
|
||||||
|
4. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */
|
||||||
|
font-feature-settings: normal; /* 2 */
|
||||||
|
font-variation-settings: normal; /* 3 */
|
||||||
|
font-size: 1em; /* 4 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||||
|
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||||
|
3. Remove gaps between table borders by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
table {
|
||||||
|
text-indent: 0; /* 1 */
|
||||||
|
border-color: inherit; /* 2 */
|
||||||
|
border-collapse: collapse; /* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Change the font styles in all browsers.
|
||||||
|
2. Remove the margin in Firefox and Safari.
|
||||||
|
3. Remove default padding in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit; /* 1 */
|
||||||
|
font-feature-settings: inherit; /* 1 */
|
||||||
|
font-variation-settings: inherit; /* 1 */
|
||||||
|
font-size: 100%; /* 1 */
|
||||||
|
font-weight: inherit; /* 1 */
|
||||||
|
line-height: inherit; /* 1 */
|
||||||
|
color: inherit; /* 1 */
|
||||||
|
margin: 0; /* 2 */
|
||||||
|
padding: 0; /* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the inheritance of text transform in Edge and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
2. Remove default button styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'] {
|
||||||
|
-webkit-appearance: button; /* 1 */
|
||||||
|
background-color: transparent; /* 2 */
|
||||||
|
background-image: none; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Use the modern Firefox focus style for all focusable elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:-moz-focusring {
|
||||||
|
outline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:-moz-ui-invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct vertical alignment in Chrome and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Correct the cursor style of increment and decrement buttons in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type='search'] {
|
||||||
|
-webkit-appearance: textfield; /* 1 */
|
||||||
|
outline-offset: -2px; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the inner padding in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
2. Change font properties to `inherit` in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button; /* 1 */
|
||||||
|
font: inherit; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct display in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Removes the default spacing for appropriate elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
blockquote,
|
||||||
|
dl,
|
||||||
|
dd,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
hr,
|
||||||
|
figure,
|
||||||
|
p,
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
menu {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prevent resizing textareas horizontally by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||||
|
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||||
|
*/
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
opacity: 1; /* 1 */
|
||||||
|
color: #9ca3af; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Set the default cursor for buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Make sure disabled buttons don't get the pointer cursor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||||
|
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||||
|
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
audio,
|
||||||
|
iframe,
|
||||||
|
embed,
|
||||||
|
object {
|
||||||
|
display: block; /* 1 */
|
||||||
|
vertical-align: middle; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||||
|
*/
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Make elements with the HTML hidden attribute stay hidden by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[hidden]:where(:not([hidden="until-found"])) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="ad-fade">
|
||||||
|
<div v-if="!isHidden && displayedAds.length > 0" class="advertisement-wrapper" :class="[`ad-${type}`, position && type === 'float' ? `ad-position-${position}` : '']">
|
||||||
|
<div
|
||||||
|
v-for="ad in displayedAds"
|
||||||
|
:key="ad.id"
|
||||||
|
class="ad-item"
|
||||||
|
>
|
||||||
|
<!-- 简单模式 -->
|
||||||
|
<component
|
||||||
|
v-if="type === 'simple'"
|
||||||
|
:is="SimpleModeRaw"
|
||||||
|
:ad="ad"
|
||||||
|
@click="handleClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 悬浮模式 -->
|
||||||
|
<component
|
||||||
|
v-else-if="type === 'float'"
|
||||||
|
:is="FloatModeRaw"
|
||||||
|
:ad="ad"
|
||||||
|
:closable="closable"
|
||||||
|
@click="handleClick"
|
||||||
|
@close="handleClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 自定义扩展模式 -->
|
||||||
|
<component
|
||||||
|
v-else-if="customModeComponent"
|
||||||
|
:is="customModeComponent"
|
||||||
|
:ad="ad"
|
||||||
|
v-bind="customModeProps"
|
||||||
|
@click="handleClick"
|
||||||
|
@close="handleClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 默认模式(向后兼容) -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="ad-card"
|
||||||
|
@click="handleClick(ad)"
|
||||||
|
>
|
||||||
|
<div class="ad-image-wrapper">
|
||||||
|
<img
|
||||||
|
:src="ad.image"
|
||||||
|
:alt="ad.title"
|
||||||
|
class="ad-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="ad-gradient"></div>
|
||||||
|
<div v-if="showTitle" class="ad-content">
|
||||||
|
<h3 class="ad-title">{{ ad.title }}</h3>
|
||||||
|
<p v-if="ad.description" class="ad-description">{{ ad.description }}</p>
|
||||||
|
<span class="ad-badge">广告</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
class="ad-close-btn"
|
||||||
|
@click.stop="handleClose(ad)"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, markRaw, shallowRef } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { getAdvertisements } from '~/api/site'
|
||||||
|
import type { AdvertisementItem } from '~/api/site/types'
|
||||||
|
import SimpleMode from './modes/simple.vue'
|
||||||
|
import FloatMode from './modes/float.vue'
|
||||||
|
import { getModeComponent, registerMode } from './modes'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type?: string // 简化类型定义,支持字符串类型
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
showTitle?: boolean
|
||||||
|
closable?: boolean
|
||||||
|
showTodayBtn?: boolean
|
||||||
|
autoCloseDelay?: number
|
||||||
|
position?: 'top' | 'center' | 'bottom'
|
||||||
|
customModeProps?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'simple',
|
||||||
|
offset: 0,
|
||||||
|
limit: 0,
|
||||||
|
showTitle: false,
|
||||||
|
closable: false,
|
||||||
|
showTodayBtn: false,
|
||||||
|
autoCloseDelay: 0,
|
||||||
|
position: 'center',
|
||||||
|
customModeProps: () => ({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [ad: AdvertisementItem]
|
||||||
|
close: [ad: AdvertisementItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 将组件标记为原始组件,避免响应式代理
|
||||||
|
const SimpleModeRaw = markRaw(SimpleMode)
|
||||||
|
const FloatModeRaw = markRaw(FloatMode)
|
||||||
|
|
||||||
|
// 使用这些标记为原始的组件
|
||||||
|
|
||||||
|
const ads = ref<AdvertisementItem[]>([])
|
||||||
|
const isHidden = ref(false)
|
||||||
|
const customModeComponent = shallowRef<Component | null>(null)
|
||||||
|
|
||||||
|
const COMPONENT_STORAGE_KEY = `ad_component_${props.type}_${props.offset}`
|
||||||
|
|
||||||
|
// 动态获取自定义模式组件
|
||||||
|
onMounted(() => {
|
||||||
|
if (!['simple', 'float'].includes(props.type!)) {
|
||||||
|
const component = getModeComponent(props.type!)
|
||||||
|
if (component) {
|
||||||
|
customModeComponent.value = markRaw(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkTodayClosed = () => {
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
const stored = localStorage.getItem(`ad_today_${COMPONENT_STORAGE_KEY}`)
|
||||||
|
return stored === today
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (checkTodayClosed()) {
|
||||||
|
isHidden.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ads.value = await getAdvertisements()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取广告失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayedAds = computed(() => {
|
||||||
|
const now = Date.now() / 1000
|
||||||
|
const validAds = ads.value.filter(
|
||||||
|
ad =>
|
||||||
|
ad.enabled === 1 &&
|
||||||
|
ad.start_time <= now &&
|
||||||
|
ad.end_time >= now
|
||||||
|
)
|
||||||
|
const slicedAds = validAds.slice(props.offset)
|
||||||
|
const result = props.limit > 0 ? slicedAds.slice(0, props.limit) : slicedAds
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClick = (ad: AdvertisementItem) => {
|
||||||
|
emit('click', ad)
|
||||||
|
if (ad.link) {
|
||||||
|
window.open(ad.link, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (ad: AdvertisementItem) => {
|
||||||
|
isHidden.value = true
|
||||||
|
emit('close', ad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出注册方法供外部使用
|
||||||
|
defineExpose({
|
||||||
|
registerMode
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// 基础广告卡片
|
||||||
|
.advertisement-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.ad-simple {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-float {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-item {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 悬浮广告定位
|
||||||
|
.ad-float {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
width: 200px;
|
||||||
|
|
||||||
|
.ad-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右上位置
|
||||||
|
.ad-float.ad-position-top {
|
||||||
|
top: 24px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右中位置(默认)
|
||||||
|
.ad-float.ad-position-center {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右下位置
|
||||||
|
.ad-float.ad-position-bottom {
|
||||||
|
top: auto;
|
||||||
|
bottom: 24px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认卡片样式(向后兼容)
|
||||||
|
.ad-card {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px rgba(0, 0, 0, 0.07),
|
||||||
|
0 10px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 12px rgba(102, 126, 234, 0.2),
|
||||||
|
0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
.ad-close-btn {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-gradient {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60%;
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(0, 0, 0, 0.8) 0%,
|
||||||
|
rgba(0, 0, 0, 0.4) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-content {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-description {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #666;
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
.ad-fade-enter-active,
|
||||||
|
.ad-fade-leave-active {
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-fade-enter-from,
|
||||||
|
.ad-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ad-float {
|
||||||
|
right: 12px;
|
||||||
|
width: 160px;
|
||||||
|
|
||||||
|
.ad-card {
|
||||||
|
.ad-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-card {
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 8px 12px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-close-btn {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ad-float-card" @click="handleClick">
|
||||||
|
<div class="ad-image-wrapper">
|
||||||
|
<img :src="ad.image" :alt="ad.title" class="ad-image" />
|
||||||
|
</div>
|
||||||
|
<div class="ad-content">
|
||||||
|
<h3 class="ad-title">{{ ad.title }}</h3>
|
||||||
|
<p v-if="ad.description" class="ad-description">{{ ad.description }}</p>
|
||||||
|
<span class="ad-badge">广告</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
class="ad-close-btn"
|
||||||
|
@click.stop="handleClose"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdvertisementItem } from '~/api/site/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ad: AdvertisementItem
|
||||||
|
closable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
closable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [ad: AdvertisementItem]
|
||||||
|
close: [ad: AdvertisementItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
emit('click', props.ad)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close', props.ad)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ad-float-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.ad-image-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 1px 1px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-title {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-description {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: rgba(0, 0, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
// 内置模式
|
||||||
|
import SimpleMode from './simple.vue'
|
||||||
|
import FloatMode from './float.vue'
|
||||||
|
|
||||||
|
// 动态加载自定义模式
|
||||||
|
const customModes: Record<string, Component> = {
|
||||||
|
simple: SimpleMode,
|
||||||
|
float: FloatMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册自定义模式
|
||||||
|
export const registerMode = (name: string, component: Component) => {
|
||||||
|
customModes[name] = component
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模式组件
|
||||||
|
export const getModeComponent = (modeName: string): Component | undefined => {
|
||||||
|
return customModes[modeName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有可用模式
|
||||||
|
export const getAvailableModes = (): string[] => {
|
||||||
|
return Object.keys(customModes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ad-simple-layout" @click="handleClick">
|
||||||
|
<img :src="ad.image" :alt="ad.title" class="ad-simple-image" />
|
||||||
|
<!-- <span class="ad-badge">广告</span> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdvertisementItem } from '~/api/site/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ad: AdvertisementItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [ad: AdvertisementItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
emit('click', props.ad)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ad-simple-layout {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgb(218, 225, 225);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-simple-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 2px;
|
||||||
|
line-height: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="shouldLoadAnalytics">
|
||||||
|
<!-- 百度统计代码将被注入到这里 -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useConfigStore } from '~/stores/config'
|
||||||
|
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const shouldLoadAnalytics = ref(false)
|
||||||
|
const analyticsLoaded = ref(false)
|
||||||
|
|
||||||
|
// 加载百度统计脚本
|
||||||
|
const loadBaiduAnalytics = () => {
|
||||||
|
if (!configStore.analytics.baidu_tongji_enabled || !configStore.analytics.baidu_tongji_code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analyticsLoaded.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建百度统计脚本
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.text = `
|
||||||
|
var _hmt = _hmt || [];
|
||||||
|
(function() {
|
||||||
|
var hm = document.createElement("script");
|
||||||
|
hm.src = "https://hm.baidu.com/hm.js?${configStore.analytics.baidu_tongji_code}";
|
||||||
|
var s = document.getElementsByTagName("script")[0];
|
||||||
|
s.parentNode.insertBefore(hm, s);
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
document.head.appendChild(script)
|
||||||
|
analyticsLoaded.value = true
|
||||||
|
console.log('百度统计已加载')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('百度统计加载失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 - 配置已在 app.vue 中统一加载,这里直接使用
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查是否应该加载统计
|
||||||
|
shouldLoadAnalytics.value = configStore.analytics.baidu_tongji_enabled === 1 && !!configStore.analytics.baidu_tongji_code
|
||||||
|
|
||||||
|
// 加载统计脚本
|
||||||
|
if (shouldLoadAnalytics.value) {
|
||||||
|
loadBaiduAnalytics()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听配置变化
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
configStore.analytics.baidu_tongji_enabled,
|
||||||
|
configStore.analytics.baidu_tongji_code
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
shouldLoadAnalytics.value = configStore.analytics.baidu_tongji_enabled === 1 && !!configStore.analytics.baidu_tongji_code
|
||||||
|
if (shouldLoadAnalytics.value && !analyticsLoaded.value) {
|
||||||
|
loadBaiduAnalytics()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// 这是一个无界面的组件,主要用于加载百度统计
|
||||||
|
export default {
|
||||||
|
name: 'BaiduAnalytics'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { CSSProperties } from 'vue'
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'Icon',
|
|
||||||
props: {
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '18px',
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: '#000000',
|
|
||||||
},
|
|
||||||
attr: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props:any) {
|
|
||||||
// 添加isExternal函数定义
|
|
||||||
const isExternal = (path: string): boolean => {
|
|
||||||
return /^(https?:|mailto:|tel:)/.test(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconStyle = computed((): CSSProperties => {
|
|
||||||
const { size, color } = props
|
|
||||||
// 修复:添加类型检查,确保size是字符串才能调用replace方法
|
|
||||||
let fontSize = typeof size === 'string' ? `${size.replace('px', '')}px` : `${size}px`
|
|
||||||
return {
|
|
||||||
fontSize: fontSize,
|
|
||||||
color: color,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (props.name.startsWith('el-icon-')) {
|
|
||||||
// 提取去掉前缀后的组件名
|
|
||||||
const iconName = props.name.replace('el-icon-', '')
|
|
||||||
return () =>
|
|
||||||
h(resolveComponent('el-icon'), { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr }, () =>
|
|
||||||
h(resolveComponent(iconName))
|
|
||||||
)
|
|
||||||
} else if (props.name.startsWith('local-') || isExternal(props.name)) {
|
|
||||||
const name = props.name.replace('local-', '')
|
|
||||||
return () => h(resolveComponent('nuxt-icon'), { class: [props.name, 'icon'], name: name, style: iconStyle.value, ...props.attr })
|
|
||||||
} else {
|
|
||||||
// 处理fa前缀和其他普通图标
|
|
||||||
return () => h('i', { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<div class="icon-selector">
|
||||||
|
<div
|
||||||
|
class="select"
|
||||||
|
@click="handleClick"
|
||||||
|
:style="{ width: props.width }"
|
||||||
|
:class="[size, { 'is-disabled': disabled }, { 'has-icon': selectValue }]"
|
||||||
|
>
|
||||||
|
<div class="icon">
|
||||||
|
<Icon v-show="selectValue" :icon="selectValue" />
|
||||||
|
</div>
|
||||||
|
<div class="text">{{ props.text }}</div>
|
||||||
|
<div class="arrow">
|
||||||
|
<Icon icon="ant-design:down-outlined" class="arrow-icon" />
|
||||||
|
<Icon icon="ant-design:close-outlined" @click.stop="clearIcon" class="clear-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog title="选择图标" width="40%" v-model="visible" align-center>
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane v-for="prefix in iconifyPrefixs" :key="prefix" :label="prefix" :name="prefix">
|
||||||
|
<el-scrollbar height="400px">
|
||||||
|
<el-input v-model="searchText" placeholder="请输入图标名称"></el-input>
|
||||||
|
<ul class="icons-list" style="margin-top: 10px">
|
||||||
|
<li
|
||||||
|
v-for="icon in paginatedIconifyList"
|
||||||
|
:key="icon"
|
||||||
|
@click="selectorIconifyIcon(icon)"
|
||||||
|
>
|
||||||
|
<i class="iconfont-sys">
|
||||||
|
<Icon :icon="icon" />
|
||||||
|
</i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<el-pagination
|
||||||
|
v-if="filteredIconifyList.length > iconifyPageSize"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:total="filteredIconifyList.length"
|
||||||
|
:page-size="iconifyPageSize"
|
||||||
|
v-model:current-page="iconifyCurrentPage"
|
||||||
|
size="small"
|
||||||
|
style="margin-top: 10px; justify-content: center"
|
||||||
|
/>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="Svg图标" name="svg">
|
||||||
|
<el-scrollbar height="400px">
|
||||||
|
<ul class="icons-list">
|
||||||
|
<li v-for="icon in svgList" :key="icon" @click="selectorIconifyIcon(icon)">
|
||||||
|
<i class="iconfont-sys">
|
||||||
|
<Icon :icon="icon" />
|
||||||
|
</i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
<el-button type="primary" @click="visible = false">确 定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { listIcons } from '@iconify/vue'
|
||||||
|
import { fetchIconsData } from '~/utils/icon-utils'
|
||||||
|
import Icon from './iconify.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ArtIconSelector' })
|
||||||
|
|
||||||
|
const searchText = ref('')
|
||||||
|
const activeTab = ref('ant-design')
|
||||||
|
const iconifyCurrentPage = ref(1)
|
||||||
|
const iconifyPageSize = ref(60)
|
||||||
|
|
||||||
|
const svgList = computed(() => {
|
||||||
|
return listIcons('', 'svg')
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconifyMap = reactive<any>({})
|
||||||
|
const iconifyPrefixs = ['ant-design', 'carbon', 'ic', 'lucide', 'ep', 'ri']
|
||||||
|
|
||||||
|
const iconifyList = computed(() => {
|
||||||
|
return iconifyMap[activeTab.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
iconifyPrefixs.forEach((prefix: any) => {
|
||||||
|
fetchIconsData(prefix).then((icons) => {
|
||||||
|
iconifyMap[prefix] = icons
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => activeTab.value,
|
||||||
|
() => {
|
||||||
|
if (iconifyPrefixs.includes(activeTab.value)) {
|
||||||
|
fetchIconsData(activeTab.value).then((icons) => {
|
||||||
|
iconifyMap[activeTab.value] = icons
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredIconifyList = computed<string[]>(() => {
|
||||||
|
if (!searchText.value) {
|
||||||
|
return iconifyList.value || []
|
||||||
|
}
|
||||||
|
return (iconifyList.value || []).filter((icon: any) => icon.includes(searchText.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedIconifyList = computed<string[]>(() => {
|
||||||
|
const start = (iconifyCurrentPage.value - 1) * iconifyPageSize.value
|
||||||
|
const end = start + iconifyPageSize.value
|
||||||
|
return filteredIconifyList.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
type ComponentSize = 'large' | 'default' | 'small'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
iconType?: any
|
||||||
|
modelValue?: string
|
||||||
|
text?: string
|
||||||
|
width?: string
|
||||||
|
size?: ComponentSize
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
getIcon: [value: string]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
iconType: '',
|
||||||
|
modelValue: '',
|
||||||
|
text: '图标选择器',
|
||||||
|
width: '200px',
|
||||||
|
size: 'default',
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const selectValue = ref<string>(props.modelValue)
|
||||||
|
const visible = ref<boolean>(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal: string) => {
|
||||||
|
selectValue.value = newVal
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
iconifyCurrentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectorIconifyIcon = (icon: string): void => {
|
||||||
|
selectValue.value = icon
|
||||||
|
visible.value = false
|
||||||
|
emits('update:modelValue', icon)
|
||||||
|
emits('getIcon', icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!props.disabled) {
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearIcon = () => {
|
||||||
|
selectValue.value = ''
|
||||||
|
emits('update:modelValue', '')
|
||||||
|
emits('getIcon', '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.icon-selector {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-icon {
|
||||||
|
.text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.default {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconfont-sys {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 图标组件使用说明
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 支持多种图标库:Ant Design、Element Plus、Carbon、IC、Lucide、Ri 等
|
||||||
|
- 支持自定义图标
|
||||||
|
- 支持图标选择器组件
|
||||||
|
- 支持离线模式
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @iconify/vue @iconify/json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
### 1. 基础图标组件 (Iconify)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Iconify icon="ant-design:home-outlined" />
|
||||||
|
<Iconify icon="ep:home-filled" />
|
||||||
|
<Iconify icon="carbon:home" />
|
||||||
|
<Iconify icon="ic:baseline-home" />
|
||||||
|
<Iconify icon="lucide:home" />
|
||||||
|
<Iconify icon="ri:home-line" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Iconify from '~/components/iconify.vue'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 自定义尺寸和颜色
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Iconify icon="ant-design:home-outlined" width="24" height="24" />
|
||||||
|
<Iconify icon="ant-design:home-outlined" width="32" height="32" color="#409eff" />
|
||||||
|
<Iconify icon="ant-design:home-outlined" size="1.5em" color="#67c23a" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 图标选择器 (IconSelector)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<IconSelector v-model="selectedIcon" text="选择图标" width="300px" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import IconSelector from '~/components/icon-selector.vue'
|
||||||
|
|
||||||
|
const selectedIcon = ref('')
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 监听图标选择事件
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<IconSelector v-model="selectedIcon" @get-icon="handleGetIcon" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import IconSelector from '~/components/icon-selector.vue'
|
||||||
|
|
||||||
|
const selectedIcon = ref('')
|
||||||
|
|
||||||
|
const handleGetIcon = (icon: string) => {
|
||||||
|
console.log('选择的图标:', icon)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的图标库
|
||||||
|
|
||||||
|
| 图标库 | 前缀 | 示例 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Ant Design | `ant-design` | `ant-design:home-outlined` |
|
||||||
|
| Element Plus | `ep` | `ep:home-filled` |
|
||||||
|
| Carbon | `carbon` | `carbon:home` |
|
||||||
|
| IC | `ic` | `ic:baseline-home` |
|
||||||
|
| Lucide | `lucide` | `lucide:home` |
|
||||||
|
| Ri | `ri` | `ri:home-line` |
|
||||||
|
|
||||||
|
## 自定义添加图标库
|
||||||
|
|
||||||
|
### 1. 安装图标库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @iconify-json/your-icon-library
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 在 load-icon.ts 中添加
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import YourIcons from '@iconify/json/json/your-icon-library.json'
|
||||||
|
addCollection(YourIcons)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 在 icon-selector.vue 中添加
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const iconifyPrefixs = ['ant-design', 'carbon', 'ic', 'lucide', 'ep', 'ri', 'your-icon-library']
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 离线模式
|
||||||
|
|
||||||
|
在 `.env` 文件中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_APP_ICON_OFFLINE=true
|
||||||
|
VITE_APP_ICONIFY_PREFIX=ant-design,carbon,ic,lucide,ep,ri
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图标选择器配置
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| modelValue | string | '' | v-model 绑定的图标值 |
|
||||||
|
| text | string | '图标选择器' | 显示文本 |
|
||||||
|
| width | string | '200px' | 组件宽度 |
|
||||||
|
| size | string | 'default' | 组件大小:large/default/small |
|
||||||
|
| disabled | boolean | false | 是否禁用 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 图标名称格式:`图标库名称:图标名称`
|
||||||
|
2. 离线模式下需要预先加载所有图标数据
|
||||||
|
3. 图标选择器支持搜索功能
|
||||||
|
4. 图标选择器支持分页显示
|
||||||
|
|
||||||
|
## 示例代码
|
||||||
|
|
||||||
|
完整示例请参考项目中的使用场景。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<i class="iconfont-sys" v-if="iconType === 'unicode'" v-html="icon"></i>
|
||||||
|
<i :class="`iconfont-sys ${icon}`" v-else-if="iconType === 'className'"></i>
|
||||||
|
<ElIcon class="m-icon" v-else-if="typeof icon === 'string'">
|
||||||
|
<Icon :icon="icon" :width="width" :height="height" :color="color"></Icon>
|
||||||
|
</ElIcon>
|
||||||
|
<ElIcon class="m-icon" v-else-if="typeof icon === 'object'"
|
||||||
|
><component :is="icon" :width="width" :height="height" :color="color"></component
|
||||||
|
></ElIcon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import type { VNode } from 'vue'
|
||||||
|
import { ElIcon } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
icon: {
|
||||||
|
type: [String, Object] as PropType<string | VNode>
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '1em'
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '1em'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconType = computed(() => {
|
||||||
|
if (props.icon?.toString().startsWith('iconsys-')) {
|
||||||
|
return 'className'
|
||||||
|
} else if (props.icon?.toString().includes(':')) {
|
||||||
|
return 'iconify'
|
||||||
|
}
|
||||||
|
return 'unicode'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.m-icon {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Icon } from './iconify.vue'
|
||||||
|
export { default as IconSelector } from './icon-selector.vue'
|
||||||
|
export { default } from './iconify.vue'
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
<template>
|
||||||
|
<div class="forget-password-container" :class="{'dark-mode': isDarkMode}">
|
||||||
|
<div class="forget-password-form-container">
|
||||||
|
<!-- 第一步:邮箱验证 -->
|
||||||
|
<div v-if="currentStep === 1">
|
||||||
|
<div class="forget-password-title">{{ t('auth.forget.password.title') }}</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
:model="step1Data"
|
||||||
|
ref="step1FormRef"
|
||||||
|
:rules="step1Rules"
|
||||||
|
:validate-on-rule-change="false"
|
||||||
|
class="forget-password-form"
|
||||||
|
>
|
||||||
|
<el-form-item prop="email" class="form-item">
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="step1Data.email"
|
||||||
|
:placeholder="t('auth.forget.password.email_placeholder')"
|
||||||
|
clearable
|
||||||
|
class="forget-password-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="ant-design:mail-outlined"
|
||||||
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
|
class="input-prefix-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="captcha_code" class="form-item">
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="step1Data.captcha_code"
|
||||||
|
:placeholder="t('auth.forget.password.captcha_placeholder')"
|
||||||
|
class="forget-password-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="ant-design:safety-outlined"
|
||||||
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
|
class="input-prefix-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<div class="captcha-image" @click="captcha.refresh()">
|
||||||
|
<img v-if="captcha.image.value" :src="captcha.image.value" :alt="t('auth.forget.password.captcha_alt')" />
|
||||||
|
<Icon v-else icon="ant-design:loading-outlined" :size="24" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item class="form-item">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
class="next-step-button"
|
||||||
|
@click="handleNextStep"
|
||||||
|
>
|
||||||
|
{{ t('auth.forget.password.next_step') }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="login-link">
|
||||||
|
{{ t('auth.forget.password.back_to_login') }}
|
||||||
|
<span class="login-button" @click="switchToLogin">
|
||||||
|
{{ t('auth.forget.password.login') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二步:设置新密码 -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="forget-password-title">{{ t('auth.forget.password.reset_title') }}</div>
|
||||||
|
|
||||||
|
<!-- 验证码发送提示 -->
|
||||||
|
<div class="verification-tip" v-if="verificationSent">
|
||||||
|
{{ t('auth.forget.password.verification_sent', { email: step1Data.email }) }}<br>
|
||||||
|
{{ t('auth.forget.password.verification_instruction') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
:model="step2Data"
|
||||||
|
ref="step2FormRef"
|
||||||
|
:rules="step2Rules"
|
||||||
|
:validate-on-rule-change="false"
|
||||||
|
class="forget-password-form"
|
||||||
|
>
|
||||||
|
<el-form-item prop="password" class="form-item">
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="step2Data.password"
|
||||||
|
:placeholder="t('auth.forget.password.password_placeholder')"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
class="forget-password-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="ant-design:lock-outlined"
|
||||||
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
|
class="input-prefix-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="confirm_password" class="form-item">
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="step2Data.confirm_password"
|
||||||
|
:placeholder="t('auth.forget.password.confirm_password_placeholder')"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
class="forget-password-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="ant-design:lock-outlined"
|
||||||
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
|
class="input-prefix-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="code" class="form-item">
|
||||||
|
<div class="email-code-container">
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="step2Data.code"
|
||||||
|
:placeholder="t('auth.forget.password.verification_code_placeholder')"
|
||||||
|
class="forget-password-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="ant-design:key-outlined"
|
||||||
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
|
class="input-prefix-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
class="send-email-code-button"
|
||||||
|
:disabled="emailCodeCountdown > 0"
|
||||||
|
@click="handleSendEmailCode"
|
||||||
|
>
|
||||||
|
{{ emailCodeCountdown > 0 ? `${emailCodeCountdown}s` : t('auth.forget.password.resend_code') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item class="form-item">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
class="reset-password-button"
|
||||||
|
@click="handleResetPassword"
|
||||||
|
>
|
||||||
|
{{ t('auth.forget.password.reset_password') }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="prev-step-container">
|
||||||
|
<span class="prev-step" @click="handlePrevStep">
|
||||||
|
<Icon icon="ant-design:arrow-left-outlined" />
|
||||||
|
{{ t('auth.forget.password.prev_step') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { forgetPassword, verifyEmail, sendEmailCode } from '@/api/auth'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
import { t } from '~/composables/lang'
|
||||||
|
import { useCaptcha } from '~/composables/captcha'
|
||||||
|
import validate from '~/utils/validate'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// 暗黑模式状态
|
||||||
|
const isDarkMode = computed(() => {
|
||||||
|
const root = document.querySelector('html')
|
||||||
|
return root?.classList.contains('dark') || false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前步骤
|
||||||
|
const currentStep = ref(1)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const emailCodeCountdown = ref(0)
|
||||||
|
const verificationSent = ref(false)
|
||||||
|
let emailCodeTimer: any = null
|
||||||
|
|
||||||
|
// 第一步数据:邮箱 + 图片验证码
|
||||||
|
const step1Data = reactive({
|
||||||
|
email: '',
|
||||||
|
captcha_code: '',
|
||||||
|
captcha_key: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 第二步数据:新密码 + 确认密码 + 验证码
|
||||||
|
const step2Data = reactive({
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
code: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 存储邮箱,用于第二步
|
||||||
|
const savedEmail = ref('')
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const step1FormRef = ref<FormInstance>()
|
||||||
|
const step2FormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
// 使用验证码组合式函数
|
||||||
|
const captcha = useCaptcha(step1Data)
|
||||||
|
|
||||||
|
// 第一步验证规则
|
||||||
|
const step1Rules = computed(() => ({
|
||||||
|
email: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
message: t('auth.forget.password.email_required'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string) => {
|
||||||
|
return validate.email(value)
|
||||||
|
},
|
||||||
|
message: t('auth.forget.password.email_invalid'),
|
||||||
|
trigger: ['blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
captcha_code: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
message: t('auth.forget.password.captcha_required'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
}
|
||||||
|
}) as any)
|
||||||
|
|
||||||
|
// 第二步验证规则
|
||||||
|
const step2Rules = computed(() => ({
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
message: t('auth.forget.password.password_required'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
},
|
||||||
|
confirm_password: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
message: t('auth.forget.password.confirm_password_required'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string) => {
|
||||||
|
return value === step2Data.password
|
||||||
|
},
|
||||||
|
message: t('auth.forget.password.password_mismatch'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
message: t('auth.forget.password.verification_code_required'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
}
|
||||||
|
}) as any)
|
||||||
|
|
||||||
|
// 发送邮箱验证码
|
||||||
|
const handleSendEmailCode = async () => {
|
||||||
|
if (!step1Data.email || !validate.email(step1Data.email)) {
|
||||||
|
ElMessage.error(t('auth.forget.password.email_invalid'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailCodeCountdown.value > 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailCode({
|
||||||
|
email: step1Data.email,
|
||||||
|
captcha_key: step1Data.captcha_key,
|
||||||
|
captcha_code: step1Data.captcha_code
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success(t('auth.forget.password.code_sent'))
|
||||||
|
emailCodeCountdown.value = 60
|
||||||
|
|
||||||
|
emailCodeTimer = setInterval(() => {
|
||||||
|
emailCodeCountdown.value--
|
||||||
|
if (emailCodeCountdown.value <= 0) {
|
||||||
|
clearInterval(emailCodeTimer)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} catch (error) {
|
||||||
|
captcha.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时加载验证码
|
||||||
|
onMounted(() => {
|
||||||
|
captcha.refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下一步:验证邮箱
|
||||||
|
const handleNextStep = async () => {
|
||||||
|
await step1FormRef.value?.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
if (isLoading.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 发送验证码
|
||||||
|
await sendEmailCode({
|
||||||
|
email: step1Data.email,
|
||||||
|
captcha_key: step1Data.captcha_key,
|
||||||
|
captcha_code: step1Data.captcha_code
|
||||||
|
})
|
||||||
|
|
||||||
|
savedEmail.value = step1Data.email
|
||||||
|
verificationSent.value = true
|
||||||
|
currentStep.value = 2
|
||||||
|
|
||||||
|
// 开始倒计时
|
||||||
|
emailCodeCountdown.value = 60
|
||||||
|
emailCodeTimer = setInterval(() => {
|
||||||
|
emailCodeCountdown.value--
|
||||||
|
if (emailCodeCountdown.value <= 0) {
|
||||||
|
clearInterval(emailCodeTimer)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
ElMessage.success(t('auth.forget.password.code_sent_check_email'))
|
||||||
|
} catch (error) {
|
||||||
|
captcha.refresh()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一步
|
||||||
|
const handlePrevStep = () => {
|
||||||
|
currentStep.value = 1
|
||||||
|
verificationSent.value = false
|
||||||
|
if (emailCodeTimer) {
|
||||||
|
clearInterval(emailCodeTimer)
|
||||||
|
emailCodeCountdown.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置密码
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
await step2FormRef.value?.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
if (isLoading.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await forgetPassword({
|
||||||
|
email: savedEmail.value,
|
||||||
|
code: step2Data.code,
|
||||||
|
new_password: step2Data.password,
|
||||||
|
confirm_password: step2Data.confirm_password
|
||||||
|
})
|
||||||
|
ElMessage.success(t('auth.forget.password.reset_success'))
|
||||||
|
emit('typeChange', 'login')
|
||||||
|
} catch (error) {
|
||||||
|
captcha.refresh()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到登录
|
||||||
|
const emit = defineEmits(['typeChange'])
|
||||||
|
const switchToLogin = () => {
|
||||||
|
emit('typeChange', 'login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.forget-password-container {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.dark-mode {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forget-password-form-container {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
width: 100%;
|
||||||
|
padding: 60px 30px;
|
||||||
|
border-radius: var(--rounded-big);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forget-password-title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-family: 'OppoSans-R';
|
||||||
|
}
|
||||||
|
|
||||||
|
.forget-password-tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-tip {
|
||||||
|
background-color: #E8F5E8;
|
||||||
|
color: #2E7D32;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forget-password-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image {
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconify {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 50px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forget-password-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.el-input__inner {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix) {
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prefix-icon {
|
||||||
|
margin-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-code-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-email-code-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 50px;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-step-button,
|
||||||
|
.reset-password-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev-step-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
.prev-step {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
>
|
>
|
||||||
<login v-if="type === 'login' && dialogVisible" @typeChange="typeChange" />
|
<login v-if="type === 'login' && dialogVisible" @typeChange="typeChange" />
|
||||||
<register v-if="type === 'register' && dialogVisible" @typeChange="typeChange" />
|
<register v-if="type === 'register' && dialogVisible" @typeChange="typeChange" />
|
||||||
|
<forget-password v-if="type === 'forget-password' && dialogVisible" @typeChange="typeChange" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import login from "./login.vue";
|
import login from "./login.vue";
|
||||||
import register from "./register.vue";
|
import register from "./register.vue";
|
||||||
|
import forgetPassword from "./forget-password.vue";
|
||||||
|
import { useMemberStore } from '~/stores/member';
|
||||||
|
|
||||||
const type = ref("login");
|
const type = ref("login");
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<Icon
|
<Icon
|
||||||
v-if="isAccountLoginActive"
|
v-if="isAccountLoginActive"
|
||||||
@click="toggleLoginMode"
|
@click="toggleLoginMode"
|
||||||
name="local-qr"
|
icon="ant-design:qrcode-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
size="50px"
|
size="50px"
|
||||||
class="login-toggle-icon"
|
class="login-toggle-icon"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<Icon
|
<Icon
|
||||||
v-else
|
v-else
|
||||||
@click="toggleLoginMode"
|
@click="toggleLoginMode"
|
||||||
name="local-pc"
|
icon="ant-design:desktop-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
size="50px"
|
size="50px"
|
||||||
class="login-toggle-icon"
|
class="login-toggle-icon"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.username"
|
v-model="formData.username"
|
||||||
:placeholder="t('username_placeholder')"
|
:placeholder="t('auth.login.username_placeholder')"
|
||||||
clearable
|
clearable
|
||||||
:inline-message="true"
|
:inline-message="true"
|
||||||
:readonly="isUsernameInputReadonly"
|
:readonly="isUsernameInputReadonly"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-User"
|
icon="ant-design:user-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.password"
|
v-model="formData.password"
|
||||||
:placeholder="t('password_placeholder')"
|
:placeholder="t('auth.login.password_placeholder')"
|
||||||
type="password"
|
type="password"
|
||||||
clearable
|
clearable
|
||||||
show-password
|
show-password
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Lock"
|
icon="ant-design:lock-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -97,13 +97,13 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.mobile"
|
v-model="formData.mobile"
|
||||||
:placeholder="t('mobile_placeholder')"
|
:placeholder="t('auth.login.mobile_placeholder')"
|
||||||
clearable
|
clearable
|
||||||
class="login-input"
|
class="login-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Phone"
|
icon="ant-design:phone-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -116,12 +116,12 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.mobile_code"
|
v-model="formData.mobile_code"
|
||||||
:placeholder="t('code_placeholder')"
|
:placeholder="t('auth.login.code_placeholder')"
|
||||||
class="login-input"
|
class="login-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Key"
|
icon="ant-design:key-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -140,10 +140,19 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 注册链接 -->
|
<!-- 注册链接和忘记密码 -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<el-button type="primary" link @click="switchToRegister" class="register-link">
|
<el-button type="primary" link @click="switchToRegister" class="register-link">
|
||||||
{{ t("no_account") }},{{ t("to_register") }}
|
{{ t("auth.login.no_account") }},{{ t("auth.login.to_register") }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="currentLoginType === 'username'"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="switchToForgetPassword"
|
||||||
|
class="forget-password-link"
|
||||||
|
>
|
||||||
|
{{ t("auth.login.resetpwd") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,7 +165,7 @@
|
|||||||
@click="handleLoginSubmit"
|
@click="handleLoginSubmit"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
>
|
>
|
||||||
{{ isLoading ? t("logining") : t("login") }}
|
{{ isLoading ? t("auth.login.logining") : t("auth.login.login") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,11 +178,11 @@
|
|||||||
></span>
|
></span>
|
||||||
{{ t("agree_tips") }}
|
{{ t("agree_tips") }}
|
||||||
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
|
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
|
||||||
<span class="agreement-link">{{ t("user_agreement") }}</span>
|
<span class="agreement-link">{{ t("auth.register.user_agreement") }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
{{ t("and") }}
|
{{ t("and") }}
|
||||||
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
|
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
|
||||||
<span class="agreement-link">{{ t("privacy_agreement") }}</span>
|
<span class="agreement-link">{{ t("auth.register.privacy_agreement") }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -181,19 +190,19 @@
|
|||||||
|
|
||||||
<!-- 微信扫码登录 -->
|
<!-- 微信扫码登录 -->
|
||||||
<div v-else class="wechat-login-container">
|
<div v-else class="wechat-login-container">
|
||||||
<div class="wechat-login-title">{{ t("wechat_scan_login") }}</div>
|
<div class="wechat-login-title">{{ t("auth.login.wechat_scan_login") }}</div>
|
||||||
<div class="qrcode-container">
|
<div class="qrcode-container">
|
||||||
<div class="qrcode-wrapper">
|
<div class="qrcode-wrapper">
|
||||||
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
|
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
|
||||||
<div v-else class="qrcode-placeholder"></div>
|
<div v-else class="qrcode-placeholder"></div>
|
||||||
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
|
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
|
||||||
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
|
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
|
||||||
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("click_refresh") }}</span>
|
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("auth.login.click_refresh") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wechat-tip">
|
<div class="wechat-tip">
|
||||||
<span class="iconfont icon-weixin1 wechat-icon"></span>
|
<span class="iconfont icon-weixin1 wechat-icon"></span>
|
||||||
<span class="wechat-text">{{ t("wechat_scan_tip") }}</span>
|
<span class="wechat-text">{{ t("auth.login.wechat_scan_tip") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,8 +213,13 @@
|
|||||||
import { ref, reactive, computed, onUnmounted } from "vue";
|
import { ref, reactive, computed, onUnmounted } from "vue";
|
||||||
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
|
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
|
||||||
import useConfigStore from "@/stores/config";
|
import useConfigStore from "@/stores/config";
|
||||||
|
import { useMemberStore } from "@/stores/member";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import type { FormInstance } from "element-plus";
|
import type { FormInstance } from "element-plus";
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
import {t}from "~/composables/lang";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import validate from "~/utils/validate";
|
||||||
|
|
||||||
// Store 实例
|
// Store 实例
|
||||||
const memberStore = useMemberStore();
|
const memberStore = useMemberStore();
|
||||||
@@ -213,7 +227,8 @@ const configStore = useConfigStore();
|
|||||||
|
|
||||||
// 暗黑模式状态
|
// 暗黑模式状态
|
||||||
const isDarkMode = computed(() => {
|
const isDarkMode = computed(() => {
|
||||||
return configStore.isDarkMode;
|
const root = document.querySelector('html')
|
||||||
|
return root?.classList.contains('dark') || false
|
||||||
});
|
});
|
||||||
|
|
||||||
// 协议链接配置
|
// 协议链接配置
|
||||||
@@ -259,10 +274,10 @@ const smsCodeRef = ref<AnyObject | null>(null);
|
|||||||
const availableLoginTypes = computed(() => {
|
const availableLoginTypes = computed(() => {
|
||||||
const types = [];
|
const types = [];
|
||||||
if (configStore.login.is_username) {
|
if (configStore.login.is_username) {
|
||||||
types.push({ type: "username", title: t("username_login") });
|
types.push({ type: "username", title: t("auth.login.username_login") });
|
||||||
}
|
}
|
||||||
if (configStore.login.is_mobile) {
|
if (configStore.login.is_mobile) {
|
||||||
types.push({ type: "mobile", title: t("mobile_login") });
|
types.push({ type: "mobile", title: t("auth.login.mobile_login") });
|
||||||
}
|
}
|
||||||
currentLoginType.value = types[0] ? types[0].type : "";
|
currentLoginType.value = types[0] ? types[0].type : "";
|
||||||
return types;
|
return types;
|
||||||
@@ -271,12 +286,12 @@ const availableLoginTypes = computed(() => {
|
|||||||
const formRules = computed(() => ({
|
const formRules = computed(() => ({
|
||||||
username: {
|
username: {
|
||||||
required: currentLoginType.value === "username",
|
required: currentLoginType.value === "username",
|
||||||
message: t("username_placeholder"),
|
message: t("auth.login.username_placeholder"),
|
||||||
trigger: ["blur", "change"],
|
trigger: ["blur", "change"],
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
required: currentLoginType.value === "username",
|
required: currentLoginType.value === "username",
|
||||||
message: t("password_placeholder"),
|
message: t("auth.login.password_placeholder"),
|
||||||
trigger: ["blur", "change"],
|
trigger: ["blur", "change"],
|
||||||
},
|
},
|
||||||
mobile: [
|
mobile: [
|
||||||
@@ -290,13 +305,13 @@ const formRules = computed(() => ({
|
|||||||
if (currentLoginType.value !== "mobile") return true;
|
if (currentLoginType.value !== "mobile") return true;
|
||||||
return validate.mobile(value);
|
return validate.mobile(value);
|
||||||
},
|
},
|
||||||
message: t("mobile_error"),
|
message: t("auth.login.mobile_error"),
|
||||||
trigger: ["blur"],
|
trigger: ["blur"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
mobile_code: {
|
mobile_code: {
|
||||||
required: currentLoginType.value === "mobile",
|
required: currentLoginType.value === "mobile",
|
||||||
message: t("code_placeholder"),
|
message: t("auth.login.sms_code_placeholder"),
|
||||||
trigger: ["change"],
|
trigger: ["change"],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -307,28 +322,34 @@ onUnmounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 初始化配置
|
// 初始化配置
|
||||||
configStore.getLoginConfig();
|
configStore.getWebGroupConfigs();
|
||||||
|
|
||||||
// 登录模式切换
|
// 登录模式切换
|
||||||
const toggleLoginMode = () => {
|
const toggleLoginMode = () => {
|
||||||
isAccountLoginActive.value = !isAccountLoginActive.value;
|
|
||||||
if (!isAccountLoginActive.value) {
|
if (!isAccountLoginActive.value) {
|
||||||
handleGenerateWechatQrCode();
|
isAccountLoginActive.value = !isAccountLoginActive.value;
|
||||||
} else {
|
|
||||||
clearTimeout(qrCodeTimer);
|
clearTimeout(qrCodeTimer);
|
||||||
|
} else {
|
||||||
|
// 检查是否开启微信登录
|
||||||
|
if (!configStore.login?.is_wechat) {
|
||||||
|
ElMessage.error(t('auth.login.wechat_login_not_available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAccountLoginActive.value = !isAccountLoginActive.value;
|
||||||
|
handleGenerateWechatQrCode();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 微信扫码登录相关方法
|
// 微信扫码登录相关方法
|
||||||
const handleGenerateWechatQrCode = async () => {
|
const handleGenerateWechatQrCode = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await generateWechatQrCode() as any;
|
const data = await generateWechatQrCode() as any;
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
|
wechatQrCode.value.key = data.scene_id;
|
||||||
|
|
||||||
wechatQrCode.value.key = data.key;
|
if (data.qr_code_url) {
|
||||||
|
const qrCodeUrl = await QRCode.toDataURL(data.qr_code_url, {
|
||||||
if (data.url) {
|
|
||||||
const qrCodeUrl = await QRCode.toDataURL(data.url, {
|
|
||||||
errorCorrectionLevel: "L",
|
errorCorrectionLevel: "L",
|
||||||
margin: 0,
|
margin: 0,
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -351,10 +372,9 @@ const handleGenerateWechatQrCode = async () => {
|
|||||||
* 检查微信扫码登录状态
|
* 检查微信扫码登录状态
|
||||||
* @param key - 扫码登录密钥
|
* @param key - 扫码登录密钥
|
||||||
*/
|
*/
|
||||||
const checkScan = (key: string) => {
|
const checkScan = (scene_id: string) => {
|
||||||
checkWechatScanStatus({ key })
|
checkWechatScanStatus({ scene_id })
|
||||||
.then((res: any) => {
|
.then((data: any) => {
|
||||||
const data = res.data;
|
|
||||||
switch (data.status) {
|
switch (data.status) {
|
||||||
case "wait":
|
case "wait":
|
||||||
qrCodeTimer = setTimeout(() => {
|
qrCodeTimer = setTimeout(() => {
|
||||||
@@ -377,12 +397,12 @@ const checkScan = (key: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWechatLoginSuccess = (loginData: any) => {
|
const handleWechatLoginSuccess = (loginData: any) => {
|
||||||
if (!loginData.token) {
|
if (!loginData.access_token) {
|
||||||
useCookie("openId").value = loginData.openid;
|
useCookie("openId").value = loginData.openid;
|
||||||
navigateTo("/auth/bind");
|
navigateTo("/auth/bind");
|
||||||
memberStore.logClose();
|
memberStore.logClose();
|
||||||
} else {
|
} else {
|
||||||
memberStore.setToken(loginData.token);
|
memberStore.setToken(loginData.access_token, loginData.refresh_token || '');
|
||||||
memberStore.logClose();
|
memberStore.logClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -393,7 +413,7 @@ const handleLoginSubmit = async () => {
|
|||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
|
||||||
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
|
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
|
||||||
ElMessage.error(t("is_agree_tips"));
|
ElMessage.error(t("auth.login.is_agree_tips"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,9 +422,9 @@ const handleLoginSubmit = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
|
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
|
||||||
const response = await loginMethod(formData) as any;
|
const data = await loginMethod(formData) as any;
|
||||||
|
|
||||||
await memberStore.setToken(response?.data?.token || "");
|
await memberStore.setToken(data?.access_token || '', data?.refresh_token || '');
|
||||||
memberStore.logClose();
|
memberStore.logClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
@@ -423,6 +443,9 @@ const emit = defineEmits(["typeChange"]);
|
|||||||
const switchToRegister = () => {
|
const switchToRegister = () => {
|
||||||
emit("typeChange", "register");
|
emit("typeChange", "register");
|
||||||
};
|
};
|
||||||
|
const switchToForgetPassword = () => {
|
||||||
|
emit("typeChange", "forget-password");
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -521,10 +544,13 @@ const switchToRegister = () => {
|
|||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.register-link {
|
.register-link,
|
||||||
|
.forget-password-link {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.username"
|
v-model="formData.username"
|
||||||
:placeholder="t('username_placeholder')"
|
:placeholder="t('auth.register.username_placeholder')"
|
||||||
clearable
|
clearable
|
||||||
:inline-message="true"
|
:inline-message="true"
|
||||||
:readonly="realNameInput"
|
:readonly="realNameInput"
|
||||||
@@ -39,7 +39,26 @@
|
|||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-User"
|
icon="ant-design:user-outlined"
|
||||||
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
|
class="input-prefix-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="email" class="form-item">
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.email"
|
||||||
|
:placeholder="t('auth.register.email_placeholder')"
|
||||||
|
clearable
|
||||||
|
class="register-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="ant-design:mail-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -52,7 +71,7 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.password"
|
v-model="formData.password"
|
||||||
:placeholder="t('password_placeholder')"
|
:placeholder="t('auth.register.password_placeholder')"
|
||||||
type="password"
|
type="password"
|
||||||
clearable
|
clearable
|
||||||
show-password
|
show-password
|
||||||
@@ -60,7 +79,7 @@
|
|||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Lock"
|
icon="ant-design:lock-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -72,8 +91,8 @@
|
|||||||
<el-form-item prop="confirmPassword" class="form-item">
|
<el-form-item prop="confirmPassword" class="form-item">
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.confirmPassword"
|
v-model="formData.confirm_password"
|
||||||
:placeholder="t('confirm_password_placeholder')"
|
:placeholder="t('auth.register.confirm_password_placeholder')"
|
||||||
type="password"
|
type="password"
|
||||||
clearable
|
clearable
|
||||||
show-password
|
show-password
|
||||||
@@ -81,7 +100,7 @@
|
|||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Lock"
|
icon="ant-design:lock-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -93,13 +112,13 @@
|
|||||||
<el-form-item prop="captchaCode" class="form-item">
|
<el-form-item prop="captchaCode" class="form-item">
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.captchaCode"
|
v-model="formData.captcha_code"
|
||||||
:placeholder="t('captcha_placeholder')"
|
:placeholder="t('auth.register.captcha_placeholder')"
|
||||||
class="register-input"
|
class="register-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="local-verify"
|
icon="ant-design:safety-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -124,13 +143,13 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.mobile"
|
v-model="formData.mobile"
|
||||||
:placeholder="t('mobile_placeholder')"
|
:placeholder="t('auth.register.mobile_placeholder')"
|
||||||
clearable
|
clearable
|
||||||
class="register-input"
|
class="register-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Phone"
|
icon="ant-design:phone-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -142,13 +161,13 @@
|
|||||||
<el-form-item prop="mobileCode" class="form-item">
|
<el-form-item prop="mobileCode" class="form-item">
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.mobileCode"
|
v-model="formData.mobile_code"
|
||||||
:placeholder="t('code_placeholder')"
|
:placeholder="t('auth.register.code_placeholder')"
|
||||||
class="register-input"
|
class="register-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon
|
<Icon
|
||||||
name="el-icon-Key"
|
icon="ant-design:key-outlined"
|
||||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||||
class="input-prefix-icon"
|
class="input-prefix-icon"
|
||||||
/>
|
/>
|
||||||
@@ -157,7 +176,7 @@
|
|||||||
<sms-code
|
<sms-code
|
||||||
:mobile="formData.mobile"
|
:mobile="formData.mobile"
|
||||||
type="login"
|
type="login"
|
||||||
v-model="formData.mobileKey"
|
v-model="formData.mobile_key"
|
||||||
@click="sendSmsCode"
|
@click="sendSmsCode"
|
||||||
ref="smsCodeRef"
|
ref="smsCodeRef"
|
||||||
></sms-code>
|
></sms-code>
|
||||||
@@ -170,7 +189,7 @@
|
|||||||
<!-- 登录链接 -->
|
<!-- 登录链接 -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<el-button type="primary" link @click="switchToLogin" class="login-link">
|
<el-button type="primary" link @click="switchToLogin" class="login-link">
|
||||||
{{ t('have_account') }},{{ t('to_login') }}
|
{{ t('auth.register.have_account') }},{{ t('auth.register.to_login') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,7 +202,7 @@
|
|||||||
@click="handleRegister"
|
@click="handleRegister"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
>
|
>
|
||||||
{{ isLoading ? t('registering') : t('register') }}
|
{{ isLoading ? t('auth.register.registering') : t('auth.register.register') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,13 +216,13 @@
|
|||||||
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
|
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
|
||||||
@click="toggleAgreement"
|
@click="toggleAgreement"
|
||||||
></span>
|
></span>
|
||||||
{{ t('register_agree_tips') }}
|
{{ t('auth.register.register_agree_tips') }}
|
||||||
<NuxtLink :to="protocolUrls.service" target="_blank">
|
<NuxtLink :to="protocolUrls.service" target="_blank">
|
||||||
<span class="agreement-link">{{ t('user_agreement') }}</span>
|
<span class="agreement-link">{{ t('auth.register.user_agreement') }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
{{ t('and') }}
|
{{ t('auth.register.and') }}
|
||||||
<NuxtLink :to="protocolUrls.privacy" target="_blank">
|
<NuxtLink :to="protocolUrls.privacy" target="_blank">
|
||||||
<span class="agreement-link">{{ t('privacy_agreement') }}</span>
|
<span class="agreement-link">{{ t('auth.register.privacy_agreement') }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -214,8 +233,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { registerUser, registerWithMobile } from '@/api/auth'
|
import { registerUser, registerWithMobile } from '@/api/auth'
|
||||||
import useConfigStore from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import { useMemberStore } from '@/stores/member'
|
||||||
|
import { useCaptcha } from '~/composables/captcha'
|
||||||
import type { FormInstance } from 'element-plus'
|
import type { FormInstance } from 'element-plus'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
import {t} from '~/composables/lang'
|
||||||
|
import validate from '~/utils/validate'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const memberStore = useMemberStore()
|
const memberStore = useMemberStore()
|
||||||
@@ -227,7 +253,7 @@ const isDarkMode = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 初始化配置
|
// 初始化配置
|
||||||
configStore.getLoginConfig()
|
// 配置已在 app.vue 中统一加载,这里不再重复请求
|
||||||
|
|
||||||
// 协议链接配置
|
// 协议链接配置
|
||||||
const protocolUrls = computed(() => {
|
const protocolUrls = computed(() => {
|
||||||
@@ -243,10 +269,10 @@ const currentType = ref('')
|
|||||||
const registerTypes = computed(() => {
|
const registerTypes = computed(() => {
|
||||||
const types = []
|
const types = []
|
||||||
if (configStore.login.is_username) {
|
if (configStore.login.is_username) {
|
||||||
types.push({ type: 'username', title: t('username_register') })
|
types.push({ type: 'username', title: t('auth.register.username_register') })
|
||||||
}
|
}
|
||||||
if (configStore.login.is_mobile && !configStore.login.is_bind_mobile) {
|
if (configStore.login.is_mobile && !configStore.login.is_bind_mobile) {
|
||||||
types.push({ type: 'mobile', title: t('mobile_register') })
|
types.push({ type: 'mobile', title: t('auth.register.mobile_register') })
|
||||||
}
|
}
|
||||||
currentType.value = types[0]?.type || ''
|
currentType.value = types[0]?.type || ''
|
||||||
return types
|
return types
|
||||||
@@ -256,13 +282,14 @@ const registerTypes = computed(() => {
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirm_password: '',
|
||||||
mobile: '',
|
mobile: '',
|
||||||
mobileCode: '',
|
mobile_code: '',
|
||||||
mobileKey: '',
|
mobile_key: '',
|
||||||
captchaKey: '',
|
captcha_key: '',
|
||||||
captchaCode: ''
|
captcha_code: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
@@ -271,27 +298,43 @@ const formRules = computed(() => ({
|
|||||||
username: {
|
username: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: currentType.value === 'username',
|
required: currentType.value === 'username',
|
||||||
message: t('username_placeholder'),
|
message: t('auth.register.username_placeholder'),
|
||||||
trigger: ['blur', 'change'],
|
trigger: ['blur', 'change'],
|
||||||
},
|
},
|
||||||
password: {
|
email: [
|
||||||
type: 'string',
|
|
||||||
required: currentType.value === 'username',
|
|
||||||
message: t('password_placeholder'),
|
|
||||||
trigger: ['blur', 'change']
|
|
||||||
},
|
|
||||||
confirmPassword: [
|
|
||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: currentType.value === 'username',
|
required: currentType.value === 'username',
|
||||||
message: t('confirm_password_placeholder'),
|
message: t('auth.register.email_required'),
|
||||||
|
trigger: ['blur', 'change'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator(rule: any, value: string, callback: any) {
|
||||||
|
if (currentType.value !== 'username') return true
|
||||||
|
return validate.email(value)
|
||||||
|
},
|
||||||
|
message: t('auth.register.email_error'),
|
||||||
|
trigger: ['change', 'blur'],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
required: currentType.value === 'username',
|
||||||
|
message: t('auth.register.password_placeholder'),
|
||||||
|
trigger: ['blur', 'change']
|
||||||
|
},
|
||||||
|
confirm_password: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
required: currentType.value === 'username',
|
||||||
|
message: t('auth.register.confirm_password_placeholder'),
|
||||||
trigger: ['blur', 'change']
|
trigger: ['blur', 'change']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
validator(rule: any, value: string, callback: any) {
|
validator(rule: any, value: string, callback: any) {
|
||||||
return value === formData.password
|
return value === formData.password
|
||||||
},
|
},
|
||||||
message: t('confirm_password_error'),
|
message: t('auth.register.confirm_password_error'),
|
||||||
trigger: ['change', 'blur'],
|
trigger: ['change', 'blur'],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -299,7 +342,7 @@ const formRules = computed(() => ({
|
|||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
||||||
message: t('mobile_placeholder'),
|
message: t('auth.register.mobile_placeholder'),
|
||||||
trigger: ['blur', 'change'],
|
trigger: ['blur', 'change'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -307,20 +350,20 @@ const formRules = computed(() => ({
|
|||||||
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
|
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
|
||||||
return validate.mobile(value)
|
return validate.mobile(value)
|
||||||
},
|
},
|
||||||
message: t('mobile_error'),
|
message: t('auth.register.mobile_error'),
|
||||||
trigger: ['change', 'blur'],
|
trigger: ['change', 'blur'],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
mobileCode: {
|
mobile_code: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
||||||
message: t('code_placeholder'),
|
message: t('auth.register.code_placeholder'),
|
||||||
trigger: ['blur', 'change']
|
trigger: ['blur', 'change']
|
||||||
},
|
},
|
||||||
captchaCode: {
|
captcha_code: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: currentType.value === 'username',
|
required: currentType.value === 'username',
|
||||||
message: t('captcha_placeholder'),
|
message: t('auth.register.captcha_placeholder'),
|
||||||
trigger: ['blur', 'change'],
|
trigger: ['blur', 'change'],
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -338,7 +381,7 @@ const handleRegister = async () => {
|
|||||||
|
|
||||||
// 检查协议同意
|
// 检查协议同意
|
||||||
if (configStore.login.agreement_show && !isAgreeChecked.value) {
|
if (configStore.login.agreement_show && !isAgreeChecked.value) {
|
||||||
ElMessage.error(t('is_agree_tips'))
|
ElMessage.error(t('auth.register.is_agree_tips'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +390,8 @@ const handleRegister = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
|
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
|
||||||
const res = await registerApi(formData)
|
const data = await registerApi(formData) as any
|
||||||
memberStore.setToken(res.data.token)
|
memberStore.setToken(data.access_token, data.refresh_token || '')
|
||||||
memberStore.logClose()
|
memberStore.logClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="configStore.search.keyword_enabled" class="search-box">
|
||||||
|
<form
|
||||||
|
:action="getSearchUrl()"
|
||||||
|
method="get"
|
||||||
|
target="_blank"
|
||||||
|
class="search-form"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
type="text"
|
||||||
|
:name="getInputName()"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="search-input"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="search-button">
|
||||||
|
<Icon icon="ant-design:search-outlined" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
import { useConfigStore } from '~/stores/config'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
placeholder?: string
|
||||||
|
defaultValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: '搜索...',
|
||||||
|
defaultValue: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [keyword: string]
|
||||||
|
input: [keyword: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const searchKeyword = ref(props.defaultValue)
|
||||||
|
|
||||||
|
// 获取搜索URL
|
||||||
|
const getSearchUrl = (): string => {
|
||||||
|
if (configStore.search.keyword) {
|
||||||
|
// 如果配置了关键字,使用关键字搜索
|
||||||
|
return 'https://www.baidu.com/s'
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取输入框name属性
|
||||||
|
const getInputName = (): string => {
|
||||||
|
if (configStore.search.keyword) {
|
||||||
|
return 'wd' // 百度搜索使用wd参数
|
||||||
|
}
|
||||||
|
return 'q'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理输入事件
|
||||||
|
const handleInput = () => {
|
||||||
|
emit('input', searchKeyword.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理提交事件
|
||||||
|
const handleSubmit = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
emit('search', searchKeyword.value)
|
||||||
|
|
||||||
|
// 如果没有配置关键字,阻止默认提交行为
|
||||||
|
if (!configStore.search.keyword) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
// 配置已在 app.vue 或系统初始化时加载
|
||||||
|
// 这里不需要额外调用
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--el-fill-color-blank);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-primary-light-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--el-color-primary-dark-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<!-- SEO Meta 组件 - Nuxt 4.2+ 支持 -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watchEffect } from 'vue'
|
||||||
|
import { useHead, useRoute } from 'nuxt/app'
|
||||||
|
import { useConfigStore } from '~/stores/config'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
// 从路由 meta 中读取页面自定义 SEO 配置
|
||||||
|
const pageSeo = computed(() => ({
|
||||||
|
// 支持 definePageMeta 和路由配置两种方式
|
||||||
|
title: route.meta.title as string || '',
|
||||||
|
description: route.meta.description as string || '',
|
||||||
|
keywords: route.meta.keywords as string || '',
|
||||||
|
image: route.meta.image as string || '',
|
||||||
|
noIndex: route.meta.noIndex as boolean || false,
|
||||||
|
noFollow: route.meta.noFollow as boolean || false
|
||||||
|
}))
|
||||||
|
|
||||||
|
// SEO 配置数据 - 直接从 configStore 读取
|
||||||
|
const seoConfig = computed(() => ({
|
||||||
|
// 基础 SEO 配置
|
||||||
|
title: configStore.siteSetting?.seo_title || 'madong通用快速开发框架',
|
||||||
|
description: configStore.siteSetting?.seo_description || 'madong开源服务平台',
|
||||||
|
keywords: configStore.siteSetting?.seo_keywords || ['madong', 'madong-admin', 'madong工作流'],
|
||||||
|
siteName: configStore.siteSetting?.site_name || 'madong官网',
|
||||||
|
siteUrl: configStore.siteSetting?.site_url || (typeof window !== 'undefined' ? window.location.origin : ''),
|
||||||
|
shareImage: configStore.siteSetting?.share_image || '/images/share-default.png'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 组合 SEO 标题(页面标题优先)
|
||||||
|
const seoTitle = computed(() => {
|
||||||
|
if (pageSeo.value.title) {
|
||||||
|
return `${pageSeo.value.title} - ${seoConfig.value.siteName}`
|
||||||
|
}
|
||||||
|
return seoConfig.value.title
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组合 SEO 描述(页面描述优先)
|
||||||
|
const seoDescription = computed(() => {
|
||||||
|
return pageSeo.value.description || seoConfig.value.description
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组合 SEO 关键词(页面关键词优先)
|
||||||
|
const seoKeywords = computed(() => {
|
||||||
|
const baseKeywords = Array.isArray(seoConfig.value.keywords)
|
||||||
|
? seoConfig.value.keywords.join(',')
|
||||||
|
: seoConfig.value.keywords
|
||||||
|
|
||||||
|
if (pageSeo.value.keywords) {
|
||||||
|
return `${pageSeo.value.keywords},${baseKeywords}`
|
||||||
|
}
|
||||||
|
return baseKeywords
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组合分享图片(页面图片优先)
|
||||||
|
const seoImage = computed(() => {
|
||||||
|
return pageSeo.value.image || seoConfig.value.shareImage
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建完整的 SEO 元数据数组
|
||||||
|
const seoMetaArray = computed(() => {
|
||||||
|
const metaArray = [
|
||||||
|
// 基础 SEO Meta
|
||||||
|
{ name: 'description', content: seoDescription.value },
|
||||||
|
{ name: 'keywords', content: seoKeywords.value },
|
||||||
|
{ name: 'author', content: seoConfig.value.siteName },
|
||||||
|
|
||||||
|
// Open Graph (社交媒体分享)
|
||||||
|
{ property: 'og:title', content: seoTitle.value },
|
||||||
|
{ property: 'og:description', content: seoDescription.value },
|
||||||
|
{ property: 'og:type', content: 'website' },
|
||||||
|
{ property: 'og:url', content: `${seoConfig.value.siteUrl}${route.path}` },
|
||||||
|
{ property: 'og:image', content: seoImage.value },
|
||||||
|
{ property: 'og:site_name', content: seoConfig.value.siteName },
|
||||||
|
|
||||||
|
// Twitter Card
|
||||||
|
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||||
|
{ name: 'twitter:title', content: seoTitle.value },
|
||||||
|
{ name: 'twitter:description', content: seoDescription.value },
|
||||||
|
{ name: 'twitter:image', content: seoImage.value }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 添加 robots meta(如果需要)
|
||||||
|
if (pageSeo.value.noIndex || pageSeo.value.noFollow) {
|
||||||
|
const robotsDirectives = []
|
||||||
|
if (pageSeo.value.noIndex) robotsDirectives.push('noindex')
|
||||||
|
if (pageSeo.value.noFollow) robotsDirectives.push('nofollow')
|
||||||
|
metaArray.push({ name: 'robots', content: robotsDirectives.join(',') })
|
||||||
|
}
|
||||||
|
|
||||||
|
return metaArray
|
||||||
|
})
|
||||||
|
|
||||||
|
// 链接标签数组
|
||||||
|
const linkArray = computed(() => [
|
||||||
|
// Canonical URL
|
||||||
|
{ rel: 'canonical', href: `${seoConfig.value.siteUrl}${route.path}` },
|
||||||
|
// Favicon
|
||||||
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// 直接使用 useHead,它会自动响应式更新
|
||||||
|
const headData = computed(() => ({
|
||||||
|
title: seoTitle.value,
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: 'zh-CN'
|
||||||
|
},
|
||||||
|
meta: seoMetaArray.value,
|
||||||
|
link: linkArray.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
useHead(headData)
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
watchEffect(() => {
|
||||||
|
// console.log('SEO 数据更新:', {
|
||||||
|
// title: seoTitle.value,
|
||||||
|
// description: seoDescription.value,
|
||||||
|
// keywords: seoKeywords.value,
|
||||||
|
// siteSetting: configStore.siteSetting
|
||||||
|
// })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -63,18 +63,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
import { sendSmsVerificationCode } from '@/api/system'
|
import { sendSmsVerificationCode } from '@/api/system'
|
||||||
import type { FormInstance } from 'element-plus'
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
interface SmsCodeProps {
|
interface SmsCodeProps {
|
||||||
mobile: string
|
mobile?: string
|
||||||
|
email?: string
|
||||||
type: string
|
type: string
|
||||||
modelValue: string
|
modelValue: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CaptchaFormData {
|
interface CaptchaFormData {
|
||||||
mobile: string
|
mobile?: string
|
||||||
|
email?: string
|
||||||
captcha_code: string
|
captcha_code: string
|
||||||
captcha_key: string
|
captcha_key: string
|
||||||
type: string
|
type: string
|
||||||
@@ -93,12 +95,21 @@ let countdownTimer: number | undefined = undefined
|
|||||||
|
|
||||||
// 验证码相关
|
// 验证码相关
|
||||||
const captchaForm = reactive<CaptchaFormData>({
|
const captchaForm = reactive<CaptchaFormData>({
|
||||||
mobile: '',
|
mobile: props.mobile || '',
|
||||||
|
email: props.email || '',
|
||||||
captcha_code: '',
|
captcha_code: '',
|
||||||
captcha_key: '',
|
captcha_key: '',
|
||||||
type: props.type
|
type: props.type
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听 props 变化,更新 captchaForm
|
||||||
|
watch(() => props.mobile, (newVal) => {
|
||||||
|
captchaForm.mobile = newVal || ''
|
||||||
|
})
|
||||||
|
watch(() => props.email, (newVal) => {
|
||||||
|
captchaForm.email = newVal || ''
|
||||||
|
})
|
||||||
|
|
||||||
const captchaImage = ref('')
|
const captchaImage = ref('')
|
||||||
const captchaFormRef = ref<FormInstance>()
|
const captchaFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
@@ -126,14 +137,15 @@ buttonText.value = t('get_sms_code')
|
|||||||
|
|
||||||
// 发送短信验证码
|
// 发送短信验证码
|
||||||
const sendSmsCode = async () => {
|
const sendSmsCode = async () => {
|
||||||
captchaForm.mobile = props.mobile
|
captchaForm.mobile = props.mobile || ''
|
||||||
|
captchaForm.email = props.email || ''
|
||||||
|
|
||||||
if (!canSendCode.value) return
|
if (!canSendCode.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
const response = await sendSmsVerificationCode(captchaForm) as any
|
const response = await sendSmsVerificationCode(captchaForm) as any
|
||||||
|
|
||||||
if (response?.code !== -1) {
|
if (response?.code !== -1) {
|
||||||
smsValue.value = response.data.key
|
smsValue.value = response.data.key
|
||||||
startCountdown()
|
startCountdown()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { hasAuth } from '~/plugins/permission'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限检查组合式函数
|
||||||
|
*/
|
||||||
|
export function useAuth() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* 检查用户是否有权限
|
||||||
|
* @param permissions 权限码或权限码数组
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
hasAuth,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否没有权限
|
||||||
|
* @param permissions 权限码或权限码数组
|
||||||
|
* @returns 是否没有权限
|
||||||
|
*/
|
||||||
|
lacksAuth: (permissions: string | string[]) => !hasAuth(permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-10
@@ -1,24 +1,33 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getCaptchaImage } from '@/api/system'
|
import { getCaptchaImage } from '@/api/system'
|
||||||
|
|
||||||
interface formData {
|
interface CaptchaFormData {
|
||||||
captcha_code: string,
|
captcha_code: string
|
||||||
captcha_key: string
|
captcha_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCaptcha(formData: formData) {
|
interface CaptchaResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: {
|
||||||
|
uuid: string
|
||||||
|
base64: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCaptcha(formData: CaptchaFormData) {
|
||||||
const image = ref('')
|
const image = ref('')
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
try {
|
try {
|
||||||
await getCaptchaImage().then((res: any) => {
|
const res = await getCaptchaImage() as CaptchaResponse
|
||||||
if (res.code == 1) {
|
if (res.code === 0 && res.data) {
|
||||||
formData.captcha_key = res.data.captcha_key
|
formData.captcha_key = res.data.uuid
|
||||||
formData.captcha_code = ''
|
formData.captcha_code = ''
|
||||||
image.value = res.data.img.replace(/\r\n/g, '')
|
image.value = res.data.base64.replace(/\r\n/g, '')
|
||||||
}
|
}
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('获取验证码失败:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,3 +36,5 @@ export function useCaptcha(formData: formData) {
|
|||||||
refresh
|
refresh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import Language from '~/utils/language'
|
|
||||||
|
|
||||||
export function t(message: string) {
|
export function t(message: string) {
|
||||||
const i18n = useNuxtApp().$getI18n()
|
const nuxtApp: any = useNuxtApp()
|
||||||
const route = useRoute()
|
const i18nInstance = nuxtApp.$getI18n()
|
||||||
const file = new Language(i18n).getFileKey((route?.meta.plugin || 'app'), route?.path || '')
|
|
||||||
const key = `${file}.${message}`
|
// 直接使用 i18n 的 t 方法,让它自动根据 locale 选择对应的翻译
|
||||||
return i18n.global.t(key) != key ? i18n.global.t(key) : i18n.global.t(message)
|
return i18nInstance.global.t(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"title": "Forgot Password",
|
||||||
|
"tip": "Please enter your email, we will send a verification code to your email",
|
||||||
|
"step2_title": "Set New Password",
|
||||||
|
"step2_tip": "Please enter your new password",
|
||||||
|
"email_placeholder": "Please enter email",
|
||||||
|
"email_error": "Please enter a valid email",
|
||||||
|
"captcha_placeholder": "Please enter image verification code",
|
||||||
|
"email_code_placeholder": "Please enter email verification code",
|
||||||
|
"code_placeholder": "Please enter verification code",
|
||||||
|
"send_code": "Send Code",
|
||||||
|
"code_sent": "Verification code sent",
|
||||||
|
"new_password_placeholder": "Please enter new password",
|
||||||
|
"confirm_password_placeholder": "Please confirm password",
|
||||||
|
"confirm_password_error": "Passwords do not match",
|
||||||
|
"reset": "Reset Password",
|
||||||
|
"resetting": "Resetting",
|
||||||
|
"reset_success": "Password reset successfully",
|
||||||
|
"back_to_login": "Back to login",
|
||||||
|
"next_step": "Next Step",
|
||||||
|
"prev_step": "Previous Step",
|
||||||
|
"verifying": "Verifying",
|
||||||
|
"captcha_alt": "Verification code",
|
||||||
|
"reset_title": "Reset Password",
|
||||||
|
"verification_sent": "Verification code has been sent to {email}",
|
||||||
|
"verification_instruction": "Please enter the verification code to complete password reset",
|
||||||
|
"password_placeholder": "Password",
|
||||||
|
"verification_code_placeholder": "Enter verification code",
|
||||||
|
"resend_code": "Resend",
|
||||||
|
"reset_password": "Reset Password",
|
||||||
|
"email_required": "Please enter email",
|
||||||
|
"email_invalid": "Invalid email format",
|
||||||
|
"captcha_required": "Please enter image verification code",
|
||||||
|
"password_required": "Please enter password",
|
||||||
|
"confirm_password_required": "Please confirm password",
|
||||||
|
"password_mismatch": "Passwords do not match",
|
||||||
|
"verification_code_required": "Please enter verification code",
|
||||||
|
"code_sent_check_email": "Verification code has been sent, please check your email",
|
||||||
|
"login": "Login"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"login": "Login",
|
||||||
|
"logining": "Logging in",
|
||||||
|
"username_placeholder": "Please enter username",
|
||||||
|
"password_placeholder": "Please enter password",
|
||||||
|
"resetpwd": "Forgot Password",
|
||||||
|
"no_account": "Don't have an account",
|
||||||
|
"to_register": "Register",
|
||||||
|
"and": "and",
|
||||||
|
"agree_tips": "By logging in, you agree to",
|
||||||
|
"username_login": "Password Login",
|
||||||
|
"mobile_login": "SMS Code Login",
|
||||||
|
"mobile_placeholder": "Please enter mobile number",
|
||||||
|
"wechat_scan_login": "Scan QR Code Login",
|
||||||
|
"wechat_scan_tips": "Please scan QR code with WeChat to login",
|
||||||
|
"qrcode_generate_failed": "Failed to generate QR code, please refresh and try again",
|
||||||
|
"click_refresh": "Click to refresh",
|
||||||
|
"wechat_scan_tip": "Please scan QR code with WeChat to login",
|
||||||
|
"wechat_login_not_available": "WeChat scan login is not available"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"title": "Register",
|
||||||
|
"username_register": "Username Register",
|
||||||
|
"mobile_register": "Mobile Register",
|
||||||
|
"username_placeholder": "Please enter username",
|
||||||
|
"email_placeholder": "Please enter email",
|
||||||
|
"email_required": "Please enter email",
|
||||||
|
"email_error": "Invalid email format",
|
||||||
|
"password_placeholder": "Please enter password",
|
||||||
|
"confirm_password_placeholder": "Please confirm password",
|
||||||
|
"confirm_password_error": "Passwords do not match",
|
||||||
|
"mobile_placeholder": "Please enter mobile phone",
|
||||||
|
"mobile_error": "Invalid mobile phone format",
|
||||||
|
"code_placeholder": "Please enter verification code",
|
||||||
|
"captcha_placeholder": "Please enter verification code",
|
||||||
|
"register": "Register",
|
||||||
|
"registering": "Registering",
|
||||||
|
"have_account": "Already have an account",
|
||||||
|
"to_login": "Go to login",
|
||||||
|
"register_agree_tips": "Registering means you agree to",
|
||||||
|
"user_agreement": "User Agreement",
|
||||||
|
"privacy_agreement": "Privacy Agreement",
|
||||||
|
"and": "and",
|
||||||
|
"is_agree_tips": "Please read and agree to the User Agreement and Privacy Agreement"
|
||||||
|
}
|
||||||
+40
-2
@@ -1,3 +1,41 @@
|
|||||||
{
|
{
|
||||||
"language": "语言en"
|
"language": "Language",
|
||||||
}
|
"logout_login": "Logout",
|
||||||
|
"member_center": "Member Center",
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"user": "User",
|
||||||
|
"get_sms_code": "Get SMS Code",
|
||||||
|
"sms_code_change_text": "seconds to resend",
|
||||||
|
"captcha_title": "Please complete security verification",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"siteClose": "Site Closed",
|
||||||
|
"setting": {
|
||||||
|
"config": {
|
||||||
|
"common": {
|
||||||
|
"title": "Common Config",
|
||||||
|
"description": "Common Config - Key-Value format with hierarchical data support",
|
||||||
|
"noData": "No Data",
|
||||||
|
"viewData": "View Data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"unknownError": "Unknown Error",
|
||||||
|
"400": "Bad Request",
|
||||||
|
"401": "Please Login Again",
|
||||||
|
"403": "Access Denied",
|
||||||
|
"404": "Request Error",
|
||||||
|
"405": "Method Not Allowed",
|
||||||
|
"408": "Request Timeout",
|
||||||
|
"409": "Request Cross-Origin",
|
||||||
|
"500": "Server Error, Reason:",
|
||||||
|
"501": "Network Not Implemented",
|
||||||
|
"502": "Network Error",
|
||||||
|
"503": "Service Unavailable",
|
||||||
|
"504": "Network Timeout",
|
||||||
|
"505": "HTTP Version Not Supported",
|
||||||
|
"timeout": "Network Request Timeout!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Home"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"page_title": "Balance Record",
|
||||||
|
"current_balance": "Current Balance",
|
||||||
|
"total_transaction": "Total Transaction",
|
||||||
|
"record_id": "Record ID",
|
||||||
|
"type": "Type",
|
||||||
|
"type_income": "Income",
|
||||||
|
"type_expense": "Expense",
|
||||||
|
"amount": "Amount",
|
||||||
|
"description": "Description",
|
||||||
|
"time": "Time",
|
||||||
|
"loading": "Loading",
|
||||||
|
"no_data": "No Data",
|
||||||
|
"recharge": "Recharge",
|
||||||
|
"withdraw": "Withdraw"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"page_title": "Change Password",
|
||||||
|
"current_password": "Current Password",
|
||||||
|
"current_password_placeholder": "Please enter current password",
|
||||||
|
"new_password": "New Password",
|
||||||
|
"new_password_placeholder": "Please enter new password",
|
||||||
|
"confirm_password": "Confirm New Password",
|
||||||
|
"confirm_password_placeholder": "Please confirm new password",
|
||||||
|
"password_error": "Passwords do not match",
|
||||||
|
"submit_button": "Submit",
|
||||||
|
"reset_button": "Reset",
|
||||||
|
"success": "Password changed successfully",
|
||||||
|
"failed": "Password change failed",
|
||||||
|
"validation": {
|
||||||
|
"current_password_required": "Please enter current password",
|
||||||
|
"new_password_required": "Please enter new password",
|
||||||
|
"new_password_min_length": "Password must be at least 6 characters",
|
||||||
|
"confirm_password_required": "Please confirm new password",
|
||||||
|
"confirm_password_mismatch": "Passwords do not match"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"page_title": "Points Record",
|
||||||
|
"current_points": "Current Points",
|
||||||
|
"monthly_points": "This Month Earned",
|
||||||
|
"total_points": "Total Earned",
|
||||||
|
"record_id": "Record ID",
|
||||||
|
"type": "Type",
|
||||||
|
"type_earn": "Earned",
|
||||||
|
"type_deduct": "Deducted",
|
||||||
|
"points": "Points",
|
||||||
|
"reason": "Reason",
|
||||||
|
"time": "Time",
|
||||||
|
"loading": "Loading",
|
||||||
|
"no_data": "No Data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"page_title": "Profile",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "Email",
|
||||||
|
"email_placeholder": "Please enter email",
|
||||||
|
"phone": "Mobile Number",
|
||||||
|
"nickname": "Nickname",
|
||||||
|
"nickname_placeholder": "Please enter nickname",
|
||||||
|
"gender": "Gender",
|
||||||
|
"gender_male": "Male",
|
||||||
|
"gender_female": "Female",
|
||||||
|
"gender_other": "Other",
|
||||||
|
"birthday": "Birthday",
|
||||||
|
"birthday_placeholder": "Please select birthday",
|
||||||
|
"bio": "Bio",
|
||||||
|
"bio_placeholder": "Please enter bio",
|
||||||
|
"save_button": "Save",
|
||||||
|
"reset_button": "Reset",
|
||||||
|
"avatar": "Avatar",
|
||||||
|
"upload_avatar": "Upload Avatar",
|
||||||
|
"update_success": "Profile saved successfully",
|
||||||
|
"update_failed": "Save failed",
|
||||||
|
"validation": {
|
||||||
|
"nickname_required": "Please enter nickname",
|
||||||
|
"email_format": "Please enter correct email address"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"page_title": "User Settings",
|
||||||
|
"account_info": "Account Information",
|
||||||
|
"login_phone": "Login Mobile",
|
||||||
|
"change_phone": "Change Number",
|
||||||
|
"email": "Email",
|
||||||
|
"change_email": "Change Email",
|
||||||
|
"login_password": "Login Password",
|
||||||
|
"change_password": "Change Password",
|
||||||
|
"third_party_bind": "Third Party Account Binding",
|
||||||
|
"bind": "Bind",
|
||||||
|
"bind_time": "Bind Time",
|
||||||
|
"status": "Status",
|
||||||
|
"options": "Options",
|
||||||
|
"unbind": "Unbind",
|
||||||
|
"bind_success": "Binding successful",
|
||||||
|
"unbind_success": "Unbinding successful",
|
||||||
|
"bind_failed": "Binding failed",
|
||||||
|
"unbind_failed": "Unbinding failed",
|
||||||
|
"confirm_unbind": "Are you sure you want to unbind {name}?",
|
||||||
|
"wechat": "WeChat",
|
||||||
|
"qq": "QQ",
|
||||||
|
"weibo": "Weibo",
|
||||||
|
"douyin": "Douyin",
|
||||||
|
"using": "Using",
|
||||||
|
"not_bound": "Not Bound",
|
||||||
|
"phone_bind": "Mobile Number Binding",
|
||||||
|
"phone_bind_dialog": "Change Mobile Number",
|
||||||
|
"old_phone": "Old Mobile Number",
|
||||||
|
"old_phone_placeholder": "Please enter old mobile number",
|
||||||
|
"new_phone": "New Mobile Number",
|
||||||
|
"new_phone_placeholder": "Please enter new mobile number",
|
||||||
|
"verification_code": "Verification Code",
|
||||||
|
"verification_code_placeholder": "Please enter verification code",
|
||||||
|
"get_code": "Get Code",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"email_bind": "Email Binding",
|
||||||
|
"email_bind_dialog": "Change Email",
|
||||||
|
"new_email": "New Email",
|
||||||
|
"new_email_placeholder": "Please enter new email",
|
||||||
|
"qr_bind_title": "Bind {name}",
|
||||||
|
"qr_generating": "Generating QR code...",
|
||||||
|
"qr_expired": "QR code expired",
|
||||||
|
"qr_regenerate": "Regenerate",
|
||||||
|
"qr_success": "Binding successful!",
|
||||||
|
"qr_tip": "Please scan QR code with {name} to bind",
|
||||||
|
"phone_update_success": "Mobile number updated successfully",
|
||||||
|
"phone_update_failed": "Mobile number update failed",
|
||||||
|
"email_update_success": "Email updated successfully",
|
||||||
|
"email_update_failed": "Email update failed",
|
||||||
|
"code_send_success": "Verification code sent successfully",
|
||||||
|
"code_send_failed": "Verification code send failed",
|
||||||
|
"please_enter_phone": "Please enter new mobile number",
|
||||||
|
"please_enter_email": "Please enter email",
|
||||||
|
"please_enter_old_phone": "Please enter old mobile number",
|
||||||
|
"please_enter_code": "Please enter verification code",
|
||||||
|
"confirm_unbind_title": "Confirm",
|
||||||
|
"cancel_unbind": "Unbinding cancelled",
|
||||||
|
"opening_app": "Opening {name}...",
|
||||||
|
"open_app_failed": "Failed to open {name}, please ensure {name} client is installed",
|
||||||
|
"please_bind": "Please click to bind {name}",
|
||||||
|
"qr_generate_failed": "Failed to generate QR code",
|
||||||
|
"validation": {
|
||||||
|
"old_phone_required": "Please enter old mobile number",
|
||||||
|
"new_phone_required": "Please enter new mobile number",
|
||||||
|
"code_required": "Please enter verification code",
|
||||||
|
"email_required": "Please enter email",
|
||||||
|
"new_email_required": "Please enter new email",
|
||||||
|
"new_email_code_required": "Please enter verification code"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"page_title": "Daily Check-in",
|
||||||
|
"sign_rules": "Check-in Rules",
|
||||||
|
"sign_button": "Check in",
|
||||||
|
"signed": "Already Checked in",
|
||||||
|
"consecutive_days": "Consecutive Check-in",
|
||||||
|
"days": "days",
|
||||||
|
"tomorrow_points": "Continue check-in tomorrow to get {points} points~",
|
||||||
|
"sign_success": "Check-in successful! Got {points} points",
|
||||||
|
"sign_failed": "Check-in failed",
|
||||||
|
"get_sign_status_failed": "Failed to get check-in status",
|
||||||
|
"get_sign_calendar_failed": "Failed to get check-in calendar",
|
||||||
|
"rules_content": "Check-in Rules:\n1. Daily check-in can get 1-5 points\n2. The more consecutive check-in days, the more points you get\n3. Consecutive check-in for 7 days can get extra rewards"
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": {
|
|
||||||
"index": "Index",
|
|
||||||
"code": {
|
|
||||||
"code": "Code"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"logining": "登录中",
|
|
||||||
"username_placeholder": "请输入账号",
|
|
||||||
"password_placeholder": "请输入密码",
|
|
||||||
"resetpwd": "忘记密码",
|
|
||||||
"no_account": "还没有账号",
|
|
||||||
"to_register": "去注册",
|
|
||||||
"and": "和",
|
|
||||||
"agree_tips": "登录代表您同意",
|
|
||||||
"username_login": "密码登录",
|
|
||||||
"mobile_login": "验证码登录",
|
|
||||||
"mobile_placeholder": "请输入手机号",
|
|
||||||
"wechat_scan_login": "扫码登录",
|
|
||||||
"wechat_scan_tips": "请使用微信扫描二维码登录",
|
|
||||||
"qrcode_generate_failed": "二维码生成失败,请刷新重试",
|
|
||||||
"click_refresh": "点击刷新",
|
|
||||||
"wechat_scan_tip": "请使用微信扫描二维码登录"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"registering": "注册中",
|
|
||||||
"username_placeholder": "请输入账号",
|
|
||||||
"password_placeholder": "请输入密码",
|
|
||||||
"confirm_password_placeholder": "请再次确认密码",
|
|
||||||
"confirm_password_error": "两次输入的密码不一致",
|
|
||||||
"resetpwd": "忘记密码",
|
|
||||||
"have_account": "已有账号",
|
|
||||||
"to_login": "去登录",
|
|
||||||
"and": "和",
|
|
||||||
"register_agree_tips": "注册代表您同意",
|
|
||||||
"username_register": "账号注册",
|
|
||||||
"mobile_register": "手机号注册",
|
|
||||||
"mobile_placeholder": "请输入手机号"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "忘记密码",
|
||||||
|
"tip": "请输入您的邮箱,我们将发送验证码到您的邮箱",
|
||||||
|
"step2_title": "设置新密码",
|
||||||
|
"step2_tip": "请输入您的新密码",
|
||||||
|
"email_placeholder": "请输入邮箱",
|
||||||
|
"email_error": "请输入正确的邮箱",
|
||||||
|
"captcha_placeholder": "请输入图片验证码",
|
||||||
|
"email_code_placeholder": "请输入邮箱验证码",
|
||||||
|
"code_placeholder": "请输入验证码",
|
||||||
|
"send_code": "发送验证码",
|
||||||
|
"code_sent": "验证码已发送",
|
||||||
|
"new_password_placeholder": "请输入新密码",
|
||||||
|
"confirm_password_placeholder": "请确认新密码",
|
||||||
|
"confirm_password_error": "两次输入的密码不一致",
|
||||||
|
"reset": "重置密码",
|
||||||
|
"resetting": "重置中",
|
||||||
|
"reset_success": "密码重置成功",
|
||||||
|
"back_to_login": "返回登录",
|
||||||
|
"next_step": "下一步",
|
||||||
|
"prev_step": "上一步",
|
||||||
|
"verifying": "验证中",
|
||||||
|
"email_placeholder": "输入邮箱",
|
||||||
|
"captcha_placeholder": "输入图形验证码",
|
||||||
|
"captcha_alt": "验证码",
|
||||||
|
"reset_title": "重置密码",
|
||||||
|
"verification_sent": "验证码已经发送至{email}",
|
||||||
|
"verification_instruction": "请将验证码填写到此表单中完成密码重置",
|
||||||
|
"password_placeholder": "密码",
|
||||||
|
"confirm_password_placeholder": "重复密码",
|
||||||
|
"verification_code_placeholder": "输入验证码",
|
||||||
|
"resend_code": "重新发送",
|
||||||
|
"reset_password": "重置密码",
|
||||||
|
"email_required": "请输入邮箱",
|
||||||
|
"email_invalid": "邮箱格式不正确",
|
||||||
|
"captcha_required": "请输入图形验证码",
|
||||||
|
"password_required": "请输入密码",
|
||||||
|
"confirm_password_required": "请确认密码",
|
||||||
|
"password_mismatch": "两次密码输入不一致",
|
||||||
|
"verification_code_required": "请输入验证码",
|
||||||
|
"code_sent_check_email": "验证码已发送,请查收",
|
||||||
|
"back_to_login": "返回登录",
|
||||||
|
"login": "登录",
|
||||||
|
"next_step": "下一步",
|
||||||
|
"prev_step": "上一步",
|
||||||
|
"code_sent": "验证码已发送"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"login": "登录",
|
||||||
|
"logining": "登录中",
|
||||||
|
"username_placeholder": "请输入账号",
|
||||||
|
"password_placeholder": "请输入密码",
|
||||||
|
"resetpwd": "忘记密码",
|
||||||
|
"no_account": "还没有账号",
|
||||||
|
"to_register": "去注册",
|
||||||
|
"and": "和",
|
||||||
|
"agree_tips": "登录代表您同意",
|
||||||
|
"username_login": "密码登录",
|
||||||
|
"mobile_login": "验证码登录",
|
||||||
|
"mobile_placeholder": "请输入手机号",
|
||||||
|
"wechat_scan_login": "扫码登录",
|
||||||
|
"wechat_scan_tips": "请使用微信扫描二维码登录",
|
||||||
|
"qrcode_generate_failed": "二维码生成失败,请刷新重试",
|
||||||
|
"click_refresh": "点击刷新",
|
||||||
|
"wechat_scan_tip": "请使用微信扫描二维码登录",
|
||||||
|
"wechat_login_not_available": "未开通微信扫描登录"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"title": "注册",
|
||||||
|
"username_register": "用户名注册",
|
||||||
|
"mobile_register": "手机号注册",
|
||||||
|
"username_placeholder": "请输入用户名",
|
||||||
|
"email_placeholder": "请输入邮箱",
|
||||||
|
"email_required": "请输入邮箱",
|
||||||
|
"email_error": "邮箱格式不正确",
|
||||||
|
"password_placeholder": "请输入密码",
|
||||||
|
"confirm_password_placeholder": "请确认密码",
|
||||||
|
"confirm_password_error": "两次输入的密码不一致",
|
||||||
|
"mobile_placeholder": "请输入手机号",
|
||||||
|
"mobile_error": "手机号格式不正确",
|
||||||
|
"code_placeholder": "请输入验证码",
|
||||||
|
"captcha_placeholder": "请输入验证码",
|
||||||
|
"register": "注册",
|
||||||
|
"registering": "注册中",
|
||||||
|
"have_account": "已有账号",
|
||||||
|
"to_login": "去登录",
|
||||||
|
"register_agree_tips": "注册即表示同意",
|
||||||
|
"user_agreement": "用户协议",
|
||||||
|
"privacy_agreement": "隐私协议",
|
||||||
|
"and": "和",
|
||||||
|
"is_agree_tips": "请阅读并同意用户协议和隐私协议"
|
||||||
|
}
|
||||||
+39
-54
@@ -1,56 +1,41 @@
|
|||||||
{
|
{
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"logout_login": "注销登录",
|
"logout_login": "注销登录",
|
||||||
"member_center": "个人中心",
|
"member_center": "个人中心",
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
"register": "注册",
|
"register": "注册",
|
||||||
"getSmsCode": "获取短信验证码",
|
"user": "用户",
|
||||||
"smsCodeChangeText": "秒后重新获取",
|
"get_sms_code": "获取短信验证码",
|
||||||
"captchaTitle": "请先完成安全验证",
|
"sms_code_change_text": "秒后重新获取",
|
||||||
"logining": "登录中",
|
"captcha_title": "请先完成安全验证",
|
||||||
"usernamePlaceholder": "请输入账号",
|
"confirm": "确认",
|
||||||
"passwordPlaceholder": "请输入密码",
|
"cancel": "取消",
|
||||||
"resetpwd": "忘记密码",
|
"siteClose": "站点已关闭",
|
||||||
"noAccount": "还没有账号",
|
"setting": {
|
||||||
"toRegister": "去注册",
|
"config": {
|
||||||
"registering": "注册中",
|
"common": {
|
||||||
"confirmPasswordPlaceholder": "请再次确认密码",
|
"title": "通用配置",
|
||||||
"confirmPasswordError": "两次输入的密码不一致",
|
"description": "通用配置 - Key-Value 形式存储,支持层级数据",
|
||||||
"haveAccount": "已有账号",
|
"noData": "暂无数据",
|
||||||
"toLogin": "去登录",
|
"viewData": "查看数据"
|
||||||
"and": "和",
|
}
|
||||||
"registerAgreeTips": "注册代表您同意",
|
|
||||||
"usernameRegister": "账号注册",
|
|
||||||
"mobileRegister": "手机号注册",
|
|
||||||
"agreeTips": "请阅读并同意",
|
|
||||||
"isAgreeTips":"请先阅读并同意协议",
|
|
||||||
"usernameLogin": "密码登录",
|
|
||||||
"mobileLogin": "验证码登录",
|
|
||||||
"confirm": "确认",
|
|
||||||
"cancel": "取消",
|
|
||||||
"captchaPlaceholder": "请输入验证码",
|
|
||||||
"mobilePlaceholder": "请输入手机号码",
|
|
||||||
"mobileError": "请输入正确的手机号",
|
|
||||||
"codePlaceholder": "请输入手机验证码",
|
|
||||||
"userAgreement": "用户协议",
|
|
||||||
"privacyAgreement": "隐私协议",
|
|
||||||
"protocolNotConfigured": "未配置协议",
|
|
||||||
"siteClose": "站点已关闭",
|
|
||||||
"request": {
|
|
||||||
"unknownError": "未知错误",
|
|
||||||
"400": "错误的请求",
|
|
||||||
"401": "请重新登录",
|
|
||||||
"403": "拒绝访问",
|
|
||||||
"404": "请求错误",
|
|
||||||
"405": "请求方法未允许",
|
|
||||||
"408": "请求超时",
|
|
||||||
"409": "请求跨域",
|
|
||||||
"500": "服务器端出错,错误原因:",
|
|
||||||
"501": "网络未实现",
|
|
||||||
"502": "网络错误",
|
|
||||||
"503": "服务不可用",
|
|
||||||
"504": "网络超时",
|
|
||||||
"505": "http版本不支持该请求",
|
|
||||||
"timeout": "网络请求超时!"
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"request": {
|
||||||
|
"unknownError": "未知错误",
|
||||||
|
"400": "错误的请求",
|
||||||
|
"401": "请重新登录",
|
||||||
|
"403": "拒绝访问",
|
||||||
|
"404": "请求错误",
|
||||||
|
"405": "请求方法未允许",
|
||||||
|
"408": "请求超时",
|
||||||
|
"409": "请求跨域",
|
||||||
|
"500": "服务器端出错,错误原因:",
|
||||||
|
"501": "网络未实现",
|
||||||
|
"502": "网络错误",
|
||||||
|
"503": "服务不可用",
|
||||||
|
"504": "网络超时",
|
||||||
|
"505": "http版本不支持该请求",
|
||||||
|
"timeout": "网络请求超时!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "首页"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"page_title": "余额记录",
|
||||||
|
"current_balance": "当前余额",
|
||||||
|
"total_transaction": "总交易额",
|
||||||
|
"record_id": "记录ID",
|
||||||
|
"type": "类型",
|
||||||
|
"type_income": "收入",
|
||||||
|
"type_expense": "支出",
|
||||||
|
"amount": "金额",
|
||||||
|
"description": "描述",
|
||||||
|
"time": "时间",
|
||||||
|
"loading": "加载中",
|
||||||
|
"no_data": "暂无数据",
|
||||||
|
"recharge": "充值",
|
||||||
|
"withdraw": "提现"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"page_title": "修改密码",
|
||||||
|
"current_password": "当前密码",
|
||||||
|
"current_password_placeholder": "请输入当前密码",
|
||||||
|
"new_password": "新密码",
|
||||||
|
"new_password_placeholder": "请输入新密码",
|
||||||
|
"confirm_password": "确认新密码",
|
||||||
|
"confirm_password_placeholder": "请确认新密码",
|
||||||
|
"password_error": "两次输入的密码不一致",
|
||||||
|
"submit_button": "提交修改",
|
||||||
|
"reset_button": "重置",
|
||||||
|
"success": "密码修改成功",
|
||||||
|
"failed": "密码修改失败",
|
||||||
|
"validation": {
|
||||||
|
"current_password_required": "请输入当前密码",
|
||||||
|
"new_password_required": "请输入新密码",
|
||||||
|
"new_password_min_length": "密码长度不能少于6位",
|
||||||
|
"confirm_password_required": "请确认新密码",
|
||||||
|
"confirm_password_mismatch": "两次输入的密码不一致"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"page_title": "积分记录",
|
||||||
|
"current_points": "当前积分",
|
||||||
|
"monthly_points": "本月获得",
|
||||||
|
"total_points": "总获得",
|
||||||
|
"record_id": "记录ID",
|
||||||
|
"type": "类型",
|
||||||
|
"type_earn": "获得",
|
||||||
|
"type_deduct": "扣除",
|
||||||
|
"points": "积分",
|
||||||
|
"reason": "原因",
|
||||||
|
"time": "时间",
|
||||||
|
"loading": "加载中",
|
||||||
|
"no_data": "暂无数据"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"page_title": "个人资料",
|
||||||
|
"username": "用户名",
|
||||||
|
"email": "邮箱",
|
||||||
|
"email_placeholder": "请输入邮箱",
|
||||||
|
"phone": "手机号",
|
||||||
|
"nickname": "昵称",
|
||||||
|
"nickname_placeholder": "请输入昵称",
|
||||||
|
"gender": "性别",
|
||||||
|
"gender_male": "男",
|
||||||
|
"gender_female": "女",
|
||||||
|
"gender_other": "其他",
|
||||||
|
"birthday": "生日",
|
||||||
|
"birthday_placeholder": "请选择生日",
|
||||||
|
"bio": "个人简介",
|
||||||
|
"bio_placeholder": "请输入个人简介",
|
||||||
|
"save_button": "保存修改",
|
||||||
|
"reset_button": "重置",
|
||||||
|
"avatar": "头像",
|
||||||
|
"upload_avatar": "上传头像",
|
||||||
|
"update_success": "个人资料保存成功",
|
||||||
|
"update_failed": "保存失败",
|
||||||
|
"validation": {
|
||||||
|
"nickname_required": "请输入昵称",
|
||||||
|
"email_format": "请输入正确的邮箱地址"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"page_title": "用户设置",
|
||||||
|
"account_info": "账户信息",
|
||||||
|
"login_phone": "登录手机",
|
||||||
|
"change_phone": "更换号码",
|
||||||
|
"email": "邮箱",
|
||||||
|
"change_email": "修改邮箱",
|
||||||
|
"login_password": "登录密码",
|
||||||
|
"change_password": "修改密码",
|
||||||
|
"third_party_bind": "第三方账号绑定",
|
||||||
|
"bind": "绑定",
|
||||||
|
"bind_time": "绑定时间",
|
||||||
|
"status": "状态",
|
||||||
|
"options": "操作",
|
||||||
|
"unbind": "解绑",
|
||||||
|
"bind_success": "绑定成功",
|
||||||
|
"unbind_success": "解绑成功",
|
||||||
|
"bind_failed": "绑定失败",
|
||||||
|
"unbind_failed": "解绑失败",
|
||||||
|
"confirm_unbind": "确定要解绑{name}吗?",
|
||||||
|
"wechat": "微信",
|
||||||
|
"qq": "QQ",
|
||||||
|
"weibo": "微博",
|
||||||
|
"douyin": "抖音",
|
||||||
|
"using": "使用中",
|
||||||
|
"not_bound": "未绑定",
|
||||||
|
"phone_bind": "手机号绑定",
|
||||||
|
"phone_bind_dialog": "更换手机号码",
|
||||||
|
"old_phone": "旧手机号",
|
||||||
|
"old_phone_placeholder": "请输入旧手机号",
|
||||||
|
"new_phone": "新手机号",
|
||||||
|
"new_phone_placeholder": "请输入新手机号",
|
||||||
|
"verification_code": "验证码",
|
||||||
|
"verification_code_placeholder": "请输入验证码",
|
||||||
|
"get_code": "获取验证码",
|
||||||
|
"confirm": "确定",
|
||||||
|
"cancel": "取消",
|
||||||
|
"email_bind": "邮箱绑定",
|
||||||
|
"email_bind_dialog": "修改邮箱",
|
||||||
|
"new_email": "新邮箱",
|
||||||
|
"new_email_placeholder": "请输入新邮箱",
|
||||||
|
"qr_bind_title": "绑定{name}",
|
||||||
|
"qr_generating": "正在生成二维码...",
|
||||||
|
"qr_expired": "二维码已过期",
|
||||||
|
"qr_regenerate": "重新生成",
|
||||||
|
"qr_success": "绑定成功!",
|
||||||
|
"qr_tip": "请使用{name}扫描二维码进行绑定",
|
||||||
|
"phone_update_success": "手机号更新成功",
|
||||||
|
"phone_update_failed": "手机号更新失败",
|
||||||
|
"email_update_success": "邮箱更新成功",
|
||||||
|
"email_update_failed": "邮箱更新失败",
|
||||||
|
"code_send_success": "验证码发送成功",
|
||||||
|
"code_send_failed": "验证码发送失败",
|
||||||
|
"please_enter_phone": "请输入新手机号",
|
||||||
|
"please_enter_email": "请输入邮箱",
|
||||||
|
"please_enter_old_phone": "请输入旧手机号",
|
||||||
|
"please_enter_code": "请输入验证码",
|
||||||
|
"confirm_unbind_title": "提示",
|
||||||
|
"cancel_unbind": "已取消解绑",
|
||||||
|
"opening_app": "正在唤起{name}...",
|
||||||
|
"open_app_failed": "唤起{name}失败,请确保已安装{name}客户端",
|
||||||
|
"please_bind": "请点击绑定{name}",
|
||||||
|
"qr_generate_failed": "生成二维码失败",
|
||||||
|
"validation": {
|
||||||
|
"old_phone_required": "请输入旧手机号",
|
||||||
|
"new_phone_required": "请输入新手机号",
|
||||||
|
"code_required": "请输入验证码",
|
||||||
|
"email_required": "请输入邮箱",
|
||||||
|
"new_email_required": "请输入新邮箱",
|
||||||
|
"new_email_code_required": "请输入验证码"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"page_title": "每日签到",
|
||||||
|
"sign_rules": "签到规则",
|
||||||
|
"sign_button": "签到",
|
||||||
|
"signed": "已签到",
|
||||||
|
"consecutive_days": "连续签到",
|
||||||
|
"days": "天",
|
||||||
|
"tomorrow_points": "明天继续签到可获得 {points} 积分~",
|
||||||
|
"sign_success": "签到成功!获得{points}积分",
|
||||||
|
"sign_failed": "签到失败",
|
||||||
|
"get_sign_status_failed": "获取签到状态失败",
|
||||||
|
"get_sign_calendar_failed": "获取签到日历失败",
|
||||||
|
"rules_content": "签到规则:\n1. 每日签到可获得1-5积分\n2. 连续签到天数越多,获得积分越多\n3. 连续签到7天可获得额外奖励"
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": {
|
|
||||||
"article": {
|
|
||||||
"list": "文章资讯",
|
|
||||||
"detail": "文章详情"
|
|
||||||
},
|
|
||||||
"index": {
|
|
||||||
"index": "首页"
|
|
||||||
},
|
|
||||||
"app":{
|
|
||||||
"index": "首页",
|
|
||||||
"member":{
|
|
||||||
"center": "个人中心",
|
|
||||||
"point": "我的积分",
|
|
||||||
"balance": "我的余额"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"agreement": "协议",
|
|
||||||
"login": "登录",
|
|
||||||
"register": "注册",
|
|
||||||
"bind": "手机号绑定"
|
|
||||||
},
|
|
||||||
"member": {
|
|
||||||
"index": "欢迎页",
|
|
||||||
"center": "个人中心",
|
|
||||||
"point": "我的积分",
|
|
||||||
"balance": "我的余额"
|
|
||||||
},
|
|
||||||
"site": {
|
|
||||||
"close": "站点已关闭"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<el-header class="layout-header">
|
||||||
|
<layout-header />
|
||||||
|
</el-header>
|
||||||
|
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||||
|
<div class="layout-main">
|
||||||
|
<div class="layout-content">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useTemplateRef, watch, provide } from 'vue';
|
||||||
|
import layoutHeader from "./components/header.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const mainScrollbarRef = useTemplateRef('mainScrollbarRef');
|
||||||
|
|
||||||
|
// 计算滚动区域高度
|
||||||
|
const calcHeight = (headerHeight: number) => {
|
||||||
|
return {
|
||||||
|
height: `calc(100vh - ${headerHeight}px)`,
|
||||||
|
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 路由切换时滚动条滚动至顶部
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
if (!route.meta.disableScrollTo) {
|
||||||
|
mainScrollbarRef.value?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将滚动条的 ref provide 给子级组件
|
||||||
|
provide('mainScrollbarRef', mainScrollbarRef);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.layout-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: var(--ma-bg-color-overlay);
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
|
||||||
|
.layout-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: var(--content-min-height, 500px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局重置 */
|
||||||
|
:global(html, body) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式断点 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.layout-container {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
--content-min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1024px) {
|
||||||
|
.layout-container {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
--content-min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组件样式适配 */
|
||||||
|
.layout-header {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
:deep(.header-logo) {
|
||||||
|
span {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.frontend-header-menu) {
|
||||||
|
background: transparent;
|
||||||
|
.el-menu-item,
|
||||||
|
.el-sub-menu .el-sub-menu__title {
|
||||||
|
&.is-active {
|
||||||
|
color: var(--el-color-primary) !important;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--el-menu-hover-text-color) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面特定样式 */
|
||||||
|
.page-index {
|
||||||
|
.layout-content {
|
||||||
|
/* 首页特定样式 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-member {
|
||||||
|
.layout-content {
|
||||||
|
/* 会员页面特定样式 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式优化 */
|
||||||
|
.main-scrollbar {
|
||||||
|
:deep(.el-scrollbar__thumb) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__track) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式滚动条 */
|
||||||
|
@at-root html.dark {
|
||||||
|
.main-scrollbar {
|
||||||
|
:deep(.el-scrollbar__thumb) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__track) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-index {
|
||||||
|
background: url(~/assets/images/bg-dark.jpg) repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式适配 */
|
||||||
|
@media screen and (max-width: 1024px) {
|
||||||
|
.layout-container {
|
||||||
|
.main-scrollbar {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
padding: 0 15px;
|
||||||
|
--content-min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.layout-container {
|
||||||
|
.main-scrollbar {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
padding: 0 10px;
|
||||||
|
--content-min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height: 650px) {
|
||||||
|
.layout-container {
|
||||||
|
.main-scrollbar {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
--content-min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,38 +11,38 @@
|
|||||||
<el-menu
|
<el-menu
|
||||||
class="member-sidebar__menu"
|
class="member-sidebar__menu"
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
router
|
|
||||||
unique-opened
|
unique-opened
|
||||||
>
|
>
|
||||||
<template v-for="(item, idx) in personalCenterStore.state.user_menus" :key="idx">
|
<div v-for="(item, idx) in systemStore.site.member_menu" :key="idx">
|
||||||
<el-sub-menu
|
<el-sub-menu
|
||||||
v-if="item.children && item.children.length"
|
v-if="item.children && item.children.length && checkMenuShow(item)"
|
||||||
:index="item.id.toString()"
|
:index="item.id.toString()"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<Icon v-if="item.icon" :name="item.icon" size="16" />
|
<Icon v-if="item.icon" :icon="item.icon" size="16" />
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item
|
<div v-for="(menu, index) in item.children.filter(m => m)" :key="index">
|
||||||
v-for="(menu, index) in item.children"
|
<el-menu-item
|
||||||
:key="index"
|
v-if="checkMenuShow(menu)"
|
||||||
:index="menu.path"
|
:index="menu.path"
|
||||||
@click="routerPush(menu)"
|
@click="routerPush(menu)"
|
||||||
>
|
>
|
||||||
<Icon v-if="menu.icon" :name="menu.icon" size="16" />
|
<Icon v-if="menu.icon" :icon="menu.icon" size="16" />
|
||||||
<span>{{ menu.title }}</span>
|
<span>{{ menu.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
</div>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-else
|
v-else-if="checkMenuShow(item)"
|
||||||
:index="item.path"
|
:index="item.path"
|
||||||
@click="routerPush(item)"
|
@click="routerPush(item)"
|
||||||
>
|
>
|
||||||
<Icon v-if="item.icon" :name="item.icon" size="16" />
|
<Icon v-if="item.icon" :icon="item.icon" size="16" />
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</template>
|
</div>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,10 +51,31 @@
|
|||||||
import type { Menus } from '~/stores/interface'
|
import type { Menus } from '~/stores/interface'
|
||||||
// 在组件顶部导入图片
|
// 在组件顶部导入图片
|
||||||
import defaultAvatar from '~/assets/images/default_avatar.png'
|
import defaultAvatar from '~/assets/images/default_avatar.png'
|
||||||
|
import { useSystemStore } from '~/stores/system'
|
||||||
|
import { useMemberStore } from '~/stores/member'
|
||||||
|
import { useRoute, navigateTo } from 'nuxt/app'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { fullUrl } from '~/utils/common'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const memberStore = useMemberStore()
|
const memberStore = useMemberStore()
|
||||||
const personalCenterStore = usePersonalCenterStore()
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
|
const checkMenuShow = (menu: Menus): boolean => {
|
||||||
|
if (!menu) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = menu.meta?.permissions
|
||||||
|
|
||||||
|
if (!permissions || permissions.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = systemStore.checkMenuPermission(menu)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// 当前激活的菜单
|
// 当前激活的菜单
|
||||||
const activeMenu = computed(() => {
|
const activeMenu = computed(() => {
|
||||||
@@ -71,6 +92,27 @@ const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
|
|||||||
return fullUrl(avatarUrl)
|
return fullUrl(avatarUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理菜单点击
|
||||||
|
*/
|
||||||
|
const onClickMenu = (menu: Menus) => {
|
||||||
|
if (systemStore.isDirectory(menu)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemStore.isExternalLink(menu)) {
|
||||||
|
const target = systemStore.getTarget(menu)
|
||||||
|
if (menu.url) {
|
||||||
|
window.open(menu.url, target)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.path) {
|
||||||
|
navigateTo(menu.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 菜单跳转
|
* 菜单跳转
|
||||||
*/
|
*/
|
||||||
@@ -78,7 +120,7 @@ const routerPush = (route: string | Menus) => {
|
|||||||
if (typeof route === 'string') {
|
if (typeof route === 'string') {
|
||||||
navigateTo(route)
|
navigateTo(route)
|
||||||
} else {
|
} else {
|
||||||
onClickMenu(route as any)
|
onClickMenu(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -86,7 +128,7 @@ const routerPush = (route: string | Menus) => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.member-sidebar {
|
.member-sidebar {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
background-color: var(--ba-bg-color-overlay);
|
background-color: var(--ma-bg-color-overlay);
|
||||||
box-shadow: var(--el-box-shadow-light);
|
box-shadow: var(--el-box-shadow-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -124,7 +166,7 @@ const routerPush = (route: string | Menus) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: var(--ba-bg-color-overlay);
|
background-color: var(--ma-bg-color-overlay);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: var(--el-box-shadow);
|
box-shadow: var(--el-box-shadow);
|
||||||
}
|
}
|
||||||
@@ -197,4 +239,10 @@ const routerPush = (route: string | Menus) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.member-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,20 +2,22 @@
|
|||||||
<div class="theme-toggle-content" @click="toggleDark">
|
<div class="theme-toggle-content" @click="toggleDark">
|
||||||
<div class="switch">
|
<div class="switch">
|
||||||
<div class="switch-action">
|
<div class="switch-action">
|
||||||
<Icon name="local-dark" color="#ffffff" size="13px" class="switch-icon dark-icon" />
|
<Icon icon="ant-design:moon-outlined" color="#ffffff" size="13px" class="switch-icon dark-icon" />
|
||||||
<Icon name="local-light" color="#303133" size="13px" class="switch-icon light-icon" />
|
<Icon icon="ant-design:sun-outlined" color="#303133" size="13px" class="switch-icon light-icon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
const color = useColorMode()
|
import { Icon } from '~/components/icon'
|
||||||
|
import { getDark, setDark } from '~/utils/dark'
|
||||||
|
|
||||||
|
const isDark = computed(() => getDark())
|
||||||
|
|
||||||
function toggleDark() {
|
function toggleDark() {
|
||||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
setDark(!isDark.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const color = useColorMode()
|
import { computed } from 'vue'
|
||||||
|
import { getDark, setDark } from '~/utils/dark'
|
||||||
|
|
||||||
|
const isDark = computed(() => getDark())
|
||||||
|
|
||||||
function toggleDark() {
|
function toggleDark() {
|
||||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
setDark(!isDark.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,31 +1,192 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-footer class="md-footer">
|
<el-footer class="md-footer">
|
||||||
<div>
|
<div class="footer-content">
|
||||||
Copyright @ 2020~2026版权所有
|
<!-- 友情链接区域 -->
|
||||||
<a href="http://beian.miit.gov.cn/">189456784215</a>
|
<div v-if="links.length > 0" class="footer-links-section">
|
||||||
|
<h3 class="footer-links-title">友情链接</h3>
|
||||||
|
<div class="footer-links-container">
|
||||||
|
<a
|
||||||
|
v-for="link in links"
|
||||||
|
:key="link.id"
|
||||||
|
:href="link.url"
|
||||||
|
:target="link.target || '_blank'"
|
||||||
|
:title="link.name"
|
||||||
|
class="footer-link"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="link.logo"
|
||||||
|
:src="link.logo"
|
||||||
|
:alt="link.name"
|
||||||
|
class="footer-link-logo"
|
||||||
|
/>
|
||||||
|
<span v-else class="footer-link-text">{{ link.name }}</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</el-footer>
|
</div>
|
||||||
|
|
||||||
|
<!-- 版权区域 -->
|
||||||
|
<div class="footer-copyright">
|
||||||
|
<div class="copyright-text">{{ configStore.siteSetting?.copyright }}</div>
|
||||||
|
<div
|
||||||
|
v-if="configStore.siteSetting?.icp || configStore.siteSetting?.network_security"
|
||||||
|
class="copyright-links"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="configStore.siteSetting?.icp"
|
||||||
|
:href="configStore.siteSetting?.icp_url || 'https://beian.miit.gov.cn/'"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="copyright-link"
|
||||||
|
>
|
||||||
|
{{ configStore.siteSetting?.icp }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="configStore.siteSetting?.network_security"
|
||||||
|
:href="configStore.siteSetting?.network_security_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="copyright-link"
|
||||||
|
>
|
||||||
|
{{ configStore.siteSetting?.network_security }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useConfigStore } from '~/stores/config'
|
||||||
|
import { getLinks } from '~/api/site'
|
||||||
|
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const links = ref([])
|
||||||
|
|
||||||
|
// 初始化加载友情链接
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await getLinks({ type: 'footer' })
|
||||||
|
links.value = response || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取页脚链接失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'LayoutFooter'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
.md-footer {
|
.md-footer {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
max-width: 1450px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 友情链接区域样式
|
||||||
|
.footer-links-section {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
.footer-links-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: var(--el-color-info-light-7);
|
padding: 5px 15px;
|
||||||
a {
|
color: var(--el-text-color-secondary);
|
||||||
color: var(--el-text-color-secondary);
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 768px) {
|
}
|
||||||
a {
|
|
||||||
display: block;
|
.footer-link-logo {
|
||||||
text-align: center;
|
max-width: 120px;
|
||||||
}
|
max-height: 120px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版权区域样式
|
||||||
|
.footer-copyright {
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
.copyright-text {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.copyright-link {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.footer-links-section {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-container {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright-links {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||||
<el-sub-menu v-blur index="language-switch" class="language-switch">
|
<!-- 语言切换 -->
|
||||||
|
<el-sub-menu
|
||||||
|
v-if="configStore.language.is_enabled && availableLanguages.length > 1"
|
||||||
|
index="language-switch"
|
||||||
|
class="language-switch"
|
||||||
|
:key="languageKey"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<Icon v-if="showIcon" name="local-lang" color="var(--el-text-color-primary)" />
|
<Icon v-if="showIcon" icon="ant-design:translation-outlined" color="var(--el-text-color-primary)" />
|
||||||
{{ t("language") }}
|
{{ t("common.language") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
@click="systemStore.setLanguage(item.name)"
|
@click="handleLanguageChange(item.name)"
|
||||||
v-for="item in availableLanguages"
|
v-for="item in availableLanguages"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
:index="'language-switch-' + item.value"
|
:index="'language-switch-' + item.name"
|
||||||
class="language-switch"
|
class="language-switch"
|
||||||
|
:class="{ 'is-active': item.name === currentLanguage }"
|
||||||
|
:title="`item.name: ${item.name}, currentLanguage: ${currentLanguage}`"
|
||||||
>
|
>
|
||||||
{{ item.value }}
|
{{ item.value }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@@ -18,6 +27,7 @@
|
|||||||
|
|
||||||
<!-- 主题切换 -->
|
<!-- 主题切换 -->
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
|
v-if="configStore.theme.is_enabled"
|
||||||
index="theme-switch"
|
index="theme-switch"
|
||||||
class="theme-switch"
|
class="theme-switch"
|
||||||
:class="$attrs.mode + '-theme-switch'"
|
:class="$attrs.mode + '-theme-switch'"
|
||||||
@@ -29,8 +39,6 @@
|
|||||||
<!-- 已登录用户场景 -->
|
<!-- 已登录用户场景 -->
|
||||||
<el-sub-menu
|
<el-sub-menu
|
||||||
v-if="memberStore.info"
|
v-if="memberStore.info"
|
||||||
@click="$attrs.mode == 'vertical' ? '' : navigateTo('/member/profile')"
|
|
||||||
v-blur
|
|
||||||
index="user-box"
|
index="user-box"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -41,63 +49,87 @@
|
|||||||
:src="getAvatarUrl(memberStore?.info?.avatar || '')"
|
:src="getAvatarUrl(memberStore?.info?.avatar || '')"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
{{ memberStore?.info?.nickname || t("user") }}
|
{{ memberStore?.info?.nickname || t("common.user") }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-menu-item @click="navigateTo('/member/profile')" v-blur index="user">
|
<el-menu-item index="user" @click="$emit('menu-click'); navigateTo('/member/profile')">
|
||||||
<Icon
|
<Icon
|
||||||
v-if="showIcon"
|
v-if="showIcon"
|
||||||
name="fa fa-user-circle"
|
icon="ant-design:user-outlined"
|
||||||
color="var(--el-text-color-primary)"
|
color="var(--el-text-color-primary)"
|
||||||
/>
|
/>
|
||||||
{{ t("member_center") }}
|
{{ t("common.member_center") }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<!-- 动态菜单 -->
|
|
||||||
<MenuSub :menus="personalCenterStore.state.nav_user_menus" :show-icon="showIcon" />
|
|
||||||
|
|
||||||
<!-- 会员中心菜单 -->
|
<!-- 会员中心菜单 -->
|
||||||
<MenuSub :menus="personalCenterStore.state.user_menus" :show-icon="showIcon" />
|
<MenuSub :menus="systemStore.site.member_menu" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||||
|
|
||||||
<el-menu-item @click="memberStore.logout()" v-blur index="user-logout">
|
<el-menu-item @click="$emit('menu-click'); memberStore.logout()" index="user-logout">
|
||||||
<Icon
|
<Icon
|
||||||
v-if="showIcon"
|
v-if="showIcon"
|
||||||
name="fa fa-sign-out"
|
icon="ant-design:logout-outlined"
|
||||||
color="var(--el-text-color-primary)"
|
color="var(--el-text-color-primary)"
|
||||||
/>
|
/>
|
||||||
{{ t("logout_login") }}
|
{{ t("common.logout_login") }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
<!-- 未登录场景 -->
|
<!-- 未登录场景 -->
|
||||||
<el-menu-item v-else @click="handleLogin" v-blur index="user">
|
<el-menu-item v-else @click="handleLoginClick" index="user">
|
||||||
<Icon
|
<Icon
|
||||||
v-if="showIcon"
|
v-if="showIcon"
|
||||||
name="fa fa-user-circle"
|
icon="ant-design:user-outlined"
|
||||||
color="var(--el-text-color-primary)"
|
color="var(--el-text-color-primary)"
|
||||||
/>
|
/>
|
||||||
{{ t("login") }}
|
{{ t("common.login") }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<LoadingDialog />
|
<LoadingDialog />
|
||||||
|
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, nextTick, onMounted, onUnmounted, watch } from "vue";
|
||||||
|
import { navigateTo } from "nuxt/app";
|
||||||
import type { Menus } from "~/stores/interface";
|
import type { Menus } from "~/stores/interface";
|
||||||
import defaultAvatar from "~/assets/images/default_avatar.png";
|
import defaultAvatar from "~/assets/images/default_avatar.png";
|
||||||
|
import { fullUrl, getToken } from "~/utils/common";
|
||||||
import DarkToggle from "./dark-toggle.vue";
|
import DarkToggle from "./dark-toggle.vue";
|
||||||
import DarkSwitch from "./dark-switch.vue";
|
import DarkSwitch from "./dark-switch.vue";
|
||||||
import LoadingDialog from "@/components/login-dialog/index.vue";
|
import LoadingDialog from "@/components/login-dialog/index.vue";
|
||||||
import MenuSub from "./menu-sub.vue";
|
import MenuSub from "./menu-sub.vue";
|
||||||
|
import { Icon } from '~/components/icon';
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { useMemberStore } from "~/stores/member";
|
||||||
|
import { useSystemStore } from "~/stores/system";
|
||||||
|
import { useConfigStore } from "~/stores/config";
|
||||||
|
import { t } from "~/composables/lang";
|
||||||
|
import { getDark, setDark } from "~/utils/dark";
|
||||||
|
|
||||||
|
|
||||||
const memberStore = useMemberStore();
|
const memberStore = useMemberStore();
|
||||||
const systemStore = useSystemStore();
|
const systemStore = useSystemStore();
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const availableLanguages = computed(() => systemStore.getLanguages);
|
|
||||||
const currentLanguage = computed(() => systemStore.lang);
|
// 添加响应式的语言 key,用于强制组件重新渲染
|
||||||
const personalCenterStore = usePersonalCenterStore();
|
const languageKey = ref(systemStore.lang);
|
||||||
|
|
||||||
|
// 监听语言变化,更新 languageKey
|
||||||
|
watch(() => systemStore.lang, (newLang, oldLang) => {
|
||||||
|
if (newLang !== oldLang) {
|
||||||
|
languageKey.value = newLang;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const availableLanguages = computed(() => {
|
||||||
|
const languages = systemStore.getLanguages;
|
||||||
|
return languages;
|
||||||
|
});
|
||||||
|
const currentLanguage = computed(() => {
|
||||||
|
// 从 systemStore 中获取语言设置,确保与状态保持同步
|
||||||
|
return systemStore.lang;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -105,9 +137,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showIcon: false,
|
showIcon: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'menu-click': []
|
||||||
|
}>();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
activeMenu: "",
|
activeMenu: "",
|
||||||
});
|
});
|
||||||
@@ -145,6 +181,24 @@ const handleLogin = () => {
|
|||||||
memberStore.logOpen();
|
memberStore.logOpen();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoginClick = () => {
|
||||||
|
handleLogin();
|
||||||
|
$emit('menu-click');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理语言切换
|
||||||
|
* @param lang 语言代码
|
||||||
|
*/
|
||||||
|
const handleLanguageChange = (lang: string) => {
|
||||||
|
const success = systemStore.setLanguage(lang);
|
||||||
|
|
||||||
|
// 强制更新 languageKey,触发组件重新渲染
|
||||||
|
languageKey.value = lang;
|
||||||
|
|
||||||
|
// 不再刷新页面,语言切换应该立即生效
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
||||||
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
||||||
@@ -250,6 +304,8 @@ onUnmounted(() => {
|
|||||||
.theme-switch.is-active,
|
.theme-switch.is-active,
|
||||||
.language-switch.is-active {
|
.language-switch.is-active {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
color: var(--el-menu-active-color) !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
:deep(.el-sub-menu__title) {
|
:deep(.el-sub-menu__title) {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
@@ -258,6 +314,9 @@ onUnmounted(() => {
|
|||||||
.theme-switch {
|
.theme-switch {
|
||||||
--el-menu-hover-bg-color: none;
|
--el-menu-hover-bg-color: none;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.vertical-theme-switch {
|
.vertical-theme-switch {
|
||||||
.theme-toggle-content {
|
.theme-toggle-content {
|
||||||
@@ -267,4 +326,10 @@ onUnmounted(() => {
|
|||||||
.theme-toggle-content {
|
.theme-toggle-content {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 语言切换选中状态
|
||||||
|
.language-switch.is-active {
|
||||||
|
color: var(--el-menu-active-color) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<!-- 中间菜单区域(靠右显示) -->
|
<!-- 中间菜单区域(靠右显示) -->
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
<client-only>
|
<client-only>
|
||||||
<el-scrollbar class="hidden-sm-and-down">
|
<el-scrollbar class="hidden-sm-and-down" style="height: var(--el-header-height);">
|
||||||
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" />
|
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" :show-icon="true" />
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
</client-only>
|
</client-only>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,16 +18,18 @@
|
|||||||
<!-- 右侧功能区域 -->
|
<!-- 右侧功能区域 -->
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<client-only>
|
<client-only>
|
||||||
<el-scrollbar class="hidden-sm-and-down" >
|
<div v-if="isMobile" class="mobile-menu-wrapper">
|
||||||
<HeaderActions class="frontend-header-menu" :ellipsis="false" mode="horizontal" v-if="!isMobile"/>
|
<div
|
||||||
<div
|
v-if="!systemStore.$state.site.menu_expand"
|
||||||
v-if="isMobile && !personalCenter.state.menu_expand"
|
@click="systemStore.toggleMenuExpand(true)"
|
||||||
@click="personalCenter.toggleMenuExpand(true)"
|
class="mobile-menu-toggle mr-2"
|
||||||
class="mobile-menu-toggle mr-2"
|
>
|
||||||
>
|
<Icon icon="ant-design:menu-outlined" color="var(--el-color-primary)" size="20" />
|
||||||
<Icon name="el-icon-Expand" color="var(--el-color-primary)" size="20" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-scrollbar>
|
<el-scrollbar class="hidden-sm-and-down" style="height: var(--el-header-height);">
|
||||||
|
<HeaderActions class="frontend-header-menu" :ellipsis="false" mode="horizontal" v-if="!isMobile"/>
|
||||||
|
</el-scrollbar>
|
||||||
</client-only>
|
</client-only>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
<!-- 移动端抽屉菜单(仅在移动端显示) -->
|
<!-- 移动端抽屉菜单(仅在移动端显示) -->
|
||||||
<MobileDrawer
|
<MobileDrawer
|
||||||
v-if="isMobile"
|
v-if="isMobile"
|
||||||
v-model="personalCenterStore.state.menu_expand"
|
v-model="systemStore.$state.site.menu_expand"
|
||||||
@language-changed="handleLanguageChange"
|
@language-changed="handleLanguageChange"
|
||||||
@logged-out="handleLogout"
|
@logged-out="handleLogout"
|
||||||
/>
|
/>
|
||||||
@@ -47,12 +49,13 @@ import Menu from './menu.vue'
|
|||||||
import Logo from './logo.vue'
|
import Logo from './logo.vue'
|
||||||
import MobileDrawer from './mobile-drawer.vue'
|
import MobileDrawer from './mobile-drawer.vue'
|
||||||
import HeaderActions from './header-actions.vue'
|
import HeaderActions from './header-actions.vue'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
import { useSystemStore } from '~/stores/system'
|
||||||
|
|
||||||
const personalCenterStore = usePersonalCenterStore()
|
|
||||||
|
|
||||||
// 判断是否为移动端
|
// 判断是否为移动端
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
|
const systemStore = useSystemStore()
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
isMobile.value = window.innerWidth <= 768
|
isMobile.value = window.innerWidth <= 768
|
||||||
}
|
}
|
||||||
@@ -68,7 +71,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
const handleLanguageChange = (languageName: string) => {
|
const handleLanguageChange = (languageName: string) => {
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
personalCenter.toggleMenuExpand(false)
|
useSystemStore().toggleMenuExpand(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +86,12 @@ const toggleDarkMode = () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.ma-header {
|
.ma-header {
|
||||||
background-color: var(--ba-bg-color-overlay);
|
background-color: var(--ma-bg-color-overlay);
|
||||||
box-shadow: 0 0 8px rgba(0 0 0 / 8%);
|
/* 暂时注释掉阴影,避免左右两边出现阴影线条 */
|
||||||
|
/* box-shadow: 0 2px 4px rgba(0 0 0 / 8%); */
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -113,6 +118,17 @@ const toggleDarkMode = () => {
|
|||||||
|
|
||||||
.frontend-header-menu {
|
.frontend-header-menu {
|
||||||
height: var(--el-header-height);
|
height: var(--el-header-height);
|
||||||
|
background: transparent;
|
||||||
|
.el-menu-item,
|
||||||
|
.el-sub-menu .el-sub-menu__title {
|
||||||
|
&.is-active {
|
||||||
|
color: var(--el-color-primary) !important;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--el-menu-hover-text-color) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu--horizontal) {
|
:deep(.el-menu--horizontal) {
|
||||||
@@ -126,6 +142,21 @@ const toggleDarkMode = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|
||||||
|
.frontend-header-menu {
|
||||||
|
height: var(--el-header-height);
|
||||||
|
background: transparent;
|
||||||
|
.el-menu-item,
|
||||||
|
.el-sub-menu .el-sub-menu__title {
|
||||||
|
&.is-active {
|
||||||
|
color: var(--el-color-primary) !important;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--el-menu-hover-text-color) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -156,12 +187,20 @@ const toggleDarkMode = () => {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--el-fill-color-light);
|
background-color: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-menu-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
// 响应式设计
|
// 响应式设计
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -171,6 +210,10 @@ const toggleDarkMode = () => {
|
|||||||
.header-center {
|
.header-center {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 414px) {
|
@media screen and (max-width: 414px) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click="handleClick" class="logo-container" :class="{ 'clickable': clickable, [`size-${size}`]: true }">
|
<div @click="handleClick" class="logo-container" :class="{ 'clickable': clickable, [`size-${size}`]: true }">
|
||||||
<img :src="effectiveLogoUrl" :alt="altText" class="logo-image" />
|
<img :src="effectiveLogoUrl" :alt="altText" class="logo-image" />
|
||||||
<span v-if="showText" class="site-name">{{ siteName }}</span>
|
<span v-if="showText" class="site-name">{{ siteName }} </span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<template v-for="(item, idx) in props.menus" :key="idx">
|
<div v-for="(item, idx) in props.menus" :key="idx">
|
||||||
<template v-if="!isEmpty(item.children)">
|
<el-sub-menu v-if="systemStore.hasChildren(item) && checkMenuShow(item)" :index="`column-${item.id}`">
|
||||||
<el-sub-menu @click="onClickSubMenu(item)" v-blur :index="`column-${item.id}`">
|
<template #title>
|
||||||
<template #title>
|
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
|
||||||
<Icon v-if="showIcon && item.icon" :name="item.icon" color="var(--el-text-color-primary)" />
|
{{ item.title }}
|
||||||
{{ item.title }}
|
</template>
|
||||||
</template>
|
<MenuSub :menus="item.children" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||||
<MenuSub :menus="item.children" :show-icon="showIcon" />
|
</el-sub-menu>
|
||||||
</el-sub-menu>
|
<el-menu-item
|
||||||
</template>
|
v-else-if="checkMenuShow(item)"
|
||||||
<template v-else>
|
@click="onClickMenu(item)"
|
||||||
<el-menu-item @click="onClickMenu(item)" v-blur :index="'column-' + item.id" :class="item.name.replace(/[\/]/g, '-')">
|
:index="'column-' + item.id"
|
||||||
<Icon v-if="showIcon && item.icon" :name="item.icon" color="var(--el-text-color-primary)" />
|
:class="item.name.replace(/[\/]/g, '-')"
|
||||||
<template #title>{{ item.title }}</template>
|
>
|
||||||
</el-menu-item>
|
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
|
||||||
</template>
|
<template #title>{{ item.title }}</template>
|
||||||
</template>
|
</el-menu-item>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isEmpty } from 'lodash-es'
|
|
||||||
import type { Menus } from '~/stores/interface'
|
import type { Menus } from '~/stores/interface'
|
||||||
|
import { navigateTo } from 'nuxt/app'
|
||||||
|
import { useSystemStore } from '~/stores/system'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menus: Menus[]
|
menus: Menus[]
|
||||||
@@ -32,13 +35,50 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
showIcon: false,
|
showIcon: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const onClickSubMenu = (menu: Menus) => {
|
const emit = defineEmits<{
|
||||||
/**
|
'menu-click': []
|
||||||
* 1、'/'表示菜单规则的 path 为空
|
}>()
|
||||||
* 2、会员中心菜单目录不需要跳转
|
|
||||||
*/
|
const systemStore = useSystemStore()
|
||||||
if (menu.path == '/' || menu.type == 'menu_dir') return
|
|
||||||
onClickMenu(menu)
|
const checkMenuShow = (menu: Menus): boolean => {
|
||||||
|
if (!menu) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = menu.meta?.permissions
|
||||||
|
|
||||||
|
if (!permissions || permissions.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemStore.checkMenuPermission(menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickMenu = (menu: Menus) => {
|
||||||
|
// 检查权限
|
||||||
|
if (!systemStore.checkMenuPermission(menu)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemStore.isDirectory(menu)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先触发菜单点击事件,关闭弹窗
|
||||||
|
emit('menu-click')
|
||||||
|
|
||||||
|
if (systemStore.isExternalLink(menu)) {
|
||||||
|
const target = systemStore.getTarget(menu)
|
||||||
|
if (menu.url) {
|
||||||
|
window.open(menu.url, target)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.path) {
|
||||||
|
navigateTo(menu.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||||
<el-menu-item @click="handleClick('/')" v-blur index="index">
|
<MenuSub :menus="systemStore.navMenu" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||||
<Icon v-if="props.showIcon" name="fa fa-home" color="var(--el-text-color-primary)" />
|
|
||||||
<template #title>{{ t('主页') }}</template>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<!-- 动态菜单 -->
|
|
||||||
<MenuSub :menus="systemStore.site.head_nav" :show-icon="showIcon" />
|
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
import type { Menus } from '~/stores/interface'
|
import type { Menus } from '~/stores/interface'
|
||||||
|
import { reactive, nextTick, watch } from 'vue'
|
||||||
|
import { useRoute, navigateTo } from 'nuxt/app'
|
||||||
|
import { useSystemStore } from '~/stores/system'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import MenuSub from './menu-sub.vue'
|
import MenuSub from './menu-sub.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showIcon?: boolean
|
showIcon?: boolean
|
||||||
@@ -26,14 +25,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
showIcon: false,
|
showIcon: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'menu-click': []
|
||||||
|
}>()
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
activeMenu: '',
|
activeMenu: '',
|
||||||
switchingLanguage: false,
|
switchingLanguage: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置激活菜单
|
|
||||||
*/
|
|
||||||
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
||||||
if (route.path == '/') return (state.activeMenu = 'index')
|
if (route.path == '/') return (state.activeMenu = 'index')
|
||||||
|
|
||||||
@@ -45,12 +45,8 @@ const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
|
||||||
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
|
||||||
*/
|
|
||||||
const onSelect = (index: string) => {
|
const onSelect = (index: string) => {
|
||||||
if (noNeedActive(systemStore.site.head_nav, index)) {
|
if (noNeedActive(systemStore.headNav, index)) {
|
||||||
const oldActiveMenu = state.activeMenu
|
const oldActiveMenu = state.activeMenu
|
||||||
state.activeMenu = ''
|
state.activeMenu = ''
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -59,11 +55,6 @@ const onSelect = (index: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查一个菜单是否需要激活态
|
|
||||||
* @param menus
|
|
||||||
* @param index
|
|
||||||
*/
|
|
||||||
const noNeedActive = (menus: Menus[], index: string) => {
|
const noNeedActive = (menus: Menus[], index: string) => {
|
||||||
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
|
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
|
||||||
return true
|
return true
|
||||||
@@ -71,53 +62,40 @@ const noNeedActive = (menus: Menus[], index: string) => {
|
|||||||
return isExternalLink(menus, index)
|
return isExternalLink(menus, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查一个菜单是否是外站链接,如果是,不要激活
|
|
||||||
* @param menus
|
|
||||||
* @param index
|
|
||||||
*/
|
|
||||||
const isExternalLink = (menus: Menus[], index: string): boolean => {
|
const isExternalLink = (menus: Menus[], index: string): boolean => {
|
||||||
// 修复:添加空值检查
|
|
||||||
if (!menus || !Array.isArray(menus)) {
|
if (!menus || !Array.isArray(menus)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in menus) {
|
for (const menu of menus) {
|
||||||
// 修复:检查 menus[key] 是否存在
|
if (!menu) continue
|
||||||
if (!menus[key]) continue
|
|
||||||
|
|
||||||
const columnIndex = `column-${menus[key].id}`
|
const columnIndex = `column-${menu.id}`
|
||||||
if (columnIndex == index) {
|
if (columnIndex == index) {
|
||||||
return menus[key].meta?.menu_type == 'link'
|
return systemStore.isExternalLink(menu)
|
||||||
}
|
}
|
||||||
// 修复:添加 children 存在性检查
|
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
|
||||||
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
|
const result = isExternalLink(menu.children, index)
|
||||||
const result = isExternalLink(menus[key].children, index)
|
|
||||||
if (result) return true
|
if (result) return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 递归的搜索菜单 Index
|
|
||||||
*/
|
|
||||||
const searchMenuIndex = (menus: Menus[], route: RouteLocationNormalizedLoaded): number | false => {
|
const searchMenuIndex = (menus: Menus[], route: RouteLocationNormalizedLoaded): number | false => {
|
||||||
// 修复:添加空值检查
|
|
||||||
if (!menus || !Array.isArray(menus)) {
|
if (!menus || !Array.isArray(menus)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let find: boolean | number = false
|
let find: boolean | number = false
|
||||||
for (const key in menus) {
|
for (const menu of menus) {
|
||||||
// 修复:检查 menus[key] 是否存在
|
if (!menu) continue
|
||||||
if (!menus[key]) continue
|
|
||||||
|
|
||||||
if (menus[key].id && menus[key].path == route.fullPath) {
|
if (menu.id && menu.path == route.fullPath) {
|
||||||
return menus[key].id
|
return menu.id
|
||||||
}
|
}
|
||||||
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
|
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
|
||||||
find = searchMenuIndex(menus[key].children, route)
|
find = searchMenuIndex(menu.children, route)
|
||||||
if (find !== false) return find
|
if (find !== false) return find
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,16 +106,12 @@ const handleClick = (path: string) => {
|
|||||||
navigateTo(path)
|
navigateTo(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从动态菜单中-搜索一个菜单
|
|
||||||
*/
|
|
||||||
const findMenus = (route: RouteLocationNormalizedLoaded) => {
|
const findMenus = (route: RouteLocationNormalizedLoaded) => {
|
||||||
// 修复:添加空值检查
|
if (!systemStore.navMenu || !Array.isArray(systemStore.navMenu)) {
|
||||||
if (!systemStore.site.head_nav || !Array.isArray(systemStore.site.head_nav)) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const headNavIndex = searchMenuIndex(systemStore.site.head_nav, route)
|
const headNavIndex = searchMenuIndex(systemStore.navMenu, route)
|
||||||
if (headNavIndex !== false) return headNavIndex
|
if (headNavIndex !== false) return headNavIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +122,6 @@ watch(
|
|||||||
setActiveMenu(route)
|
setActiveMenu(route)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -203,4 +175,4 @@ watch(
|
|||||||
.theme-toggle-content {
|
.theme-toggle-content {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,22 +5,23 @@
|
|||||||
v-model="drawerVisible"
|
v-model="drawerVisible"
|
||||||
:with-header="false"
|
:with-header="false"
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
|
:size="'50%'"
|
||||||
>
|
>
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
<Logo clickable show-text size="small" />
|
<Logo clickable show-text size="small" />
|
||||||
<div @click="closeDrawer" class="drawer-close">
|
<div @click="closeDrawer" class="drawer-close">
|
||||||
<Icon name="el-icon-Close" color="var(--el-color-primary)" size="20" />
|
<Icon icon="ant-design:close-outlined" color="var(--el-color-primary)" size="20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 移动端菜单 -->
|
<!-- 移动端菜单 -->
|
||||||
<div class="mobile-menu-section">
|
<div class="mobile-menu-section">
|
||||||
<Menu :show-icon="true" mode="vertical" />
|
<Menu :show-icon="true" mode="vertical" @menu-click="closeDrawer" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 移动端-会员菜单 -->
|
<!-- 移动端-会员菜单 -->
|
||||||
<div class="mobile-menu-section">
|
<div class="mobile-menu-section">
|
||||||
<HeaderActions :show-icon="true" mode="vertical" />
|
<HeaderActions :show-icon="true" mode="vertical" @menu-click="closeDrawer" />
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
import Menu from './menu.vue'
|
import Menu from './menu.vue'
|
||||||
import Logo from './logo.vue'
|
import Logo from './logo.vue'
|
||||||
import HeaderActions from './header-actions.vue'
|
import HeaderActions from './header-actions.vue'
|
||||||
|
import { Icon } from '~/components/icon'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
|||||||
+123
-57
@@ -3,62 +3,94 @@
|
|||||||
<el-header class="layout-header">
|
<el-header class="layout-header">
|
||||||
<layout-header />
|
<layout-header />
|
||||||
</el-header>
|
</el-header>
|
||||||
<el-main class="layout-main">
|
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||||
<div class="layout-content">
|
<div class="layout-main">
|
||||||
<slot></slot>
|
<div class="layout-content">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<layout-footer />
|
||||||
</div>
|
</div>
|
||||||
<layout-footer />
|
</el-scrollbar>
|
||||||
</el-main>
|
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useTemplateRef, watch, provide, onMounted } from 'vue';
|
||||||
import layoutHeader from "./components/header.vue";
|
import layoutHeader from "./components/header.vue";
|
||||||
import layoutFooter from "./components/footer.vue";
|
import layoutFooter from "./components/footer.vue";
|
||||||
|
import { useMemberStore } from '@/stores/member';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const mainScrollbarRef = useTemplateRef('mainScrollbarRef');
|
||||||
|
const memberStore = useMemberStore();
|
||||||
|
|
||||||
|
// 计算滚动区域高度
|
||||||
|
const calcHeight = (headerHeight: number) => {
|
||||||
|
return {
|
||||||
|
height: `calc(100vh - ${headerHeight}px)`,
|
||||||
|
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 路由切换时滚动条滚动至顶部
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
if (!route.meta.disableScrollTo) {
|
||||||
|
mainScrollbarRef.value?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将滚动条的 ref provide 给子级组件
|
||||||
|
provide('mainScrollbarRef', mainScrollbarRef);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.layout-container {
|
.layout-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.layout-header {
|
.layout-header {
|
||||||
position: fixed;
|
position: relative;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background-color: var(--el-bg-color);
|
background-color: var(--ma-bg-color-overlay);
|
||||||
box-shadow: var(--el-box-shadow-light);
|
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-main {
|
.main-scrollbar {
|
||||||
--el-main-padding: 0 !important;
|
flex: 1;
|
||||||
padding: 0 !important;
|
overflow: hidden;
|
||||||
min-height: calc(100vh - 60px);
|
|
||||||
margin-top: 60px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
.layout-content {
|
.layout-main {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-height: var(--content-min-height, 500px);
|
max-width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
min-height: 100%;
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: hidden;
|
.layout-content {
|
||||||
}
|
flex: 1;
|
||||||
|
min-height: var(--content-min-height, 500px);
|
||||||
.layout-footer {
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-direction: column;
|
||||||
margin-top: auto;
|
width: 100%;
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
max-width: 100%;
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +126,6 @@ import layoutFooter from "./components/footer.vue";
|
|||||||
|
|
||||||
/* 组件样式适配 */
|
/* 组件样式适配 */
|
||||||
.layout-header {
|
.layout-header {
|
||||||
background-color: var(--el-bg-color) !important;
|
|
||||||
box-shadow: var(--el-box-shadow-light) !important;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -146,41 +176,77 @@ import layoutFooter from "./components/footer.vue";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式优化 */
|
||||||
|
.main-scrollbar {
|
||||||
|
:deep(.el-scrollbar__thumb) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__track) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式滚动条 */
|
||||||
|
@at-root html.dark {
|
||||||
|
.main-scrollbar {
|
||||||
|
:deep(.el-scrollbar__thumb) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__track) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-index {
|
||||||
|
background: url(~/assets/images/bg-dark.jpg) repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式适配 */
|
/* 响应式适配 */
|
||||||
@media screen and (max-width: 1024px) {
|
@media screen and (max-width: 1024px) {
|
||||||
.page-index {
|
.layout-container {
|
||||||
.layout-main {
|
.main-scrollbar {
|
||||||
.layout-content {
|
.layout-main {
|
||||||
/* 平板端首页样式 */
|
.layout-content {
|
||||||
|
padding: 0 15px;
|
||||||
|
--content-min-height: 400px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 375px) {
|
@media screen and (max-width: 768px) {
|
||||||
.page-index {
|
.layout-container {
|
||||||
.layout-main {
|
.main-scrollbar {
|
||||||
.layout-content {
|
.layout-main {
|
||||||
/* 小屏手机首页样式 */
|
.layout-content {
|
||||||
|
padding: 0 10px;
|
||||||
|
--content-min-height: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-height: 650px) {
|
@media screen and (max-height: 650px) {
|
||||||
.page-index {
|
.layout-container {
|
||||||
.layout-main {
|
.main-scrollbar {
|
||||||
.layout-content {
|
.layout-main {
|
||||||
/* 矮屏设备样式 */
|
.layout-content {
|
||||||
|
--content-min-height: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式 */
|
|
||||||
@at-root html.dark {
|
|
||||||
.page-index {
|
|
||||||
background: url(~/assets/images/bg-dark.jpg) repeat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
+195
-12
@@ -1,29 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="member-layout">
|
<div class="member-layout">
|
||||||
<el-container class="is-vertical">
|
<el-container class="layout-container">
|
||||||
<Header />
|
<el-header class="layout-header">
|
||||||
|
<Header />
|
||||||
|
</el-header>
|
||||||
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||||
<el-row class="layouts-main" justify="center">
|
<div class="layout-main">
|
||||||
<el-col class="user-layouts" :span="16" :xs="24">
|
<div class="layout-content">
|
||||||
<Aside class="hidden-sm-and-down" />
|
<el-row class="layouts-main" justify="center">
|
||||||
<el-main class="layout-main">
|
<el-col class="user-layouts" :span="16" :xs="24">
|
||||||
<slot />
|
<client-only>
|
||||||
</el-main>
|
<Aside class="hidden-sm-and-down" />
|
||||||
</el-col>
|
</client-only>
|
||||||
</el-row>
|
<el-main class="layout-main-content">
|
||||||
<Footer />
|
<slot />
|
||||||
|
</el-main>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
</el-container>
|
</el-container>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useTemplateRef, watch, provide } from 'vue'
|
||||||
import Header from './components/header.vue'
|
import Header from './components/header.vue'
|
||||||
import Aside from './components/aside.vue'
|
import Aside from './components/aside.vue'
|
||||||
import Footer from './components/footer.vue'
|
import Footer from './components/footer.vue'
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const mainScrollbarRef = useTemplateRef('mainScrollbarRef')
|
const mainScrollbarRef = useTemplateRef('mainScrollbarRef')
|
||||||
|
|
||||||
|
// 计算滚动区域高度
|
||||||
|
const calcHeight = (headerHeight: number) => {
|
||||||
|
return {
|
||||||
|
height: `calc(100vh - ${headerHeight}px)`,
|
||||||
|
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 路由切换时滚动条滚动至顶部
|
// 路由切换时滚动条滚动至顶部
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
@@ -39,13 +56,179 @@ provide('mainScrollbarRef', mainScrollbarRef)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.layout-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: var(--ma-bg-color-overlay);
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
|
||||||
|
.layout-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: var(--content-min-height, 500px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.user-layouts {
|
.user-layouts {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.layout-main {
|
.layout-main-content {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
background-color: var(--ma-bg-color-overlay);
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.layout-main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main-content-mobile {
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background-color: var(--ma-bg-color-overlay);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组件样式适配 */
|
||||||
|
.layout-header {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
:deep(.header-logo) {
|
||||||
|
span {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.frontend-header-menu) {
|
||||||
|
background: transparent;
|
||||||
|
.el-menu-item,
|
||||||
|
.el-sub-menu .el-sub-menu__title {
|
||||||
|
&.is-active {
|
||||||
|
color: var(--el-color-primary) !important;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--el-menu-hover-text-color) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-footer {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background-color: transparent !important;
|
||||||
|
position: relative;
|
||||||
|
bottom: auto;
|
||||||
|
width: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式断点 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.layout-container {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
--content-min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-layouts-mobile {
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1024px) {
|
||||||
|
.layout-container {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
--content-min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式适配 */
|
||||||
|
@media screen and (max-width: 1024px) {
|
||||||
|
.layout-container {
|
||||||
|
.main-scrollbar {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
padding: 0 15px;
|
||||||
|
--content-min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.layout-container {
|
||||||
|
.main-scrollbar {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
padding: 0 10px;
|
||||||
|
--content-min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height: 650px) {
|
||||||
|
.layout-container {
|
||||||
|
.main-scrollbar {
|
||||||
|
.layout-main {
|
||||||
|
.layout-content {
|
||||||
|
--content-min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineNuxtRouteMiddleware, useMemberStore, useSystemStore, navigateTo } from '#imports'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限中间件
|
||||||
|
* 检查路由是否需要权限,如果需要则验证用户权限
|
||||||
|
*/
|
||||||
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||||||
|
const memberStore = useMemberStore()
|
||||||
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
|
// 获取路由的权限配置
|
||||||
|
const routePermissions = to.meta.permissions
|
||||||
|
|
||||||
|
// 如果路由没有配置权限,直接通过
|
||||||
|
if (!routePermissions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否登录
|
||||||
|
if (!memberStore.info) {
|
||||||
|
// 如果用户未登录,打开登录弹窗
|
||||||
|
memberStore.logOpen()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否有权限访问
|
||||||
|
const hasPermission = systemStore.checkMenuPermission({
|
||||||
|
id: 0,
|
||||||
|
meta: {
|
||||||
|
permissions: routePermissions
|
||||||
|
}
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
// 如果用户没有权限,跳转到个人资料页面
|
||||||
|
return navigateTo('/member/profile', {
|
||||||
|
replace: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// 导入权限中间件
|
||||||
|
import authMiddleware from './auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局中间件
|
||||||
|
* 应用于所有路由
|
||||||
|
*/
|
||||||
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||||||
|
// 运行权限中间件
|
||||||
|
return authMiddleware(to, from)
|
||||||
|
})
|
||||||
+82
-11
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
</div>
|
||||||
<!-- 轮播图区域 -->
|
<!-- 轮播图区域 -->
|
||||||
<section class="hero-section">
|
<section class="hero-section">
|
||||||
<el-carousel height="500px" arrow="never" indicator-position="none">
|
<el-carousel height="500px" arrow="never" indicator-position="none">
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
<h3 class="feature-title">官方教程</h3>
|
<h3 class="feature-title">官方教程</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="feature-description">详尽细致的逐步官方教程,帮助您系统全面地接触Madong,建议在使用前阅读。</p>
|
<p class="feature-description">详尽细致的逐步官方教程,帮助您系统全面地接触Madong,建议在使用前阅读。</p>
|
||||||
<NuxtLink to="https://madong.tech/doc" target="_blank" class="feature-link">
|
<NuxtLink to="https://madong.tech/docs" target="_blank" class="feature-link">
|
||||||
<el-button type="primary" class="w-full">
|
<el-button type="primary" class="w-full">
|
||||||
<span>前往教程</span>
|
<span>前往教程</span>
|
||||||
<el-icon class="ml-2"><ArrowRight /></el-icon>
|
<el-icon class="ml-2"><ArrowRight /></el-icon>
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
<h3 class="feature-title">问答社区</h3>
|
<h3 class="feature-title">问答社区</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="feature-description">浏览其他用户的问题和解答,获取使用技巧,遇到问题也可以随时提问交流。</p>
|
<p class="feature-description">浏览其他用户的问题和解答,获取使用技巧,遇到问题也可以随时提问交流。</p>
|
||||||
<NuxtLink to="https://madong.tech/question" target="_blank" class="feature-link">
|
<NuxtLink to="https://madong.tech/ask" target="_blank" class="feature-link">
|
||||||
<el-button type="primary" class="w-full">
|
<el-button type="primary" class="w-full">
|
||||||
<span>前往问答社区</span>
|
<span>前往问答社区</span>
|
||||||
<el-icon class="ml-2"><ArrowRight /></el-icon>
|
<el-icon class="ml-2"><ArrowRight /></el-icon>
|
||||||
@@ -92,11 +94,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 右侧悬浮广告 -->
|
||||||
|
<Advertisement
|
||||||
|
type="simple"
|
||||||
|
:offset="0"
|
||||||
|
:limit="5"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ArrowRight, ChatDotRound } from '@element-plus/icons-vue'
|
import { ArrowRight, ChatDotRound } from '@element-plus/icons-vue'
|
||||||
|
import Advertisement from '~/components/advertisement'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -128,6 +138,48 @@ import { ArrowRight, ChatDotRound } from '@element-plus/icons-vue'
|
|||||||
padding: 60px 0 200px;
|
padding: 60px 0 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ad-section {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, #f8f9ff 0%, #fff5f7 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// 装饰性背景
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.05) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 50%, rgba(118, 75, 162, 0.05) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 40px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-section-top {
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-section-middle {
|
||||||
|
padding: 48px 0;
|
||||||
|
margin: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-section-bottom {
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.content-container {
|
.content-container {
|
||||||
max-width: 1400px; // 增加整体宽度以容纳4个容器
|
max-width: 1400px; // 增加整体宽度以容纳4个容器
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -249,47 +301,66 @@ import { ArrowRight, ChatDotRound } from '@element-plus/icons-vue'
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.features-grid {
|
||||||
grid-template-columns: 1fr; // 移动端显示1列
|
grid-template-columns: 1fr; // 移动端显示1列
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-section .hero-background {
|
.hero-section .hero-background {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
padding: 40px 0 120px;
|
padding: 40px 0 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-header {
|
.feature-header {
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-title {
|
.feature-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-description {
|
.feature-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
min-height: auto; // 移动端取消固定高度
|
min-height: auto; // 移动端取消固定高度
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ad-section {
|
||||||
|
.ad-container {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-section-top {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-section-middle {
|
||||||
|
padding: 32px 0;
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ad-section-bottom {
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超大屏幕适配 (1400px以上)
|
// 超大屏幕适配 (1400px以上)
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-balance">
|
||||||
|
<h2 class="page-title">{{ t('member.balance.page_title') }}</h2>
|
||||||
|
<el-card class="balance-card">
|
||||||
|
<div class="balance-summary">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<div class="balance-item">
|
||||||
|
<div class="balance-label">{{ t('member.balance.current_balance') }}</div>
|
||||||
|
<div class="balance-value">{{ currentBalance }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<div class="balance-item">
|
||||||
|
<div class="balance-label">{{ t('member.balance.total_transaction') }}</div>
|
||||||
|
<div class="balance-value">{{ totalTransaction }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
<div class="balance-list">
|
||||||
|
<el-table :data="balanceRecords" style="width: 100%">
|
||||||
|
<el-table-column prop="id" :label="t('member.balance.record_id')" width="100" />
|
||||||
|
<el-table-column prop="type" :label="t('member.balance.type')" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.type === 'income' ? 'success' : 'danger'">
|
||||||
|
{{ scope.row.type === 'income' ? t('member.balance.type_income') : t('member.balance.type_expense') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="amount" :label="t('member.balance.amount')" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<span :class="scope.row.type === 'income' ? 'text-success' : 'text-danger'">
|
||||||
|
{{ scope.row.type === 'income' ? '+' : '-' }}{{ scope.row.amount.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" :label="t('member.balance.description')" />
|
||||||
|
<el-table-column prop="created_at" :label="t('member.balance.time')" width="180" />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
|
||||||
|
// 余额概览数据
|
||||||
|
const currentBalance = ref(8888.88)
|
||||||
|
const totalTransaction = ref(128888.88)
|
||||||
|
|
||||||
|
// 分页数据
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(80)
|
||||||
|
|
||||||
|
// 余额记录数据
|
||||||
|
const balanceRecords = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'income',
|
||||||
|
amount: 1000.00,
|
||||||
|
description: '充值',
|
||||||
|
created_at: '2026-01-30 10:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'expense',
|
||||||
|
amount: 199.99,
|
||||||
|
description: '购买商品',
|
||||||
|
created_at: '2026-01-29 15:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'income',
|
||||||
|
amount: 500.00,
|
||||||
|
description: '提现',
|
||||||
|
created_at: '2026-01-28 09:15:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: 'expense',
|
||||||
|
amount: 99.00,
|
||||||
|
description: '服务费用',
|
||||||
|
created_at: '2026-01-27 14:20:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
type: 'income',
|
||||||
|
amount: 200.00,
|
||||||
|
description: '退款',
|
||||||
|
created_at: '2026-01-26 11:45:00'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// 分页方法
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
// 这里应该重新获取数据
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (current: number) => {
|
||||||
|
currentPage.value = current
|
||||||
|
// 这里应该重新获取数据
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-balance {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-summary {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-password">
|
||||||
|
<h2 class="page-title">{{ t('member.password.page_title') }}</h2>
|
||||||
|
<el-card class="password-card">
|
||||||
|
<el-form :model="passwordForm" :rules="rules" ref="passwordFormRef" label-width="120px">
|
||||||
|
<el-form-item :label="t('member.password.current_password')" prop="oldPassword">
|
||||||
|
<el-input v-model="passwordForm.oldPassword" type="password" :placeholder="t('member.password.current_password_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.password.new_password')" prop="newPassword">
|
||||||
|
<el-input v-model="passwordForm.newPassword" type="password" :placeholder="t('member.password.new_password_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.password.confirm_password')" prop="confirmPassword">
|
||||||
|
<el-input v-model="passwordForm.confirmPassword" type="password" :placeholder="t('member.password.confirm_password_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="form-actions">
|
||||||
|
<el-button type="primary" @click="submitForm" class="submit-btn" :loading="loading">{{ t('member.password.submit_button') }}</el-button>
|
||||||
|
<el-button @click="resetForm" class="reset-btn">{{ t('member.password.reset_button') }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { changePassword } from '~/api/member'
|
||||||
|
|
||||||
|
const passwordFormRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = reactive<FormRules>({
|
||||||
|
oldPassword: [
|
||||||
|
{ required: true, message: t('member.password.validation.current_password_required'), trigger: 'blur' }
|
||||||
|
],
|
||||||
|
newPassword: [
|
||||||
|
{ required: true, message: t('member.password.validation.new_password_required'), trigger: 'blur' },
|
||||||
|
{ min: 6, message: t('member.password.validation.new_password_min_length'), trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: t('member.password.validation.confirm_password_required'), trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
if (value !== passwordForm.newPassword) {
|
||||||
|
callback(new Error(t('member.password.validation.confirm_password_mismatch')))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!passwordFormRef.value) return
|
||||||
|
await passwordFormRef.value.validate(async (valid, fields) => {
|
||||||
|
if (valid) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await changePassword({
|
||||||
|
old_password: passwordForm.oldPassword,
|
||||||
|
new_password: passwordForm.newPassword
|
||||||
|
})
|
||||||
|
ElMessage.success(t('member.password.success'))
|
||||||
|
resetForm()
|
||||||
|
}finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('验证失败', fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
passwordFormRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-password {
|
||||||
|
padding: 20px;
|
||||||
|
/* min-height: calc(100vh - 120px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-card {
|
||||||
|
max-width: 600px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
/* background-color: #ffffff; */
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化表单标签样式 */
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化输入框样式 - 移除边框 */
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* 优化按钮样式 */
|
||||||
|
:deep(.el-button) {
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button--primary) {
|
||||||
|
background-color: #409eff;
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button--primary:hover) {
|
||||||
|
background-color: #66b1ff;
|
||||||
|
border-color: #66b1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button--default) {
|
||||||
|
border-color: #dcdfe6;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button--default:hover) {
|
||||||
|
border-color: #c0c4cc;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-points">
|
||||||
|
<h2 class="page-title">{{ t('member.points.page_title') }}</h2>
|
||||||
|
<el-card class="points-card">
|
||||||
|
<div class="points-summary">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="points-item">
|
||||||
|
<div class="points-label">{{ t('member.points.current_points') }}</div>
|
||||||
|
<div class="points-value">{{ pointsTotal.current_points }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="points-item">
|
||||||
|
<div class="points-label">{{ t('member.points.monthly_points') }}</div>
|
||||||
|
<div class="points-value">{{ pointsTotal.monthly_points }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="points-item">
|
||||||
|
<div class="points-label">{{ t('member.points.total_points') }}</div>
|
||||||
|
<div class="points-value">{{ pointsTotal.total_points }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
<div class="points-list">
|
||||||
|
<el-table :data="pointsRecords" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" :label="t('member.points.record_id')" width="200" />
|
||||||
|
<el-table-column prop="type" :label="t('member.points.type')" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.type === 1 ? 'success' : 'danger'">
|
||||||
|
{{ scope.row.type === 1 ? t('member.points.type_earn') : t('member.points.type_deduct') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="points" :label="t('member.points.points')" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<span :class="scope.row.type === 1 ? 'text-success' : 'text-danger'">
|
||||||
|
{{ scope.row.type === 1 ? '+' : '-' }}{{ scope.row.points }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="remark" :label="t('member.points.reason')" />
|
||||||
|
<el-table-column prop="created_at" :label="t('member.points.time')" width="180">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ timeFormat(scope.row.created_at, 'yyyy-mm-dd hh:MM:ss') }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { getPointTransactions, getMemberPointsTotal } from '~/api/member'
|
||||||
|
import { timeFormat } from '~/utils/common'
|
||||||
|
|
||||||
|
// 积分概览数据
|
||||||
|
const pointsTotal = reactive({
|
||||||
|
current_points: 0,
|
||||||
|
monthly_points: 0,
|
||||||
|
total_points: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分页数据
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 积分记录数据
|
||||||
|
const pointsRecords = ref([])
|
||||||
|
|
||||||
|
// 获取积分总额
|
||||||
|
const fetchPointsTotal = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await getMemberPointsTotal()
|
||||||
|
if (res) {
|
||||||
|
pointsTotal.current_points = res.current_points || 0
|
||||||
|
pointsTotal.monthly_points = res.monthly_points || 0
|
||||||
|
pointsTotal.total_points = res.total_points || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取积分总额失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取积分记录
|
||||||
|
const fetchPointsRecords = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await getPointTransactions({
|
||||||
|
page: currentPage.value,
|
||||||
|
limit: pageSize.value
|
||||||
|
})
|
||||||
|
if (res) {
|
||||||
|
pointsRecords.value = res.items || []
|
||||||
|
total.value = res.total || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取积分记录失败', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页方法
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
fetchPointsRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (current: number) => {
|
||||||
|
currentPage.value = current
|
||||||
|
fetchPointsRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPointsTotal()
|
||||||
|
fetchPointsRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-points {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
/* background-color: #ffffff; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-summary {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-profile">
|
||||||
|
<h2 class="page-title">{{ t('member.profile.page_title') }}</h2>
|
||||||
|
<el-form
|
||||||
|
:model="profileForm"
|
||||||
|
:rules="rules"
|
||||||
|
ref="profileFormRef"
|
||||||
|
label-width="120px"
|
||||||
|
>
|
||||||
|
<el-form-item :label="t('member.profile.username')">
|
||||||
|
<el-input v-model="profileForm.username" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.profile.email')" prop="email">
|
||||||
|
<el-input
|
||||||
|
v-model="profileForm.email"
|
||||||
|
type="email"
|
||||||
|
:placeholder="t('member.profile.email_placeholder')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.profile.phone')">
|
||||||
|
<el-input v-model="profileForm.phone" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.profile.nickname')" prop="nickname">
|
||||||
|
<el-input v-model="profileForm.nickname" :placeholder="t('member.profile.nickname_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.profile.gender')">
|
||||||
|
<el-radio-group v-model="profileForm.gender">
|
||||||
|
<el-radio label="male">{{ t('member.profile.gender_male') }}</el-radio>
|
||||||
|
<el-radio label="female">{{ t('member.profile.gender_female') }}</el-radio>
|
||||||
|
<el-radio label="other">{{ t('member.profile.gender_other') }}</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.profile.birthday')">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="profileForm.birthday"
|
||||||
|
type="date"
|
||||||
|
:placeholder="t('member.profile.birthday_placeholder')"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.profile.bio')">
|
||||||
|
<el-input
|
||||||
|
v-model="profileForm.bio"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:placeholder="t('member.profile.bio_placeholder')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="form-actions">
|
||||||
|
<el-button type="primary" @click="submitProfileForm" class="submit-btn"
|
||||||
|
>{{ t('member.profile.save_button') }}</el-button
|
||||||
|
>
|
||||||
|
<el-button @click="resetProfileForm" class="reset-btn">{{ t('member.profile.reset_button') }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from "vue";
|
||||||
|
import type { FormInstance, FormRules } from "element-plus";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { updateMemberInfo } from "~/api/member";
|
||||||
|
import { useMemberStore } from "~/stores/member";
|
||||||
|
|
||||||
|
const memberStore = useMemberStore();
|
||||||
|
const profileFormRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
const profileForm = reactive({
|
||||||
|
username: "",
|
||||||
|
nickname: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
gender: "male",
|
||||||
|
birthday: "",
|
||||||
|
bio: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = reactive<FormRules>({
|
||||||
|
nickname: [{ required: true, message: t('member.profile.validation.nickname_required'), trigger: "blur" }],
|
||||||
|
email: [{ type: "email", message: t('member.profile.validation.email_format'), trigger: "blur" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await memberStore.getMemberInfo();
|
||||||
|
initProfileForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
const initProfileForm = () => {
|
||||||
|
if (memberStore.info) {
|
||||||
|
profileForm.username = memberStore.info.username || "";
|
||||||
|
profileForm.nickname = memberStore.info.nickname || "";
|
||||||
|
profileForm.email = memberStore.info.email || "";
|
||||||
|
profileForm.phone = memberStore.info.phone || "";
|
||||||
|
profileForm.gender = memberStore.info.gender === 1? "male": memberStore.info.gender === 2 ? "female": "other";
|
||||||
|
profileForm.birthday = memberStore.info.birthday || "";
|
||||||
|
profileForm.bio = memberStore.info.bio || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitProfileForm = async () => {
|
||||||
|
if (!profileFormRef.value) return;
|
||||||
|
await profileFormRef.value.validate(async (valid, fields) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
nickname: profileForm.nickname,
|
||||||
|
gender:
|
||||||
|
profileForm.gender === "male" ? 1 : profileForm.gender === "female" ? 2 : 0,
|
||||||
|
birthday: profileForm.birthday,
|
||||||
|
bio: profileForm.bio,
|
||||||
|
};
|
||||||
|
await updateMemberInfo(data);
|
||||||
|
ElMessage.success(t('member.profile.update_success'));
|
||||||
|
await memberStore.getMemberInfo();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || t('member.profile.update_failed'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("验证失败", fields);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetProfileForm = () => {
|
||||||
|
initProfileForm();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-profile {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,784 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-settings">
|
||||||
|
<h2 class="page-title">{{ t('member.settings.page_title') }}</h2>
|
||||||
|
|
||||||
|
<div class="card-header">{{ t('member.settings.account_info') }}</div>
|
||||||
|
<el-form :model="accountForm" label-width="120px" class="account-form">
|
||||||
|
<!-- 手机号码修改 -->
|
||||||
|
<el-form-item :label="t('member.settings.login_phone')">
|
||||||
|
<div class="account-item">
|
||||||
|
<el-input v-model="accountForm.phone" disabled />
|
||||||
|
<el-button type="primary" @click="showPhoneDialog = true">{{ t('member.settings.change_phone') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 邮箱修改 -->
|
||||||
|
<el-form-item :label="t('member.settings.email')">
|
||||||
|
<div class="account-item">
|
||||||
|
<el-input v-model="accountForm.email" disabled />
|
||||||
|
<el-button type="primary" @click="showEmailDialog = true">{{ t('member.settings.change_email') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 登录密码 -->
|
||||||
|
<el-form-item :label="t('member.settings.login_password')">
|
||||||
|
<div class="account-item">
|
||||||
|
<el-input
|
||||||
|
type="password"
|
||||||
|
v-model="passwordPlaceholder"
|
||||||
|
disabled
|
||||||
|
placeholder="******"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="navigateToPassword">{{ t('member.settings.change_password') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="card-header">{{ t('member.settings.third_party_bind') }}</div>
|
||||||
|
|
||||||
|
<!-- 绑定列表 -->
|
||||||
|
<el-table :data="thirdPartyList" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="序号" width="80" />
|
||||||
|
<el-table-column :label="t('member.settings.bind')" width="150" align="left">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="platform-info">
|
||||||
|
<!-- <Icon :icon="getPlatformIcon(scope.row.platform)" class="platform-icon" /> -->
|
||||||
|
<span class="platform-name">{{ scope.row.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('member.settings.bind')" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="platform-detail">
|
||||||
|
<img
|
||||||
|
v-if="scope.row.info?.avatar"
|
||||||
|
:src="scope.row.info.avatar"
|
||||||
|
class="avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span>{{ scope.row.info?.account || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="bind_time" :label="t('member.settings.bind_time')" width="180" />
|
||||||
|
<el-table-column prop="status" :label="t('member.settings.status')" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag size="small" type="success" v-if="scope.row.binded">{{ t('member.settings.using') }}</el-tag>
|
||||||
|
<el-tag size="small" type="danger" v-else>{{ t('member.settings.not_bound') }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('member.settings.options')" width="120" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click="handleUnbind(scope.row)"
|
||||||
|
v-if="scope.row.binded"
|
||||||
|
>
|
||||||
|
{{ t('member.settings.unbind') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="handleBind(scope.row)" v-else>
|
||||||
|
{{ t('member.settings.bind') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 手机号修改弹窗 -->
|
||||||
|
<el-dialog v-model="showPhoneDialog" :title="t('member.settings.phone_bind_dialog')" width="400px">
|
||||||
|
<el-form
|
||||||
|
:model="phoneForm"
|
||||||
|
:rules="phoneRules"
|
||||||
|
ref="phoneFormRef"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item :label="t('member.settings.old_phone')" prop="oldMobile">
|
||||||
|
<el-input v-model="phoneForm.oldMobile" :placeholder="t('member.settings.old_phone_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.settings.new_phone')" prop="newMobile">
|
||||||
|
<el-input v-model="phoneForm.newMobile" :placeholder="t('member.settings.new_phone_placeholder')">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="sendSmsCode('phone')" :disabled="smsCountdown > 0">
|
||||||
|
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('member.settings.get_code') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.settings.verification_code')" prop="code">
|
||||||
|
<el-input v-model="phoneForm.code" :placeholder="t('member.settings.verification_code_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="showPhoneDialog = false">{{ t('member.settings.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" @click="updatePhone">{{ t('member.settings.confirm') }}</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 邮箱修改弹窗 -->
|
||||||
|
<el-dialog v-model="showEmailDialog" :title="t('member.settings.email_bind_dialog')" width="400px">
|
||||||
|
<el-form
|
||||||
|
:model="emailForm"
|
||||||
|
:rules="emailRules"
|
||||||
|
ref="emailFormRef"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item :label="t('member.settings.new_email')" prop="email">
|
||||||
|
<el-input v-model="emailForm.email" type="email" :placeholder="t('member.settings.new_email_placeholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('member.settings.verification_code')" prop="code">
|
||||||
|
<el-input v-model="emailForm.code" :placeholder="t('member.settings.verification_code_placeholder')">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="sendSmsCode('email')" :disabled="emailCountdown > 0">
|
||||||
|
{{ emailCountdown > 0 ? `${emailCountdown}s` : t('member.settings.get_code') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="showEmailDialog = false">{{ t('member.settings.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" @click="updateEmail">{{ t('member.settings.confirm') }}</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="qrDialogVisible" :title="qrDialogTitle" width="400px" center>
|
||||||
|
<div class="qr-dialog-content">
|
||||||
|
<div v-if="qrLoading" class="qr-loading">
|
||||||
|
<el-icon class="is-loading"><loading /></el-icon>
|
||||||
|
<p>{{ t('member.settings.qr_generating') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="qrExpired" class="qr-expired">
|
||||||
|
<el-icon><warning /></el-icon>
|
||||||
|
<p>{{ t('member.settings.qr_expired') }}</p>
|
||||||
|
<el-button type="primary" @click="generateQrCode">{{ t('member.settings.qr_regenerate') }}</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="qrSuccess" class="qr-success">
|
||||||
|
<el-icon><success-filled /></el-icon>
|
||||||
|
<p>{{ t('member.settings.qr_success') }}</p>
|
||||||
|
<el-button type="primary" @click="closeQrDialog">{{ t('member.settings.confirm') }}</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="qr-code-container">
|
||||||
|
<img v-if="qrCode" :src="qrCode" alt="绑定二维码" class="qr-code" />
|
||||||
|
<p class="qr-tip">{{ t('member.settings.qr_tip', { name: currentBindItem?.name }) }}</p>
|
||||||
|
<el-progress
|
||||||
|
:percentage="qrProgress"
|
||||||
|
:status="qrProgress === 100 ? 'success' : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Loading, Warning, SuccessFilled } from "@element-plus/icons-vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import {
|
||||||
|
getThirdPartyList,
|
||||||
|
unbindThirdParty,
|
||||||
|
generateBindQrCode,
|
||||||
|
checkBindQrStatus,
|
||||||
|
} from "~/api/auth";
|
||||||
|
import { getVerificationCode, updateMemberMobile, updateMemberEmail } from "~/api/member";
|
||||||
|
import { useMemberStore } from "~/stores/member";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const memberStore = useMemberStore();
|
||||||
|
|
||||||
|
const getPlatformIcon = (platform: number): string => {
|
||||||
|
const iconMap: Record<number, string> = {
|
||||||
|
1: "ant-design:qq-circle-filled",
|
||||||
|
2: "ant-design:wechat-filled",
|
||||||
|
3: "ant-design:weibo-circle-filled",
|
||||||
|
4: "ant-design:youtube-filled",
|
||||||
|
};
|
||||||
|
return iconMap[platform] || "ant-design:question-circle-filled";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 账户表单
|
||||||
|
const accountForm = reactive({
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordPlaceholder = ref("");
|
||||||
|
|
||||||
|
// 绑定列表
|
||||||
|
const bindList = reactive([
|
||||||
|
{
|
||||||
|
type: "qq",
|
||||||
|
name: t('member.settings.qq'),
|
||||||
|
platform: 1,
|
||||||
|
binded: false,
|
||||||
|
scan: true,
|
||||||
|
info: null,
|
||||||
|
bind_time: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wechat",
|
||||||
|
name: t('member.settings.wechat'),
|
||||||
|
platform: 2,
|
||||||
|
binded: false,
|
||||||
|
scan: true,
|
||||||
|
info: null,
|
||||||
|
bind_time: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "douyin",
|
||||||
|
name: t('member.settings.douyin'),
|
||||||
|
platform: 4,
|
||||||
|
binded: false,
|
||||||
|
scan: false,
|
||||||
|
info: null,
|
||||||
|
bind_time: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "weibo",
|
||||||
|
name: t('member.settings.weibo'),
|
||||||
|
platform: 3,
|
||||||
|
binded: false,
|
||||||
|
scan: false,
|
||||||
|
info: null,
|
||||||
|
bind_time: "-",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 第三方绑定列表
|
||||||
|
const thirdPartyList = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 手机号修改
|
||||||
|
const showPhoneDialog = ref(false);
|
||||||
|
const phoneForm = reactive({
|
||||||
|
oldMobile: "",
|
||||||
|
newMobile: "",
|
||||||
|
code: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const phoneRules = reactive({
|
||||||
|
oldMobile: [{ required: true, message: t('member.settings.validation.old_phone_required'), trigger: "blur" }],
|
||||||
|
newMobile: [{ required: true, message: t('member.settings.validation.new_phone_required'), trigger: "blur" }],
|
||||||
|
code: [{ required: true, message: t('member.settings.validation.code_required'), trigger: "blur" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const phoneFormRef = ref();
|
||||||
|
|
||||||
|
// 邮箱修改
|
||||||
|
const showEmailDialog = ref(false);
|
||||||
|
const emailForm = reactive({
|
||||||
|
email: "",
|
||||||
|
code: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailRules = reactive({
|
||||||
|
email: [{ required: true, message: t('member.settings.validation.email_required'), trigger: "blur" }],
|
||||||
|
code: [{ required: true, message: t('member.settings.validation.new_email_code_required'), trigger: "blur" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailFormRef = ref();
|
||||||
|
|
||||||
|
// 验证码倒计时
|
||||||
|
const smsCountdown = ref(0);
|
||||||
|
const emailCountdown = ref(0);
|
||||||
|
|
||||||
|
// 二维码绑定
|
||||||
|
const qrDialogVisible = ref(false);
|
||||||
|
const qrDialogTitle = ref("");
|
||||||
|
const qrLoading = ref(false);
|
||||||
|
const qrExpired = ref(false);
|
||||||
|
const qrSuccess = ref(false);
|
||||||
|
const qrCode = ref("");
|
||||||
|
const qrProgress = ref(0);
|
||||||
|
const currentBindItem = ref<any>(null);
|
||||||
|
const currentSceneId = ref("");
|
||||||
|
const qrTimer = ref<any>(null);
|
||||||
|
|
||||||
|
// 加载会员信息
|
||||||
|
const loadMemberInfo = async () => {
|
||||||
|
try {
|
||||||
|
await memberStore.getMemberInfo();
|
||||||
|
if (memberStore.info) {
|
||||||
|
accountForm.phone = memberStore.info.phone || "";
|
||||||
|
accountForm.email = memberStore.info.email || "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载会员信息失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载第三方绑定列表
|
||||||
|
const loadThirdPartyList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getThirdPartyList();
|
||||||
|
console.log("获取第三方绑定列表结果:", res);
|
||||||
|
|
||||||
|
if (res && Array.isArray(res)) {
|
||||||
|
// 更新绑定状态
|
||||||
|
res.forEach((item: any) => {
|
||||||
|
const bindItem = bindList.find((b) => b.platform === item.platform);
|
||||||
|
if (bindItem) {
|
||||||
|
bindItem.binded = item.binded;
|
||||||
|
bindItem.info = item.info;
|
||||||
|
bindItem.bind_time = item.bind_time;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示所有平台(包括已绑定和未绑定)
|
||||||
|
thirdPartyList.value = bindList.map((item: any, index: number) => ({
|
||||||
|
id: index + 1,
|
||||||
|
...item,
|
||||||
|
bind_time: item.bind_time || "-",
|
||||||
|
status: item.binded ? t('member.settings.using') : t('member.settings.not_bound'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载第三方绑定列表失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadMemberInfo();
|
||||||
|
await loadThirdPartyList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送验证码
|
||||||
|
const sendSmsCode = async (type: string) => {
|
||||||
|
try {
|
||||||
|
let data: any = {};
|
||||||
|
|
||||||
|
if (type === "phone") {
|
||||||
|
if (!phoneForm.newMobile) {
|
||||||
|
ElMessage.warning(t('member.settings.please_enter_phone'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
mobile: phoneForm.newMobile,
|
||||||
|
type: "update_phone",
|
||||||
|
};
|
||||||
|
} else if (type === "email") {
|
||||||
|
if (!emailForm.email) {
|
||||||
|
ElMessage.warning(t('member.settings.please_enter_email'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
email: emailForm.email,
|
||||||
|
type: "update_email",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await getVerificationCode(data);
|
||||||
|
ElMessage.success(t('member.settings.code_send_success'));
|
||||||
|
|
||||||
|
// 开始倒计时
|
||||||
|
if (type === "phone") {
|
||||||
|
smsCountdown.value = 60;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
smsCountdown.value--;
|
||||||
|
if (smsCountdown.value <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else if (type === "email") {
|
||||||
|
emailCountdown.value = 60;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
emailCountdown.value--;
|
||||||
|
if (emailCountdown.value <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || t('member.settings.code_send_failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新手机号
|
||||||
|
const updatePhone = async () => {
|
||||||
|
if (!phoneFormRef.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateMemberMobile(phoneForm);
|
||||||
|
ElMessage.success(t('member.settings.phone_update_success'));
|
||||||
|
showPhoneDialog.value = false;
|
||||||
|
await loadMemberInfo();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || t('member.settings.phone_update_failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新邮箱
|
||||||
|
const updateEmail = async () => {
|
||||||
|
if (!emailFormRef.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateMemberEmail(emailForm);
|
||||||
|
ElMessage.success(t('member.settings.email_update_success'));
|
||||||
|
showEmailDialog.value = false;
|
||||||
|
await loadMemberInfo();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || t('member.settings.email_update_failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 跳转到修改密码页面
|
||||||
|
const navigateToPassword = () => {
|
||||||
|
router.push("/member/password");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理绑定
|
||||||
|
const handleBind = async (item: any) => {
|
||||||
|
console.log("handleBind 被调用", item);
|
||||||
|
|
||||||
|
if (item.binded) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(t('member.settings.confirm_unbind', { name: item.name }), t('member.settings.confirm_unbind_title'), {
|
||||||
|
confirmButtonText: t('member.settings.confirm'),
|
||||||
|
cancelButtonText: t('member.settings.cancel'),
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await unbindThirdParty(item.platform);
|
||||||
|
ElMessage.success(t('member.settings.unbind_success'));
|
||||||
|
await loadThirdPartyList();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || t('member.settings.unbind_failed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.info(t('member.settings.cancel_unbind'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("开始绑定流程", item);
|
||||||
|
|
||||||
|
if (item.scan) {
|
||||||
|
console.log("使用扫码绑定");
|
||||||
|
currentBindItem.value = item;
|
||||||
|
qrDialogTitle.value = t('member.settings.qr_bind_title', { name: item.name });
|
||||||
|
generateQrCode();
|
||||||
|
} else {
|
||||||
|
console.log("使用应用跳转绑定");
|
||||||
|
const schemes: Record<string, string> = {
|
||||||
|
douyin: "snssdk1128://",
|
||||||
|
weibo: "sinaweibo://",
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheme = schemes[item.type];
|
||||||
|
if (scheme) {
|
||||||
|
try {
|
||||||
|
window.location.href = scheme;
|
||||||
|
ElMessage.info(t('member.settings.opening_app', { name: item.name }));
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(t('member.settings.open_app_failed', { name: item.name }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.info(t('member.settings.please_bind', { name: item.name }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理解绑
|
||||||
|
const handleUnbind = async (item: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(t('member.settings.confirm_unbind', { name: item.name }), t('member.settings.confirm_unbind_title'), {
|
||||||
|
confirmButtonText: t('member.settings.confirm'),
|
||||||
|
cancelButtonText: t('member.settings.cancel'),
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await unbindThirdParty(item.platform);
|
||||||
|
ElMessage.success(t('member.settings.unbind_success'));
|
||||||
|
await loadThirdPartyList();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || t('member.settings.unbind_failed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.info(t('member.settings.cancel_unbind'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成二维码
|
||||||
|
const generateQrCode = async () => {
|
||||||
|
console.log("generateQrCode 被调用", currentBindItem.value);
|
||||||
|
|
||||||
|
if (!currentBindItem.value) {
|
||||||
|
console.log("currentBindItem 为空,返回");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qrDialogVisible.value = true;
|
||||||
|
qrLoading.value = true;
|
||||||
|
qrExpired.value = false;
|
||||||
|
qrSuccess.value = false;
|
||||||
|
qrCode.value = "";
|
||||||
|
qrProgress.value = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("开始生成二维码,platform:", currentBindItem.value.platform);
|
||||||
|
const res = await generateBindQrCode(currentBindItem.value.platform);
|
||||||
|
console.log("二维码生成结果:", res);
|
||||||
|
console.log("qr_code URL:", res.qr_code);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
currentSceneId.value = res.scene_id;
|
||||||
|
qrCode.value = res.qr_code;
|
||||||
|
console.log("qrCode URL:", qrCode.value);
|
||||||
|
qrLoading.value = false;
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("生成二维码失败:", error);
|
||||||
|
ElMessage.error(error.message || t('member.settings.qr_generate_failed'));
|
||||||
|
qrDialogVisible.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始轮询
|
||||||
|
const startPolling = () => {
|
||||||
|
if (!currentSceneId.value) return;
|
||||||
|
|
||||||
|
qrTimer.value = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await checkBindQrStatus(currentSceneId.value);
|
||||||
|
if (res) {
|
||||||
|
if (res.status === "success") {
|
||||||
|
qrSuccess.value = true;
|
||||||
|
qrProgress.value = 100;
|
||||||
|
stopPolling();
|
||||||
|
await loadThirdPartyList();
|
||||||
|
} else if (res.status === "pending") {
|
||||||
|
qrProgress.value = Math.min(qrProgress.value + 10, 90);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes("过期")) {
|
||||||
|
qrExpired.value = true;
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止轮询
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (qrTimer.value) {
|
||||||
|
clearInterval(qrTimer.value);
|
||||||
|
qrTimer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭二维码弹窗
|
||||||
|
const closeQrDialog = () => {
|
||||||
|
stopPolling();
|
||||||
|
qrDialogVisible.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-settings {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-form {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-item .el-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-bind-list {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-platforms {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platforms-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-item:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-icon.qq {
|
||||||
|
background-color: #12b7f5;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-icon.wechat {
|
||||||
|
background-color: #07c160;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-icon.douyin {
|
||||||
|
background-color: #000000;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-icon.weibo {
|
||||||
|
background-color: #e6162d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-info .platform-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-detail .avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-dialog-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-expired {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-expired p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-success {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-success p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-tip {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="sign-in">
|
||||||
|
<!-- 标题和操作区 -->
|
||||||
|
<div class="sign-in__header flex justify-between items-center mb-2">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800">{{ t('member.sign.page_title') }}</h3>
|
||||||
|
<div class="sign-in__actions flex gap-2">
|
||||||
|
<el-button type="text" @click="showRules" size="small">{{ t('member.sign.sign_rules') }}</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="isSignedToday || isLoading"
|
||||||
|
@click="signInAction"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<el-icon v-if="isLoading"><Loading /></el-icon>
|
||||||
|
{{ isSignedToday ? t('member.sign.signed') : t('member.sign.sign_button') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 签到提示 -->
|
||||||
|
<div v-if="showTip" class="sign-in__tip bg-green-50 border border-green-200 rounded-md p-2 flex justify-between items-center mb-3">
|
||||||
|
<span class="text-green-700 text-sm">{{ t('member.sign.tomorrow_points', { points: tomorrowPoints }) }}</span>
|
||||||
|
<el-button type="text" size="small" @click="closeTip">×</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div class="sign-in__divider border-t border-gray-200 my-3"></div>
|
||||||
|
|
||||||
|
<!-- 日历导航 -->
|
||||||
|
<div class="sign-in__calendar-header flex justify-between items-center mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<el-button type="text" @click="prevMonth" size="small">‹</el-button>
|
||||||
|
<span class="text-gray-700 text-sm">{{ currentYear }}年{{ currentMonth }}月</span>
|
||||||
|
<el-button type="text" @click="nextMonth" size="small">›</el-button>
|
||||||
|
</div>
|
||||||
|
<span class="sign-in__consecutive text-primary text-sm">{{ t('member.sign.consecutive_days') }} {{ consecutiveDays }} {{ t('member.sign.days') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日历 -->
|
||||||
|
<div class="sign-in__calendar">
|
||||||
|
<!-- 星期标题和日期表格 -->
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="day in weekdays" :key="day" class="w-10 h-8 text-center text-gray-500 text-xs">
|
||||||
|
{{ day }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- 日期行 -->
|
||||||
|
<tr v-for="(week, index) in calendarWeeks" :key="index">
|
||||||
|
<td v-for="(date, dayIndex) in week" :key="dayIndex" class="h-14 text-center">
|
||||||
|
<div
|
||||||
|
v-if="date"
|
||||||
|
class="w-10 h-10 mx-auto flex items-center justify-center text-sm rounded-full cursor-pointer transition-colors duration-200"
|
||||||
|
:class="{
|
||||||
|
'bg-[#67c23a] text-white font-medium': isSigned(date),
|
||||||
|
'bg-blue-50 text-blue-600': isToday(date) && !isSigned(date),
|
||||||
|
'text-gray-800': !isToday(date) && !isSigned(date)
|
||||||
|
}"
|
||||||
|
@mouseenter="!isSigned(date) && $event.target.classList.add('bg-blue-50', 'text-blue-600')"
|
||||||
|
@mouseleave="!isSigned(date) && $event.target.classList.remove('bg-blue-50', 'text-blue-600')"
|
||||||
|
>
|
||||||
|
{{ date.getDate() }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-10 h-10 mx-auto"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useMemberStore } from '~/stores/member'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { signIn, getSignStatus, getSignCalendar } from '~/api/member'
|
||||||
|
|
||||||
|
const memberStore = useMemberStore()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const currentYear = ref(new Date().getFullYear())
|
||||||
|
const currentMonth = ref(new Date().getMonth() + 1)
|
||||||
|
const isSignedToday = ref(false)
|
||||||
|
const consecutiveDays = ref(0)
|
||||||
|
const showTip = ref(true)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const signedDates = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日'] // 以星期一为起始
|
||||||
|
|
||||||
|
const tomorrowPoints = computed(() => {
|
||||||
|
// 根据连续签到天数计算明天可获得的积分
|
||||||
|
if (consecutiveDays.value >= 7) return 5
|
||||||
|
if (consecutiveDays.value >= 3) return 3
|
||||||
|
return 2
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成日历数据(按周分组)
|
||||||
|
const calendarWeeks = computed(() => {
|
||||||
|
const weeks = []
|
||||||
|
const firstDay = new Date(currentYear.value, currentMonth.value - 1, 1)
|
||||||
|
const lastDay = new Date(currentYear.value, currentMonth.value, 0)
|
||||||
|
// 计算当月第一天是星期几(1-7,1=星期一)
|
||||||
|
let firstDayWeekday = firstDay.getDay()
|
||||||
|
firstDayWeekday = firstDayWeekday === 0 ? 7 : firstDayWeekday
|
||||||
|
|
||||||
|
// 生成日期数组
|
||||||
|
let week: (Date | null)[] = []
|
||||||
|
|
||||||
|
// 填充月初空白
|
||||||
|
for (let i = 0; i < firstDayWeekday - 1; i++) {
|
||||||
|
week.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充当月日期
|
||||||
|
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||||
|
// 确保创建日期时设置为当天的开始时间
|
||||||
|
const currentDate = new Date(currentYear.value, currentMonth.value - 1, day)
|
||||||
|
currentDate.setHours(0, 0, 0, 0)
|
||||||
|
week.push(currentDate)
|
||||||
|
|
||||||
|
// 每周结束,开始新周
|
||||||
|
if (week.length === 7) {
|
||||||
|
weeks.push([...week])
|
||||||
|
week = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充月末空白
|
||||||
|
while (week.length > 0 && week.length < 7) {
|
||||||
|
week.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加最后一周
|
||||||
|
if (week.length === 7) {
|
||||||
|
weeks.push(week)
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeks
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查日期是否是今天
|
||||||
|
const isToday = (date: Date) => {
|
||||||
|
// 使用固定的日期格式化方法,确保不受时区影响
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(today.getDate()).padStart(2, '0')
|
||||||
|
const todayStr = `${year}-${month}-${day}`
|
||||||
|
const checkYear = date.getFullYear()
|
||||||
|
const checkMonth = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const checkDay = String(date.getDate()).padStart(2, '0')
|
||||||
|
const checkDateStr = `${checkYear}-${checkMonth}-${checkDay}`
|
||||||
|
console.log('Checking if today:', {
|
||||||
|
today: todayStr,
|
||||||
|
checkDate: checkDateStr,
|
||||||
|
isEqual: todayStr === checkDateStr
|
||||||
|
})
|
||||||
|
return todayStr === checkDateStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查日期是否已签到
|
||||||
|
const isSigned = (date: Date) => {
|
||||||
|
// 使用固定的日期格式化方法,确保不受时区影响
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const dateStr = `${year}-${month}-${day}`
|
||||||
|
console.log('Checking if signed:', {
|
||||||
|
date: dateStr,
|
||||||
|
isSigned: signedDates.value.has(dateStr),
|
||||||
|
signedDates: Array.from(signedDates.value)
|
||||||
|
})
|
||||||
|
return signedDates.value.has(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 调用
|
||||||
|
const fetchSignStatus = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getSignStatus() as any
|
||||||
|
isSignedToday.value = data.is_signed_today
|
||||||
|
consecutiveDays.value = data.continuous_days
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取签到状态失败:', error)
|
||||||
|
ElMessage.error(error.message || t('member.sign.get_sign_status_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSignCalendar = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getSignCalendar(currentYear.value, currentMonth.value) as any
|
||||||
|
signedDates.value.clear()
|
||||||
|
result.calendar.forEach((date: string) => {
|
||||||
|
signedDates.value.add(date)
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取签到日历失败:', error)
|
||||||
|
ElMessage.error(error.message || t('member.sign.get_sign_calendar_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const showRules = () => {
|
||||||
|
// 显示签到规则
|
||||||
|
ElMessage.info(t('member.sign.rules_content'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const signInAction = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
const data = await signIn()
|
||||||
|
isSignedToday.value = true
|
||||||
|
consecutiveDays.value = data.continuous_days
|
||||||
|
ElMessage.success(t('member.sign.sign_success', { points: data.points }))
|
||||||
|
|
||||||
|
// 更新签到日历
|
||||||
|
await fetchSignCalendar()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('签到失败:', error)
|
||||||
|
ElMessage.error(error.message || t('member.sign.sign_failed'))
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTip = () => {
|
||||||
|
showTip.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevMonth = async () => {
|
||||||
|
if (currentMonth.value === 1) {
|
||||||
|
currentMonth.value = 12
|
||||||
|
currentYear.value--
|
||||||
|
} else {
|
||||||
|
currentMonth.value--
|
||||||
|
}
|
||||||
|
await fetchSignCalendar()
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = async () => {
|
||||||
|
if (currentMonth.value === 12) {
|
||||||
|
currentMonth.value = 1
|
||||||
|
currentYear.value++
|
||||||
|
} else {
|
||||||
|
currentMonth.value++
|
||||||
|
}
|
||||||
|
await fetchSignCalendar()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(async () => {
|
||||||
|
// 初始化签到状态
|
||||||
|
await fetchSignStatus()
|
||||||
|
await fetchSignCalendar()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-views">
|
<div class="user-views">
|
||||||
<el-card class="user-views-card" shadow="hover">
|
<el-card class="user-views-card" shadow="hover">
|
||||||
个人资料
|
<template #header>
|
||||||
</el-card>
|
<div class="card-header">
|
||||||
</div>
|
<span>个人资料</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="profile-content">
|
||||||
|
<ProfileComponent />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="user-views-card" shadow="hover" style="margin-top: 20px;">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>用户设置</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="settings-content">
|
||||||
|
<SettingsComponent />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ProfileComponent from './components/profile/index.vue'
|
||||||
|
import SettingsComponent from './components/settings/index.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.user-views {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-views-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-content {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+48
-3
@@ -1,14 +1,59 @@
|
|||||||
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
component: () => import('~/pages/index.vue')
|
component: () => import('~/pages/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '首页',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/member/profile",
|
path: "/member/profile",
|
||||||
component: () => import('~/pages/member/profile.vue'),
|
component: () => import('~/pages/member/components/profile/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '会员中心',
|
title: '个人资料',
|
||||||
layout: 'member'
|
layout: 'member'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/member/settings",
|
||||||
|
component: () => import('~/pages/member/components/settings/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '用户设置',
|
||||||
|
layout: 'member'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/member/password",
|
||||||
|
component: () => import('~/pages/member/components/password/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '修改密码',
|
||||||
|
layout: 'member'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/member/points",
|
||||||
|
component: () => import('~/pages/member/components/points/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '积分记录',
|
||||||
|
layout: 'member'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/member/balance",
|
||||||
|
component: () => import('~/pages/member/components/balance/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '余额记录',
|
||||||
|
layout: 'member'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/member/sign",
|
||||||
|
component: () => import('~/pages/member/components/sign/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '每日签到',
|
||||||
|
layout: 'member'
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemberStore } from '~/stores/member'
|
||||||
|
import { getMemberProfile } from '~/api/member'
|
||||||
|
import { refreshToken } from '~/api/auth'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
// 仅在客户端执行
|
||||||
|
if (!import.meta.client) return
|
||||||
|
|
||||||
|
// 等待一下让 pinia 持久化插件完成初始化
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
const memberStore = useMemberStore()
|
||||||
|
|
||||||
|
// 如果没有 token 但有 refreshToken,尝试刷新
|
||||||
|
if (memberStore.refreshToken && !memberStore.token) {
|
||||||
|
try {
|
||||||
|
const response: any = await refreshToken(memberStore.refreshToken)
|
||||||
|
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
memberStore.token = response.data.access_token
|
||||||
|
memberStore.refreshToken = response.data.refresh_token
|
||||||
|
} else {
|
||||||
|
memberStore.logout()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
memberStore.logout()
|
||||||
|
}
|
||||||
|
} else if (memberStore.token && memberStore.refreshToken) {
|
||||||
|
// 如果有 token 和 refreshToken,尝试获取用户信息
|
||||||
|
try {
|
||||||
|
const data: any = await getMemberProfile()
|
||||||
|
|
||||||
|
if (data && data.id) {
|
||||||
|
memberStore.info = data
|
||||||
|
} else {
|
||||||
|
memberStore.info = null
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 如果是 401 错误,尝试刷新 token
|
||||||
|
if (error?.message?.includes('401') || error?.response?.status === 401) {
|
||||||
|
try {
|
||||||
|
const refreshResponse: any = await refreshToken(memberStore.refreshToken)
|
||||||
|
|
||||||
|
if (refreshResponse.code === 0 && refreshResponse.data) {
|
||||||
|
memberStore.token = refreshResponse.data.access_token
|
||||||
|
memberStore.refreshToken = refreshResponse.data.refresh_token
|
||||||
|
|
||||||
|
// 刷新成功后重新获取用户信息
|
||||||
|
const userInfo: any = await getMemberProfile()
|
||||||
|
if (userInfo && userInfo.id) {
|
||||||
|
memberStore.info = userInfo
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
memberStore.logout()
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
memberStore.logout()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
memberStore.info = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
+103
-9
@@ -1,4 +1,5 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import storage from '@/utils/storage'
|
||||||
|
|
||||||
// 定义语言文件模块接口
|
// 定义语言文件模块接口
|
||||||
interface LanguageFileModule {
|
interface LanguageFileModule {
|
||||||
@@ -10,12 +11,26 @@ function isLanguageFileModule(module: any): module is LanguageFileModule {
|
|||||||
return module && typeof module === 'object' && 'default' in module && typeof module.default === 'object'
|
return module && typeof module === 'object' && 'default' in module && typeof module.default === 'object'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 递归处理嵌套结构
|
||||||
|
const flattenMessages = (obj: any, path: string = '') => {
|
||||||
|
let result: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const newPath = path ? `${path}.${key}` : key
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
Object.assign(result, flattenMessages(value, newPath))
|
||||||
|
} else {
|
||||||
|
result[newPath] = String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// 自动扫描加载所有语言文件
|
// 自动扫描加载所有语言文件
|
||||||
const loadAllLanguageFiles = () => {
|
const loadAllLanguageFiles = () => {
|
||||||
const messages: Record<string, Record<string, any>> = {}
|
const messages: Record<string, Record<string, any>> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载所有语言文件
|
// 加载所有语言文件(包括子目录)
|
||||||
const languageFiles = import.meta.glob('~/lang/**/*.json', {
|
const languageFiles = import.meta.glob('~/lang/**/*.json', {
|
||||||
eager: true
|
eager: true
|
||||||
}) as Record<string, LanguageFileModule>
|
}) as Record<string, LanguageFileModule>
|
||||||
@@ -28,16 +43,92 @@ const loadAllLanguageFiles = () => {
|
|||||||
|
|
||||||
if (langIndex >= 0 && pathParts.length > langIndex + 1) {
|
if (langIndex >= 0 && pathParts.length > langIndex + 1) {
|
||||||
const langCode = pathParts[langIndex + 1] ?? ''
|
const langCode = pathParts[langIndex + 1] ?? ''
|
||||||
const fileName = (pathParts[pathParts.length - 1] ?? '').replace('.json', '')
|
|
||||||
|
|
||||||
if (langCode && fileName) {
|
if (langCode) {
|
||||||
if (!messages[langCode]) {
|
if (!messages[langCode]) {
|
||||||
messages[langCode] = {}
|
messages[langCode] = {}
|
||||||
}
|
}
|
||||||
// 直接合并所有文件内容到对应语言下
|
|
||||||
messages[langCode] = {
|
// 只使用文件名作为键前缀,不包含目录名
|
||||||
...messages[langCode],
|
const fileName = (pathParts[pathParts.length - 1] ?? '').replace('.json', '')
|
||||||
...module.default
|
const fileKey = fileName
|
||||||
|
|
||||||
|
// 处理嵌套结构
|
||||||
|
const flattenedMessages = flattenMessages(module.default)
|
||||||
|
|
||||||
|
// 如果有文件键前缀,添加到所有消息键前
|
||||||
|
if (fileKey) {
|
||||||
|
const prefixedMessages: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(flattenedMessages)) {
|
||||||
|
prefixedMessages[`${fileKey}.${key}`] = value
|
||||||
|
}
|
||||||
|
messages[langCode] = {
|
||||||
|
...messages[langCode],
|
||||||
|
...prefixedMessages
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有前缀,直接合并
|
||||||
|
messages[langCode] = {
|
||||||
|
...messages[langCode],
|
||||||
|
...flattenedMessages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载 apps 目录下的语言文件
|
||||||
|
const appsLanguageFiles = import.meta.glob('~/apps/**/lang/**/*.json', {
|
||||||
|
eager: true
|
||||||
|
}) as Record<string, LanguageFileModule>
|
||||||
|
|
||||||
|
// 处理 apps 语言文件
|
||||||
|
for (const [filePath, module] of Object.entries(appsLanguageFiles)) {
|
||||||
|
if (isLanguageFileModule(module)) {
|
||||||
|
const pathParts = filePath.split('/')
|
||||||
|
const appsIndex = pathParts.indexOf('apps')
|
||||||
|
const langIndex = pathParts.indexOf('lang')
|
||||||
|
|
||||||
|
if (appsIndex >= 0 && langIndex >= 0 && pathParts.length > langIndex + 1) {
|
||||||
|
const langCode = pathParts[langIndex + 1] ?? ''
|
||||||
|
|
||||||
|
if (langCode) {
|
||||||
|
if (!messages[langCode]) {
|
||||||
|
messages[langCode] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取模块名(apps目录下的子目录名)
|
||||||
|
const moduleName = pathParts[appsIndex + 1] ?? ''
|
||||||
|
|
||||||
|
// 只使用文件名作为键前缀,不包含目录名
|
||||||
|
const fileName = (pathParts[pathParts.length - 1] ?? '').replace('.json', '')
|
||||||
|
|
||||||
|
// 构建文件键,包含模块名前缀
|
||||||
|
let fileKey = moduleName
|
||||||
|
if (fileName) {
|
||||||
|
fileKey = `${moduleName}.${fileName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理嵌套结构
|
||||||
|
const flattenedMessages = flattenMessages(module.default)
|
||||||
|
|
||||||
|
// 如果有文件键前缀,添加到所有消息键前
|
||||||
|
if (fileKey) {
|
||||||
|
const prefixedMessages: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(flattenedMessages)) {
|
||||||
|
prefixedMessages[`${fileKey}.${key}`] = value
|
||||||
|
}
|
||||||
|
messages[langCode] = {
|
||||||
|
...messages[langCode],
|
||||||
|
...prefixedMessages
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有前缀,直接合并
|
||||||
|
messages[langCode] = {
|
||||||
|
...messages[langCode],
|
||||||
|
...flattenedMessages
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,9 +157,12 @@ export default defineNuxtPlugin((NuxtApp) => {
|
|||||||
// 加载所有语言消息
|
// 加载所有语言消息
|
||||||
const messages = loadAllLanguageFiles()
|
const messages = loadAllLanguageFiles()
|
||||||
|
|
||||||
|
// 从存储中读取语言设置,默认为 zh-cn
|
||||||
|
const savedLocale = storage.get('lang') ?? 'zh-cn'
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false, // 使用Composition API
|
legacy: false, // 使用Composition API
|
||||||
locale: 'zh-cn', // 默认语言
|
locale: savedLocale, // 使用存储中的语言设置
|
||||||
fallbackLocale: 'zh-cn', // 回退语言
|
fallbackLocale: 'zh-cn', // 回退语言
|
||||||
globalInjection: true, // 全局注入
|
globalInjection: true, // 全局注入
|
||||||
messages: messages,
|
messages: messages,
|
||||||
@@ -84,4 +178,4 @@ export default defineNuxtPlugin((NuxtApp) => {
|
|||||||
getI18n: () => i18n
|
getI18n: () => i18n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
// console.log('Icon plugin loaded')
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { App, Directive, Plugin } from 'vue'
|
||||||
|
import { useMemberStore } from '~/stores/member'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限指令配置
|
||||||
|
*/
|
||||||
|
interface AuthDirectiveOptions {
|
||||||
|
permissions: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有权限
|
||||||
|
* @param permissions 权限码或权限码数组
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
export function hasAuth(permissions: string | string[]): boolean {
|
||||||
|
const memberStore = useMemberStore()
|
||||||
|
const userPermissions = memberStore.info?.permissions || []
|
||||||
|
|
||||||
|
// 如果用户拥有所有权限,直接返回 true
|
||||||
|
if (userPermissions.includes('*')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理字符串权限
|
||||||
|
if (typeof permissions === 'string') {
|
||||||
|
return userPermissions.includes(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数组权限,只要拥有任一权限就返回 true
|
||||||
|
return permissions.some(permission => userPermissions.includes(permission))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限指令
|
||||||
|
*/
|
||||||
|
const authDirective: Directive = {
|
||||||
|
mounted(el: HTMLElement, binding: any) {
|
||||||
|
const hasPermission = hasAuth(binding.value)
|
||||||
|
if (!hasPermission) {
|
||||||
|
el.style.display = 'none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated(el: HTMLElement, binding: any) {
|
||||||
|
const hasPermission = hasAuth(binding.value)
|
||||||
|
if (!hasPermission) {
|
||||||
|
el.style.display = 'none'
|
||||||
|
} else {
|
||||||
|
el.style.display = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限插件
|
||||||
|
*/
|
||||||
|
const permissionPlugin: Plugin = {
|
||||||
|
install(app: App) {
|
||||||
|
// 注册 v-auth 指令
|
||||||
|
app.directive('auth', authDirective)
|
||||||
|
|
||||||
|
// 注册全局 hasAuth 函数
|
||||||
|
app.config.globalProperties.$hasAuth = hasAuth
|
||||||
|
|
||||||
|
// 提供给组合式 API 使用
|
||||||
|
app.provide('hasAuth', hasAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default permissionPlugin
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
|
import { initDark } from '~/utils/dark'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(({ $pinia }) => {
|
||||||
|
// 仅在客户端使用持久化插件
|
||||||
|
if (import.meta.client) {
|
||||||
|
// @ts-ignore
|
||||||
|
$pinia.use(piniaPluginPersistedstate)
|
||||||
|
initDark()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -9,8 +9,8 @@ interface PluginRouteModule {
|
|||||||
type PluginRoutes = Record<string, PluginRouteModule>
|
type PluginRoutes = Record<string, PluginRouteModule>
|
||||||
|
|
||||||
// import.meta.glob 自动加载模块路由
|
// import.meta.glob 自动加载模块路由
|
||||||
const pluginRoutes: PluginRoutes = import.meta.glob('@/features/**/pages/routes.ts', {
|
const pluginRoutes: PluginRoutes = import.meta.glob('~/apps/**/pages/routes.ts', {
|
||||||
eager: true
|
eager: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建可修改的路由数组副本
|
// 创建可修改的路由数组副本
|
||||||
|
|||||||
+207
-24
@@ -1,46 +1,229 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { getLoginConfig } from '@/api/auth'
|
import { getWebGroupConfigs } from '~/api/site'
|
||||||
|
import { navigateTo } from 'nuxt/app'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录配置接口定义
|
||||||
|
* 用于管理登录相关的配置选项
|
||||||
|
*/
|
||||||
interface loginConfig {
|
interface loginConfig {
|
||||||
is_username: number | boolean,
|
is_username: number | boolean, // 是否启用用户名登录 (1/0 或 true/false)
|
||||||
is_mobile: number | boolean,
|
is_mobile: number | boolean, // 是否启用手机号登录 (1/0 或 true/false)
|
||||||
is_auth_register: number | boolean,
|
is_auth_register: number | boolean, // 是否启用授权注册 (1/0 或 true/false)
|
||||||
is_bind_mobile: number | boolean,
|
is_bind_mobile: number | boolean, // 是否启用绑定手机号 (1/0 或 true/false)
|
||||||
agreement_show: number | boolean
|
is_wechat_scan: number | boolean, // 是否启用微信扫描登录 (1/0 或 true/false)
|
||||||
|
agreement_show: number | boolean // 是否显示用户协议 (1/0 或 true/false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多语言配置接口定义
|
||||||
|
* 用于管理多语言相关的配置选项
|
||||||
|
*/
|
||||||
|
interface languageConfig {
|
||||||
|
is_enabled: number | boolean, // 是否启用多语言支持 (1/0 或 true/false)
|
||||||
|
default_lang: string, // 默认语言代码
|
||||||
|
available_langs: string[] // 可用语言代码数组
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题配置接口定义
|
||||||
|
* 用于管理主题相关的配置选项
|
||||||
|
*/
|
||||||
|
interface themeConfig {
|
||||||
|
is_enabled: number | boolean, // 是否启用主题切换 (1/0 或 true/false)
|
||||||
|
default_theme: string, // 默认主题 (light/dark)
|
||||||
|
available_themes: string[] // 可用主题数组
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付配置接口定义
|
||||||
|
* 用于管理支付相关的配置选项
|
||||||
|
*/
|
||||||
|
interface paymentConfig {
|
||||||
|
is_wechat_enabled: number | boolean, // 是否启用微信支付 (1/0 或 true/false)
|
||||||
|
is_alipay_enabled: number | boolean // 是否启用支付宝支付 (1/0 或 true/false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索配置接口定义
|
||||||
|
* 用于管理搜索相关的配置选项
|
||||||
|
*/
|
||||||
|
interface searchConfig {
|
||||||
|
keyword: string, // 搜索引擎关键字
|
||||||
|
keyword_enabled: number | boolean // 是否启用关键字搜索 (0/1 或 true/false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计分析配置接口定义
|
||||||
|
* 用于管理统计分析相关的配置选项
|
||||||
|
*/
|
||||||
|
interface analyticsConfig {
|
||||||
|
baidu_tongji_code: string, // 百度统计代码
|
||||||
|
baidu_tongji_enabled: number | boolean // 是否启用百度统计 (0/1 或 true/false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点设置配置接口定义
|
||||||
|
* 用于管理站点基础设置相关的配置选项
|
||||||
|
*/
|
||||||
|
interface siteSettingConfig {
|
||||||
|
copyright: string, // 版权信息
|
||||||
|
icp: string, // ICP备案号
|
||||||
|
icp_url: string, // ICP备案链接
|
||||||
|
network_security: string, // 公安备案号
|
||||||
|
network_security_url: string, // 公安备案链接
|
||||||
|
|
||||||
|
// SEO配置数组数据
|
||||||
|
seo_title: string, // SEO标题
|
||||||
|
seo_keywords: string[], // SEO关键词数组
|
||||||
|
seo_description: string, // SEO描述
|
||||||
|
site_name: string, // 站点名称
|
||||||
|
site_url: string, // 站点URL
|
||||||
|
share_image: string, // 分享图片
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局配置接口定义
|
||||||
|
* 包含应用的所有配置模块
|
||||||
|
*/
|
||||||
interface Config {
|
interface Config {
|
||||||
login: loginConfig
|
login: loginConfig, // 登录配置模块
|
||||||
|
language: languageConfig, // 多语言配置模块
|
||||||
|
theme: themeConfig, // 主题配置模块
|
||||||
|
payment: paymentConfig, // 支付配置模块
|
||||||
|
search: searchConfig, // 搜索配置模块
|
||||||
|
analytics: analyticsConfig, // 统计分析配置模块
|
||||||
|
siteSetting: siteSettingConfig // 站点设置配置模块
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置状态管理 Store
|
||||||
|
* 使用 Pinia 管理应用的全局配置状态
|
||||||
|
*/
|
||||||
export const useConfigStore = defineStore('config', {
|
export const useConfigStore = defineStore('config', {
|
||||||
state: (): Config => {
|
state: (): Config => {
|
||||||
return {
|
return {
|
||||||
login: {
|
login: {
|
||||||
is_username: 1,
|
is_username: 1, // 默认启用用户名登录
|
||||||
is_mobile: 0,
|
is_mobile: 0, // 默认禁用手机号登录
|
||||||
is_auth_register: 1,
|
is_auth_register: 1, // 默认启用授权注册
|
||||||
is_bind_mobile: 0,
|
is_bind_mobile: 0, // 默认禁用绑定手机号
|
||||||
agreement_show: 0
|
is_wechat_scan: 0, // 默认禁用微信扫描登录
|
||||||
|
agreement_show: 0 // 默认不显示用户协议
|
||||||
},
|
},
|
||||||
|
language: {
|
||||||
|
is_enabled: 1, // 默认启用多语言支持
|
||||||
|
default_lang: 'zh-cn', // 默认语言为中文
|
||||||
|
available_langs: ['zh-cn', 'en'] // 可用语言
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
is_enabled: 1, // 默认启用主题切换
|
||||||
|
default_theme: 'light', // 默认主题为浅色
|
||||||
|
available_themes: ['light', 'dark'] // 可用主题
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
is_wechat_enabled: 1, // 默认启用微信支付
|
||||||
|
is_alipay_enabled: 1 // 默认启用支付宝支付
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
keyword: '', // 默认搜索关键字为空
|
||||||
|
keyword_enabled: 0 // 默认不启用关键字搜索
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
baidu_tongji_code: '', // 默认百度统计代码为空
|
||||||
|
baidu_tongji_enabled: 0 // 默认不启用百度统计
|
||||||
|
},
|
||||||
|
siteSetting: {
|
||||||
|
copyright: '', // 默认版权信息为空
|
||||||
|
icp: '', // 默认ICP备案号为空
|
||||||
|
icp_url: 'https://beian.miit.gov.cn/', // 默认ICP备案链接
|
||||||
|
network_security: '', // 默认公安备案号为空
|
||||||
|
network_security_url: '', // 默认公安备案链接为空
|
||||||
|
|
||||||
|
// SEO配置默认值
|
||||||
|
seo_title: 'madong', // 默认SEO标题
|
||||||
|
seo_keywords: ['madong', 'madong-admin', 'madong工作流'], // 默认SEO关键词数组
|
||||||
|
seo_description: 'madong通用快速开发框架是一个基于Vue3 + TypeScript + ElementPlus的快速开发框架,提供了一个完整的后台管理系统模板', // 默认SEO描述
|
||||||
|
site_name: 'madong', // 默认站点名称
|
||||||
|
site_url: '', // 默认站点URL(留空自动获取)
|
||||||
|
share_image: '/images/share-default.png' // 默认分享图片
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async getLoginConfig(router: any = null) {
|
/**
|
||||||
await getLoginConfig().then(({ data }:any) => {
|
* 获取 Web 分组配置
|
||||||
this.login.is_username = parseInt(data.is_username)
|
* @description 一次性获取所有 Web 分组配置(站点设置、搜索、统计分析、登录配置等)
|
||||||
this.login.is_mobile = parseInt(data.is_mobile)
|
* @param router - 可选的路由器实例,用于页面跳转控制
|
||||||
this.login.is_auth_register = parseInt(data.is_auth_register)
|
*/
|
||||||
this.login.is_bind_mobile = parseInt(data.is_bind_mobile)
|
async getWebGroupConfigs(router?: any) {
|
||||||
this.login.agreement_show = parseInt(data.agreement_show)
|
try {
|
||||||
if(data && router && router.currentRoute.value.path === '/site/close'){
|
const response = await getWebGroupConfigs()
|
||||||
navigateTo('/', { replace: true })
|
const data = response?.data || response || {}
|
||||||
|
|
||||||
|
// 更新登录配置
|
||||||
|
if (data.web_login_config && !Array.isArray(data.web_login_config)) {
|
||||||
|
this.login.is_username = parseInt(data.web_login_config.is_username)
|
||||||
|
this.login.is_mobile = parseInt(data.web_login_config.is_mobile)
|
||||||
|
this.login.is_auth_register = parseInt(data.web_login_config.is_auth_register)
|
||||||
|
this.login.is_bind_mobile = parseInt(data.web_login_config.is_bind_mobile)
|
||||||
|
this.login.is_wechat_scan = parseInt(data.web_login_config.is_wechat_scan)
|
||||||
|
this.login.agreement_show = parseInt(data.web_login_config.agreement_show)
|
||||||
|
|
||||||
|
// 如果当前在关闭页面且有配置数据,自动跳转到首页
|
||||||
|
if(data.web_login_config && router && router.currentRoute.value.path === '/site/close'){
|
||||||
|
navigateTo('/', { replace: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
|
||||||
})
|
// 更新站点设置
|
||||||
|
if (data.web_site_setting && !Array.isArray(data.web_site_setting)) {
|
||||||
|
this.siteSetting.copyright = data.web_site_setting.copyright || ''
|
||||||
|
this.siteSetting.icp = data.web_site_setting.icp || ''
|
||||||
|
this.siteSetting.icp_url = data.web_site_setting.icp_url || 'https://beian.miit.gov.cn/'
|
||||||
|
this.siteSetting.network_security = data.web_site_setting.network_security || ''
|
||||||
|
this.siteSetting.network_security_url = data.web_site_setting.network_security_url || ''
|
||||||
|
|
||||||
|
// 更新SEO配置(支持数组形式)
|
||||||
|
this.siteSetting.seo_title = data.web_site_setting.seo_title || 'madong 通用快速框架'
|
||||||
|
this.siteSetting.seo_keywords = data.web_site_setting.seo_keywords || ['madong', 'madong-admin', 'ingenious','工作流引擎']
|
||||||
|
this.siteSetting.seo_description = data.web_site_setting.seo_description || '专业开源服务平台'
|
||||||
|
this.siteSetting.site_name = data.web_site_setting.site_name || 'madong'
|
||||||
|
this.siteSetting.site_url = data.web_site_setting.site_url || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
|
this.siteSetting.share_image = data.web_site_setting.share_image || '/images/share-default.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新搜索配置
|
||||||
|
if (data.web_search_config && !Array.isArray(data.web_search_config)) {
|
||||||
|
this.search.keyword = data.web_search_config.keyword || ''
|
||||||
|
this.search.keyword_enabled = parseInt(data.web_search_config.keyword_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新统计分析配置
|
||||||
|
if (data.web_analytics_config && !Array.isArray(data.web_analytics_config)) {
|
||||||
|
this.analytics.baidu_tongji_code = data.web_analytics_config.baidu_tongji_code || ''
|
||||||
|
this.analytics.baidu_tongji_enabled = parseInt(data.web_analytics_config.baidu_tongji_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可继续添加其他配置的处理
|
||||||
|
// 如有新的 web 分组配置,在此添加
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 Web 分组配置失败:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化所有配置
|
||||||
|
* @description 一次性获取所有配置信息
|
||||||
|
*/
|
||||||
|
async initAllConfigs(router?: any) {
|
||||||
|
await this.getWebGroupConfigs(router)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认导出配置Store
|
||||||
|
* 可在组件中使用 useConfigStore() 来访问配置状态
|
||||||
|
*/
|
||||||
export default useConfigStore
|
export default useConfigStore
|
||||||
|
|||||||
@@ -1,33 +1,56 @@
|
|||||||
export interface Globals {
|
export interface Globals {
|
||||||
// 防抖计时器
|
|
||||||
lazy: NodeJS.Timeout | null
|
lazy: NodeJS.Timeout | null
|
||||||
// 随机数生成时的唯一性自增种子
|
|
||||||
unique: Ref<number>
|
unique: Ref<number>
|
||||||
// 语言包懒加载句柄
|
|
||||||
loadLangHandle: Record<string, any>
|
loadLangHandle: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Menus {
|
export enum MenuType {
|
||||||
id: number
|
DIRECTORY = 'directory',
|
||||||
name: string
|
PAGE = 'page',
|
||||||
type: string
|
LINK = 'link',
|
||||||
path?: string
|
IFRAME = 'iframe'
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
icon: string
|
|
||||||
meta: {
|
|
||||||
/**
|
|
||||||
* 在 meta 中存储多个可能用到的属性
|
|
||||||
* 兼容 RouteRecordRaw 和 RouteLocationNormalizedLoaded 类型
|
|
||||||
*/
|
|
||||||
id: number
|
|
||||||
type: string
|
|
||||||
menu_type: 'tab' | 'link' | 'iframe'
|
|
||||||
}
|
|
||||||
children: Menus[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteConfig {
|
export enum MenuDisplayType {
|
||||||
|
TAB = 'tab',
|
||||||
|
LINK = 'link',
|
||||||
|
IFRAME = 'iframe'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LinkTarget {
|
||||||
|
SELF = '_self',
|
||||||
|
BLANK = '_blank',
|
||||||
|
PARENT = '_parent',
|
||||||
|
TOP = '_top'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuMeta {
|
||||||
|
type: MenuType
|
||||||
|
target?: LinkTarget
|
||||||
|
hidden?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
badge?: string | number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Menus {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: MenuType
|
||||||
|
path?: string
|
||||||
|
title: string
|
||||||
|
url?: string
|
||||||
|
icon?: string
|
||||||
|
meta: MenuMeta
|
||||||
|
children?: Menus[]
|
||||||
|
sort?: number
|
||||||
|
belong?: string
|
||||||
|
belong_id?: number
|
||||||
|
permissions?: string[]
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfig {
|
||||||
site_name: string
|
site_name: string
|
||||||
record_number?: string
|
record_number?: string
|
||||||
version: string
|
version: string
|
||||||
@@ -59,18 +82,11 @@ export interface Member {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonalCenter {
|
export interface PersonalCenter {
|
||||||
// 是否开启会员中心
|
|
||||||
open: boolean
|
open: boolean
|
||||||
// 从后台加载到的会员中心菜单数据
|
|
||||||
user_menus: Menus[]
|
user_menus: Menus[]
|
||||||
// 是否显示一级菜单标题(当有多个一级菜单分组时显示)
|
|
||||||
show_headline: boolean
|
show_headline: boolean
|
||||||
// 权限节点
|
|
||||||
auth_node: Map<string, string[]>
|
auth_node: Map<string, string[]>
|
||||||
// 收缩布局(小屏设备)
|
|
||||||
shrink: boolean
|
shrink: boolean
|
||||||
// 菜单展开状态(小屏设备)
|
|
||||||
menu_expand: boolean
|
menu_expand: boolean
|
||||||
// 顶栏会员菜单下拉项
|
|
||||||
nav_user_menus: Menus[]
|
nav_user_menus: Menus[]
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-39
@@ -1,71 +1,79 @@
|
|||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { MEMBER_KEY } from './constant/keys'
|
|
||||||
import { getMemberProfile } from '~/api/member'
|
import { getMemberProfile } from '~/api/member'
|
||||||
|
import { navigateTo } from 'nuxt/app'
|
||||||
|
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
token: string | null
|
token: string | null
|
||||||
|
refreshToken: string | null
|
||||||
info: Record<string, any> | null,
|
info: Record<string, any> | null,
|
||||||
loginPopup:boolean
|
loginPopup: boolean
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const info={
|
|
||||||
"id": 1001,
|
|
||||||
"username": "john_doe",
|
|
||||||
"nickname": "John",
|
|
||||||
"email": "john.doe@example.com",
|
|
||||||
"mobile": "13800138000",
|
|
||||||
"gender": 1,
|
|
||||||
"birthday": "1990-05-15",
|
|
||||||
"money": 1500.5,
|
|
||||||
"score": 2500,
|
|
||||||
"avatar": "",
|
|
||||||
"last_login_time": "2024-05-20T14:30:00Z",
|
|
||||||
"last_login_ip": "192.168.1.100",
|
|
||||||
"motto": "Stay curious, keep learning",
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
|
||||||
"refresh_token": "rt_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const useMemberStore = defineStore('member', {
|
export const useMemberStore = defineStore('member', {
|
||||||
state: (): Member => {
|
state: (): Member => {
|
||||||
return {
|
return {
|
||||||
token: useCookie('token').value||'',
|
token: '',
|
||||||
info: info,
|
refreshToken: '',
|
||||||
loginPopup:false
|
info: null,
|
||||||
|
loginPopup: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async setToken(token: string) {
|
async setToken(token: string, refreshToken: string) {
|
||||||
this.token = token
|
this.token = token
|
||||||
useCookie('token').value = token
|
this.refreshToken = refreshToken
|
||||||
await this.getMemberInfo()
|
this.info = null
|
||||||
|
// 静默获取用户信息,不阻塞 token 设置
|
||||||
|
// 如果失败不执行 logout,保留 refreshToken 以便后续刷新
|
||||||
|
this.getMemberInfoSilent()
|
||||||
},
|
},
|
||||||
async getMemberInfo() {
|
async getMemberInfo() {
|
||||||
if (!this.token) return
|
if (!this.token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await getMemberProfile()
|
await getMemberProfile()
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
this.info = res.data
|
this.info = res || null
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.logout()
|
console.error('[getMemberInfo] Error:', err)
|
||||||
|
// 这里不调用 logout,保留 refreshToken 以便 token 刷新机制工作
|
||||||
|
this.info = null
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
logout() {
|
async getMemberInfoSilent() {
|
||||||
if (!this.token) return
|
// 静默获取用户信息,不抛出错误
|
||||||
this.token = ''
|
if (!this.token) {
|
||||||
this.info = null
|
return
|
||||||
useCookie('token').value = null
|
}
|
||||||
// logout().then().catch()
|
try {
|
||||||
|
const res: any = await getMemberProfile()
|
||||||
|
this.info = res || null
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[getMemberInfoSilent] Error:', err)
|
||||||
|
// 不执行 logout,只清除 info
|
||||||
|
this.info = null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
logOpen(){
|
logout() {
|
||||||
|
this.token = ''
|
||||||
|
this.refreshToken = ''
|
||||||
|
this.info = null
|
||||||
|
this.loginPopup = false
|
||||||
|
// 跳转到首页
|
||||||
|
navigateTo('/')
|
||||||
|
},
|
||||||
|
logOpen() {
|
||||||
this.loginPopup = true
|
this.loginPopup = true
|
||||||
},
|
},
|
||||||
logClose(){
|
logClose() {
|
||||||
this.loginPopup = false
|
this.loginPopup = false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
persist: {
|
||||||
|
key: 'member-store',
|
||||||
|
pick: ['token', 'refreshToken', 'info', 'loginPopup']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user