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:
Mr.April
2026-04-07 11:01:11 +08:00
parent 1b83f1890b
commit d84d972e73
127 changed files with 103052 additions and 14661 deletions
Binary file not shown.
+13
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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/)
+76 -21
View File
@@ -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/)
-87
View File
@@ -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')
}
+188
View File
@@ -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)
}
+99
View File
@@ -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
}
-55
View File
@@ -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')
}
+189
View File
@@ -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
})
}
+122
View File
@@ -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
}
+47
View File
@@ -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')
}
+82
View File
@@ -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)
}
-97
View File
@@ -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')
}
+64
View File
@@ -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)
}
+41
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
+387
View File
@@ -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;
}
+400
View File
@@ -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>
+77
View File
@@ -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>
-56
View File
@@ -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>
+299
View File
@@ -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>
+141
View File
@@ -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. 图标选择器支持分页显示
## 示例代码
完整示例请参考项目中的使用场景。
+49
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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");
+71 -45
View File
@@ -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;
} }
+93 -50
View File
@@ -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
+154
View File
@@ -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>
+128
View File
@@ -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>
+19 -7
View File
@@ -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()
+22
View File
@@ -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
View File
@@ -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
} }
} }
+5 -6
View File
@@ -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"
}
+20
View File
@@ -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"
}
+25
View File
@@ -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
View File
@@ -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!"
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"title": "Home"
}
+16
View File
@@ -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"
}
+21
View File
@@ -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"
}
}
+15
View File
@@ -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"
}
+27
View File
@@ -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"
}
}
+72
View File
@@ -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"
}
}
+14
View File
@@ -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"
}
-8
View File
@@ -1,8 +0,0 @@
{
"pages": {
"index": "Index",
"code": {
"code": "Code"
}
}
}
-18
View File
@@ -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": "请使用微信扫描二维码登录"
}
-15
View File
@@ -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": "验证码已发送"
}
+20
View File
@@ -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": "未开通微信扫描登录"
}
+25
View File
@@ -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
View File
@@ -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": "网络请求超时!"
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"title": "首页"
}
+16
View File
@@ -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": "两次输入的密码不一致"
}
}
+15
View File
@@ -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": "暂无数据"
}
+27
View File
@@ -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": "请输入验证码"
}
}
+14
View File
@@ -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天可获得额外奖励"
}
-34
View File
@@ -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": "站点已关闭"
}
}
}
+230
View File
@@ -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>
+68 -20
View File
@@ -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>
+7 -5
View File
@@ -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>
+4 -2
View File
@@ -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>
+177 -16
View File
@@ -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>
+91 -26
View File
@@ -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>
+60 -17
View File
@@ -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 -1
View File
@@ -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>
+65 -25
View File
@@ -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>
+26 -54
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+40
View File
@@ -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
})
}
})
+11
View File
@@ -0,0 +1,11 @@
// 导入权限中间件
import authMiddleware from './auth'
/**
* 全局中间件
* 应用于所有路由
*/
export default defineNuxtRouteMiddleware((to, from) => {
// 运行权限中间件
return authMiddleware(to, from)
})
+82 -11
View File
@@ -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>
+264
View File
@@ -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>
+52 -5
View File
@@ -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
View File
@@ -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'
}
}
] ]
+64
View File
@@ -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
View File
@@ -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
} }
} }
}) })
+3
View File
@@ -0,0 +1,3 @@
export default defineNuxtPlugin(() => {
// console.log('Icon plugin loaded')
})
+70
View File
@@ -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
+11
View File
@@ -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()
}
})
+2 -2
View File
@@ -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
View File
@@ -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
+45 -29
View File
@@ -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
View File
@@ -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