docs(backend): 重构后端README文档,完善项目文档与开发指南

- 全面更新README,添加项目特性、架构设计与技术栈介绍
- 详细补充项目结构说明及模块设计规范
- 增加快速开始步骤,包括环境配置、数据库初始化和服务启动
- 补充主要API模块路径和认证授权使用示例
- 添加开发指南、数据库迁移与测试方法
- 集成监控、日志级别说明及性能监控内容
- 完善Docker、传统部署及Nginx配置示例
- 添加贡献指南和代码规范说明
- 新增MCP模块概述及智能对话API接口文档
- 清理和移除module_ai中旧的mcp_server相关实现代码
- 在api/v1初始化文件中注册AI模块路由
- 修改resource模块,增强资源路径安全检查和文件类型检测逻辑
- 增强资源搜索和上传服务的健壮性及安全性
This commit is contained in:
zhangtao
2025-09-08 00:45:34 +08:00
parent 8cb93d31d1
commit 01fd6c8ea4
66 changed files with 5692 additions and 5193 deletions
+363 -72
View File
@@ -1,105 +1,396 @@
# backend
# FastAPI Vue3 Admin - Backend
## 项目结构
一个基于 FastAPI 的现代化后端管理系统,为前端 Vue3 管理系统提供完整的 API 服务支持。
```sh
fastapi_project/backend
├─ app # 项目核心代码
│ ├─ almbic # 数据库迁移文件
│ ├─ api # 接口模块
│ │ └─ v1 # 接口版本模块
│ │ ├─ controllers # 控制器模块
│ │ ├─ crud # 数据库操作模块
│ │ ├─ models # orm模型模块
│ │ ├─ params # 参数模块
│ │ ├─ schemas # pydantic模型模块
│ │ ├─ services # 业务模块
│ │ └─ urls # 路由模块
│ ├─ common # 公共模块
│ ├─ config # 项目配置文件
│ ├─ core # 项目核心模块
│ ├─ module_task # 项目任务模块
│ ├─ plugin # 项目插件模块
│ ├─ scripts # 项目初始化模块
│ └─ utils # 工具模块
├─ env # 项目环境配置文件
├─ logs # 项目日志文件
├─ sql # 项目数据库文件
├─ static # 项目静态文件
├─ main.py # 项目启动文件
├─ alembic.ini # alembic配置文件
├─ requirements.txt # 项目依赖文件
├─ mkdocs.yml # mkdocs配置文件
├─ dev_sql.db # 项目数据库文件
└─ README.md # 项目说明文档
## 🚀 项目特性
- **现代技术栈**: FastAPI + SQLAlchemy 2.0 + Pydantic 2.x
- **多数据库支持**: MySQL、PostgreSQL、SQLite
- **异步架构**: 支持高并发异步数据库操作
- **权限管理**: 完整的 RBAC 权限控制体系
- **任务调度**: 基于 APScheduler 的定时任务系统
- **日志监控**: 完整的操作日志和系统监控
- **代码生成**: 智能化代码生成工具
- **AI 集成**: 支持 OpenAI 大模型调用
- **云存储**: 支持阿里云 OSS 对象存储
## 🏗️ 系统架构
### 技术栈
| 技术 | 版本 | 说明 |
|------|------|------|
| FastAPI | 0.115.2 | 现代 Web 框架 |
| SQLAlchemy | 2.0.36 | ORM 框架 |
| Alembic | 1.15.1 | 数据库迁移工具 |
| Pydantic | 2.x | 数据验证与序列化 |
| APScheduler | 3.11.0 | 定时任务调度 |
| Redis | 5.2.1 | 缓存与会话存储 |
| Uvicorn | 0.30.6 | ASGI 服务器 |
| Python | 3.10+ | 运行环境 |
### 架构设计
```txt
📦 分层架构 (MVC)
├── 🎯 Controller # 控制器层 - 处理HTTP请求
├── 🏢 Service # 业务层 - 核心业务逻辑
├── 💾 CRUD # 数据访问层 - 数据库操作
└── 📊 Model # 模型层 - 数据模型定义
```
## 快速开始
## 📁 项目结构
```txt
fastapi_vue3_admin/backend/
├── 📁 app/ # 项目核心代码
│ ├── 💾 alembic/ # 数据库迁移管理
│ ├── 🌐 api/ # API 接口模块
│ │ └── v1/ # API v1 版本
│ │ ├── module_system/ # 系统管理模块
│ │ ├── module_monitor/ # 系统监控模块
│ │ ├── module_ai/ # AI 功能模块
│ │ └── module_*/ # 其他业务模块
│ ├── 📄 common/ # 公共组件(常量、枚举、响应封装)
│ ├── ⚙️ config/ # 项目配置文件
│ ├── 💖 core/ # 核心模块(数据库、中间件、安全)
│ ├── ⏰ module_task/ # 定时任务模块
│ ├── 🔌 plugin/ # 插件模块
│ ├── 📜 scripts/ # 初始化脚本和数据
│ └── 🛠️ utils/ # 工具类(验证码、文件上传等)
├── 🌍 env/ # 环境配置文件
├── 📄 logs/ # 日志输出目录
├── 📊 sql/ # SQL 初始化脚本
├── 📷 static/ # 静态资源文件
├── 🚀 main.py # 项目启动入口
├── 📄 alembic.ini # Alembic 迁移配置
├── 📎 requirements.txt # Python 依赖包
└── 📝 README.md # 项目说明文档
```
### 模块设计
每个业务模块采用统一的分层结构:
```txt
module_*/
├── controller.py # 控制器 - HTTP 请求处理
├── service.py # 服务层 - 业务逻辑处理
├── crud.py # 数据层 - 数据库操作
├── model.py # ORM 模型 - 数据库表定义
├── schema.py # Pydantic 模型 - 数据验证
└── param.py # 参数模型 - 请求参数
```
## 🚀 快速开始
### 环境要求
- **Python**: 3.10+
- **数据库**: MySQL 8.0+ / PostgreSQL 13+ / SQLite 3.x
- **Redis**: 6.0+ (可选)
### 安装与运行
#### 1. 项目初始化
```bash
# 克隆项目
git clone <repository-url>
cd fastapi_vue3_admin/backend
# 创建虚拟环境(推荐)
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
```sh
# 进入后端工程目录
cd backend
# 安装依赖
pip3 install -r requirements.txt
# 启动后端服务
python3 main.py run
python3 main.py run--env=dev
# 生成迁移文件
python3 main.py revision "初始化迁移" --env=dev(不加默认为dev)
# 应用迁移
python3 main.py upgrade --env=dev(不加默认为dev)
```
## Markdown转静态网站(mkdocs-material使用)介绍
#### 2. 环境配置
- 名称:mkdocs-material
- 官网:<https://squidfunk.github.io/mkdocs-material/>
- 完整文档信息清访问 [mkdocs官网](https://www.mkdocs.org).
复制并编辑环境配置文件:
## 工具安装
```sh
pip install mkdocs-material
```bash
cp env/dev.env.example env/dev.env
# 编辑 env/dev.env 文件,配置数据库连接和其他参数
```
## 工具使用
主要配置项:
### 创建项目
- `DATABASE_URL`: 数据库连接地址
- `SECRET_KEY`: JWT 加密密钥
- `REDIS_URL`: Redis 连接地址(可选)
```sh
mkdocs new 项目名称
#### 3. 数据库初始化
```bash
# 生成迁移文件(仅首次或模型变更时)
python3 main.py revision "初始化迁移" --env=dev
# 应用数据库迁移
python3 main.py upgrade --env=dev
# 初始化数据(可选,系统会自动初始化)
python3 main.py init-data --env=dev
```
### 编辑构建文档
#### 4. 启动服务
```yaml
theme:
name: material
```bash
# 开发环境启动
python3 main.py run --env=dev
# 生产环境启动
python3 main.py run --env=prod
# 或使用 uvicorn 直接启动
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
### 启动服务
服务成功启动后,您可以访问:
```sh
mkdocs serve
- **API 文档**: [http://localhost:8000/docs](http://localhost:8000/docs) (Swagger UI)
- **替代文档**: [http://localhost:8000/redoc](http://localhost:8000/redoc) (ReDoc)
- **健康检查**: [http://localhost:8000/health](http://localhost:8000/health)
## 📚 API 文档
### 主要接口模块
| 模块 | 路径 | 说明 |
|------|------|------|
| 用户管理 | `/api/v1/system/user` | 用户增删改查、角色分配 |
| 角色管理 | `/api/v1/system/role` | 角色管理、权限分配 |
| 菜单管理 | `/api/v1/system/menu` | 系统菜单、权限节点 |
| 部门管理 | `/api/v1/system/dept` | 组织架构管理 |
| 岗位管理 | `/api/v1/system/position` | 岗位信息管理 |
| 系统监控 | `/api/v1/monitor/*` | 系统性能、日志监控 |
| 任务调度 | `/api/v1/monitor/job` | 定时任务管理 |
| 文件管理 | `/api/v1/common/file` | 文件上传下载 |
| 代码生成 | `/api/v1/generator/*` | 代码生成工具 |
### 认证授权
系统使用 JWT Bearer Token 进行身份验证:
```bash
# 登录获取 Token
curl -X POST "http://localhost:8000/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "123456"}'
# 使用 Token 访问受保护的资源
curl -X GET "http://localhost:8000/api/v1/system/user/list" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
### 构建静态站点
## 🛠️ 开发指南
```sh
mkdocs build
### 项目配置
主要配置文件位于 `app/config/setting.py`,支持通过环境变量进行覆盖。
### 数据库迁移
```bash
# 查看当前迁移状态
python3 main.py current --env=dev
# 查看迁移历史
python3 main.py history --env=dev
# 回滚到上一个版本
python3 main.py downgrade -1 --env=dev
```
### 获取帮助
### 添加新模块
```sh
mkdocs -h
1.`app/api/v1/` 下创建新的模块目录
2. 按照现有模块结构创建文件:
- `model.py` - SQLAlchemy ORM 模型
- `schema.py` - Pydantic 数据模型
- `crud.py` - 数据库操作层
- `service.py` - 业务逻辑层
- `controller.py` - API 控制器
- `param.py` - 请求参数模型
3. 在主路由中注册新模块
### 测试
```bash
# 运行单元测试
pytest tests/
# 运行指定测试文件
pytest tests/test_user.py -v
# 生成测试覆盖率报告
pytest --cov=app tests/
```
### 生成所有依赖
## 📊 监控与日志
```sh
pip freeze > requirements.txt
### 日志级别
- **DEBUG**: 详细的调试信息
- **INFO**: 一般信息(默认级别)
- **WARNING**: 警告信息
- **ERROR**: 错误信息
- **CRITICAL**: 严重错误
### 性能监控
系统内置了完整的性能监控功能:
- API 响应时间监控
- 数据库连接池监控
- 内存与 CPU 使用率监控
- 自定义业务指标监控
## 🚀 部署指南
### Docker 部署
```bash
# 构建镜像
docker build -t fastapi-admin .
# 运行容器
docker run -d \
--name fastapi-admin \
-p 8000:8000 \
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
fastapi-admin
```
### 传统部署
```bash
# 使用 Gunicorn 作为 WSGI 服务器
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile -
```
### Nginx 配置
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
### 开发流程
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 发起 Pull Request
### 代码规范
- 遵循 PEP 8 Python 编码规范
- 使用类型注解 (Type Hints)
- 编写单元测试
- 添加必要的注释和文档
## 📝 更新日志
### v1.0.0 (2024-09-06)
- ✨ 初始版本发布
- ✨ 完整的用户权限管理系统
- ✨ 支持多数据库类型
- ✨ 定时任务调度功能
- ✨ 代码生成工具
- ✨ AI 集成功能
## 📜 相关链接
- **FastAPI 官方文档**: [https://fastapi.tiangolo.com/](https://fastapi.tiangolo.com/)
- **SQLAlchemy 文档**: [https://docs.sqlalchemy.org/](https://docs.sqlalchemy.org/)
- **Pydantic 文档**: [https://pydantic-docs.helpmanual.io/](https://pydantic-docs.helpmanual.io/)
## 💬 支持与反馈
如果您在使用过程中遇到问题或有任何建议,请通过以下方式联系我们:
- 🐛 **Bug 报告**: 请在 GitHub Issues 中提交
- 💡 **功能建议**: 请在 GitHub Discussions 中讨论
- 💬 **技术交流**: 欢迎参与项目讨论
---
❤️ **感谢您的关注和支持!** 如果这个项目对您有帮助,请给我们一个 ⭐️ Star!
# MCP 模块
## 概述
MCP (Model Context Protocol) 模块为系统提供与AI模型交互的能力,基于FastAPI-MCP实现。
## 功能特性
- 智能对话接口
- 流式和非流式响应支持
- WebSocket聊天支持
- MCP服务器状态监控
## API接口
### 智能对话
```
POST /api/v1/mcp/chat
```
请求参数:
- `message` (string, required): 聊天消息
- `stream` (boolean, optional): 是否流式返回,默认为false
### 获取服务器状态
```
GET /api/v1/mcp/status
```
### WebSocket聊天
```
GET /api/v1/mcp/ws/chat
```
## MCP集成
系统已集成FastAPI-MCP,可通过以下URL访问MCP服务器:
```
http://localhost:8000/mcp
```
## 配置
在系统配置中设置以下参数:
- `QWEN_API_KEY`: Qwen API密钥
- `QWEN_BASE_URL`: Qwen API基础URL
- `QWEN_MODEL`: Qwen模型名称
+9
View File
@@ -26,6 +26,8 @@ from .module_example.demo.controller import DemoRouter
from .module_application.myapp.controller import MyAppRouter
from .module_ai.mcp.controller import MCPRouter
from .module_resource.resource.controller import ResourceRouter
@@ -58,6 +60,8 @@ EXAMPLE_MODULES = [{"router": DemoRouter}]
APPLICATION_MODULES = [{"router": MyAppRouter}]
AI_MODULES = [{"router": MCPRouter}]
router = APIRouter()
for module in SYSTEM_MODULES:
@@ -88,4 +92,9 @@ for module in APPLICATION_MODULES:
for module in RESOURCE_MODULES:
router.include_router(
router=module["router"], prefix="/resource"
)
for module in AI_MODULES:
router.include_router(
router=module["router"], prefix="/ai"
)
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
"""
AI模块初始化文件
"""
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""
AI模块初始化文件
"""
from .controller import MCPRouter
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from app.common.response import StreamResponse, SuccessResponse
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.core.logger import logger
from app.api.v1.module_system.auth.schema import AuthSchema
from .service import MCPService
from .schema import ChatQuerySchema
MCPRouter = APIRouter(route_class=OperationLogRoute, prefix="/mcp", tags=["MCP智能助手"])
@MCPRouter.post("/chat", summary="智能对话", description="与MCP智能助手进行对话")
async def chat_controller(
query: ChatQuerySchema,
auth: AuthSchema = Depends(AuthPermission(permissions=["ai:mcp:chat"]))
) -> StreamingResponse:
"""智能对话接口"""
logger.info(f"用户 {auth.user.name} 发起智能对话: {query.message[:50]}...")
async def generate_response():
async for chunk in MCPService.chat_query(query.message):
yield chunk
return StreamingResponse(generate_response(), media_type="text/plain")
@MCPRouter.websocket("/ws/chat", name="WebSocket聊天")
async def websocket_chat_controller(
websocket: WebSocket,
):
"""WebSocket聊天接口
ws://127.0.0.1:8001/api/v1/ai/mcp/ws/chat
"""
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
# 流式发送响应
async for chunk in MCPService.chat_query(data):
await websocket.send_text(chunk)
except Exception as e:
logger.error(f"WebSocket聊天出错: {str(e)}")
await websocket.close()
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, Field
from typing import Optional
class ChatQuerySchema(BaseModel):
"""聊天查询模型"""
message: str = Field(..., min_length=1, max_length=4000, description="聊天消息")
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from app.utils.ai_util import AIClient
class MCPService:
"""MCP服务层 - 适配FastAPI-MCP"""
@classmethod
async def chat_query(cls, message: str):
"""处理聊天查询"""
# 创建MCP客户端实例
mcp_client = AIClient()
# 处理消息
async for response in mcp_client.process(message):
yield response
@@ -1,37 +0,0 @@
from fastapi import FastAPI
from starlette.websockets import WebSocket, WebSocketDisconnect
from mcp_server.mcp_client import MCPClient
async def init_ai_websocket(app: FastAPI):
@app.websocket("/ws/chat")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
user_id = id(websocket)
user_contexts = {}
user_contexts[user_id] = [{"role": "system", "content": "你是一个有帮助的助手。"}]
client = MCPClient()
await client.connect_to_server('mcp_server/mcp_server.py')
try:
while True:
user_msg = await websocket.receive_text()
user_contexts[user_id].append({"role": "user", "content": user_msg})
await websocket.send_json({"role": "user", "content": user_msg})
assistant_reply = ""
response = client.put_query(user_msg)
await websocket.send_json({"start": True})
async for content_piece in response:
assistant_reply += content_piece
await websocket.send_json({"role": "assistant", "content": content_piece})
await websocket.send_json({"done": True})
except WebSocketDisconnect:
print("WebSocket 断开连接")
# 清理上下文
user_contexts.pop(user_id, None)
await client.cleanup()
@@ -1,215 +0,0 @@
import argparse
import asyncio
import os
import json
from typing import Optional
from contextlib import AsyncExitStack
from click import argument
from openai import AsyncOpenAI
from dotenv import load_dotenv
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class MCPClient:
def __init__(self):
"""初始化 MCP 客户端"""
self.exit_stack = AsyncExitStack()
self.openai_api_key = os.getenv("OPENAI_API_KEY") # 读取 OpenAI API Key
self.base_url = os.getenv("OPENAI_API_URL") # 读取 BASE YRL
self.model = os.getenv("OPENAI_API_MODEL") # 读取 model
if not self.openai_api_key:
raise ValueError("❌ 未找到 OpenAI API Key,请在 .env 文件中设置 OPENAI_API_KEY")
self.client = AsyncOpenAI(api_key=self.openai_api_key, base_url=self.base_url) # 创建OpenAI client
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
self.messages = []
async def connect_to_server(self, server_script_path: str):
"""连接到 MCP 服务器并列出可用工具"""
is_python = server_script_path.endswith('.py')
is_js = server_script_path.endswith('.js')
if not (is_python or is_js):
raise ValueError("服务器脚本必须是 .py 或 .js 文件")
# 必须设置项目根目录,否则无法获取到其他引用代码文件
project_root = os.path.abspath(os.getcwd())
python_cmd_path = os.getenv("PYTHON_PATH")
command = python_cmd_path if is_python else "node"
parser = argparse.ArgumentParser(description='命令行参数')
parser.add_argument('--env', type=str, default='', help='运行环境')
args, unknown = parser.parse_known_args()
server_params = StdioServerParameters(
command=command,
args=[server_script_path, f'--env={args.env}'],
env={"PYTHONPATH": project_root}
)
# 启动 MCP 服务器并建立通信
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
await self.session.initialize()
# 列出 MCP 服务器上的工具
response = await self.session.list_tools()
tools = response.tools
print("\n已连接到服务器,支持以下工具:", [tool.name for tool in tools])
async def process_query(self, query: str):
"""
使用大模型处理查询并调用可用的 MCP 工具 (Function Calling)
"""
self.messages.append({"role": "user", "content": query})
response = await self.session.list_tools()
available_tools = [{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"input_schema": tool.inputSchema
}
} for tool in response.tools]
# print(available_tools)
response = await self.client.chat.completions.create(
model=self.model,
messages=self.messages,
stream=True,
tools=available_tools
)
is_tool_call = False
tool_name = None
tool_args = ''
tool_call_id = None
content = ''
yield f'🤖AI'
async for chunk in response:
print(chunk)
if chunk.choices and chunk.choices[0].delta.tool_calls:
#调用工具
tool_call = chunk.choices[0].delta.tool_calls[0]
if tool_call.id:
is_tool_call = True
tool_name = tool_call.function.name
tool_call_id = tool_call.id
yield f'开始调用工具【{tool_call.function.name}】,参数为'
if tool_call.function:
tool_args += tool_call.function.arguments
print(f'tool_args==={tool_args}')
yield tool_call.function.arguments
elif tool_call.function:
tool_args += tool_call.function.arguments
print(f'tool_args==={tool_args}')
yield tool_call.function.arguments
elif chunk.choices and chunk.choices[0].delta.content:
# 大模型解答
content += chunk.choices[0].delta.content
yield chunk.choices[0].delta.content
elif chunk.choices and chunk.choices[0].finish_reason == 'tool_calls':
# 参数处理完毕
pass
elif chunk.choices and chunk.choices[0].finish_reason == 'stop':
self.messages.append({
"role": "assistant",
"content": content
})
pass
# 处理返回的内容
if is_tool_call:
# 如何是需要使用工具,就解析工具
# 执行工具
print(f"\n\n[Calling tool {tool_name} with args {tool_args}]\n\n")
result = await self.session.call_tool(tool_name, json.loads(tool_args))
print(result)
# 将模型返回的调用哪个工具数据和工具执行完成后的数据都存入messages中
self.messages.append({
"role": "assistant",
"content": "",
"index": 0,
"tool_calls": [{
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": tool_args
}
}]
})
self.messages.append({
"role": "tool",
"content": result.content[0].text,
"tool_call_id": tool_call_id,
})
# 将上面的结果再返回给大模型用于生产最终的结果
result_response = await self.client.chat.completions.create(
model=self.model,
messages=self.messages,
stream=True,
)
result_content = ''
async for chunk in result_response:
if chunk.choices and chunk.choices[0].delta.content:
result_content += chunk.choices[0].delta.content
yield chunk.choices[0].delta.content
self.messages.append({
"role": "assistant",
'content': result_content,
})
return
async def put_query(self, query: str):
print(f"\n🤖 OpenAI: ", end="", flush=True)
response = self.process_query(query) # 发送用户输入到 OpenAI API
async for value in response:
print(value, end="", flush=True)
yield value
async def chat_loop(self):
"""运行交互式聊天循环"""
print("\n🤖 MCP 客户端已启动!输入 'quit' 退出")
while True:
try:
query = input("\n你: ").strip()
if query.lower() == 'quit':
break
print(f"\n🤖 OpenAI: ", end="", flush=True)
response = self.process_query(query) # 发送用户输入到 OpenAI API
async for value in response:
print(value, end="", flush=True)
except Exception as e:
print(f"\n⚠️ 发生错误: {str(e)}")
async def cleanup(self):
"""清理资源"""
await self.exit_stack.aclose()
async def main(server_script_path: str):
client = MCPClient()
try:
await client.connect_to_server(server_script_path)
await client.chat_loop()
finally:
await client.cleanup()
if __name__ == "__main__":
asyncio.run(main('mcp_server.py'))
@@ -1,39 +0,0 @@
from typing import Literal
from mcp.server.fastmcp import FastMCP
from tool_table import TableTool
from tool_weather import WeatherTool
# 初始化 MCP 服务器
mcp = FastMCP("FluxMcpServer")
@mcp.tool()
async def query_weather(city: str) -> str:
"""
输入指定城市的英文名称,返回今日天气查询结果。
:param city: 城市名称(需使用英文)
:return: 格式化后的天气信息
"""
data = await WeatherTool.fetch_weather(city)
return WeatherTool.format_weather(data)
@mcp.tool()
async def query_table(table_name: Literal["car_driver", "student_info"]) -> str:
"""
输入指定表名,获取表内的数据。
Args:
table_name: 表名选项:
- car_driver: 司机信息
- student_info: 学生信息表
return: 数据表内容
"""
data = await TableTool.fetch_table_data(table_name)
return data
if __name__ == "__main__":
# 以标准 I/O 方式运行 MCP 服务器
mcp.run(transport='stdio')
@@ -1,30 +0,0 @@
import json
from fastapi.encoders import jsonable_encoder
from sqlalchemy import select
from config.database import Base
from config.get_db import get_db
import logging
from module_admin.entity.do.car_driver_do import CarDriver
from module_admin.entity.do.student_info_do import StudentInfo
class TableTool:
logger = logging.getLogger(__name__)
# 因为mcp服务是在另外进程里面,需要导入模型,否则Base.registry.mappers是空的
support_modules = [CarDriver, StudentInfo]
@classmethod
async def fetch_table_data(cls, table_name: str) -> str:
async for query_db in get_db():
for mapper in Base.registry.mappers:
table_cls = mapper.class_
if hasattr(table_cls, '__tablename__') and table_cls.__tablename__ == table_name:
result = await query_db.execute(select(table_cls))
data = result.scalars().all()
json_str = json.dumps(jsonable_encoder(data), ensure_ascii=False)
return json_str
raise ValueError(f"No model found for table name: {table_name}to check if you have imported it")
@@ -1,72 +0,0 @@
import json
from typing import Any
import httpx
class WeatherTool:
# OpenWeather API 配置
OPENWEATHER_API_BASE = "https://api.openweathermap.org/data/2.5/weather"
API_KEY = "146d600baa0f4f7a7687bdb573fb9138" # 请替换为你自己的 OpenWeather API Key
USER_AGENT = "weather-app/1.0"
@classmethod
async def fetch_weather(cls, city: str) -> dict[str, Any] | None:
"""
从 OpenWeather API 获取天气信息。
:param city: 城市名称(需使用英文,如 Beijing)
:return: 天气数据字典;若出错返回包含 error 信息的字典
"""
params = {
"q": city,
"appid": cls.API_KEY,
"units": "metric",
"lang": "zh_cn"
}
headers = {"User-Agent": cls.USER_AGENT}
async with httpx.AsyncClient() as client:
try:
response = await client.get(cls.OPENWEATHER_API_BASE, params=params, headers=headers, timeout=30.0)
response.raise_for_status()
return response.json() # 返回字典类型
except httpx.HTTPStatusError as e:
return {"error": f"HTTP 错误: {e.response.status_code}"}
except Exception as e:
return {"error": f"请求失败: {str(e)}"}
@classmethod
def format_weather(cls, data: dict[str, Any] | str) -> str:
"""
将天气数据格式化为易读文本。
:param data: 天气数据(可以是字典或 JSON 字符串)
:return: 格式化后的天气信息字符串
"""
# 如果传入的是字符串,则先转换为字典
if isinstance(data, str):
try:
data = json.loads(data)
except Exception as e:
return f"无法解析天气数据: {e}"
# 如果数据中包含错误信息,直接返回错误提示
if "error" in data:
return f"⚠️ {data['error']}"
# 提取数据时做容错处理
city = data.get("name", "未知")
country = data.get("sys", {}).get("country", "未知")
temp = data.get("main", {}).get("temp", "N/A")
humidity = data.get("main", {}).get("humidity", "N/A")
wind_speed = data.get("wind", {}).get("speed", "N/A")
# weather 可能为空列表,因此用 [0] 前先提供默认字典
weather_list = data.get("weather", [{}])
description = weather_list[0].get("description", "未知")
return (
f"🌍 {city}, {country}\n"
f"🌡 温度: {temp}°C\n"
f"💧 湿度: {humidity}%\n"
f"🌬 风速: {wind_speed} m/s\n"
f"🌤 天气: {description}\n"
)
@@ -19,6 +19,8 @@ class ResourceType(Enum):
class ResourceItemSchema(BaseModel):
"""资源项目模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
name: str = Field(..., description="文件名")
path: str = Field(..., description="文件路径")
relative_path: str = Field(..., description="相对路径")
@@ -37,6 +39,8 @@ class ResourceItemSchema(BaseModel):
class ResourceDirectorySchema(BaseModel):
"""资源目录模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
path: str = Field(..., description="目录路径")
name: str = Field(..., description="目录名称")
items: List[ResourceItemSchema] = Field(default_factory=list, description="目录项")
@@ -47,6 +51,8 @@ class ResourceDirectorySchema(BaseModel):
class ResourceStatsSchema(BaseModel):
"""资源统计模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
mount_point: str = Field(..., description="挂载点")
total_files: int = Field(0, description="文件总数")
total_dirs: int = Field(0, description="目录总数")
@@ -60,6 +66,8 @@ class ResourceStatsSchema(BaseModel):
class ResourceSearchSchema(BaseModel):
"""资源搜索模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
keyword: Optional[str] = Field(None, description="关键词")
file_type: Optional[str] = Field(None, description="文件类型")
resource_type: Optional[ResourceType] = Field(None, description="资源类型")
@@ -74,6 +82,8 @@ class ResourceSearchSchema(BaseModel):
class ResourceUploadSchema(BaseModel):
"""资源上传响应模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
filename: str = Field(..., description="文件名")
file_path: str = Field(..., description="文件路径")
file_url: str = Field(..., description="访问URL")
@@ -10,6 +10,8 @@ from pathlib import Path
from fastapi import UploadFile
from PIL import Image
# 尝试导入 magic 库,如果失败则标记为不可用
try:
import magic
MAGIC_AVAILABLE = True
@@ -18,6 +20,10 @@ except ImportError:
from app.core.exceptions import CustomException
from app.core.logger import logger
# 如果 magic 不可用,记录日志
if not MAGIC_AVAILABLE:
logger.info("没有找到 python-magic 库,将使用基于扩展名的文件类型检测")
from app.utils.excel_util import ExcelUtil
from app.config.setting import settings
from ...module_system.auth.schema import AuthSchema
@@ -38,32 +44,48 @@ from .schema import (
class ResourceService:
"""
资源管理模块服务层 - 直接操作文件系统
资源管理模块服务层 - 管理系统静态文件目录
"""
# 默认挂载点配置
DEFAULT_MOUNT_POINT = getattr(settings, 'RESOURCE_MOUNT_POINT', '/Users/tao/workspace/fastapi_vue3_admin/backend/static/upload')
ALLOWED_MOUNT_POINTS = getattr(settings, 'ALLOWED_MOUNT_POINTS', ['/Users/tao/workspace/fastapi_vue3_admin/backend/static', '/Users/tao/workspace/fastapi_vue3_admin/backend/static/upload'])
# 配置常量
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
MAX_SEARCH_RESULTS = 1000 # 最大搜索结果数
MAX_PATH_DEPTH = 20 # 最大路径深度
@classmethod
def _get_safe_path(cls, path: str) -> str:
def _get_resource_root(cls) -> str:
"""获取资源管理根目录"""
if not settings.STATIC_ENABLE:
raise CustomException(msg='静态文件服务未启用')
return str(settings.STATIC_ROOT)
@classmethod
def _get_safe_path(cls, path: str = None) -> str:
"""获取安全的文件路径"""
if not path:
return cls.DEFAULT_MOUNT_POINT
# 规范化路径
safe_path = os.path.normpath(os.path.abspath(path))
resource_root = cls._get_resource_root()
# 检查路径是否在允许的挂载点内
allowed = False
for mount_point in cls.ALLOWED_MOUNT_POINTS:
mount_abs = os.path.normpath(os.path.abspath(mount_point))
if safe_path.startswith(mount_abs):
allowed = True
break
if not allowed:
if not path:
return resource_root
# 清理路径,移除危险字符
path = path.strip().replace('..', '').replace('//', '/')
# 规范化路径
if os.path.isabs(path):
safe_path = os.path.normpath(path)
else:
safe_path = os.path.normpath(os.path.join(resource_root, path))
# 检查路径是否在允许的范围内
resource_root_abs = os.path.normpath(os.path.abspath(resource_root))
safe_path_abs = os.path.normpath(os.path.abspath(safe_path))
if not safe_path_abs.startswith(resource_root_abs):
raise CustomException(msg=f'访问路径不在允许范围内: {path}')
# 防止路径遍历攻击
if '..' in safe_path or safe_path.count('/') > cls.MAX_PATH_DEPTH: # 限制最大目录深度
raise CustomException(msg=f'不安全的路径格式: {path}')
return safe_path
@@ -86,16 +108,45 @@ class ResourceService:
stat = os.stat(safe_path)
path_obj = Path(safe_path)
resource_root = cls._get_resource_root()
# 获取文件扩展名和类型
file_extension = path_obj.suffix.lower() if path_obj.suffix else None
file_type = cls._get_mime_type_from_extension(file_extension) if file_extension else None
# 优先使用 magic 库检测 MIME 类型
file_type = None
if MAGIC_AVAILABLE and os.path.isfile(safe_path):
try:
file_type = magic.from_file(safe_path, mime=True)
except Exception as e:
logger.debug(f"magic 库检测文件类型失败: {e}")
# 如果 magic 检测失败或不可用,使用扩展名检测
if not file_type and file_extension:
file_type = cls._get_mime_type_from_extension(file_extension)
# 如果仍然没有类型,使用默认值
if not file_type:
file_type = 'application/octet-stream' if os.path.isfile(safe_path) else None
resource_type = cls._determine_resource_type(file_type, file_extension)
# 计算相对路径
try:
relative_path = os.path.relpath(safe_path, resource_root)
except ValueError:
relative_path = os.path.basename(safe_path)
# 计算深度
try:
depth = len(Path(safe_path).relative_to(resource_root).parts)
except ValueError:
depth = 0
return {
'name': path_obj.name,
'path': safe_path,
'relative_path': os.path.relpath(safe_path, cls.DEFAULT_MOUNT_POINT),
'relative_path': relative_path,
'is_file': os.path.isfile(safe_path),
'is_dir': os.path.isdir(safe_path),
'size': stat.st_size if os.path.isfile(safe_path) else None,
@@ -106,7 +157,7 @@ class ResourceService:
'modified_time': datetime.fromtimestamp(stat.st_mtime),
'accessed_time': datetime.fromtimestamp(stat.st_atime),
'parent_path': str(path_obj.parent),
'depth': len(path_obj.relative_to(cls.DEFAULT_MOUNT_POINT).parts) if cls.DEFAULT_MOUNT_POINT in safe_path else 0
'depth': depth
}
except Exception as e:
logger.error(f'获取文件信息失败: {str(e)}')
@@ -122,8 +173,11 @@ class ResourceService:
) -> Dict:
"""获取目录列表"""
try:
target_path = path or cls.DEFAULT_MOUNT_POINT
safe_path = cls._get_safe_path(target_path)
# 如果没有指定路径,使用静态文件根目录
if path is None:
safe_path = cls._get_resource_root()
else:
safe_path = cls._get_safe_path(path)
if not os.path.exists(safe_path):
raise CustomException(msg='目录不存在')
@@ -171,7 +225,7 @@ class ResourceService:
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size
).model_dump()
).model_dump(mode='json')
except CustomException:
raise
@@ -214,27 +268,52 @@ class ResourceService:
) -> List[Dict]:
"""搜索资源"""
try:
mount_point = cls.DEFAULT_MOUNT_POINT
# 使用静态文件根目录作为搜索起点
search_root = cls._get_resource_root()
results = []
for root, dirs, files in os.walk(mount_point):
for root, dirs, files in os.walk(search_root):
# 控制搜索深度
depth = len(Path(root).relative_to(mount_point).parts)
try:
depth = len(Path(root).relative_to(search_root).parts)
except ValueError:
depth = 0
if depth > search.max_depth:
dirs.clear() # 阻止进一步深入
continue
# 过滤隐藏文件夹
# 过滤隐藏文件夹(性能优化)
if not search.include_hidden:
dirs[:] = [d for d in dirs if not d.startswith('.')]
files = [f for f in files if not f.startswith('.')]
# 优化:先过滤文件名,再进行详细检查
if search.keyword:
files = [f for f in files if search.keyword.lower() in f.lower()]
# 搜索文件
for file in files:
file_path = os.path.join(root, file)
# 优化:先进行快速检查
if search.extensions:
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in search.extensions:
continue
file_info = cls._get_file_info(file_path)
if cls._match_search_criteria(file_info, search):
results.append(file_info)
# 限制结果数量防止内存溢出
if len(results) >= cls.MAX_SEARCH_RESULTS:
logger.warning(f"搜索结果过多,已截断到前{cls.MAX_SEARCH_RESULTS}")
break
if len(results) >= cls.MAX_SEARCH_RESULTS:
break
# 排序结果
return cls._sort_results(results, search)
@@ -314,10 +393,21 @@ class ResourceService:
if not file or not file.filename:
raise CustomException(msg="请选择要上传的文件")
# 文件名安全检查
if '..' in file.filename or '/' in file.filename or '\\' in file.filename:
raise CustomException(msg="文件名包含不安全字符")
try:
# 确定上传目录
upload_dir = target_path or cls.DEFAULT_MOUNT_POINT
safe_dir = cls._get_safe_path(upload_dir)
# 检查文件大小
content = await file.read()
if len(content) > cls.MAX_UPLOAD_SIZE:
raise CustomException(msg=f"文件太大,最大支持{cls.MAX_UPLOAD_SIZE // (1024*1024)}MB")
# 确定上传目录,如果没有指定目标路径,使用静态文件根目录
if target_path is None:
safe_dir = cls._get_resource_root()
else:
safe_dir = cls._get_safe_path(target_path)
# 创建目录(如果不存在)
os.makedirs(safe_dir, exist_ok=True)
@@ -337,24 +427,34 @@ class ResourceService:
counter += 1
filename = os.path.basename(file_path)
# 保存文件
content = await file.read()
# 保存文件(使用已读取的内容)
with open(file_path, 'wb') as f:
f.write(content)
# 获取文件信息
file_info = cls._get_file_info(file_path)
# 生成相对于资源根目录的URL路径
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(file_path, resource_root)
# 确保路径使用正斜杠(URL格式)
file_url_path = relative_path.replace(os.sep, '/')
file_url = f"/resource/download?path={file_url_path}"
except ValueError:
# 如果无法计算相对路径,使用文件名
file_url = f"/resource/download?path={filename}"
logger.info(f"文件上传成功: {filename}")
return ResourceUploadSchema(
filename=filename,
file_path=file_path,
file_url=f"/resource/download?path={file_path}",
file_url=file_url,
file_size=file_info.get('size', 0),
resource_type=file_info.get('resource_type', ResourceType.OTHER),
upload_time=datetime.now()
).model_dump()
).model_dump(mode='json')
except Exception as e:
logger.error(f"文件上传失败: {str(e)}")
@@ -531,10 +631,11 @@ class ResourceService:
async def get_stats_service(cls, auth: AuthSchema) -> Dict:
"""获取资源统计信息"""
try:
mount_point = cls.DEFAULT_MOUNT_POINT
# 使用静态文件根目录
stats_root = cls._get_resource_root()
# 获取磁盘空间信息
statvfs = os.statvfs(mount_point)
statvfs = os.statvfs(stats_root)
total_space = statvfs.f_frsize * statvfs.f_blocks
free_space = statvfs.f_frsize * statvfs.f_bavail
used_space = total_space - free_space
@@ -546,7 +647,7 @@ class ResourceService:
type_stats = {}
extension_stats = {}
for root, dirs, files in os.walk(mount_point):
for root, dirs, files in os.walk(stats_root):
total_dirs += len(dirs)
for file in files:
@@ -572,7 +673,7 @@ class ResourceService:
continue
return ResourceStatsSchema(
mount_point=mount_point,
mount_point=stats_root,
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size,
@@ -581,7 +682,7 @@ class ResourceService:
total_space=total_space,
type_stats=type_stats,
extension_stats=extension_stats
).model_dump()
).model_dump(mode='json')
except Exception as e:
logger.error(f"获取统计信息失败: {str(e)}")
@@ -658,36 +759,39 @@ class ResourceService:
if not file_extension:
return 'application/octet-stream'
# 扩展更全面的MIME类型映射
mime_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
# 图片类型
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.tiff': 'image/tiff',
# 视频类型
'.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv', '.flv': 'video/x-flv', '.webm': 'video/webm',
'.mkv': 'video/x-matroska', '.m4v': 'video/x-m4v',
# 音频类型
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.aac': 'audio/aac',
'.ogg': 'audio/ogg', '.flac': 'audio/flac', '.m4a': 'audio/mp4',
# 文档类型
'.pdf': 'application/pdf', '.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.zip': 'application/zip',
'.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed',
'.tar': 'application/x-tar',
'.gz': 'application/gzip'
'.txt': 'text/plain', '.csv': 'text/csv', '.rtf': 'application/rtf',
# 代码文件
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
'.json': 'application/json', '.xml': 'application/xml',
'.py': 'text/x-python', '.java': 'text/x-java-source',
# 压缩文件
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed', '.tar': 'application/x-tar',
'.gz': 'application/gzip', '.bz2': 'application/x-bzip2'
}
return mime_types.get(file_extension.lower(), 'application/octet-stream')
+8
View File
@@ -260,6 +260,14 @@ class Settings(BaseSettings):
# if not os.path.exists(self.GEN_PATH):
# os.makedirs(self.GEN_PATH)
# ================================================= #
# ******************* AI大模型配置 ****************** #
# ================================================= #
# https://bailian.console.aliyun.com/?spm=5176.29619931.J_AHgvE-XDhTWrtotIBlDQQ.13.74cd521clrmQ7o&tab=api#/api/?type=model&url=https%3A%2F%2Fhelp.aliyun.com%2Fdocument_detail%2F2712576.html&renderType=iframe
QWEN_BASE_URL: str
QWEN_API_KEY: str
QWEN_MODEL: str
# ================================================= #
# ******************* 其他配置 ******************* #
# ================================================= #
+6 -3
View File
@@ -24,7 +24,7 @@ from apscheduler.triggers.interval import IntervalTrigger
from pymongo import MongoClient
from app.config.setting import settings
from app.core.database import engine, session_connect, SessionLocal, async_session
from app.core.database import engine, session_connect, SessionLocal, AsyncSessionLocal
from app.core.exceptions import CustomException
from app.core.logger import logger
@@ -41,7 +41,10 @@ job_stores = {
)),
}
# 配置执行器
executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)}
executors = {
'default': AsyncIOExecutor(),
'processpool': ProcessPoolExecutor(max_workers=1) # 减少进程数量以减少资源消耗
}
# 配置默认参数
job_defaults = {
'coalesce': False, # 是否合并执行
@@ -110,7 +113,7 @@ class SchedulerUtil:
exception_info=exception_info,
create_time=datetime.now(),
)
session = async_session()
session = SessionLocal()
JobLogCRUD(AuthSchema(db=session)).create_obj_log_crud(data=job_log)
session.close()
+15 -20
View File
@@ -3,7 +3,7 @@
from redis import asyncio as aioredis
from motor.motor_asyncio import AsyncIOMotorClient
from fastapi import FastAPI
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.asyncio import (
create_async_engine,
@@ -37,26 +37,21 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 异步数据库引擎
def async_db_engine() -> AsyncEngine:
"""创建异步数据库引擎"""
# 创建数据库引擎
async_engine = create_async_engine(
url=settings.ASYNC_DB_URI,
echo=settings.DATABASE_ECHO,
echo_pool=settings.ECHO_POOL,
pool_pre_ping=settings.POOL_PRE_PING,
future=settings.FUTURE,
pool_recycle=settings.POOL_RECYCLE,
# pool_size=settings.POOL_SIZE, # sqlite 不支持
# max_overflow=settings.MAX_OVERFLOW, # sqlite 不支持
# pool_timeout=settings.POOL_TIMEOUT, # sqlite 不支持
)
async_engine: AsyncEngine = create_async_engine(
url=settings.ASYNC_DB_URI,
echo=settings.DATABASE_ECHO,
echo_pool=settings.ECHO_POOL,
pool_pre_ping=settings.POOL_PRE_PING,
future=settings.FUTURE,
pool_recycle=settings.POOL_RECYCLE,
# pool_size=settings.POOL_SIZE, # sqlite 不支持
# max_overflow=settings.MAX_OVERFLOW, # sqlite 不支持
# pool_timeout=settings.POOL_TIMEOUT, # sqlite 不支持
)
return async_engine
# 异步数据库会话工厂
async_session = async_sessionmaker(
bind=async_db_engine(),
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
autocommit=settings.AUTOCOMMIT,
autoflush=settings.AUTOFETCH,
expire_on_commit=settings.EXPIRE_ON_COMMIT,
@@ -68,7 +63,7 @@ def session_connect() -> AsyncSession:
try:
if not settings.SQL_DB_ENABLE:
raise CustomException(msg="请先开启数据库连接", data="请启用 app/config/setting.py: SQL_DB_ENABLE")
return async_session()
return AsyncSessionLocal()
except Exception as e:
raise CustomException(msg=f"数据库连接失败: {e}")
+16 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
import asyncio
from typing import Any, AsyncGenerator
from fastapi_mcp import FastApiMCP
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.concurrency import asynccontextmanager
@@ -10,7 +10,6 @@ from fastapi.openapi.docs import (
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html
)
from sqlalchemy import text
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil
@@ -103,6 +102,20 @@ def register_routers(app: FastAPI) -> None:
"""
app.include_router(router=router)
def register_fastapi_mcp(app: FastAPI) -> None:
"""
注册FastAPI-MCP路由
"""
mcp = FastApiMCP(
app,
name="FastAPI Vue3 Admin MCP",
description="MCP server for the FastAPI Vue3 Admin system",
describe_full_response_schema=True,
describe_all_responses=True,
)
mcp.mount()
# mcp.mount_http()
def register_files(app: FastAPI) -> None:
"""
注册文件相关配置
@@ -140,4 +153,4 @@ def reset_api_docs(app: FastAPI) -> None:
title=app.title + " - ReDoc",
redoc_js_url=settings.REDOC_JS_URL,
redoc_favicon_url=settings.FAVICON_URL,
)
)
+64 -1
View File
@@ -2118,5 +2118,68 @@
"affix": false,
"redirect": null,
"description": "初始化数据"
}
},
{
"id": 102,
"name": "AI大模型",
"type": 1,
"icon": "el-icon-DataLine",
"order": 8,
"permission": null,
"route_name": "AI",
"route_path": "/ai",
"component_path": null,
"parent_id": null,
"status": true,
"keep_alive": false,
"hidden": false,
"always_show": false,
"title": "AI大模型",
"params": null,
"affix": false,
"redirect": "/ai/mcp",
"description": "AI大模型管理"
},
{
"id": 103,
"name": "MCP智能助手",
"type": 2,
"icon": "el-icon-DataLine",
"order": 1,
"permission": "ai:mcp:chat",
"route_name": "MCP",
"route_path": "/ai/mcp",
"component_path": "ai/mcp/index",
"parent_id": 102,
"status": true,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "MCP智能助手",
"params": null,
"affix": false,
"redirect": null,
"description": "MCP智能助手"
},
{
"id": 104,
"name": "智能对话",
"type": 3,
"icon": null,
"order": 1,
"permission": "ai:mcp:chat",
"route_name": null,
"route_path": null,
"component_path": null,
"parent_id": 103,
"status": true,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "智能对话",
"params": null,
"affix": false,
"redirect": null,
"description": "智能对话"
}
]
@@ -403,6 +403,18 @@
"role_id": 1,
"menu_id": 101
},
{
"role_id": 1,
"menu_id": 102
},
{
"role_id": 1,
"menu_id": 103
},
{
"role_id": 1,
"menu_id": 104
},
{
"role_id": 2,
"menu_id": 1
+42
View File
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from openai import AsyncOpenAI, OpenAI
from openai.types.chat.chat_completion import ChatCompletion
from app.config.setting import settings
from app.core.logger import logger
class AIClient:
def __init__(self):
self.model = settings.QWEN_MODEL
# 使用默认的http客户端,避免资源管理问题
self.client = AsyncOpenAI(
api_key=settings.QWEN_API_KEY,
base_url=settings.QWEN_BASE_URL,
)
async def process(self, query: str):
"""处理查询并返回流式响应"""
system_prompt = """你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。"""
try:
# 使用 await 调用异步客户端
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": query}
],
stream=True
)
# 流式返回响应
async for chunk in response:
if chunk.choices[0].delta.content is not None:
yield chunk.choices[0].delta.content
except Exception as e:
logger.error(f"AI处理查询失败: {str(e)}")
yield f"抱歉,处理您的请求时出现了错误: {str(e)}"
+1 -1
View File
@@ -86,7 +86,7 @@ class IpLocalUtil:
try:
# 使用ip-api.com API获取IP归属地信息
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=10.0) as client:
# 尝试使用 ip9.com.cn API
url = f'https://ip9.com.cn/get?ip={ip}'
response = await cls._make_api_request(client, url)
+5
View File
@@ -65,3 +65,8 @@ MONGO_DB_NAME = "admin"
# 日志配置
LOGGER_LEVEL = 'DEBUG' # 日志级别
# https://bailian.console.aliyun.com/?spm=5176.29619931.J_AHgvE-XDhTWrtotIBlDQQ.13.74cd521clrmQ7o&tab=api#/api/?type=model&url=https%3A%2F%2Fhelp.aliyun.com%2Fdocument_detail%2F2712576.html&renderType=iframe
QWEN_BASE_URL = https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_API_KEY = sk-e688534f2d984e7fa2eb46add409422f
QWEN_MODEL = qwen-plus
+6
View File
@@ -59,3 +59,9 @@ MONGO_DB_NAME = "fastapiadmin"
# 日志配置
LOGGER_LEVEL = 'INFO' # 日志级别
# https://bailian.console.aliyun.com/?spm=5176.29619931.J_AHgvE-XDhTWrtotIBlDQQ.13.74cd521clrmQ7o&tab=api#/api/?type=model&url=https%3A%2F%2Fhelp.aliyun.com%2Fdocument_detail%2F2712576.html&renderType=iframe
QWEN_BASE_URL = https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_API_KEY = sk-e688534f2d984e7fa2eb46add409422f
QWEN_MODEL = qwen-plus
+3
View File
@@ -21,6 +21,7 @@ def create_app() -> FastAPI:
register_middlewares,
register_exceptions,
register_routers,
register_fastapi_mcp,
register_files,
reset_api_docs,
lifespan
@@ -38,6 +39,8 @@ def create_app() -> FastAPI:
register_files(app)
# 重设API文档
reset_api_docs(app)
# 注册FastAPI-MCP
register_fastapi_mcp(app)
return app
+3 -2
View File
@@ -1,6 +1,7 @@
alembic==1.15.1 # 数据库迁移
APScheduler==3.11.0 # 定时任务
fastapi==0.115.2
fastapi-mcp==0.4.0
typer==0.7.0
click==8.1.7
uvicorn==0.30.6 # uvicorn web 框架
@@ -23,8 +24,7 @@ python-multipart==0.0.9 # request.form() 对表单进行「解析」时安
greenlet==3.1.1 # 协程框架
bcrypt==4.0.1 # 密码加密解析
aiofiles==24.1.0 # 文件操作
redis==5.2.1 # redis 同步操作数据库(用户celery配套使用)
# aioredis==2.0.1 # redis 异步操作数据库 redis已经完全具备了aioredis的功能,无需重复安全,且aioredis已经不再维护也不兼容3.10+的版本
redis==5.2.1 # redis 同步操作数据库(用户celery配套使用)redis 异步操作数据库 redis已经完全具备了aioredis的功能,无需重复安全,且aioredis已经不再维护也不兼容3.10+的版本
aiosqlite==0.17.0 # sqlite 异步操作数据库
asyncmy==0.2.9 # mysql 异步操作数据库:基于 mysqlclientasyncmy 是 mysqlclient 的异步版本,mysqlclient 是一个 C 语言编写的 MySQL 客户端,性能较高。性能:asyncmy 通常在性能上优于 aiomysql,特别是在高并发和大数据量的场景下。
motor==3.6.0 # mongodb 驱动
@@ -34,3 +34,4 @@ PyMySQL==1.1.2 # mysql 异步操作数据库基于 pymysqlaiomys
cryptography==45.0.2 # mysql8 密码加密
openai==1.55.2 # ai 大模型
oss2==2.18.4 # 阿里云对象存储
python-magic==0.4.27 # 文件类型识别
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -4,6 +4,8 @@ arrowParens: "always"
bracketSameLine: false
# 对象字面量中的括号之间添加空格
bracketSpacing: true
# 在对象字面量中,如果属性名需要引号,则使用单引号
singleAttributePerLine: false
# 自动格式化嵌入的代码(如 Markdown 和 HTML 内的代码)
embeddedLanguageFormatting: "auto"
# 忽略 HTML 空白敏感度,将空白视为非重要内容
@@ -12,8 +14,8 @@ htmlWhitespaceSensitivity: "ignore"
insertPragma: false
# 在 JSX 中使用双引号
jsxSingleQuote: false
# 每行代码的最大长度限制为 100 字符
printWidth: 100
# 每行代码的最大长度限制为 200 字符
printWidth: 200
# 在 Markdown 中保留原有的换行格式
proseWrap: "preserve"
# 仅在必要时添加对象属性的引号
@@ -34,8 +36,15 @@ useTabs: false
vueIndentScriptAndStyle: false
# 根据系统自动检测换行符
endOfLine: "auto"
# 对 HTML 文件应用特定格式化规则
# 对特定文件类型应用更宽松的格式化规则
overrides:
- files: "*.html"
options:
parser: "html"
- files: "*.vue"
options:
printWidth: 300
singleAttributePerLine: false
- files: "*.{js,ts}"
options:
printWidth: 250
+11
View File
@@ -17,19 +17,27 @@ declare module 'vue' {
QiunDataCharts: typeof import('./src/components/qiun-data-charts/qiun-data-charts.vue')['default']
QiunError: typeof import('./src/components/qiun-error/qiun-error.vue')['default']
QiunLoading: typeof import('./src/components/qiun-loading/qiun-loading.vue')['default']
WdAvatar: typeof import('wot-design-uni/components/wd-avatar/wd-avatar.vue')['default']
WdBackTop: typeof import('wot-design-uni/components/wd-back-top/wd-back-top.vue')['default']
WdBadge: typeof import('wot-design-uni/components/wd-badge/wd-badge.vue')['default']
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
WdCard: typeof import('wot-design-uni/components/wd-card/wd-card.vue')['default']
WdCell: typeof import('wot-design-uni/components/wd-cell/wd-cell.vue')['default']
WdCellGroup: typeof import('wot-design-uni/components/wd-cell-group/wd-cell-group.vue')['default']
WdCheckbox: typeof import('wot-design-uni/components/wd-checkbox/wd-checkbox.vue')['default']
WdCol: typeof import('wot-design-uni/components/wd-col/wd-col.vue')['default']
WdCollapse: typeof import('wot-design-uni/components/wd-collapse/wd-collapse.vue')['default']
WdCollapseItem: typeof import('wot-design-uni/components/wd-collapse-item/wd-collapse-item.vue')['default']
WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
WdDivider: typeof import('wot-design-uni/components/wd-divider/wd-divider.vue')['default']
WdFab: typeof import('wot-design-uni/components/wd-fab/wd-fab.vue')['default']
WdFloatingPanel: typeof import('wot-design-uni/components/wd-floating-panel/wd-floating-panel.vue')['default']
WdForm: typeof import('wot-design-uni/components/wd-form/wd-form.vue')['default']
WdGrid: typeof import('wot-design-uni/components/wd-grid/wd-grid.vue')['default']
WdGridItem: typeof import('wot-design-uni/components/wd-grid-item/wd-grid-item.vue')['default']
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
WdImg: typeof import('wot-design-uni/components/wd-img/wd-img.vue')['default']
WdImgCropper: typeof import('wot-design-uni/components/wd-img-cropper/wd-img-cropper.vue')['default']
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
WdLoading: typeof import('wot-design-uni/components/wd-loading/wd-loading.vue')['default']
WdMessageBox: typeof import('wot-design-uni/components/wd-message-box/wd-message-box.vue')['default']
@@ -38,6 +46,9 @@ declare module 'vue' {
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
WdRadio: typeof import('wot-design-uni/components/wd-radio/wd-radio.vue')['default']
WdRadioGroup: typeof import('wot-design-uni/components/wd-radio-group/wd-radio-group.vue')['default']
WdRow: typeof import('wot-design-uni/components/wd-row/wd-row.vue')['default']
WdSearch: typeof import('wot-design-uni/components/wd-search/wd-search.vue')['default']
WdSkeleton: typeof import('wot-design-uni/components/wd-skeleton/wd-skeleton.vue')['default']
WdSwiper: typeof import('wot-design-uni/components/wd-swiper/wd-swiper.vue')['default']
WdSwitch: typeof import('wot-design-uni/components/wd-switch/wd-switch.vue')['default']
WdTabbar: typeof import('wot-design-uni/components/wd-tabbar/wd-tabbar.vue')['default']
-301
View File
@@ -1,301 +0,0 @@
# 主题系统使用指南
## 概述
本项目基于 Wot Design Uni 的 ConfigProvider 组件实现了完整的主题系统,支持:
- 🌙 暗黑/浅色模式切换
- 🎨 12种预设主题色
- 🎯 自定义主题色
- 💾 主题设置持久化存储
- 📱 多平台兼容(H5、小程序)
## 核心文件
### 1. useTheme Composable (`src/composables/useTheme.ts`)
主题管理的核心逻辑,提供:
```typescript
const {
theme, // 当前主题模式 'light' | 'dark'
themeVars, // ConfigProviderThemeVars 主题变量
toggleTheme, // 切换主题模式
setThemeColor, // 设置主题色
resetTheme, // 重置主题
initTheme, // 初始化主题
colorColumns, // 预设主题色列表
} = useTheme();
```
### 2. 布局文件
#### Tabbar 布局 (`src/layouts/tabbar.vue`)
```vue
<template>
<wd-config-provider :theme="theme" :theme-vars="themeVars" custom-style="min-height: 100vh" :class="{ 'wot-theme-dark': theme === 'dark' }">
<!-- 页面内容 -->
</wd-config-provider>
</template>
```
#### Default 布局 (`src/layouts/default.vue`)
```vue
<template>
<wd-config-provider :theme="theme" :theme-vars="themeVars" custom-style="background-color: #f5f5f5;min-height: 100vh" :class="{ 'wot-theme-dark': theme === 'dark' }">
<!-- 页面内容 -->
</wd-config-provider>
</template>
```
### 3. 主题设置页面 (`src/pages/mine/settings/theme/index.vue`)
提供用户界面来:
- 切换暗黑/浅色模式
- 选择预设主题色
- 输入自定义主题色
- 预览主题效果
- 重置主题设置
## 主题变量
### 支持的 ConfigProviderThemeVars
根据 Wot Design Uni 文档,主要使用:
```typescript
interface ConfigProviderThemeVars {
colorTheme?: string; // 主题色
buttonPrimaryBgColor?: string; // 主按钮背景色
buttonPrimaryColor?: string; // 主按钮文字色
// ... 更多变量
}
```
### 预设主题色
```typescript
const colorColumns = [
{ value: "#165DFF", label: "蓝色" },
{ value: "#0FC6C2", label: "青绿色" },
{ value: "#722ED1", label: "紫色" },
{ value: "#F5222D", label: "红色" },
{ value: "#FA8C16", label: "橙色" },
{ value: "#FADB14", label: "黄色" },
{ value: "#52C41A", label: "绿色" },
{ value: "#EB2F96", label: "粉色" },
{ value: "#13C2C2", label: "青色" },
{ value: "#1890FF", label: "天蓝色" },
{ value: "#CD5C5C", label: "经典红" },
{ value: "#228B22", label: "自然绿" },
];
```
## 暗黑模式实现
### 1. ConfigProvider 主题切换
```vue
<wd-config-provider :theme="theme">
<!-- theme 为 'dark' 时自动应用暗黑模式 -->
</wd-config-provider>
```
### 2. 全局样式适配
`src/uni.scss` 中定义:
```scss
.wot-theme-dark {
background-color: #1a1a1a !important;
color: #f5f5f5 !important;
/* H5 环境 body 样式 */
body {
background-color: #1a1a1a !important;
color: #f5f5f5 !important;
}
/* 其他组件暗黑模式适配 */
}
```
### 3. 动态 Body 样式
`useTheme` 中自动处理:
```typescript
const applyDarkModeBodyStyle = (isDark: boolean) => {
// #ifdef H5
if (typeof document !== "undefined") {
const body = document.body;
if (isDark) {
body.style.backgroundColor = "#1a1a1a";
body.style.color = "#f5f5f5";
body.classList.add("wot-theme-dark");
} else {
body.style.backgroundColor = "#f8f8f8";
body.style.color = "#333";
body.classList.remove("wot-theme-dark");
}
}
// #endif
};
```
## 持久化存储
主题设置自动保存到本地存储,无需手动调用:
```typescript
// 存储键名
const THEME_STORAGE_KEY = "app_theme_mode";
const THEME_COLOR_STORAGE_KEY = "app_theme_color";
// 主题设置自动持久化,在 useTheme 中已处理
// 包含主题模式、主题色、是否跟随系统、用户设置状态
```
主题设置会在以下情况自动保存:
- 切换主题模式时
- 设置主题色时
- 重置主题时
- 跟随系统设置改变时
## 使用示例
### 在页面中使用主题
```vue
<template>
<view>
<wd-button type="primary">主题色按钮</wd-button>
<text :style="{ color: themeVars.colorTheme }">主题色文本</text>
</view>
</template>
<script setup>
import { useTheme } from "@/composables/useTheme";
const { theme, themeVars, toggleTheme, setThemeColor } = useTheme();
// 切换主题模式
const handleToggleTheme = () => {
toggleTheme();
};
// 设置主题色
const handleSetColor = (color: string) => {
setThemeColor(color);
};
</script>
```
### 在组件中响应主题变化
```vue
<template>
<view :class="{ 'dark-mode': isDark }">
<!-- 组件内容 -->
</view>
</template>
<script setup>
import { computed } from "vue";
import { useTheme } from "@/composables/useTheme";
const { theme } = useTheme();
const isDark = computed(() => theme.value === "dark");
</script>
<style>
.dark-mode {
background-color: #2a2a2a;
color: #f5f5f5;
}
</style>
```
## 最佳实践
### 1. 组件开发
- 使用 Wot Design Uni 组件时,主题色会自动应用
- 自定义组件需要手动适配暗黑模式
- 使用 `:class="{ 'wot-theme-dark': theme === 'dark' }"` 来应用暗黑模式样式
### 2. 样式编写
```scss
.my-component {
background-color: #fff;
color: #333;
// 暗黑模式适配
.wot-theme-dark & {
background-color: #2a2a2a;
color: #f5f5f5;
}
}
```
### 3. 主题色使用
```vue
<template>
<!-- 直接使用主题色 -->
<view :style="{ borderColor: themeVars.colorTheme }">
<!-- 内容 -->
</view>
</template>
```
## 注意事项
1. **ConfigProvider 包裹**:确保页面被 ConfigProvider 包裹才能应用主题
2. **样式优先级**:暗黑模式样式需要足够的优先级,必要时使用 `!important`
3. **平台兼容**:小程序和 H5 的样式处理略有不同,使用条件编译处理
4. **性能考虑**:主题切换时避免频繁的 DOM 操作
## 扩展功能
### 添加新的主题色
`colorColumns` 中添加新的颜色:
```typescript
export const colorColumns = [
// ... 现有颜色
{ value: "#FF6B6B", label: "珊瑚红" },
{ value: "#4ECDC4", label: "薄荷绿" },
];
```
### 添加更多主题变量
```typescript
const themeVars = ref<ConfigProviderThemeVars>({
colorTheme: getStoredThemeColor(),
buttonPrimaryBgColor: getStoredThemeColor(),
// 添加更多变量
});
```
### 自定义暗黑模式样式
`uni.scss` 中添加更多组件的暗黑模式适配:
```scss
.wot-theme-dark {
// 新组件的暗黑模式样式
.my-custom-component {
background-color: #2a2a2a;
color: #f5f5f5;
}
}
```
-427
View File
@@ -1,427 +0,0 @@
# uni-mini-router 在UniApp中的整合教程
## 一、uni-mini-router简介
uni-mini-router是一个轻量级的路由管理库,专为uni-app设计,解决了uni-app原生路由系统中没有路由拦截等关键功能的问题。它提供了类似Vue Router的API体验,使得在uni-app项目中实现更加灵活和强大的路由管理成为可能。
### 主要特点
1. **Vue Router风格API**:提供与Vue Router相似的API,降低学习成本
2. **路由拦截功能**:支持全局导航守卫,可以在路由跳转前后执行逻辑
3. **优雅的参数传递**:支持params和query方式传参
4. **命名路由**:支持通过路由名称进行导航
5. **类型支持**:完整的TypeScript类型定义
6. **轻量级**:体积小,性能高效
## 二、安装与基本配置
### 1. 安装uni-mini-router
使用npm或yarn安装uni-mini-router
```bash
pnpm add - uni-mini-router
```
### 2. 初始化路由
在项目中创建router目录并初始化路由配置:
```typescript
// src/router/index.ts
import { createRouter } from "uni-mini-router";
import { pages, subPackages } from "virtual:uni-pages";
// 生成路由配置
function generateRoutes() {
const routes = pages.map((page) => {
const newPath = `/${page.path}`;
return { ...page, path: newPath };
});
// 处理分包路由
if (subPackages && subPackages.length > 0) {
subPackages.forEach((subPackage) => {
const subRoutes = subPackage.pages.map((page: any) => {
const newPath = `/${subPackage.root}/${page.path}`;
return { ...page, path: newPath };
});
routes.push(...subRoutes);
});
}
return routes;
}
// 创建路由实例
const router = createRouter({
routes: generateRoutes(),
});
export default router;
```
### 3. 在main.ts中挂载路由
```typescript
// src/main.ts
import { createSSRApp } from "vue";
import App from "./App.vue";
import router from "./router";
export function createApp() {
const app = createSSRApp(App);
// 使用路由
app.use(router);
return {
app,
};
}
```
### 4. 配置自动导入(可选,推荐)
使用unplugin-auto-import插件可以自动导入路由相关hooks,无需每次手动导入:
```typescript
// vite.config.ts
import AutoImport from "unplugin-auto-import/vite";
export default defineConfig({
plugins: [
AutoImport({
imports: [
"vue",
{
from: "uni-mini-router",
imports: ["createRouter", "useRouter", "useRoute"],
},
],
dts: "src/auto-imports.d.ts",
}),
],
});
```
## 三、路由基本用法
### 1. 编程式导航
uni-mini-router提供了多种导航方法:
```typescript
const router = useRouter();
// 字符串路径导航
router.push("/pages/index/index");
// 对象导航(通过路径)
router.push({ path: "/pages/index/index" });
// 对象导航(通过名称)
router.push({ name: "index" });
// 携带参数
router.push({
path: "/pages/detail/index",
query: { id: 10 },
});
// 通过名称 + 参数
router.push({
name: "detail",
params: { id: 10 },
});
// Tab页面导航
router.pushTab("/pages/home/index");
// 关闭当前页面并跳转
router.replace("/pages/index/index");
// 关闭所有页面并跳转
router.replaceAll("/pages/index/index");
// 返回上一级
router.back();
// 返回多级
router.back(2);
```
### 2. 获取和使用路由信息
```typescript
const route = useRoute();
// 访问当前路由信息
console.log(route.path); // 当前路由路径
console.log(route.name); // 当前路由名称
console.log(route.query); // 查询参数
console.log(route.params); // 路由参数
```
### 3. 接收页面参数
在页面组件中接收传递的参数:
```typescript
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
// 方法1: 直接从route对象获取
const id = route.query.id || route.params.id
// 方法2: 通过onLoad生命周期获取
onLoad((option) => {
console.log('页面参数:', option)
// option中包含所有传递的参数
})
</script>
```
> ⚠️ **重要说明**:在uni-mini-router中,params和query参数都会转换为查询字符串放在URL中,两者在实际效果上没有区别。这种设计是为了与Vue Router保持API一致性。
## 四、导航守卫
uni-mini-router提供了全局导航守卫功能,可以在路由跳转前后执行自定义逻辑。
### 1. 全局前置守卫
```typescript
// src/router/index.ts
router.beforeEach((to, from, next) => {
console.log("路由跳转:", from.path, "->", to.path);
// 检查是否需要登录
if (to.meta && to.meta.requireAuth) {
// 检查登录状态
const isLoggedIn = uni.getStorageSync("token");
if (!isLoggedIn) {
// 未登录,跳转到登录页
uni.showToast({ title: "请先登录", icon: "none" });
next("/pages/login/index");
return;
}
}
// 继续导航
next();
});
```
### 2. 全局后置守卫
```typescript
// src/router/index.ts
router.afterEach((to, from) => {
console.log("路由跳转完成:", to.path);
// 可以在这里做一些统计或记录
});
```
### 3. 路由元数据配置
可以在页面文件中使用`<route>`自定义块来定义路由元数据:
```vue
<template>
<!-- 页面内容 -->
</template>
<script setup>
// 页面逻辑
</script>
<route lang="json">
{
"name": "protected-page",
"meta": {
"requireAuth": true,
"title": "需要登录的页面"
}
}
</route>
```
## 五、实战示例:登录权限控制
### 1. 定义带有权限控制的路由
```typescript
// src/router/index.ts
import { createRouter } from "uni-mini-router";
const router = createRouter({
routes: generateRoutes(),
});
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 检查页面是否需要登录
if (to.meta && to.meta.requireAuth) {
const token = uni.getStorageSync("token");
if (!token) {
// 显示登录提示
uni.showModal({
title: "提示",
content: "该功能需要登录后使用",
confirmText: "去登录",
cancelText: "返回",
success: (res) => {
if (res.confirm) {
// 记住原来要去的页面
uni.setStorageSync("redirect", to.fullPath);
next("/pages/login/index");
} else {
// 取消则返回首页
next("/pages/index/index");
}
},
});
return;
}
}
// 继续导航
next();
});
export default router;
```
### 2. 登录成功后跳转回原页面
```vue
<!-- src/pages/login/index.vue -->
<script setup>
const router = useRouter();
function handleLogin() {
// 模拟登录请求
setTimeout(() => {
// 登录成功,存储token
uni.setStorageSync("token", "user_token_example");
// 获取之前要去的页面
const redirect = uni.getStorageSync("redirect") || "/pages/index/index";
uni.removeStorageSync("redirect");
// 跳转回原来的页面
router.replaceAll(redirect);
}, 1000);
}
</script>
```
## 六、最佳实践与性能优化
### 1. 合理使用跳转方式
- **router.push**:需要保留当前页面、可返回时使用
- **router.replace**:不需要返回当前页面时使用
- **router.replaceAll**:需要清除所有页面栈时使用(如登录后)
- **router.pushTab**:跳转到tabBar页面时使用
### 2. 参数传递最佳实践
- 对于简单数据,直接使用参数传递
- 对于复杂数据或对象,可使用以下方法:
```typescript
// 传递复杂对象
const complexData = { name: "product", details: { id: 1, features: ["a", "b"] } };
// 方法1: JSON序列化 + URL编码
router.push({
path: "/pages/detail/index",
query: { data: encodeURIComponent(JSON.stringify(complexData)) },
});
// 接收页面
onLoad((option) => {
if (option.data) {
try {
const data = JSON.parse(decodeURIComponent(option.data));
console.log(data);
} catch (e) {
console.error("参数解析错误", e);
}
}
});
// 方法2: 对于非常大的数据,考虑使用全局状态管理或本地存储
```
### 3. 路由懒加载
uni-mini-router自动支持小程序的分包加载特性,可以在pages.json中配置分包:
```json
{
"pages": [
// 主包页面
],
"subPackages": [
{
"root": "pages/module",
"pages": [
{
"path": "detail/index",
"style": {
"navigationBarTitleText": "详情页"
}
}
]
}
]
}
```
## 七、路由调试与测试
### 1. 路由日志记录
```typescript
// src/router/index.ts
router.beforeEach((to, from, next) => {
console.log(`[Router] ${from.path || "初始页面"} -> ${to.path}`, {
params: to.params,
query: to.query,
});
next();
});
```
### 2. 常见问题解决
1. **路由参数获取不到**
- 检查传参方式是否正确
- 使用`console.log`打印完整的option对象
- 尝试同时检查route.query和route.params
2. **页面未注册**
- 确保页面已在pages.json中正确注册
- 检查路径大小写是否正确
3. **导航守卫不生效**
- 确保在路由配置后调用守卫
- 检查是否正确调用next()函数
## 总结
uni-mini-router为uni-app提供了Vue Router风格的路由解决方案,特别是增加了路由拦截功能,解决了uni-app原生路由的限制。通过简单配置,就能在uni-app中实现更加灵活的路由管理,包括权限控制、参数传递和路由拦截等高级功能。
使用uni-mini-router可以让你的uni-app项目路由管理更加规范化和工程化,提升开发效率和代码质量。
参考资料:
- [uni-mini-router GitHub仓库](https://github.com/Moonofweisheng/uni-mini-router)
- [uni-mini-router官方文档](https://moonofweisheng.github.io/uni-mini-router/)
- [uni-app官方路由文档](https://uniapp.dcloud.net.cn/tutorial/page.html)
+3 -15
View File
@@ -19,17 +19,7 @@ const autoImportConfig = JSON.parse(fs.readFileSync(".eslintrc-auto-import.json"
export default [
// 忽略指定文件
{
ignores: [
"node_modules/**",
"dist/**",
"auto-imports.d.ts",
"unpackage/**",
"public/**",
"static/**",
"**/u-charts/**",
"**/qiun-**/**",
"**/auto-imports.d.ts",
],
ignores: ["node_modules/**", "dist/**", "auto-imports.d.ts", "unpackage/**", "public/**", "static/**", "**/u-charts/**", "**/qiun-**/**", "**/auto-imports.d.ts"],
},
// 检查文件的配置
{
@@ -58,7 +48,7 @@ export default [
rules: {
...configPrettier.rules, // 关闭与 Prettier 冲突的规则
...pluginPrettier.configs.recommended.rules, // 启用 Prettier 规则
"prettier/prettier": "error", // 强制 Prettier 格式化
"prettier/prettier": "off", // 完全关闭 Prettier 格式化检查
"no-unused-vars": [
"error",
{
@@ -121,9 +111,7 @@ export default [
},
rules: {
// 禁用所有规则
...Object.fromEntries(
Object.keys(pluginVue.configs["vue3-recommended"].rules || {}).map((key) => [key, "off"])
),
...Object.fromEntries(Object.keys(pluginVue.configs["vue3-recommended"].rules || {}).map((key) => [key, "off"])),
"no-unused-vars": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-explicit-any": "off",
+26 -26
View File
@@ -100,44 +100,43 @@
"commit": "git-cz"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4070520250711001",
"@dcloudio/uni-app-harmony": "3.0.0-4070520250711001",
"@dcloudio/uni-app-plus": "3.0.0-4070520250711001",
"@dcloudio/uni-components": "3.0.0-4070520250711001",
"@dcloudio/uni-h5": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-alipay": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-baidu": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-harmony": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-jd": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-lark": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-qq": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-weixin": "3.0.0-4070520250711001",
"@dcloudio/uni-mp-xhs": "3.0.0-4070520250711001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4070520250711001",
"@dcloudio/uni-app": "3.0.0-4070620250821001",
"@dcloudio/uni-app-harmony": "3.0.0-4070620250821001",
"@dcloudio/uni-app-plus": "3.0.0-4070620250821001",
"@dcloudio/uni-components": "3.0.0-4070620250821001",
"@dcloudio/uni-h5": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-alipay": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-baidu": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-harmony": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-jd": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-lark": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-qq": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
"@stomp/stompjs": "^7.1.1",
"@uni-helper/uni-use": "^0.19.14",
"@vueuse/core": "9.13.0",
"pinia": "^2.2.2",
"vue": "^3.5.13",
"vue-i18n": "^9.1.9",
"wot-design-uni": "^1.9.1"
"vue": "^3.5.18",
"vue-i18n": "^9.14.5"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4070520250711001",
"@dcloudio/uni-cli-shared": "3.0.0-4070520250711001",
"@dcloudio/uni-stacktracey": "3.0.0-4070520250711001",
"@dcloudio/vite-plugin-uni": "3.0.0-4070520250711001",
"@dcloudio/types": "^3.4.19",
"@dcloudio/uni-automator": "3.0.0-4070620250821001",
"@dcloudio/uni-cli-shared": "3.0.0-4070620250821001",
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
"@eslint/js": "^9.10.0",
"@uni-helper/uni-types": "1.0.0-alpha.6",
"@uni-helper/vite-plugin-uni-components": "^0.2.0",
"@uni-helper/vite-plugin-uni-layouts": "^0.1.10",
"@uni-helper/vite-plugin-uni-pages": "^0.2.28",
"@vue/runtime-core": "^3.4.21",
"@vue/runtime-core": "^3.5.18",
"commitizen": "^4.3.0",
"cz-git": "^1.9.4",
"eslint": "^9.10.0",
@@ -168,6 +167,7 @@
"unplugin-auto-import": "^0.18.3",
"vite": "5.2.8",
"vue-eslint-parser": "^9.4.3",
"vue-tsc": "^1.0.24"
"vue-tsc": "^1.0.24",
"wot-design-uni": "^1.9.1"
}
}
+2 -13
View File
@@ -1,11 +1,5 @@
<template>
<wd-calendar
v-model="dateRange"
:label="label"
type="daterange"
:placeholder="placeholder"
@confirm="handleConfirm"
/>
<wd-calendar v-model="dateRange" :label="label" type="daterange" :placeholder="placeholder" @confirm="handleConfirm" />
</template>
<script lang="ts" setup>
@@ -38,12 +32,7 @@ watch(
} else if (typeof val === "string" && val.includes(",")) {
const [startDate, endDate] = val.split(",");
if (
startDate &&
endDate &&
!isNaN(new Date(startDate).getTime()) &&
!isNaN(new Date(endDate).getTime())
) {
if (startDate && endDate && !isNaN(new Date(startDate).getTime()) && !isNaN(new Date(endDate).getTime())) {
dateRange.value = [new Date(startDate).getTime(), new Date(endDate).getTime()];
} else {
dateRange.value = null;
+5 -27
View File
@@ -1,13 +1,5 @@
<template>
<wd-picker
v-model="selectedValues"
:columns="pickerColumns"
:display-format="displayFormat"
:column-change="handleColumnChange"
:label="label"
:placeholder="pickerColumns.length ? '请选择' : '加载中...'"
:required="required"
/>
<wd-picker v-model="selectedValues" :columns="pickerColumns" :display-format="displayFormat" :column-change="handleColumnChange" :label="label" :placeholder="pickerColumns.length ? '请选择' : '加载中...'" :required="required" />
</template>
<script setup lang="ts">
@@ -80,9 +72,7 @@ const findTreePath = (value: number | string): number[] | string[] => {
* @param treeData 树形数据
* @returns Picker 所需的 columns 格式
*/
const transformTreeToColumns = (
treeData: OptionType[]
): Array<Array<{ label: string; value: string | number }>> => {
const transformTreeToColumns = (treeData: OptionType[]): Array<Array<{ label: string; value: string | number }>> => {
const columns: Array<Array<{ label: string; value: string | number }>> = [];
for (let depth = 0; depth <= selectedValues.value.length; depth++) {
@@ -117,12 +107,7 @@ watch(
/**
* 处理列的变化动态更新后续列的数据
*/
function handleColumnChange(
pickerView: PickerViewInstance,
value: Record<string, any> | Record<string, any>[],
columnIndex: number,
resolve: () => void
) {
function handleColumnChange(pickerView: PickerViewInstance, value: Record<string, any> | Record<string, any>[], columnIndex: number, resolve: () => void) {
const selectedValue = selectedValues.value[selectedValues.value.length - 1] || undefined;
emits("update:modelValue", selectedValue);
@@ -134,11 +119,7 @@ function handleColumnChange(
/**
* 动态更新所有后续列的数据
*/
function updatePickerColumns(
pickerView: PickerViewInstance,
parentId: string | number,
columnIndex: number
) {
function updatePickerColumns(pickerView: PickerViewInstance, parentId: string | number, columnIndex: number) {
const nextColumnIndex = columnIndex + 1;
const children = findChildren(parentId, props.data);
@@ -151,10 +132,7 @@ function updatePickerColumns(
/**
* 根据节点value查找其子节点数据
*/
function findChildren(
parentId: string | number,
list: Record<string, any>[]
): Record<string, any>[] {
function findChildren(parentId: string | number, list: Record<string, any>[]): Record<string, any>[] {
for (const item of list) {
if (item.value === parentId && item.children) {
return item.children;
@@ -28,17 +28,7 @@
<!-- APP和H5采用renderjs渲染图表 -->
<!-- #ifdef APP-VUE || H5 -->
<block v-if="echarts">
<view
v-show="showchart"
:id="'EC' + cid"
:style="{ background: background }"
style="width: 100%; height: 100%"
:data-directory="directory"
:prop="echartsOpts"
:change:prop="rdcharts.ecinit"
:resize="echartsResize"
:change:resize="rdcharts.ecresize"
/>
<view v-show="showchart" :id="'EC' + cid" :style="{ background: background }" style="width: 100%; height: 100%" :data-directory="directory" :prop="echartsOpts" :change:prop="rdcharts.ecinit" :resize="echartsResize" :change:resize="rdcharts.ecresize" />
</block>
<block v-else>
<view
@@ -53,14 +43,7 @@
v-on:touchmove="rdcharts.touchMove"
v-on:touchend="rdcharts.touchEnd"
>
<canvas
v-show="showchart"
:id="cid"
:canvasId="cid"
:style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }"
:disable-scroll="disableScroll"
@error="_error"
/>
<canvas v-show="showchart" :id="cid" :canvasId="cid" :style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }" :disable-scroll="disableScroll" @error="_error" />
</view>
</block>
<!-- #endif -->
@@ -83,72 +66,25 @@
/>
</block>
<block v-if="!ontouch">
<canvas
v-show="showchart"
:id="cid"
:canvasId="cid"
:width="cWidth * pixel"
:height="cHeight * pixel"
:style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }"
:disable-scroll="disScroll"
@tap="_tap"
@error="_error"
/>
<canvas v-show="showchart" :id="cid" :canvasId="cid" :width="cWidth * pixel" :height="cHeight * pixel" :style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }" :disable-scroll="disScroll" @tap="_tap" @error="_error" />
</block>
<!-- #endif -->
<!-- 其他小程序通过vue渲染图表 -->
<!-- #ifdef MP-WEIXIN || MP-BAIDU || MP-QQ || MP-TOUTIAO || MP-KUAISHOU || MP-LARK || MP-JD || MP-360 -->
<block v-if="type2d">
<view v-if="ontouch" @tap="_tap">
<canvas
v-show="showchart"
:id="cid"
:canvasId="cid"
:style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }"
type="2d"
:disable-scroll="disScroll"
@touchstart="_touchStart"
@touchmove="_touchMove"
@touchend="_touchEnd"
@error="_error"
/>
<canvas v-show="showchart" :id="cid" :canvasId="cid" :style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }" type="2d" :disable-scroll="disScroll" @touchstart="_touchStart" @touchmove="_touchMove" @touchend="_touchEnd" @error="_error" />
</view>
<view v-if="!ontouch" @tap="_tap">
<canvas
v-show="showchart"
:id="cid"
:canvasId="cid"
:style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }"
type="2d"
:disable-scroll="disScroll"
@error="_error"
/>
<canvas v-show="showchart" :id="cid" :canvasId="cid" :style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }" type="2d" :disable-scroll="disScroll" @error="_error" />
</view>
</block>
<block v-if="!type2d">
<view v-if="ontouch" @tap="_tap">
<canvas
v-if="showchart"
:id="cid"
:canvasId="cid"
:style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }"
:disable-scroll="disScroll"
@touchstart="_touchStart"
@touchmove="_touchMove"
@touchend="_touchEnd"
@error="_error"
/>
<canvas v-if="showchart" :id="cid" :canvasId="cid" :style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }" :disable-scroll="disScroll" @touchstart="_touchStart" @touchmove="_touchMove" @touchend="_touchEnd" @error="_error" />
</view>
<view v-if="!ontouch">
<canvas
v-if="showchart"
:id="cid"
:canvasId="cid"
:style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }"
:disable-scroll="disScroll"
@tap="_tap"
@error="_error"
/>
<canvas v-if="showchart" :id="cid" :canvasId="cid" :style="{ width: cWidth + 'px', height: cHeight + 'px', background: background }" :disable-scroll="disScroll" @tap="_tap" @error="_error" />
</view>
</block>
<!-- #endif -->
@@ -166,10 +102,7 @@ function deepCloneAssign(origin = {}, ...args) {
for (let i in args) {
for (let key in args[i]) {
if (args[i].hasOwnProperty(key)) {
origin[key] =
args[i][key] && typeof args[i][key] === "object"
? deepCloneAssign(Array.isArray(args[i][key]) ? [] : {}, origin[key], args[i][key])
: args[i][key];
origin[key] = args[i][key] && typeof args[i][key] === "object" ? deepCloneAssign(Array.isArray(args[i][key]) ? [] : {}, origin[key], args[i][key]) : args[i][key];
}
}
}
@@ -474,11 +407,7 @@ export default {
optsProps: {
handler(val, oldval) {
if (typeof val === "object") {
if (
JSON.stringify(val) !== JSON.stringify(oldval) &&
this.echarts === false &&
this.optsWatch == true
) {
if (JSON.stringify(val) !== JSON.stringify(oldval) && this.echarts === false && this.optsWatch == true) {
this.checkData(this.drawData);
}
} else {
@@ -562,11 +491,7 @@ export default {
}
// #ifdef MP-WEIXIN
this.inWx = true;
if (
this.canvas2d === false ||
systemInfo.platform === "windows" ||
systemInfo.platform === "mac"
) {
if (this.canvas2d === false || systemInfo.platform === "windows" || systemInfo.platform === "mac") {
this.type2d = false;
} else {
this.type2d = true;
@@ -656,12 +581,7 @@ export default {
methods: {
beforeInit() {
this.mixinDatacomErrorMessage = null;
if (
typeof this.chartData === "object" &&
this.chartData != null &&
this.chartData.series !== undefined &&
this.chartData.series.length > 0
) {
if (typeof this.chartData === "object" && this.chartData != null && this.chartData.series !== undefined && this.chartData.series.length > 0) {
//chartDataopts
this.drawData = deepCloneAssign({}, this.chartData);
this.mixinDatacomLoading = false;
@@ -920,11 +840,7 @@ export default {
ctx.canvas.willReadFrequently = true;
cfu.option[cid].context = ctx;
cfu.option[cid].rotateLock = cfu.option[cid].rotate;
if (
cfu.instance[cid] &&
cfu.option[cid] &&
cfu.option[cid].update === true
) {
if (cfu.instance[cid] && cfu.option[cid] && cfu.option[cid].update === true) {
this._updataUChart(cid);
} else {
canvas.width = data.width * this.pixel;
@@ -939,8 +855,7 @@ export default {
}
} else {
this.showchart = false;
this.mixinDatacomErrorMessage =
"参数错误:开启2d模式后,未获取到dom节点,canvas-id:" + cid;
this.mixinDatacomErrorMessage = "参数错误:开启2d模式后,未获取到dom节点,canvas-id:" + cid;
}
});
} else {
@@ -1086,10 +1001,7 @@ export default {
offset: offset,
textList: tc.textList,
formatter: (item, category, index, opts) => {
if (
typeof cfu.option[cid].tooltipFormat === "string" &&
cfu.formatter[cfu.option[cid].tooltipFormat]
) {
if (typeof cfu.option[cid].tooltipFormat === "string" && cfu.formatter[cfu.option[cid].tooltipFormat]) {
return cfu.formatter[cfu.option[cid].tooltipFormat](item, category, index, opts);
} else {
return this._tooltipDefault(item, category, index, opts);
@@ -1099,10 +1011,7 @@ export default {
} else {
cfu.instance[cid].showToolTip(e, {
formatter: (item, category, index, opts) => {
if (
typeof cfu.option[cid].tooltipFormat === "string" &&
cfu.formatter[cfu.option[cid].tooltipFormat]
) {
if (typeof cfu.option[cid].tooltipFormat === "string" && cfu.formatter[cfu.option[cid].tooltipFormat]) {
return cfu.formatter[cfu.option[cid].tooltipFormat](item, category, index, opts);
} else {
return this._tooltipDefault(item, category, index, opts);
@@ -1224,19 +1133,10 @@ export default {
if (cfu.option[cid].enableScroll === true && e.changedTouches.length == 1) {
cfu.instance[cid].scroll(e);
}
if (
this.ontap === true &&
cfu.option[cid].enableScroll === false &&
this.onmovetip === true
) {
if (this.ontap === true && cfu.option[cid].enableScroll === false && this.onmovetip === true) {
this._tap(e, true);
}
if (
this.ontouch === true &&
cfu.option[cid].enableScroll === true &&
this.onzoom === true &&
e.changedTouches.length == 2
) {
if (this.ontouch === true && cfu.option[cid].enableScroll === true && this.onzoom === true && e.changedTouches.length == 2) {
cfu.instance[cid].dobuleZoom(e);
}
this.emitMsg({
@@ -1263,11 +1163,7 @@ export default {
opts: cfu.instance[cid].opts,
},
});
if (
this.ontap === true &&
cfu.option[cid].enableScroll === false &&
this.onmovetip === true
) {
if (this.ontap === true && cfu.option[cid].enableScroll === false && this.onmovetip === true) {
this._tap(e, true);
}
},
@@ -19,33 +19,11 @@
// 通用配置项
// 主题颜色配置:如每个图表类型需要不同主题,请在对应图表类型上更改color属性
const color = [
"#1890FF",
"#91CB74",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3CA272",
"#FC8452",
"#9A60B4",
"#ea7ccc",
];
const color = ["#1890FF", "#91CB74", "#FAC858", "#EE6666", "#73C0DE", "#3CA272", "#FC8452", "#9A60B4", "#ea7ccc"];
const cfe = {
//demotype为自定义图表类型
type: [
"pie",
"ring",
"rose",
"funnel",
"line",
"column",
"area",
"radar",
"gauge",
"candle",
"demotype",
],
type: ["pie", "ring", "rose", "funnel", "line", "column", "area", "radar", "gauge", "candle", "demotype"],
//增加自定义图表类型,如果需要categories,请在这里加入您的图表类型例如最后的"demotype"
categories: ["line", "column", "area", "radar", "gauge", "candle", "demotype"],
//instance为实例变量承载属性,option为eopts承载属性,不要删除
@@ -17,17 +17,7 @@
*/
// 主题颜色配置:如每个图表类型需要不同主题,请在对应图表类型上更改color属性
const color = [
"#1890FF",
"#91CB74",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3CA272",
"#FC8452",
"#9A60B4",
"#ea7ccc",
];
const color = ["#1890FF", "#91CB74", "#FAC858", "#EE6666", "#73C0DE", "#3CA272", "#FC8452", "#9A60B4", "#ea7ccc"];
//事件转换函数,主要用作格式化x轴为时间轴,根据需求自行修改
const formatDateTime = (timeStamp, returnType) => {
@@ -61,66 +51,11 @@ const formatDateTime = (timeStamp, returnType) => {
const cfu = {
//demotype为自定义图表类型,一般不需要自定义图表类型,只需要改根节点上对应的类型即可
type: [
"pie",
"ring",
"rose",
"word",
"funnel",
"map",
"arcbar",
"line",
"column",
"mount",
"bar",
"area",
"radar",
"gauge",
"candle",
"mix",
"tline",
"tarea",
"scatter",
"bubble",
"demotype",
],
range: [
"饼状图",
"圆环图",
"玫瑰图",
"词云图",
"漏斗图",
"地图",
"圆弧进度条",
"折线图",
"柱状图",
"山峰图",
"条状图",
"区域图",
"雷达图",
"仪表盘",
"K线图",
"混合图",
"时间轴折线",
"时间轴区域",
"散点图",
"气泡图",
"自定义类型",
],
type: ["pie", "ring", "rose", "word", "funnel", "map", "arcbar", "line", "column", "mount", "bar", "area", "radar", "gauge", "candle", "mix", "tline", "tarea", "scatter", "bubble", "demotype"],
range: ["饼状图", "圆环图", "玫瑰图", "词云图", "漏斗图", "地图", "圆弧进度条", "折线图", "柱状图", "山峰图", "条状图", "区域图", "雷达图", "仪表盘", "K线图", "混合图", "时间轴折线", "时间轴区域", "散点图", "气泡图", "自定义类型"],
//增加自定义图表类型,如果需要categories,请在这里加入您的图表类型,例如最后的"demotype"
//自定义类型时需要注意"tline","tarea","scatter","bubble"等时间轴(矢量x轴)类图表,没有categories,不需要加入categories
categories: [
"line",
"column",
"mount",
"bar",
"area",
"radar",
"gauge",
"candle",
"mix",
"demotype",
],
categories: ["line", "column", "mount", "bar", "area", "radar", "gauge", "candle", "mix", "demotype"],
//instance为实例变量承载属性,不要删除
instance: {},
//option为opts及eopts承载属性,不要删除
File diff suppressed because it is too large Load Diff
+3 -12
View File
@@ -135,10 +135,7 @@ export function useStomp(options: UseStompOptions = {}) {
}
// 如果是授权问题导致的关闭,尝试重连
if (
(event?.code === 1000 || event?.code === 1006 || event?.code === 1008) &&
reconnectCount.value < maxReconnectAttempts
) {
if ((event?.code === 1000 || event?.code === 1006 || event?.code === 1008) && reconnectCount.value < maxReconnectAttempts) {
console.log("检测到连接异常关闭,将尝试重连");
// 通过 handleReconnect 统一处理重连,避免重复计数
@@ -152,11 +149,7 @@ export function useStomp(options: UseStompOptions = {}) {
isConnecting = false;
// 检查是否是授权错误
if (
frame.headers?.message?.includes("Unauthorized") ||
frame.body?.includes("Unauthorized") ||
frame.body?.includes("Token")
) {
if (frame.headers?.message?.includes("Unauthorized") || frame.body?.includes("Unauthorized") || frame.body?.includes("Token")) {
console.warn("WebSocket授权错误,请检查登录状态");
// 授权错误不进行重连
isManualDisconnect = true;
@@ -182,9 +175,7 @@ export function useStomp(options: UseStompOptions = {}) {
console.log(`准备重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
// 使用指数退避策略增加重连间隔
const delay = useExponentialBackoff
? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay)
: reconnectDelay;
const delay = useExponentialBackoff ? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay) : reconnectDelay;
// 清除之前的计时器
if (reconnectTimer) {
+7 -32
View File
@@ -1,52 +1,27 @@
export interface TabbarItem {
name: string;
value: number | null;
active: boolean;
title: string;
icon: string;
}
const tabbarItems = ref<TabbarItem[]>([
{ name: "index", value: null, active: true, title: "首页", icon: "home" },
{ name: "work", value: null, active: false, title: "工作台", icon: "app" },
{ name: "mine", value: null, active: false, title: "我的", icon: "user" },
{ name: "index", title: "首页", icon: "home" },
{ name: "work", title: "工作台", icon: "app" },
{ name: "mine", title: "我的", icon: "user" },
]);
const activeTabbar = ref<string>("index");
export function useTabbar() {
const tabbarList = computed(() => tabbarItems.value);
const activeTabbar = computed(() => {
const item = tabbarItems.value.find((item) => item.active);
return item || tabbarItems.value[0];
});
const getTabbarItemValue = (name: string) => {
const item = tabbarItems.value.find((item) => item.name === name);
return item && item.value ? item.value : null;
};
const setTabbarItem = (name: string, value: number) => {
const tabbarItem = tabbarItems.value.find((item) => item.name === name);
if (tabbarItem) {
tabbarItem.value = value;
}
};
const setTabbarItemActive = (name: string) => {
tabbarItems.value.forEach((item) => {
if (item.name === name) {
item.active = true;
} else {
item.active = false;
}
});
activeTabbar.value = name;
};
return {
tabbarList,
activeTabbar,
getTabbarItemValue,
setTabbarItem,
setTabbarItemActive,
};
}
}
+24 -23
View File
@@ -1,30 +1,30 @@
<script lang="ts" setup>
import { onLoad } from "@dcloudio/uni-app";
import { useThemeStore } from "@/store/modules/theme.store";
import { onMounted, onUnmounted } from "vue";
const useTheme = useThemeStore();
const themeStore = useThemeStore();
const theme = themeStore.theme;
const themeVars = themeStore.themeVars;
let theme = useTheme.theme;
let themeVars = useTheme.themeVars;
//
const handleThemeChange = () => {
//
//
};
onLoad(() => {
theme = useTheme.theme;
themeVars = useTheme.themeVars;
onMounted(() => {
//
uni.$on("theme-change", handleThemeChange);
});
onUnmounted(() => {
//
uni.$off("theme-change", handleThemeChange);
});
</script>
<script lang="ts">
export default {
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: "shared",
},
};
</script>
<template>
<wd-config-provider :theme-vars="themeVars" :theme="theme" :custom-class="`page-wraper ${theme}`">
<wd-config-provider :theme-vars="themeVars" :theme="theme" :custom-class="`page-wrapper theme-adaptive ${theme}`">
<slot />
<wd-notify />
<wd-toast />
@@ -33,13 +33,14 @@ export default {
</template>
<style lang="scss" scoped>
.page-wraper {
.page-wrapper {
box-sizing: border-box;
min-height: calc(100vh - var(--window-top));
background: #f9f9f9;
background-color: var(--bg-color-2);
transition: background-color 0.3s ease;
}
.wot-theme-dark.page-wraper {
background: #222;
.wot-theme-dark.page-wrapper {
background-color: var(--bg-color);
}
</style>
</style>
+13 -24
View File
@@ -1,22 +1,10 @@
<template>
<wd-config-provider :theme-vars="themeVars" :custom-class="`page-wraper ${theme}`" :theme="theme">
<wd-config-provider :theme-vars="themeVars" :custom-class="`page-wrapper ${theme}`" :theme="theme">
<slot />
<wd-tabbar
:model-value="activeTabbar.name"
placeholder
bordered
safe-area-inset-bottom
fixed
@change="handleTabbarChange"
>
<wd-tabbar-item
v-for="(item, index) in tabbarList"
:key="index"
:name="item.name"
:value="getTabbarItemValue(item.name)"
:title="item.title"
:icon="item.icon"
/>
<wd-tabbar :model-value="activeTabbar" placeholder bordered safe-area-inset-bottom fixed
@change="handleTabbarChange">
<wd-tabbar-item v-for="(item, index) in tabbarList" :key="index" :name="item.name" :title="item.title"
:icon="item.icon" />
</wd-tabbar>
<wd-notify />
<wd-toast />
@@ -35,7 +23,7 @@ const router = useRouter();
const route = useRoute();
let theme = useTheme.theme;
let themeVars = useTheme.themeVars;
const { activeTabbar, getTabbarItemValue, setTabbarItemActive, tabbarList } = useTabbar();
const { activeTabbar, setTabbarItemActive, tabbarList } = useTabbar();
function handleTabbarChange({ value }: { value: string }) {
setTabbarItemActive(value);
@@ -47,7 +35,7 @@ onMounted(() => {
uni.hideTabBar();
// #endif
nextTick(() => {
if (route.name && route.name !== activeTabbar.value.name) {
if (route.name) {
setTabbarItemActive(route.name);
}
});
@@ -65,13 +53,14 @@ export default {
</script>
<style lang="scss">
.page-wraper {
.page-wrapper {
box-sizing: border-box;
min-height: calc(100vh - var(--window-top));
background: #f9f9f9;
background-color: var(--bg-color-2);
transition: background-color 0.3s ease;
}
.wot-theme-dark.page-wraper {
background: #222;
.wot-theme-dark.page-wrapper {
background-color: var(--bg-color);
}
</style>
</style>
+6 -3
View File
@@ -68,7 +68,11 @@
},
{
"path": "pages/mine/profile/index",
"type": "page"
"type": "page",
"name": "profile",
"style": {
"navigationBarTitleText": "个人中心"
}
},
{
"path": "pages/mine/settings/index",
@@ -110,8 +114,7 @@
"name": "privacy",
"style": {
"navigationBarTitleText": "隐私政策"
},
"layout": "tabbar"
}
},
{
"path": "pages/mine/settings/theme/index",
+17 -31
View File
@@ -1,30 +1,21 @@
<template>
<view style="width: 100%; height: var(--status-bar-height)" />
<view class="app-container">
<view class="app-container theme-adaptive">
<wd-swiper v-model:current="current" :list="swiperList" autoplay />
<!-- 快捷导航 -->
<wd-grid clickable :column="4" class="mt-2">
<wd-grid-item
v-for="(item, index) in navList"
:key="index"
use-slot
link-type="navigateTo"
:url="item.url"
>
<wd-grid-item v-for="(item, index) in navList" :key="index" use-slot link-type="navigateTo" :url="item.url">
<view class="p-2">
<image class="w-72rpx h-72rpx rounded-8rpx" :src="item.icon" />
</view>
<view class="text">{{ item.title }}</view>
<view class="text theme-text-primary">{{ item.title }}</view>
</wd-grid-item>
</wd-grid>
<!-- 通知公告 -->
<wd-notice-bar
text="fastapp 是一个基于 Vue3 + UniApp 的前端模板项目,提供了一套完整的前端解决方案,包括登录、权限、字典、接口请求、状态管理、页面布局、组件封装等功能。"
color="#34D19D"
type="info"
>
<wd-notice-bar text="fastapp 是一个基于 Vue3 + UniApp 的前端模板项目,提供了一套完整的前端解决方案,包括登录、权限、字典、接口请求、状态管理、页面布局、组件封装等功能。"
color="#34D19D" type="info">
<template #prefix>
<wd-tag color="#FAA21E" bg-color="#FAA21E" plain custom-style="margin-right:10rpx">
通知公告
@@ -39,8 +30,8 @@
<view class="flex-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/visitor.png" />
<view class="ml-5 text-left">
<view class="font-bold">访客数</view>
<view class="mt-2">{{ visitStatsData.todayUvCount }}</view>
<view class="font-bold theme-text-primary">访客数</view>
<view class="mt-2 theme-text-primary">{{ visitStatsData.todayUvCount }}</view>
</view>
</view>
</view>
@@ -50,8 +41,8 @@
<view class="flex-center">
<image class="w-80rpx h-80rpx rounded-8rpx" src="/static/icons/browser.png" />
<view class="ml-5 text-left">
<view class="font-bold">浏览量</view>
<view class="mt-2">{{ visitStatsData.todayPvCount }}</view>
<view class="font-bold theme-text-primary">浏览量</view>
<view class="mt-2 theme-text-primary">{{ visitStatsData.todayPvCount }}</view>
</view>
</view>
</view>
@@ -61,14 +52,9 @@
<wd-card type="rectangle">
<template #title>
<view class="flex-between">
<view>访问趋势</view>
<view class="theme-text-primary">访问趋势</view>
<view>
<wd-radio-group
:value="recentDaysRange"
shape="button"
inline
@change="handleDataRangeChange"
>
<wd-radio-group :value="recentDaysRange" shape="button" inline @change="handleDataRangeChange">
<wd-radio :value="7">近7天</wd-radio>
<wd-radio :value="15">近15天</wd-radio>
</wd-radio-group>
@@ -252,11 +238,11 @@ onShow(() => {
});
</script>
<route lang="json">
{
<route lang="json">{
"name": "index",
"style": { "navigationStyle": "custom" },
"style": {
"navigationStyle": "custom"
},
"layout": "tabbar"
}
</route>
<style setup lang="scss"></style>
}</route>
<style setup lang="scss"></style>
+47 -112
View File
@@ -1,109 +1,51 @@
<template>
<view class="app-container">
<view class="app-container theme-adaptive">
<!-- 背景图 -->
<image src="/static/images/login-bg.svg" mode="aspectFill" class="login-bg" />
<!-- Logo和标题区域 -->
<view class="header">
<wd-img src="./static/logo.png" class="logo" mode="aspectFit" />
<text class="title">FastApp管理系统</text>
<text class="subtitle">欢迎使用移动端管理平台</text>
<wd-img src="/static/logo.png" class="logo" mode="aspectFit" />
<text class="title theme-text-inverse">FastApp管理系统</text>
<text class="subtitle theme-text-inverse">欢迎使用移动端管理平台</text>
</view>
<view class="login-card">
<view class="login-card theme-card">
<view class="form-wrap">
<!-- 账号密码登录表单 -->
<wd-form v-if="loginType === 'account'" ref="loginFormRef" :model="loginFormData">
<!-- 用户名输入框 -->
<view class="form-item">
<wd-icon
name="user"
size="22"
:color="isDarkMode ? '#7AC5FF' : '#333'"
class="input-icon"
/>
<wd-input
v-model="loginFormData.username"
type="text"
prop="username"
class="form-input input-transparent"
placeholder="请输入用户名"
placeholder-class="input-placeholder"
clearable
clear-trigger="focus"
/>
<wd-icon name="user" size="22" :color="themeVars.darkColor3" class="input-icon" />
<wd-input v-model="loginFormData.username" type="text" prop="username" class="form-input input-transparent"
placeholder="请输入用户名" placeholder-class="input-placeholder" clearable clear-trigger="focus" />
</view>
<!-- <wd-divider /> -->
<!-- 密码输入框 -->
<view class="form-item">
<wd-icon
name="lock-on"
size="22"
:color="isDarkMode ? '#7AC5FF' : '#333'"
class="input-icon"
/>
<wd-input
v-model="loginFormData.password"
class="form-input input-transparent"
type="text"
prop="password"
show-password
placeholder="请输入密码"
clear-trigger="focus"
clearable
:rules="[{ required: true, message: '请输入密码', trigger: 'blur' }]"
/>
<wd-icon name="lock-on" size="22" :color="themeVars.darkColor3" class="input-icon" />
<wd-input v-model="loginFormData.password" class="form-input input-transparent" type="text" prop="password"
show-password placeholder="请输入密码" clear-trigger="focus" clearable
:rules="[{ required: true, message: '请输入密码', trigger: 'blur' }]" />
</view>
<!-- <wd-divider /> -->
<!-- 验证码输入框 -->
<view v-if="captchaState.enable" class="form-item">
<wd-icon
name="lock-on"
size="22"
:color="isDarkMode ? '#7AC5FF' : '#333'"
class="input-icon"
/>
<wd-input
v-model="loginFormData.captcha"
class="form-input input-transparent captcha-input"
type="text"
prop="captcha"
placeholder="请输入验证码"
clear-trigger="focus"
clearable
:rules="[{ required: true, message: '请输入验证码', trigger: 'blur' }]"
/>
<wd-img
:src="captchaState.img_base"
class="captcha-image"
mode="aspectFit"
@click="getLoginCaptcha"
/>
<view
v-if="!captchaState.img_base && captchaState.enable"
class="captcha-placeholder"
@click="getLoginCaptcha"
>
<wd-icon name="lock-on" size="22" :color="themeVars.darkColor3" class="input-icon" />
<wd-input v-model="loginFormData.captcha" class="form-input input-transparent captcha-input" type="text"
prop="captcha" placeholder="请输入验证码" clear-trigger="focus" clearable
:rules="[{ required: true, message: '请输入验证码', trigger: 'blur' }]" />
<wd-img :src="captchaState.img_base" class="captcha-image" mode="aspectFit" @click="getLoginCaptcha" />
<view v-if="!captchaState.img_base && captchaState.enable" class="captcha-placeholder"
@click="getLoginCaptcha">
<text>点击加载</text>
</view>
</view>
<!-- <wd-divider /> -->
<!-- 登录按钮 -->
<view class="footer">
<wd-button
class="login-btn"
type="primary"
size="large"
:disabled="loading || !isFormValid"
:style="{ opacity: loading || !isFormValid ? 0.7 : 1 }"
block
@click="handleAccountLogin"
>
<wd-button class="login-btn" type="primary" size="large" :disabled="loading || !isFormValid"
:style="{ opacity: loading || !isFormValid ? 0.7 : 1 }" block @click="handleAccountLogin">
<wd-loading v-if="loading" size="20" color="#fff" />
{{ loading ? "登录中..." : "账号登录" }}
</wd-button>
@@ -111,31 +53,26 @@
<!-- 切换登录方式 -->
<view class="switch-login-type" @click="loginType = 'phone'">
<text>使用手机号一键登录</text>
<wd-icon name="arrow-right" size="12" />
<text class="text-primary">使用手机号一键登录</text>
<wd-icon name="arrow-right" size="12" :color="themeVars.colorTheme" />
</view>
</wd-form>
<!-- 手机号登录 -->
<view v-else class="phone-login-form">
<view class="phone-login-title">微信一键登录</view>
<view class="phone-login-subtitle">授权后将获取您的手机号</view>
<view class="phone-login-title theme-text-primary">微信一键登录</view>
<view class="phone-login-subtitle theme-text-secondary">授权后将获取您的手机号</view>
<wd-button
class="wechat-phone-btn"
:disabled="loading"
open-type="getPhoneNumber"
size="large"
@getphonenumber="handleWechatPhoneLogin"
>
<wd-button class="wechat-phone-btn" :disabled="loading" open-type="getPhoneNumber" size="large"
@getphonenumber="handleWechatPhoneLogin">
<wd-icon name="weixin" size="24" color="#ffffff" />
<text>微信一键登录</text>
</wd-button>
<!-- 切换登录方式 -->
<view class="switch-login-type" @click="loginType = 'account'">
<text>使用账号密码登录</text>
<wd-icon name="arrow-right" size="12" />
<text class="text-primary">使用账号密码登录</text>
<wd-icon name="arrow-right" size="12" :color="themeVars.colorTheme" />
</view>
</view>
@@ -143,7 +80,7 @@
<view class="other-login">
<view class="other-login-title">
<view class="line"></view>
<text class="text">其他登录方式</text>
<text class="text theme-text-muted">其他登录方式</text>
<view class="line"></view>
</view>
@@ -157,9 +94,9 @@
<!-- 底部协议 -->
<view class="agreement">
<wd-checkbox v-model="loginFormData.remember">
<wd-text text="我已阅读并同意"></wd-text>
<wd-text text="我已阅读并同意" class="theme-text-secondary"></wd-text>
<wd-text type="primary" text="《用户协议》" @click="navigateToUserAgreement"></wd-text>
<wd-text text="和"></wd-text>
<wd-text text="和" class="theme-text-secondary"></wd-text>
<wd-text type="primary" text="《隐私政策》" @click="navigateToPrivacy"></wd-text>
</wd-checkbox>
</view>
@@ -177,14 +114,15 @@ import { useToast } from "wot-design-uni";
import AuthAPI, { type LoginFormData, type CaptchaInfo } from "@/api/auth";
import { useThemeStore } from "@/store/modules/theme.store";
const useTheme = useThemeStore();
//
const themeStore = useThemeStore();
const themeVars = themeStore.themeVars;
const loginFormRef = ref();
const toast = useToast();
const loading = ref(false);
const userStore = useUserStore();
const loginType = ref<"account" | "phone">("account");
let theme = useTheme.theme;
//
const loginFormData = ref<LoginFormData>({
@@ -282,8 +220,6 @@ const handleWechatLogin = async () => {
toast.error("微信授权登录功能开发中...");
};
//
const isDarkMode = computed(() => theme === "dark");
//
const navigateToUserAgreement = () => {
@@ -316,21 +252,21 @@ onLoad((options) => {
}
});
</script>
<route lang="json">
{
<route lang="json">{
"name": "login",
"style": {
"navigationBarTitleText": "登录"
}
}
</route>
}</route>
<style lang="scss" scoped>
.app-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
height: 100%;
min-height: 100%;
overflow: hidden;
background-color: var(--wot-color-bg-container);
@@ -349,13 +285,12 @@ onLoad((options) => {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 80rpx;
margin-top: 30rpx;
}
.logo {
width: 140rpx;
height: 140rpx;
margin-bottom: 20rpx;
width: 250rpx;
height: 250rpx;
}
.title {
@@ -377,8 +312,8 @@ onLoad((options) => {
z-index: 2;
display: flex;
flex-direction: column;
width: 90%;
margin-top: 80rpx;
width: 95%;
margin-top: 30rpx;
overflow: hidden;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
@@ -503,7 +438,7 @@ input.input-transparent {
.login-btn {
width: 100%;
height: 88rpx;
margin-top: 60rpx;
margin-top: 30rpx;
font-size: 32rpx;
font-weight: 500;
line-height: 88rpx;
@@ -518,7 +453,7 @@ input.input-transparent {
display: flex;
align-items: center;
justify-content: center;
margin-top: 30rpx;
margin-top: 60rpx;
font-size: 26rpx;
color: var(--wot-color-theme);
}
+39 -85
View File
@@ -1,81 +1,52 @@
<template>
<view class="app-container dark:text-[var(--wot-dark-color)]">
<wd-card custom-style="margin-top: 20rpx">
<view class="about-header">
<wd-img
width="120rpx"
height="120rpx"
src="./static/logo.png"
mode="aspectFit"
class="about-logo"
/>
<view class="about-meta">
<text class="app-title">{{ webTitle }}</text>
<text class="app-version">版本 {{ version }}</text>
<view class="app-container theme-adaptive">
<wd-card custom-style="margin-top: 20rpx" class="theme-card">
<view class="company-section flex-center flex-column">
<view class="about-meta text-center">
<text class="app-title theme-text-primary">{{ webTitle }}-{{ version }}</text>
<text class="section-subtitle theme-text-secondary">{{ webDescription }}</text>
</view>
</view>
</wd-card>
<!-- 公司信息区域 -->
<wd-card title="项目概述" custom-style="margin-top: 20rpx">
<view class="company-section">
<view class="section-title">
<wd-icon name="home" size="18" />
<text>{{ webTitle }}</text>
</view>
<text class="section-subtitle">{{ webDescription }}</text>
</view>
</wd-card>
<!-- 项目列表 -->
<wd-card title="技术" custom-style="margin-top: 20rpx">
<wd-card title="技术" custom-style="margin-top: 20rpx" class="theme-card">
<wd-cell-group border>
<wd-cell title="后台" icon="desktop">
<view slot="label" class="project-desc">
基于 FastAPI + Uvicorn + SQLAlchemy 构建的RBAC管理系统
<view slot="label" class="project-desc theme-text-secondary">基于 FastAPI + Uvicorn + SQLAlchemy 构建的RBAC管理系统
</view>
</wd-cell>
<wd-cell title="web前端" icon="mobile">
<view slot="label" class="project-desc">
基于 Vue3 + Vite6 + TypeScript + Element-Plus + Pinia 构建的中后台管理模板
</view>
<view slot="label" class="project-desc theme-text-secondary">基于 Vue3 + Vite6 + TypeScript + Element-Plus +
Pinia 构建的中后台管理模板</view>
</wd-cell>
<wd-cell title="移动端" icon="server" label="">
<view slot="label" class="project-desc">
基于 uni-app + Vite6 + Vue 3 + TypeScript + Wot Design Uni + Pinia 构建的移动端应用模板
</view>
<view slot="label" class="project-desc theme-text-secondary">基于 uni-app + Vite6 + Vue 3 + TypeScript + Wot
Design Uni + Pinia
构建的移动端应用模板</view>
</wd-cell>
</wd-cell-group>
</wd-card>
<!-- 联系方式 -->
<wd-card title="联系我们" custom-style="margin-top: 20rpx">
<wd-cell-group border>
<wd-cell title="联系邮箱" value="948080782@qq.com" icon="mail" ellipsis />
<wd-cell title="官方网站" :value="helpDoc" is-link :to="helpDoc" icon="link" ellipsis />
<wd-cell title="源码地址" :value="gitCode" is-link :to="gitCode" icon="github" ellipsis />
</wd-cell-group>
</wd-card>
<!-- 底部版权信息 -->
<view class="about-footer">
<text class="copyright">{{ copyright }}</text>
<text class="copyright copyright-sub">{{ keepRecord }}</text>
<view class="about-footer text-center">
<text class="copyright theme-text-secondary">{{ copyright }}</text>
<text class="copyright copyright-sub theme-text-secondary">{{ keepRecord }}</text>
</view>
</view>
</template>
<script lang="ts" setup>
//
const webTitle = ref("FastAPI Vue3 Admin");
const webDescription = ref("FastAPI Vue3 Admin 是完全开源的权限管理系统");
const version = ref("2.0.0");
const webDescription = ref("完全开源的权限管理系统");
const version = ref("V2.0.0");
const keepRecord = ref("陕ICP备2025069493号-1");
const copyright = ref("Copyright © 2025-2026 service.fastapiadmin.com 版权所有");
const helpDoc = ref("http://service.fastapiadmin.com");
const gitCode = ref("https://github.com/1014TaoTao/fastapi_vue3_admin.git");
onMounted(() => {
// #ifdef MP-WEIXIN
@@ -84,40 +55,30 @@ onMounted(() => {
});
</script>
<route lang="json">
{
<route lang="json">{
"name": "about",
"style": {
"navigationBarTitleText": "关于我们"
}
}
</route>
}</route>
<style lang="scss" scoped>
.about-header {
display: flex;
align-items: center;
padding: 20rpx;
}
.about-logo {
margin-right: 16rpx;
margin-bottom: 20rpx;
}
.about-meta {
flex: 1;
}
.app-title {
display: block;
margin-bottom: 8rpx;
font-size: 36rpx;
font-weight: 700;
}
.app-title {
display: block;
margin-bottom: 8rpx;
font-size: 32rpx;
font-weight: 700;
}
.app-version {
font-size: 24rpx;
color: #6b7280;
.app-version {
font-size: 28rpx;
}
}
.company-section {
@@ -125,39 +86,32 @@ onMounted(() => {
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 12rpx;
font-size: 28rpx;
font-weight: 500;
:deep(.wd-icon) {
margin-right: 12rpx;
}
font-size: 32rpx;
font-weight: 600;
}
.section-subtitle {
display: block;
padding-left: 32rpx;
font-size: 24rpx;
color: #6b7280;
font-size: 28rpx;
line-height: 1.5;
}
.project-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: #6b7280;
font-size: 26rpx;
line-height: 1.4;
}
.about-footer {
padding: 32rpx 0;
text-align: center;
padding: 40rpx 20rpx;
}
.copyright {
display: block;
font-size: 24rpx;
color: #9ca3af;
line-height: 1.5;
}
.copyright-sub {
+298 -348
View File
@@ -1,41 +1,41 @@
<template>
<view class="faq-container">
<view class="app-container theme-adaptive">
<scroll-view class="content-scroll" scroll-y :scroll-top="scrollTop" @scroll="handleScroll">
<view class="content-wrapper">
<!-- 技术支持卡片 -->
<view class="support-card">
<view class="card-header">
<wd-icon name="service" size="48rpx" color="#0083f0" />
<text class="card-title">技术支持</text>
<view class="support-card theme-card">
<view class="card-header flex items-center">
<wd-icon name="service" size="48rpx" :color="themeVars.colorTheme" />
<text class="card-title theme-text-primary ml-md">技术支持</text>
</view>
<view class="card-body">
<text class="card-desc">专业的技术团队为您提供7×24小时服务支持</text>
<text class="card-desc theme-text-secondary">专业的技术团队为您提供7×24小时服务支持</text>
<view class="support-grid">
<view class="support-item" @tap="handleContact('email')">
<view class="support-icon">
<wd-icon name="mail" size="40rpx" color="#0083f0" />
<view class="support-item theme-card" @tap="handleContact('email')">
<view class="support-icon flex-center">
<wd-icon name="mail" size="40rpx" :color="themeVars.colorTheme" />
</view>
<view class="support-info">
<text class="support-label">邮箱支持</text>
<text class="support-value" user-select>support@example.com</text>
<text class="support-label theme-text-primary">邮箱支持</text>
<text class="support-value theme-text-secondary" user-select>support@example.com</text>
</view>
</view>
<view class="support-item" @tap="handleContact('phone')">
<view class="support-icon">
<wd-icon name="phone" size="40rpx" color="#0083f0" />
<view class="support-item theme-card" @tap="handleContact('phone')">
<view class="support-icon flex-center">
<wd-icon name="phone" size="40rpx" :color="themeVars.colorTheme" />
</view>
<view class="support-info">
<text class="support-label">客服热线</text>
<text class="support-value">400-123-4567</text>
<text class="support-label theme-text-primary">客服热线</text>
<text class="support-value theme-text-secondary">400-123-4567</text>
</view>
</view>
<view class="support-item">
<view class="support-icon">
<wd-icon name="clock" size="40rpx" color="#0083f0" />
<view class="support-item theme-card">
<view class="support-icon flex-center">
<wd-icon name="clock" size="40rpx" :color="themeVars.colorTheme" />
</view>
<view class="support-info">
<text class="support-label">服务时间</text>
<text class="support-value">工作日 9:00-18:00</text>
<text class="support-label theme-text-primary">服务时间</text>
<text class="support-value theme-text-secondary">工作日 9:00-18:00</text>
</view>
</view>
</view>
@@ -43,31 +43,22 @@
</view>
<!-- FAQ内容区域 -->
<view class="faq-section">
<view class="faq-section theme-card mt-md">
<wd-collapse v-model="activeNames" accordion>
<wd-collapse-item title="常见问题" name="faq">
<view class="faq-list">
<view
v-for="(item, index) in faqList"
:key="index"
class="faq-item"
:class="{ active: currentFaq === index }"
@tap="toggleFaq(index)"
>
<view class="faq-header">
<view class="faq-question">
<wd-icon name="question-filled" size="28rpx" color="#ff9500" />
<text class="question-text">{{ item.question }}</text>
<view v-for="(item, index) in faqList" :key="index" class="faq-item theme-card"
:class="{ active: currentFaq === index }" @tap="toggleFaq(index)">
<view class="faq-header flex justify-between items-center">
<view class="faq-question flex items-center">
<wd-icon name="question-filled" size="28rpx" :color="themeVars.colorTheme" />
<text class="question-text theme-text-primary ml-sm">{{ item.question }}</text>
</view>
<wd-icon
name="arrow-down-bold"
size="24rpx"
color="#999"
:custom-class="currentFaq === index ? 'rotate-180' : ''"
/>
<wd-icon name="arrow-down-bold" size="24rpx" :color="themeVars.darkColor3"
:custom-class="currentFaq === index ? 'rotate-180' : ''" />
</view>
<view v-if="currentFaq === index" class="faq-answer">
<text class="answer-text">{{ item.answer }}</text>
<text class="answer-text theme-text-secondary">{{ item.answer }}</text>
</view>
</view>
</view>
@@ -75,13 +66,13 @@
<wd-collapse-item title="系统功能" name="features">
<view class="feature-list">
<view v-for="(feature, index) in featureList" :key="index" class="feature-item">
<view class="feature-icon">
<wd-icon :name="feature.icon" size="48rpx" :color="feature.color" />
<view v-for="(feature, index) in featureList" :key="index" class="feature-item theme-card">
<view class="feature-icon flex-center">
<wd-icon :name="feature.icon" size="48rpx" :color="themeVars.colorTheme" />
</view>
<view class="feature-content">
<text class="feature-name">{{ feature.name }}</text>
<text class="feature-desc">{{ feature.desc }}</text>
<text class="feature-name theme-text-primary">{{ feature.name }}</text>
<text class="feature-desc theme-text-secondary">{{ feature.desc }}</text>
</view>
</view>
</view>
@@ -89,25 +80,27 @@
<wd-collapse-item title="使用指南" name="guide">
<view class="guide-section">
<view class="guide-header">
<wd-icon name="light" size="44rpx" color="#ff9500" />
<text class="guide-title">快速开始</text>
<view class="guide-header flex-center">
<wd-icon name="light" size="44rpx" :color="themeVars.colorTheme" />
<text class="guide-title theme-text-primary ml-md">快速开始</text>
</view>
<view class="guide-steps">
<view v-for="(step, index) in guideSteps" :key="index" class="guide-step">
<view class="step-indicator">
<view class="step-number">{{ index + 1 }}</view>
<view class="step-number flex-center">{{ index + 1 }}</view>
<view v-if="index < guideSteps.length - 1" class="step-line"></view>
</view>
<view class="step-content">
<text class="step-title">{{ step.title }}</text>
<text v-if="step.desc" class="step-desc">{{ step.desc }}</text>
<text class="step-title theme-text-primary">{{ step.title }}</text>
<text v-if="step.desc" class="step-desc theme-text-secondary">
{{ step.desc }}
</text>
</view>
</view>
</view>
<view class="guide-footer">
<wd-icon name="tips" size="28rpx" color="#999" />
<text class="footer-text">详细操作手册请查看帮助文档或联系客服获取</text>
<view class="guide-footer theme-card flex items-center">
<wd-icon name="tips" size="28rpx" :color="themeVars.darkColor3" />
<text class="footer-text theme-text-secondary ml-sm">详细操作手册请查看帮助文档或联系客服获取</text>
</view>
</view>
</wd-collapse-item>
@@ -120,6 +113,10 @@
<script lang="ts" setup>
import { ref } from "vue";
import { useThemeStore } from "@/store/modules/theme.store";
const themeStore = useThemeStore();
const themeVars = themeStore.themeVars;
//
const activeNames = ref<string[]>(["faq"]);
@@ -130,23 +127,27 @@ const scrollTop = ref<number>(0);
const faqList = ref([
{
question: "如何重置密码?",
answer:
"在登录页面点击 '忘记密码',按照提示操作即可重置密码。如果无法收到验证码,请检查邮箱或手机号是否正确,或联系客服协助处理。",
answer: "在登录页面点击 '忘记密码',按照提示操作即可重置密码。如果无法收到验证码,请检查邮箱或手机号是否正确,或联系客服协助处理。建议定期修改密码以保障账户安全。",
},
{
question: "数据如何备份?",
answer:
"系统会自动定期备份数据,您也可以在'系统设置-数据管理'中手动导出数据。建议每月进行一次完整数据备份,确保数据安全。",
answer: "系统会自动定期备份数据,您也可以在'系统设置-数据管理'中手动导出数据。建议每月进行一次完整数据备份,确保数据安全。重要数据请务必做好多重备份。",
},
{
question: "支持哪些浏览器?",
answer:
"推荐使用Chrome 90+、Firefox 88+、Safari 14+、Edge 90+等现代浏览器。IE浏览器仅支持IE11及以上版本。",
answer: "推荐使用Chrome 90+、Firefox 88+、Safari 14+、Edge 90+等现代浏览器。IE浏览器仅支持IE11及以上版本。移动端建议使用最新版本的微信、QQ浏览器等。",
},
{
question: "如何联系客服?",
answer:
"您可以通过以下方式联系客服:1. 客服热线 400-123-4567(工作日9:00-18:00);2. 邮箱 support@example.com7×24小时);3. 在线客服(工作日9:00-18:00)。",
answer: "您可以通过以下方式联系客服:1. 客服热线 400-123-4567(工作日9:00-18:00);2. 邮箱 support@example.com7×24小时);3. 在线客服(工作日9:00-18:00)。我们将尽快回复您的问题。",
},
{
question: "如何反馈问题?",
answer: "您可以通过'我的-问题反馈'页面提交问题,详细描述遇到的问题并上传相关截图。我们的技术团队会在24小时内处理您的反馈。您也可以通过客服热线直接反馈问题。",
},
{
question: "如何更新应用?",
answer: "应用会自动检测更新,有新版本时会提示您更新。您也可以在应用商店中手动检查更新。建议及时更新到最新版本以获得更好的使用体验和安全保障。",
},
]);
@@ -154,47 +155,63 @@ const faqList = ref([
const featureList = ref([
{
name: "用户管理",
desc: "支持用户注册、登录、权限管理等功能",
desc: "支持用户注册、登录、权限管理等功能,提供完善的用户体系",
icon: "user",
color: "#0083f0",
},
{
name: "数据统计",
desc: "提供实时数据分析和可视化报表",
desc: "提供实时数据分析和可视化报表,帮助您更好地了解业务状况",
icon: "chart",
color: "#00c250",
},
{
name: "文件管理",
desc: "支持文件上传、下载、分类管理",
desc: "支持文件上传、下载、分类管理,提供安全的文件存储服务",
icon: "folder",
color: "#ff9500",
},
{
name: "消息通知",
desc: "实时消息推送和系统通知",
desc: "实时消息推送和系统通知,确保您不会错过重要信息",
icon: "notification",
color: "#ff3b30",
},
{
name: "权限控制",
desc: "基于RBAC的权限管理模型,灵活控制用户访问权限",
icon: "secured",
color: "#5a5a5a",
},
{
name: "日志审计",
desc: "完整的操作日志记录,便于追踪和审计用户行为",
icon: "file",
color: "#8a8a8a",
},
]);
// 使
const guideSteps = ref([
{
title: "注册账号并登录系统",
desc: "使用手机号或邮箱注册账号,完成实名认证",
desc: "使用手机号或邮箱注册账号,完成实名认证,设置安全密码",
},
{
title: "完善个人或企业信息",
desc: "填写基本信息,设置安全问题和密保邮箱",
desc: "填写基本信息,设置安全问题和密保邮箱,绑定手机号",
},
{
title: "根据需求配置功能模块",
desc: "选择需要的功能模块,设置相关参数",
desc: "选择需要的功能模块,设置相关参数,配置权限角色",
},
{
title: "开始使用各项功能",
desc: "完成初始化设置,开始使用系统各项功能",
desc: "完成初始化设置,开始使用系统各项功能,定期备份数据",
},
{
title: "获取技术支持",
desc: "遇到问题时可通过客服、FAQ或在线反馈获取技术支持",
},
]);
@@ -239,337 +256,270 @@ onMounted(() => {
//
});
</script>
<route lang="json">
{
<route lang="json">{
"name": "faq",
"style": {
"navigationBarTitleText": "常见问题"
}
}
</route>
}</route>
<style lang="scss" scoped>
.faq-container {
display: flex;
flex-direction: column;
.content-scroll {
flex: 1;
height: 100%;
background: var(--wot-color-bg);
}
.content-scroll {
flex: 1;
height: 100%;
.content-wrapper {
padding: var(--spacing-md);
}
//
.support-card {
.card-header {
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-lg);
border-bottom: 1rpx solid var(--border-color);
}
.content-wrapper {
padding: 24rpx;
.card-title {
font-size: var(--font-lg);
font-weight: 600;
}
//
.support-card {
margin-bottom: 24rpx;
overflow: hidden;
background: #fff;
border-radius: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
.card-body {
padding: var(--spacing-xl);
.card-header {
display: flex;
align-items: center;
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid var(--wot-color-border);
.card-title {
margin-left: 16rpx;
font-size: 32rpx;
font-weight: 600;
color: var(--wot-color-text);
}
.card-desc {
display: block;
margin-bottom: var(--spacing-xl);
font-size: var(--font-md);
line-height: 1.5;
text-align: center;
}
.card-body {
padding: 32rpx;
.support-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-lg);
.card-desc {
display: block;
margin-bottom: 32rpx;
font-size: 28rpx;
line-height: 1.5;
color: var(--wot-color-text-light);
text-align: center;
}
.support-grid {
display: grid;
grid-template-columns: 1fr;
gap: 24rpx;
.support-item {
display: flex;
align-items: center;
padding: 32rpx;
background: var(--wot-color-bg);
border-radius: 16rpx;
transition: all 0.3s ease;
&:active {
opacity: 0.8;
transform: scale(0.98);
}
.support-icon {
flex-shrink: 0;
margin-right: 24rpx;
}
.support-info {
flex: 1;
.support-label {
display: block;
margin-bottom: 8rpx;
font-size: 28rpx;
font-weight: 600;
color: var(--wot-color-text);
}
.support-value {
display: block;
font-size: 26rpx;
color: var(--wot-color-text-light);
}
}
}
}
}
}
// FAQ
.faq-section {
:deep(.wd-collapse-item__title) {
font-size: 32rpx;
font-weight: 600;
}
:deep(.wd-collapse-item__content) {
padding: 0;
}
.faq-list {
.faq-item {
border-bottom: 1rpx solid var(--wot-color-border);
transition: all 0.3s ease;
&:last-child {
border-bottom: none;
}
&.active {
background: rgba(0, 131, 240, 0.02);
}
.faq-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
.faq-question {
display: flex;
flex: 1;
align-items: center;
margin-right: 24rpx;
.question-text {
margin-left: 16rpx;
font-size: 30rpx;
color: var(--wot-color-text);
}
}
.wd-icon {
transition: transform 0.3s ease;
&.rotate-180 {
transform: rotate(180deg);
}
}
}
.faq-answer {
padding: 0 32rpx 32rpx 32rpx;
border-top: 1rpx solid var(--wot-color-border);
.answer-text {
display: block;
padding: 24rpx 0;
font-size: 28rpx;
line-height: 1.6;
color: var(--wot-color-text-light);
}
}
}
}
.feature-list {
.feature-item {
.support-item {
display: flex;
align-items: center;
padding: 32rpx;
border-bottom: 1rpx solid var(--wot-color-border);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
transition: all 0.3s ease;
cursor: pointer;
&:last-child {
border-bottom: none;
&:active {
opacity: 0.8;
transform: scale(0.98);
}
.feature-icon {
.support-icon {
flex-shrink: 0;
margin-right: 24rpx;
width: 80rpx;
height: 80rpx;
margin-right: var(--spacing-lg);
background-color: var(--bg-color-2);
border-radius: var(--radius-lg);
}
.feature-content {
.support-info {
flex: 1;
.feature-name {
.support-label {
display: block;
margin-bottom: 8rpx;
font-size: 30rpx;
margin-bottom: var(--spacing-xs);
font-size: var(--font-md);
font-weight: 600;
color: var(--wot-color-text);
}
.feature-desc {
.support-value {
display: block;
font-size: 26rpx;
line-height: 1.4;
color: var(--wot-color-text-light);
font-size: var(--font-sm);
}
}
}
}
}
}
.guide-section {
padding: 32rpx;
// FAQ
.faq-section {
:deep(.wd-collapse-item__title) {
font-size: var(--font-lg);
font-weight: 600;
padding: var(--spacing-lg) var(--spacing-xl);
}
.guide-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
:deep(.wd-collapse-item__content) {
padding: 0;
}
.guide-title {
margin-left: 16rpx;
font-size: 32rpx;
.faq-list {
.faq-item {
border-bottom: 1rpx solid var(--border-color);
transition: all 0.3s ease;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&.active {
background: rgba(var(--primary-color-rgb), 0.02);
}
.faq-header {
padding: var(--spacing-xl);
.faq-question {
flex: 1;
margin-right: var(--spacing-lg);
.question-text {
font-size: var(--font-md);
}
}
.wd-icon {
transition: transform 0.3s ease;
&.rotate-180 {
transform: rotate(180deg);
}
}
}
.faq-answer {
padding: 0 var(--spacing-xl) var(--spacing-xl);
border-top: 1rpx solid var(--border-color);
.answer-text {
display: block;
padding: var(--spacing-lg) 0;
font-size: var(--font-md);
line-height: 1.6;
}
}
}
}
.feature-list {
.feature-item {
display: flex;
align-items: center;
padding: var(--spacing-xl);
border-bottom: 1rpx solid var(--border-color);
&:last-child {
border-bottom: none;
}
.feature-icon {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
margin-right: var(--spacing-lg);
background-color: var(--bg-color-2);
border-radius: var(--radius-lg);
}
.feature-content {
flex: 1;
.feature-name {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-md);
font-weight: 600;
color: var(--wot-color-text);
}
.feature-desc {
display: block;
font-size: var(--font-sm);
line-height: 1.4;
}
}
}
}
.guide-steps {
.guide-step {
display: flex;
margin-bottom: 48rpx;
.guide-section {
padding: var(--spacing-xl);
&:last-child {
margin-bottom: 0;
}
.guide-header {
margin-bottom: var(--spacing-xxl);
.step-indicator {
position: relative;
flex-shrink: 0;
margin-right: 24rpx;
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
font-size: 24rpx;
font-weight: 600;
color: #fff;
background: var(--wot-color-theme);
border-radius: 50%;
}
.step-line {
position: absolute;
top: 48rpx;
left: 50%;
width: 2rpx;
height: 48rpx;
background: var(--wot-color-border);
transform: translateX(-50%);
}
}
.step-content {
flex: 1;
padding-top: 8rpx;
.step-title {
display: block;
margin-bottom: 8rpx;
font-size: 30rpx;
font-weight: 600;
color: var(--wot-color-text);
}
.step-desc {
display: block;
font-size: 26rpx;
line-height: 1.4;
color: var(--wot-color-text-light);
}
}
}
.guide-title {
font-size: var(--font-lg);
font-weight: 600;
}
}
.guide-footer {
.guide-steps {
.guide-step {
display: flex;
align-items: center;
padding: 32rpx;
margin-top: 48rpx;
background: var(--wot-color-bg);
border-radius: 16rpx;
margin-bottom: var(--spacing-xxl);
.footer-text {
margin-left: 16rpx;
font-size: 26rpx;
color: var(--wot-color-text-light);
&:last-child {
margin-bottom: 0;
}
.step-indicator {
position: relative;
flex-shrink: 0;
margin-right: var(--spacing-lg);
.step-number {
width: 48rpx;
height: 48rpx;
font-size: var(--font-sm);
font-weight: 600;
color: #fff;
background: var(--primary-color);
border-radius: 50%;
}
.step-line {
position: absolute;
top: 48rpx;
left: 50%;
width: 2rpx;
height: 48rpx;
background: var(--border-color);
transform: translateX(-50%);
}
}
.step-content {
flex: 1;
padding-top: var(--spacing-xs);
.step-title {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-md);
font-weight: 600;
}
.step-desc {
display: block;
font-size: var(--font-sm);
line-height: 1.4;
}
}
}
}
}
}
//
@media screen and (min-width: 768px) {
.faq-container {
.content-wrapper {
max-width: 750rpx;
margin: 0 auto;
}
.guide-footer {
padding: var(--spacing-xl);
margin-top: var(--spacing-xxl);
border-radius: var(--radius-lg);
.support-card {
.support-grid {
grid-template-columns: repeat(3, 1fr);
.footer-text {
font-size: var(--font-sm);
}
}
}
}
//
@media (prefers-color-scheme: dark) {
.faq-container {
--wot-color-text: #f5f5f5;
--wot-color-text-light: #969799;
--wot-color-border: #3a3a3c;
--wot-color-bg: #1c1c1e;
.support-card,
.faq-section :deep(.wd-collapse) {
color: var(--wot-color-text);
background: #2c2c2e;
}
}
}
</style>
</style>
+44 -41
View File
@@ -1,49 +1,42 @@
<template>
<view class="app-container">
<wd-text size="small">选填最多上传3张图片</wd-text>
<view class="app-container theme-adaptive">
<wd-text size="small" class="theme-text-secondary">选填最多上传3张图片</wd-text>
<wd-form ref="formRef" :model="formData" :rules="rules" errorType="toast">
<!-- 问题类型选择 -->
<wd-cell-group title="问题类型" border>
<wd-radio-group v-model="formData.feedbackType" inline prop="feedbackType">
<wd-radio v-for="item in feedbackTypes" :key="item.value" :value="item.value">
<wd-cell-group title="问题类型" custom-class="theme-card mt-md">
<wd-radio-group v-model="formData.feedbackType" inline prop="feedbackType" custom-class="p-md">
<wd-radio v-for="item in feedbackTypes" :key="item.value" :value="item.value" custom-class="mb-sm">
{{ item.label }}
</wd-radio>
</wd-radio-group>
</wd-cell-group>
<wd-textarea
v-model="formData.description"
label="问题描述"
prop="description"
placeholder="请详细描述您遇到的问题或建议..."
:maxlength="120"
show-word-limit
/>
<!-- 问题描述 -->
<wd-cell-group title="问题描述" custom-class="theme-card mt-md">
<wd-textarea v-model="formData.description" prop="description" placeholder="请详细描述您遇到的问题或建议..."
:maxlength="300" show-word-limit custom-class="p-md" />
</wd-cell-group>
<wd-upload
v-model="formData.fileList"
label="相关截图"
prop="fileList"
:max-count="3"
:before-read="beforeRead"
@delete="handleDelete"
/>
<!-- 相关截图 -->
<wd-cell-group title="相关截图" custom-class="theme-card mt-md">
<view class="p-md">
<wd-text size="small" custom-class="theme-text-secondary mb-sm">选填最多上传3张图片</wd-text>
<wd-upload v-model="formData.fileList" :max-count="3" :before-read="beforeRead"
@delete="handleDelete" />
</view>
</wd-cell-group>
<wd-input
v-model="formData.contact"
label="联系方式"
prop="contact"
placeholder="请输入您的手机号或邮箱"
clearable
:border="false"
/>
<wd-text size="small">选填便于我们与您联系</wd-text>
<!-- 联系方式 -->
<wd-cell-group title="联系方式" custom-class="theme-card mt-md">
<view class="p-md">
<wd-input v-model="formData.contact" prop="contact" placeholder="请输入您的手机号或邮箱" clearable />
<wd-text size="small" custom-class="theme-text-secondary mt-sm">选填便于我们与您联系</wd-text>
</view>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="footer">
<wd-button type="primary" size="large" :loading="submitting" block @click="handleSubmit">
提交反馈
</wd-button>
<view class="footer theme-card mt-md">
<wd-button type="primary" size="large" :loading="submitting" block custom-class="submit-btn" @click="handleSubmit">提交反馈</wd-button>
</view>
</wd-form>
</view>
@@ -79,6 +72,13 @@ const formData = reactive({
//
const rules: FormRules = {
feedbackType: [
{
required: true,
message: "请选择问题类型",
trigger: "change",
},
],
description: [
{
required: true,
@@ -99,9 +99,7 @@ const rules: FormRules = {
if (!value) return Promise.resolve(); //
const emailReg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/;
const phoneReg = /^1[3456789]\d{9}$/;
return emailReg.test(value) || phoneReg.test(value)
? Promise.resolve()
: Promise.reject("请输入正确的手机号或邮箱");
return emailReg.test(value) || phoneReg.test(value) ? Promise.resolve() : Promise.reject("请输入正确的手机号或邮箱");
},
message: "请输入正确的手机号或邮箱",
trigger: "blur",
@@ -145,7 +143,7 @@ const handleSubmit = async () => {
try {
// TODO:
await new Promise((resolve) => setTimeout(resolve, 1500)); //
toast.success("提交成功");
toast.success("提交成功,我们会尽快处理您的反馈");
//
formRef.value.reset();
@@ -170,6 +168,7 @@ const handleSubmit = async () => {
}
};
</script>
<route lang="json">
{
"name": "feedback",
@@ -178,12 +177,16 @@ const handleSubmit = async () => {
}
}
</route>
<style lang="scss" scoped>
:deep(.wd-form-item) {
margin-bottom: 12rpx;
.footer {
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.submit-btn {
margin: 40rpx 30rpx;
height: 88rpx;
font-size: 32rpx;
border-radius: 44rpx;
}
</style>
+73 -97
View File
@@ -1,40 +1,30 @@
<template>
<view class="app-container">
<view class="app-container theme-adaptive">
<!-- 用户信息卡片 -->
<view class="user-profile">
<view class="user-profile theme-card">
<view class="blur-bg"></view>
<view class="user-info">
<view class="avatar-container" @click="navigateToProfile">
<image
class="avatar"
:src="isLogin ? userInfo!.avatar : defaultAvatar"
mode="aspectFill"
/>
<image class="avatar" :src="isLogin ? userInfo!.avatar : defaultAvatar" mode="aspectFill" />
</view>
<view class="user-details">
<block v-if="isLogin">
<view class="nickname">{{ userInfo!.name || "匿名用户" }}</view>
<view class="user-id">ID: {{ userInfo?.username || "0000000" }}</view>
<view class="nickname theme-text-primary">{{ userInfo!.name || "匿名用户" }}</view>
<view class="user-id theme-text-secondary">ID: {{ userInfo?.username || "0000000" }}</view>
</block>
<block v-else>
<view class="login-prompt">立即登录获取更多功能</view>
<wd-button
custom-class="btn-login"
size="small"
type="primary"
@click="navigateToLoginPage"
>
登录/注册
</wd-button>
<view class="login-prompt text-white">立即登录获取更多功能</view>
<wd-button custom-class="btn-login" size="small" type="primary"
@click="navigateToLoginPage">登录/注册</wd-button>
</block>
</view>
<view class="actions">
<view class="action-btn" @click="navigateToSettings">
<wd-icon name="setting1" size="22" color="#333" />
<wd-icon name="setting1" size="22" :color="themeVars.darkColor3" />
</view>
<view v-if="isLogin" class="action-btn" @click="navigateToSection('messages')">
<wd-badge v-if="true" modelValue="99+">
<wd-icon name="notification" size="22" color="#333" />
<wd-icon name="notification" size="22" :color="themeVars.darkColor3" />
</wd-badge>
</view>
</view>
@@ -42,20 +32,20 @@
</view>
<!-- 数据统计 -->
<view class="stats-card">
<view class="stats-card theme-card">
<view class="stats-item" @click="navigateToSection('wallet')">
<view class="mb-8rpx text-36rpx font-600">0.00</view>
<view class="text-26rpx text-gray-500">我的余额</view>
<view class="mb-sm text-lg font-bold theme-text-primary">0.00</view>
<view class="text-sm theme-text-secondary">我的余额</view>
</view>
<view class="stats-divider"></view>
<view class="stats-item" @click="navigateToSection('favorites')">
<view class="mb-8rpx text-36rpx font-600">0</view>
<view class="text-26rpx text-gray-500">我的收藏</view>
<view class="mb-sm text-lg font-bold theme-text-primary">0</view>
<view class="text-sm theme-text-secondary">我的收藏</view>
</view>
<view class="stats-divider"></view>
<view class="stats-item" @click="navigateToSection('history')">
<view class="mb-8rpx text-36rpx font-600">0</view>
<view class="text-26rpx text-gray-500">浏览历史</view>
<view class="mb-sm text-lg font-bold theme-text-primary">0</view>
<view class="text-sm theme-text-secondary">浏览历史</view>
</view>
</view>
@@ -63,32 +53,32 @@
<view class="card-container">
<view class="card-header">
<view class="flex-start">
<wd-icon name="tools" size="18" :color="currentThemeColor" />
<wd-icon name="tools" size="18" :color="themeVars.colorTheme" />
<text class="ml-12rpx text-28rpx font-600">常用工具</text>
</view>
</view>
<view class="flex flex-wrap p-20rpx pt-20rpx pb-10rpx">
<view class="tool-item" @click="navigateToProfile">
<view class="tool-icon">
<wd-icon name="user" size="24" :color="currentThemeColor" />
<wd-icon name="user" size="24" :color="currentThemeColor.primary" />
</view>
<view class="text-24rpx">个人资料</view>
</view>
<view class="tool-item" @click="navigateToFAQ">
<view class="tool-icon">
<wd-icon name="help-circle" size="24" :color="currentThemeColor" />
<wd-icon name="help-circle" size="24" :color="currentThemeColor.primary" />
</view>
<view class="text-24rpx">常见问题</view>
</view>
<view class="tool-item" @click="handleQuestionFeedback">
<view class="tool-icon">
<wd-icon name="check-circle" size="24" :color="currentThemeColor" />
<wd-icon name="check-circle" size="24" :color="currentThemeColor.primary" />
</view>
<view class="text-24rpx">问题反馈</view>
</view>
<view class="tool-item" @click="navigateToAbout">
<view class="tool-icon">
<wd-icon name="info-circle" size="24" :color="currentThemeColor" />
<wd-icon name="info-circle" size="24" :color="currentThemeColor.primary" />
</view>
<view class="text-24rpx">关于我们</view>
</view>
@@ -99,7 +89,7 @@
<view class="card-container">
<view class="card-header">
<view class="flex-start">
<wd-icon name="star" size="18" :color="currentThemeColor" />
<wd-icon name="star" size="18" :color="currentThemeColor.primary" />
<text class="ml-12rpx text-28rpx font-600">推荐服务</text>
</view>
</view>
@@ -107,7 +97,7 @@
<view class="service-item" @click="navigateToSection('services', 'vip')">
<view class="flex-start">
<view class="service-icon">
<wd-icon name="dong" size="22" :color="currentThemeColor" />
<wd-icon name="dong" size="22" :color="currentThemeColor.primary" />
</view>
<view>
<view class="text-28rpx font-500">会员中心</view>
@@ -119,7 +109,7 @@
<view class="service-item" @click="navigateToSection('services', 'coupon')">
<view class="flex-start">
<view class="service-icon">
<wd-icon name="discount" size="22" :color="currentThemeColor" />
<wd-icon name="discount" size="22" :color="currentThemeColor.primary" />
</view>
<view>
<view class="text-28rpx font-500">优惠券</view>
@@ -128,13 +118,10 @@
</view>
<wd-icon name="arrow-right" size="14" />
</view>
<view
class="service-item service-item-last"
@click="navigateToSection('services', 'invite')"
>
<view class="service-item service-item-last" @click="navigateToSection('services', 'invite')">
<view class="flex-start">
<view class="service-icon">
<wd-icon name="share" size="22" :color="currentThemeColor" />
<wd-icon name="share" size="22" :color="currentThemeColor.primary" />
</view>
<view>
<view class="text-28rpx font-500">邀请有礼</view>
@@ -150,7 +137,6 @@
<view v-if="isLogin" class="p-30rpx">
<wd-button custom-class="logout-button" plain @click="handleLogout">退出登录</wd-button>
</view>
<wd-toast />
</view>
</template>
@@ -158,13 +144,16 @@
<script lang="ts" setup>
import { useToast } from "wot-design-uni";
import { useRouter } from "uni-mini-router";
import { useUserStore, useThemeStore } from "@/store";
import { useUserStore } from "@/store";
import { useThemeStore } from "@/store/modules/theme.store";
import { computed } from "vue";
const toast = useToast();
const userStore = useUserStore();
const themeStore = useThemeStore();
const currentThemeColor = computed(() => themeStore.themeVars.colorTheme);
const themeVars = themeStore.themeVars;
const currentThemeColor = themeStore.currentThemeColor;
const userInfo = computed(() => userStore.userInfo);
const isLogin = computed(() => !!userInfo.value);
const defaultAvatar = "/static/images/default-avatar.png";
@@ -266,7 +255,7 @@ const loadUserInfo = async () => {
//
watch(
() => userInfo.value,
() => {},
() => { },
{
deep: true,
immediate: true,
@@ -274,20 +263,23 @@ watch(
);
</script>
<route lang="json">
{
<route lang="json">{
"name": "mine",
"style": { "navigationStyle": "custom" },
"style": {
"navigationStyle": "custom"
},
"layout": "tabbar"
}
</route>
}</route>
<style lang="scss" scoped>
//
.user-profile {
position: relative;
padding: 30rpx;
margin: 12rpx;
overflow: hidden;
border-radius: 16rpx;
box-shadow: var(--shadow-medium);
.blur-bg {
position: absolute;
@@ -360,9 +352,9 @@ watch(
.card-container {
margin: 24rpx 12rpx;
overflow: hidden;
background-color: #fff;
background-color: var(--card-bg-color, #ffffff);
border-radius: 16rpx;
box-shadow: 0 1rpx 3rpx rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-medium);
}
//
@@ -371,16 +363,7 @@ watch(
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #e5e7eb;
}
//
:deep(.dark) .card-container {
background-color: #1f2937;
}
:deep(.dark) .card-header {
border-bottom-color: #374151;
border-bottom: 1rpx solid var(--border-color, #e5e7eb);
}
//
@@ -400,7 +383,7 @@ watch(
width: 90rpx;
height: 90rpx;
margin-bottom: 12rpx;
background-color: #f3f4f6;
background-color: var(--bg-color-2, #f3f4f6);
border-radius: 18rpx;
transition: transform 0.15s ease;
@@ -409,24 +392,15 @@ watch(
}
}
//
:deep(.dark) .tool-icon {
background-color: #374151;
}
//
//
.stats-card {
display: flex;
align-items: center;
padding: 30rpx;
margin: 24rpx 12rpx;
background-color: #ffffff;
background-color: var(--card-bg-color, #ffffff);
border-radius: 16rpx;
box-shadow: 0 1rpx 3rpx rgba(0, 0, 0, 0.1);
}
:deep(.dark) .stats-card {
background-color: #1f2937;
box-shadow: var(--shadow-medium);
}
.stats-item {
@@ -439,11 +413,7 @@ watch(
.stats-divider {
width: 1px;
margin: 0 20rpx;
background-color: #e5e7eb;
}
:deep(.dark) .stats-divider {
background-color: #374151;
background-color: var(--border-color, #e5e7eb);
}
//
@@ -452,11 +422,11 @@ watch(
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-bottom: 1rpx solid #e5e7eb;
border-bottom: 1rpx solid var(--border-color, #e5e7eb);
transition: background-color 0.15s ease;
&:active {
background-color: #f3f4f6;
background-color: var(--bg-color-2, #f3f4f6);
}
&.service-item-last {
@@ -464,15 +434,6 @@ watch(
}
}
//
:deep(.dark) .service-item {
border-bottom-color: #374151;
&:active {
background-color: rgba(255, 255, 255, 0.1);
}
}
//
.service-icon {
display: flex;
@@ -481,15 +442,10 @@ watch(
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
background-color: #f3f4f6;
background-color: var(--bg-color-2, #f3f4f6);
border-radius: 16rpx;
}
//
:deep(.dark) .service-icon {
background-color: #374151;
}
//
:deep(.btn-login) {
font-size: 24rpx !important;
@@ -504,4 +460,24 @@ watch(
font-weight: bold !important;
border-radius: 40rpx !important;
}
</style>
//
.text-white {
color: #ffffff !important;
}
//
:deep(.dark) {
.tool-icon,
.service-icon,
.service-item:active {
background-color: var(--bg-color-2, #374151);
}
.stats-divider,
.card-header {
border-color: var(--border-color, #374151);
}
}
</style>
+143 -46
View File
@@ -1,49 +1,122 @@
<template>
<view class="app-container">
<wd-card v-if="userProfile" custom-style="margin-top: 20rpx">
<view class="app-container theme-adaptive">
<wd-card v-if="userProfile" custom-style="margin-top: 20rpx" class="theme-card">
<wd-cell-group border>
<wd-cell class="avatar-cell" title="头像" center is-link>
<view class="avatar">
<view v-if="!userProfile.avatar" class="img" @click="avatarUpload">
<wd-icon name="fill-camera" custom-class="img-icon" />
<wd-cell class="avatar-cell" title="头像" center is-link @click="avatarUpload">
<template #title>
<text class="theme-text-primary">头像</text>
</template>
<view class="avatar flex-center">
<view v-if="!userProfile.avatar" class="img flex-center" @click="avatarUpload">
<wd-icon name="fill-camera" custom-class="img-icon" :color="themeVars.darkColor3" />
</view>
<wd-img
v-if="userProfile.avatar"
round
width="80px"
height="80px"
:src="userProfile.avatar"
mode="aspectFit"
custom-class="profile-img"
@click="avatarUpload"
/>
<wd-img v-if="userProfile.avatar" round width="80px" height="80px" :src="userProfile.avatar"
mode="aspectFit" custom-class="profile-img" @click="avatarUpload" />
</view>
</wd-cell>
<wd-cell title="昵称" :value="userProfile.name" is-link @click="handleOpenDialog()" />
<wd-cell
title="性别"
:value="userProfile.gender === '0' ? '男' : userProfile.gender === '1' ? '女' : '未知'"
is-link
@click="handleOpenDialog()"
/>
<wd-cell title="用户名" :value="userProfile.username" />
<wd-cell title="昵称" :value="userProfile.name" is-link @click="handleOpenDialog('name')">
<template #title>
<text class="theme-text-primary">昵称</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.name || '未设置' }}</text>
</template>
</wd-cell>
<wd-cell title="性别" :value="userProfile.gender === '0' ? '男' : userProfile.gender === '1' ? '女' : '未知'" is-link
@click="handleOpenDialog('gender')">
<template #title>
<text class="theme-text-primary">性别</text>
</template>
<template #value>
<text class="theme-text-secondary">
{{ userProfile.gender === "0" ? "男" : userProfile.gender === "1" ? "女" : "未知" }}
</text>
</template>
</wd-cell>
<wd-cell title="用户名" :value="userProfile.username">
<template #title>
<text class="theme-text-primary">用户名</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.username }}</text>
</template>
</wd-cell>
<wd-cell v-if="userProfile" title="状态" center>
<template #title>
<text class="theme-text-primary">状态</text>
</template>
<wd-tag :type="userProfile.status ? 'success' : 'danger'">
{{ userProfile.status ? "启用" : "停用" }}
</wd-tag>
</wd-cell>
<wd-cell v-if="userProfile" title="是否超管" center>
<template #title>
<text class="theme-text-primary">是否超管</text>
</template>
<wd-tag plain :type="userProfile.is_superuser ? 'primary' : 'default'">
{{ userProfile.is_superuser ? "是" : "否" }}
</wd-tag>
</wd-cell>
<wd-cell title="手机号" :value="userProfile.mobile" />
<wd-cell title="邮箱" :value="userProfile.email" />
<wd-cell title="部门" :value="userProfile.dept_name" />
<wd-cell title="角色" :value="userProfile.roles?.map((item) => item.name).join(', ')" />
<wd-cell title="岗位" :value="userProfile.positions?.map((item) => item.name).join(', ')" />
<wd-cell title="备注" :value="userProfile.description" />
<wd-cell title="创建日期" :value="userProfile.created_at" />
<wd-cell title="手机号" :value="userProfile.mobile">
<template #title>
<text class="theme-text-primary">手机号</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.mobile || '未绑定' }}</text>
</template>
</wd-cell>
<wd-cell title="邮箱" :value="userProfile.email">
<template #title>
<text class="theme-text-primary">邮箱</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.email || '未绑定' }}</text>
</template>
</wd-cell>
<wd-cell title="部门" :value="userProfile.dept_name">
<template #title>
<text class="theme-text-primary">部门</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.dept_name || '未分配' }}</text>
</template>
</wd-cell>
<wd-cell title="角色" :value="userProfile.roles?.map((item) => item.name).join(', ')">
<template #title>
<text class="theme-text-primary">角色</text>
</template>
<template #value>
<text class="theme-text-secondary">
{{userProfile.roles?.map((item) => item.name).join(", ") || '未分配'}}
</text>
</template>
</wd-cell>
<wd-cell title="岗位" :value="userProfile.positions?.map((item) => item.name).join(', ')">
<template #title>
<text class="theme-text-primary">岗位</text>
</template>
<template #value>
<text class="theme-text-secondary">
{{userProfile.positions?.map((item) => item.name).join(", ") || '未分配'}}
</text>
</template>
</wd-cell>
<wd-cell title="备注" :value="userProfile.description">
<template #title>
<text class="theme-text-primary">备注</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.description || '无' }}</text>
</template>
</wd-cell>
<wd-cell title="创建日期" :value="userProfile.created_at">
<template #title>
<text class="theme-text-primary">创建日期</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ userProfile.created_at }}</text>
</template>
</wd-cell>
</wd-cell-group>
</wd-card>
@@ -51,18 +124,24 @@
<wd-img-cropper v-model="avatarShow" :img-src="originalSrc" @confirm="handleAvatarConfirm" />
<!--用户信息编辑弹出框-->
<wd-popup v-model="dialog.visible" position="bottom">
<wd-popup v-model="dialog.visible" position="bottom"
custom-style="border-top-left-radius: 16rpx; border-top-right-radius: 16rpx;">
<view class="popup-header">
<wd-cell :title="dialog.title" center>
<template #right-icon>
<wd-icon name="close" size="20" @click="dialog.visible = false" />
</template>
</wd-cell>
</view>
<wd-divider />
<wd-form ref="userProfileFormRef" :model="userProfileForm" custom-class="edit-form">
<wd-cell-group border>
<wd-input
v-model="userProfileForm.name"
label="昵称"
label-width="160rpx"
placeholder="请输入昵称"
prop="name"
:rules="rules.name"
/>
<wd-cell title="性别" title-width="160rpx" center prop="gender" :rules="rules.gender">
<wd-input v-if="dialog.field === 'name'" v-model="userProfileForm.name" label="昵称" label-width="160rpx"
placeholder="请输入昵称" prop="name" :rules="rules.name" />
<wd-cell v-if="dialog.field === 'gender'" title="性别" title-width="160rpx" center prop="gender"
:rules="rules.gender">
<wd-radio-group v-model="userProfileForm.gender" shape="button" class="ef-radio-group">
<wd-radio :value="0"></wd-radio>
<wd-radio :value="1"></wd-radio>
@@ -70,13 +149,14 @@
</wd-radio-group>
</wd-cell>
</wd-cell-group>
<view class="footer">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
<view>
<wd-button type="primary" block @click="handleSubmit">提交</wd-button>
</view>
</wd-form>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import UserAPI, { type UserInfo } from "@/api/user";
import FileAPI from "@/api/file";
@@ -146,11 +226,18 @@ const userProfileFormRef = ref();
* 打开弹窗
* @param type 弹窗类型 ACCOUNT: 账号资料 PASSWORD: 修改密码 MOBILE: 绑定手机 EMAIL: 绑定邮箱
*/
const handleOpenDialog = () => {
const handleOpenDialog = (field: string) => {
dialog.field = field;
dialog.visible = true;
//
userProfileForm.name = userProfile.value?.name || "";
userProfileForm.gender = userProfile.value?.gender || "0";
if (field === "name") {
dialog.title = "编辑昵称";
userProfileForm.name = userProfile.value?.name || "";
} else if (field === "gender") {
dialog.title = "选择性别";
userProfileForm.gender = userProfile.value?.gender || "0";
}
};
//
@@ -211,6 +298,16 @@ function touchmoveListener(event: TouchEvent) {
event.preventDefault();
}
</script>
<route lang="json">
{
"name": "profile",
"style": {
"navigationBarTitleText": "个人中心"
}
}
</route>
<style lang="scss" scoped>
.avatar-cell {
:deep(.wd-cell__body) {
@@ -1,56 +1,43 @@
<template>
<view class="app-container">
<wd-card custom-style="margin-top: 20rpx">
<view class="app-container theme-adaptive">
<wd-card custom-style="margin-top: 20rpx" class="theme-card">
<wd-cell-group border>
<wd-cell
title="账户密码"
label="定期修改密码有助于保护账户安全"
value="修改"
is-link
@click="handleOpenDialog(DialogType.PASSWORD)"
/>
<wd-cell title="账户密码" label="定期修改密码有助于保护账户安全" value="修改" is-link @click="handleOpenDialog(DialogType.PASSWORD)">
<template #title>
<text class="theme-text-primary">账户密码</text>
</template>
<template #label>
<text class="theme-text-secondary">定期修改密码有助于保护账户安全</text>
</template>
<template #value>
<text class="theme-text-secondary">修改</text>
</template>
</wd-cell>
</wd-cell-group>
</wd-card>
<!--用户信息编辑弹出框-->
<wd-popup v-model="dialog.visible" position="bottom">
<wd-form
v-if="dialog.type === DialogType.PASSWORD"
ref="passwordChangeFormRef"
:model="passwordChangeForm"
custom-class="edit-form"
>
<wd-popup v-model="dialog.visible" position="bottom"
custom-style="border-top-left-radius: 16rpx; border-top-right-radius: 16rpx;">
<view class="popup-header">
<wd-cell :title="getDialogTitle" center>
<template #right-icon>
<wd-icon name="close" size="20" @click="dialog.visible = false" />
</template>
</wd-cell>
</view>
<wd-divider />
<wd-form v-if="dialog.type === DialogType.PASSWORD" ref="passwordChangeFormRef" :model="passwordChangeForm"
custom-class="edit-form">
<wd-cell-group border>
<wd-input
v-model="passwordChangeForm.old_password"
label="原密码"
label-width="160rpx"
show-password
clearable
placeholder="请输入原密码"
prop="old_password"
:rules="rules.oldPassword"
/>
<wd-input
v-model="passwordChangeForm.new_password"
label="新密码"
label-width="160rpx"
show-password
clearable
placeholder="请输入新密码"
prop="new_password"
:rules="rules.newPassword"
/>
<wd-input
v-model="passwordChangeForm.confirm_password"
label="确认密码"
label-width="160rpx"
show-password
clearable
placeholder="请确认新密码"
prop="confirm_password"
:rules="rules.confirmPassword"
/>
<wd-input v-model="passwordChangeForm.old_password" label="原密码" label-width="160rpx" show-password clearable
placeholder="请输入原密码" prop="old_password" :rules="rules.oldPassword" />
<wd-input v-model="passwordChangeForm.new_password" label="新密码" label-width="160rpx" show-password clearable
placeholder="请输入新密码" prop="new_password" :rules="rules.newPassword" />
<wd-input v-model="passwordChangeForm.confirm_password" label="确认密码" label-width="160rpx" show-password
clearable placeholder="请确认新密码" prop="confirm_password" :rules="rules.confirmPassword" />
</wd-cell-group>
<view class="p-6">
<wd-button type="primary" size="large" block @click="handleSubmit">提交</wd-button>
@@ -59,6 +46,7 @@
</wd-popup>
</view>
</template>
<script setup lang="ts">
import UserAPI, { PasswordChangeForm, UserInfo } from "@/api/user";
@@ -73,6 +61,7 @@ const validatorConfirmPassword = (value: string) => {
}
}
};
//
const rules = reactive({
oldPassword: [{ required: true, message: "请填写原密码" }],
@@ -95,6 +84,19 @@ enum DialogType {
EMAIL = "email",
}
const getDialogTitle = computed(() => {
switch (dialog.type) {
case DialogType.PASSWORD:
return "修改密码";
case DialogType.MOBILE:
return "绑定手机";
case DialogType.EMAIL:
return "绑定邮箱";
default:
return "账号设置";
}
});
const dialog = reactive({
visible: false,
type: "" as DialogType, // ,
@@ -135,8 +137,10 @@ function handleSubmit() {
passwordChangeFormRef.value.validate().then(({ valid }: { valid: boolean }) => {
if (valid) {
UserAPI.changeCurrentUserPassword(passwordChangeForm).then(() => {
uni.showToast({ title: "密码修改成功", icon: "none" });
uni.showToast({ title: "密码修改成功", icon: "success" });
dialog.visible = false;
}).catch((error: any) => {
uni.showToast({ title: error?.message || "密码修改失败", icon: "error" });
});
}
});
@@ -151,8 +155,11 @@ onMounted(() => {
<route lang="json">
{
"name": "account",
"style": { "navigationBarTitleText": "账号和安全" },
"style": {
"navigationBarTitleText": "账号和安全"
},
"layout": "tabbar"
}
</route>
<style lang="scss" scoped></style>
@@ -1,26 +1,22 @@
<template>
<view class="app-container">
<wd-card custom-style="margin-top: 20rpx">
<view class="flex-col-center py-4">
<text class="text-xl font-bold mb-2">用户协议</text>
<text class="text-sm text-gray-500">更新日期2024年3月15日</text>
<view class="app-container theme-adaptive">
<wd-card custom-style="margin-top: 20rpx" class="theme-card">
<view class="flex-col-center py-lg">
<text class="text-xl font-bold mb-md theme-text-primary">用户协议</text>
<text class="text-sm theme-text-secondary">更新日期2024年3月15日</text>
</view>
</wd-card>
<wd-collapse v-model="activeNames" accordion>
<wd-collapse-item
v-for="(section, index) in agreementContent"
:key="index"
:title="section.title"
:name="String(index)"
>
<view class="py-3 px-4">
<text class="text-base leading-relaxed text-gray-600">{{ section.content }}</text>
<wd-collapse v-model="activeNames" accordion class="theme-card mt-md">
<wd-collapse-item v-for="(section, index) in agreementContent" :key="index" :title="section.title"
:name="String(index)">
<view class="py-md px-lg">
<text class="text-base leading-relaxed theme-text-secondary">{{ section.content }}</text>
</view>
</wd-collapse-item>
</wd-collapse>
<view class="mt-6 px-4">
<view class="mt-lg px-lg theme-card">
<wd-button type="primary" block @click="handleAgree">我已阅读并同意</wd-button>
</view>
</view>
@@ -32,33 +28,35 @@ const activeNames = ref(["0"]); // 默认展开第一项
const agreementContent = [
{
title: "1. 协议的范围",
content:
"本协议是您与我们之间关于使用本应用服务所订立的协议。您在使用本应用服务时,须完全接受本协议所有条款。",
content: "本协议是您与我们之间关于使用本应用服务所订立的协议。您在使用本应用服务时,须完全接受本协议所有条款。本协议适用于本应用提供的所有服务。如果您不同意本协议的任何条款,请立即停止使用本应用。本协议构成您与我们之间的法律协议,具有法律约束力。",
},
{
title: "2. 服务内容",
content:
"本应用向您提供以下服务:网络状态检测、网络性能测试以及其他相关服务。我们将持续优化和更新服务内容,为您提供更好的使用体验。",
content: "本应用向您提供以下服务:网络状态检测、网络性能测试、数据统计分析以及其他相关服务。我们将持续优化和更新服务内容,为您提供更好的使用体验。我们保留在任何时候修改或终止部分或全部服务的权利,且无需提前通知用户。",
},
{
title: "3. 用户隐私",
content:
"我们重视用户的隐私保护,收集信息仅用于提供网络测试服务、改善用户体验和必要的系统维护。我们承诺对您的信息进行严格保密。",
content: "我们重视用户的隐私保护,收集信息仅用于提供网络测试服务、改善用户体验和必要的系统维护。我们承诺对您的信息进行严格保密,不会向任何第三方出售、出租或分享您的个人信息。我们采用行业标准的安全措施保护您的数据安全。",
},
{
title: "4. 用户行为规范",
content:
"您在使用本服务时必须遵守中华人民共和国相关法律法规。您不得利用本服务从事违法违规活动。如发现违规行为,我们有权终止服务。",
content: "您在使用本服务时必须遵守中华人民共和国相关法律法规。您不得利用本服务从事违法违规活动,包括但不限于:传播违法信息、侵犯他人合法权益、破坏网络安全等。如发现违规行为,我们有权终止服务并保留追究法律责任的权利。",
},
{
title: "5. 免责声明",
content:
"由于网络服务的特殊性,本应用不保证服务一定能满足用户的所有要求。对于因网络状态、通信线路等不可控因素导致的服务中断或其他缺陷,本应用不承担任何责任。",
content: "由于网络服务的特殊性,本应用不保证服务一定能满足用户的所有要求。对于因网络状态、通信线路等不可控因素导致的服务中断或其他缺陷,本应用不承担任何责任。我们不对任何间接、偶然、特殊或后果性损害承担责任。",
},
{
title: "6. 协议修改",
content:
"我们保留随时修改本协议的权利。协议修改后,如果您继续使用本应用服务,即视为您已接受修改后的协议。我们建议您定期查看本协议以了解任何变更。",
content: "我们保留随时修改本协议的权利。协议修改后,如果您继续使用本应用服务,即视为您已接受修改后的协议。我们建议您定期查看本协议以了解任何变更。重大修改将通过适当方式通知用户。",
},
{
title: "7. 知识产权",
content: "本应用的所有内容,包括但不限于文字、图片、音频、视频、软件等,均受知识产权法律法规保护。未经我们书面许可,您不得以任何方式使用、复制、传播或修改本应用的任何内容。用户在使用过程中产生的合法内容,其知识产权归用户所有。",
},
{
title: "8. 争议解决",
content: "因本协议引起的或与本协议有关的任何争议,双方应首先通过友好协商解决。协商不成的,任何一方均可向我们所在地有管辖权的人民法院提起诉讼。本协议的解释、效力和执行均适用中华人民共和国法律。",
},
];
@@ -73,10 +71,13 @@ const handleAgree = () => {
}, 1500);
};
</script>
<route lang="json">
{
"name": "agreement",
"style": { "navigationBarTitleText": "用户协议" },
"style": {
"navigationBarTitleText": "用户协议"
},
"layout": "tabbar"
}
</route>
@@ -1,43 +1,61 @@
<template>
<view class="app-container dark:text-[var(--wot-dark-color)]">
<view class="app-container theme-adaptive">
<!-- 网络状态展示 -->
<wd-card custom-style="margin: 20rpx">
<wd-card custom-style="margin: 20rpx" class="theme-card">
<wd-cell-group border>
<wd-cell title="网络状态">
<wd-tag :type="networkType ? 'success' : 'danger'" size="small">
<template #title>
<text class="theme-text-primary">网络状态</text>
</template>
<wd-tag :type="networkType ? 'success' : 'danger'">
{{ networkType ? "在线" : "离线" }}
</wd-tag>
</wd-cell>
<wd-cell title="网络类型" :value="networkType || '未知'" />
<wd-cell title="网络强度" :value="signalStrength" />
<wd-cell title="网络类型" :value="networkType || '未知'">
<template #title>
<text class="theme-text-primary">网络类型</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ networkType || "未知" }}</text>
</template>
</wd-cell>
<wd-cell title="网络强度" :value="signalStrength">
<template #title>
<text class="theme-text-primary">网络强度</text>
</template>
<template #value>
<text class="theme-text-secondary">{{ signalStrength }}</text>
</template>
</wd-cell>
</wd-cell-group>
</wd-card>
<!-- 网络测试 -->
<wd-card title="网络测试" custom-style="margin: 20rpx">
<wd-card title="网络测试" custom-style="margin: 20rpx" class="theme-card">
<template #title>
<text class="theme-text-primary">网络测试</text>
</template>
<view slot="extra">
<text class="text-gray-500 text-sm">测试服务器连接情况</text>
<text class="theme-text-secondary text-sm">测试服务器连接情况</text>
</view>
<wd-cell-group border>
<wd-cell title="延迟">
<template #title>
<text class="theme-text-primary">延迟</text>
</template>
<view class="flex items-center">
<text class="mr-10">{{ pingResult.delay }}ms</text>
<wd-tag v-if="getPingStatus" :type="getPingStatusType" size="small">
<text class="mr-md theme-text-secondary">{{ pingResult.delay }}ms</text>
<wd-tag v-if="getPingStatus" :type="getPingStatusType">
{{ pingResult.status }}
</wd-tag>
</view>
</wd-cell>
</wd-cell-group>
<wd-progress
v-if="testing"
:percentage="progress"
stroke-width="4"
custom-style="margin: 30rpx 0"
/>
<wd-progress v-if="testing" :percentage="progress" stroke-width="6" custom-style="margin: 30rpx 0" color="#4D7FFF" />
<wd-button block type="primary" :loading="testing" @click="startTest">
<wd-button block type="primary" :loading="testing" custom-class="test-btn" @click="startTest">
{{ testing ? "测试中..." : "开始测试" }}
</wd-button>
</wd-card>
@@ -104,9 +122,7 @@ const getNetworkType = async () => {
// H5
// #ifdef H5
signalStrength.value = (navigator as any).connection
? `${(navigator as any).connection.effectiveType || "未知"}`
: "不支持";
signalStrength.value = (navigator as any).connection ? `${(navigator as any).connection.effectiveType || "未知"}` : "不支持";
// #endif
} catch {
networkType.value = "获取失败";
@@ -1,26 +1,21 @@
<template>
<view class="app-container">
<wd-card custom-style="margin-top: 20rpx">
<view class="flex-col-center py-4">
<text class="text-xl font-bold mb-2">隐私政策</text>
<text class="text-sm text-gray-500">更新日期2024年3月15日</text>
<view class="app-container theme-adaptive">
<wd-card custom-style="margin-top: 20rpx" class="theme-card">
<view class="flex-col-center py-lg">
<text class="text-xl font-bold mb-md theme-text-primary">隐私政策</text>
<text class="text-sm theme-text-secondary">更新日期2024年3月15日</text>
</view>
</wd-card>
<wd-collapse v-model="activeNames" accordion>
<wd-collapse-item
v-for="(section, index) in privacyContent"
:key="index"
:title="section.title"
:name="String(index)"
>
<view class="py-3 px-4">
<text class="text-base leading-relaxed text-gray-600">{{ section.content }}</text>
<wd-collapse v-model="activeNames" accordion class="theme-card mt-md">
<wd-collapse-item v-for="(section, index) in privacyContent" :key="index" :title="section.title" :name="String(index)">
<view class="py-md px-lg">
<text class="text-base leading-relaxed theme-text-secondary">{{ section.content }}</text>
</view>
</wd-collapse-item>
</wd-collapse>
<view class="mt-6 px-4">
<view class="mt-lg px-lg theme-card">
<wd-button type="primary" block @click="handleAgree">我已阅读并同意</wd-button>
</view>
</view>
@@ -32,38 +27,35 @@ const activeNames = ref(["0"]); // 默认展开第一项
const privacyContent = ref([
{
title: "1. 信息收集",
content:
"我们可能收集您的基本信息(如设备信息、操作日志等)以提供更好的服务体验。我们承诺对这些信息进行严格保密,并只用于改善产品服务。",
content: "我们可能收集您的基本信息(如设备信息、操作日志等)以提供更好的服务体验。我们承诺对这些信息进行严格保密,并只用于改善产品服务。收集的信息包括但不限于:设备型号、操作系统版本、网络状态、使用习惯等。",
},
{
title: "2. 信息使用",
content:
"收集的信息将用于:优化用户体验、提供客户支持、发送重要通知、保障账号安全等。未经您的同意,我们不会向第三方分享您的个人信息。",
content: "收集的信息将用于:优化用户体验、提供客户支持、发送重要通知、保障账号安全等。未经您的同意,我们不会向第三方分享您的个人信息。我们可能会对数据进行统计分析,但不会泄露任何个人身份信息。",
},
{
title: "3. 信息安全",
content:
"我们采用业界标准的安全技术和程序来保护您的个人信息,防止未经授权的访问、使用或泄露。我们定期审查信息收集、存储和处理实践。",
content: "我们采用业界标准的安全技术和程序来保护您的个人信息,防止未经授权的访问、使用或泄露。我们定期审查信息收集、存储和处理实践,确保数据安全。我们建立了完善的安全管理制度和技术防护措施。",
},
{
title: "4. Cookie 使用",
content:
"我们使用 Cookie 和类似技术来提供、保护和改进我们的产品和服务。这些技术帮助我们了解用户行为,告诉我们哪些功能最受欢迎。",
content: "我们使用 Cookie 和类似技术来提供、保护和改进我们的产品和服务。这些技术帮助我们了解用户行为,告诉我们哪些功能最受欢迎。您可以通过浏览器设置管理 Cookie,但可能会影响部分功能的正常使用。",
},
{
title: "5. 第三方服务",
content:
"我们的应用可能包含第三方服务。这些第三方服务有自己的隐私政策,我们建议您查看这些政策。我们不对第三方的隐私实践负责。",
content: "我们的应用可能包含第三方服务。这些第三方服务有自己的隐私政策,我们建议您查看这些政策。我们不对第三方的隐私实践负责。在使用第三方服务时,请注意查看其隐私条款和使用协议。",
},
{
title: "6. 未成年人保护",
content:
"我们非常重视对未成年人个人信息的保护。若您是未成年人,请在监护人指导下使用我们的服务。如果您是监护人,当您对您所监护的未成年人的个人信息有疑问时,请联系我们。",
content: "我们非常重视对未成年人个人信息的保护。若您是未成年人,请在监护人指导下使用我们的服务。如果您是监护人,当您对您所监护的未成年人的个人信息有疑问时,请联系我们。我们不会故意收集未成年人的个人信息。",
},
{
title: "7. 隐私政策更新",
content:
"我们可能会不时更新本隐私政策。当我们更新隐私政策时,我们会在本页面上发布更新后的版本并修改更新日期。建议您定期查看本页面。",
content: "我们可能会不时更新本隐私政策。当我们更新隐私政策时,我们会在本页面上发布更新后的版本并修改更新日期。建议您定期查看本页面。重大变更将通过适当方式通知用户,确保您了解我们的数据处理方式。",
},
{
title: "8. 联系我们",
content: "如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:邮箱:privacy@fastapiadmin.com;电话:400-123-4567。我们将尽快回复您的询问,并尽力解决您的问题。您也可以通过应用内的反馈功能提交您的意见。",
},
]);
@@ -78,11 +70,14 @@ const handleAgree = () => {
}, 1500);
};
</script>
<route lang="json">
{
"name": "privacy",
"style": { "navigationBarTitleText": "隐私政策" },
"layout": "tabbar"
"style": {
"navigationBarTitleText": "隐私政策"
}
}
</route>
<style lang="scss" scoped></style>
+54 -316
View File
@@ -7,238 +7,100 @@
</view>
<!-- 暗黑模式设置 -->
<wd-card class="setting-section">
<view class="section-header">
<wd-icon name="moon" size="20" :color="isDarkMode ? '#FFD700' : '#666'" />
<text class="section-title">外观模式</text>
</view>
<wd-card title="外观模式">
<wd-cell title="暗黑模式" :value="isDarkMode ? '已开启' : '已关闭'">
<wd-switch :model-value="isDarkMode" @change="handleToggleDarkMode" />
<wd-switch v-model="isDarkMode" custom-class="theme-switch" @change="handleToggleDarkMode" />
</wd-cell>
</wd-card>
<!-- 主题色设置 -->
<wd-card class="setting-section">
<view class="section-header">
<wd-icon name="palette" size="20" color="#666" />
<text class="section-title">主题色彩</text>
</view>
<wd-card title="预设主题色">
<!-- 预设颜色选择 -->
<view class="color-section">
<text class="color-label">预设主题色</text>
<view class="color-grid">
<view
v-for="(color, index) in useTheme.themeColorOptions"
:key="index"
class="color-item"
:class="{ active: color.primary }"
@click="handleSelectColor(color)"
>
<view
class="color-preview"
:style="{
backgroundColor: color.primary,
border:
currentThemeColor === color.primary ? '3px solid #fff' : '1px solid #e0e0e0',
}"
>
<text v-if="currentThemeColor === color.primary" class="check-icon"></text>
<view v-for="(color, index) in themeOptions" :key="index" class="color-item"
:class="{ active: isActiveColor(color.primary) }" @click="selectColor(color)">
<view class="color-preview" :style="{ backgroundColor: color.primary }">
<wd-icon v-if="isActiveColor(color.primary)" name="check" size="20" color="#fff" />
</view>
<text class="color-name">{{ color.name }}</text>
</view>
</view>
</view>
<!-- 当前主题色显示 -->
<view class="current-theme-section">
<view class="current-theme-item">
<text class="current-theme-label">当前主题色</text>
<view class="current-theme-value">
<view
class="current-color-preview"
:style="{ backgroundColor: currentThemeColor }"
></view>
<text class="current-color-text">{{ currentThemeColor }}</text>
</view>
</view>
</view>
<!-- 自定义颜色 -->
<wd-cell title="自定义颜色" is-link @click="showCustomInput">
<wd-icon name="edit" size="16" color="#999" />
</wd-cell>
</wd-card>
<wd-divider />
<!-- 操作按钮 -->
<wd-card class="action-section">
<wd-button size="large" block @click="handleResetTheme">重置为默认主题</wd-button>
</wd-card>
<!-- 自定义颜色输入弹窗 -->
<wd-popup v-model="showCustomColorInput" position="bottom" :safe-area-inset-bottom="true">
<view class="custom-color-popup">
<view class="popup-header">
<text class="popup-title">自定义主题色</text>
<wd-icon name="close" size="20" color="#999" @click="showCustomColorInput = false" />
</view>
<wd-divider />
<view class="color-input-section">
<view class="input-label">请输入十六进制颜色值</view>
<view class="input-container">
<view class="color-preview-small" :style="{ backgroundColor: customColor }"></view>
<wd-input
v-model="customColor"
placeholder="例如: #165DFF"
:maxlength="7"
class="color-input"
/>
</view>
<view class="input-tip">支持格式#RGB #RRGGBB</view>
</view>
<wd-divider />
<view class="popup-actions">
<wd-button type="info" size="large" @click="showCustomColorInput = false">取消</wd-button>
<wd-button type="primary" size="large" @click="applyCustomColor">应用</wd-button>
</view>
</view>
</wd-popup>
<view class="action-section">
<wd-button block @click="resetTheme">
重置为默认主题
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, watch } from "vue";
import { useThemeStore } from "@/store/modules/theme.store";
const useTheme = useThemeStore();
//
const isDarkMode = computed(() => useTheme.isDark);
//
const customColor = ref("");
const showCustomColorInput = ref(false);
//
const currentThemeColor = computed(() => {
return useTheme.themeVars.colorTheme || useTheme.themeColorOptions[0].primary;
const themeStore = useThemeStore();
const isDarkMode = computed({
get: () => themeStore.isDark,
set: (value) => {
themeStore.toggleTheme(value ? 'dark' : 'light');
}
});
const themeOptions = themeStore.themeColorOptions;
//
const handleSelectColor = (color: (typeof useTheme.themeColorOptions)[0]) => {
useTheme.setCurrentThemeColor(color);
customColor.value = color.primary;
//
const isActiveColor = (color: string): boolean => {
return themeStore.currentThemeColor.primary.toLowerCase() === color.toLowerCase();
};
//
//
const handleToggleDarkMode = (value: boolean) => {
// toggleThemecomputedsetter
uni.showToast({
title: "主题色已更新",
title: value ? "暗黑模式" : "浅色模式",
icon: "success",
duration: 1500,
duration: 1000,
});
};
//
const showCustomInput = () => {
showCustomColorInput.value = true;
customColor.value = currentThemeColor.value;
//
const selectColor = (color: typeof themeOptions[0]) => {
themeStore.setCurrentThemeColor(color);
uni.showToast({ title: "主题色已更新", icon: "success", duration: 1000 });
};
//
const applyCustomColor = () => {
//
const colorRegex = /^#([0-9A-F]{6}|[0-9A-F]{3})$/i;
if (!colorRegex.test(customColor.value)) {
uni.showToast({
title: "请输入有效的颜色值",
icon: "none",
duration: 2000,
});
return;
}
// 36
let color = customColor.value;
if (color.length === 4) {
color = "#" + color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
}
//
const customColorOption = {
name: "自定义",
value: "custom",
primary: color,
};
useTheme.setCurrentThemeColor(customColorOption);
showCustomColorInput.value = false;
//
uni.showToast({
title: "自定义主题色已应用",
icon: "success",
duration: 1500,
});
};
//
const handleResetTheme = () => {
//
const resetTheme = () => {
uni.showModal({
title: "确认重置",
content: "确定要重置为默认主题吗?",
success: (res) => {
if (res.confirm) {
useTheme.setCurrentThemeColor(useTheme.themeColorOptions[0]);
customColor.value = useTheme.themeColorOptions[0].primary;
uni.showToast({
title: "已重置为默认主题",
icon: "success",
duration: 1500,
});
themeStore.setCurrentThemeColor(themeOptions[0]);
uni.showToast({ title: "主题已重置", icon: "success", duration: 1000 });
}
},
});
};
//
const handleToggleDarkMode = () => {
useTheme.toggleTheme();
nextTick(() => {
uni.showToast({
title: `已切换到${isDarkMode.value ? "暗黑" : "浅色"}模式`,
icon: "success",
duration: 1500,
});
});
};
onLoad(() => {
customColor.value = currentThemeColor.value;
});
onMounted(() => {
customColor.value = currentThemeColor.value;
});
//
onShow(() => {
customColor.value = currentThemeColor.value;
//
watch(() => themeStore.isDark, () => {
//
setTimeout(() => {
//
}, 100);
});
</script>
<route lang="json">
{
<route lang="json">{
"name": "theme",
"style": {
"navigationBarTitleText": "主题设置"
}
}
</route>
}</route>
<style lang="scss" scoped>
//
@@ -273,28 +135,16 @@ onShow(() => {
padding: 30rpx 30rpx 20rpx;
border-bottom: 1rpx solid var(--wot-color-border, #f0f0f0);
.section-title {
margin-left: 12rpx;
.setting-title {
font-size: 32rpx;
font-weight: 600;
color: var(--wot-color-text, #333);
}
}
//
.color-section {
padding: 30rpx;
.color-label {
margin-bottom: 20rpx;
font-size: 28rpx;
color: var(--wot-color-text-secondary, #666);
}
.color-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
gap: 24rpx;
justify-content: space-between;
}
}
@@ -303,137 +153,25 @@ onShow(() => {
display: flex;
flex-direction: column;
align-items: center;
width: calc(25% - 15rpx);
padding: 10rpx;
width: calc(33.33% - 16rpx);
cursor: pointer;
transition: all 0.3s ease;
.color-preview {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-bottom: 8rpx;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
.check-icon {
font-size: 24rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.5);
}
width: 70rpx;
height: 70rpx;
margin-bottom: 12rpx;
border-radius: 16rpx;
}
.color-name {
font-size: 22rpx;
color: var(--wot-color-text-secondary);
text-align: center;
}
&.active .color-preview {
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.2);
transform: scale(1.05);
}
&:active .color-preview {
transform: scale(0.95);
}
}
//
.current-theme-section {
padding: 30rpx;
.current-theme-item {
display: flex;
align-items: center;
justify-content: space-between;
.current-theme-label {
font-size: 28rpx;
color: var(--wot-color-text-secondary, #666);
}
.current-theme-value {
display: flex;
align-items: center;
.current-color-preview {
width: 40rpx;
height: 40rpx;
border: 2rpx solid var(--wot-color-border, #f0f0f0);
border-radius: 8rpx;
}
.current-color-text {
margin-left: 10rpx;
font-size: 28rpx;
font-weight: 500;
}
}
}
}
//
.custom-color-popup {
padding: 40rpx 30rpx;
background: var(--wot-popup-bg-color, #fff);
border-radius: 20rpx 20rpx 0 0;
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: var(--wot-color-text, #333);
}
}
.color-input-section {
margin-bottom: 40rpx;
.input-label {
margin-bottom: 20rpx;
font-size: 28rpx;
color: var(--wot-color-text-secondary, #666);
}
.input-container {
display: flex;
gap: 20rpx;
align-items: center;
margin-bottom: 10rpx;
.color-preview-small {
flex-shrink: 0;
width: 60rpx;
height: 60rpx;
border: 2rpx solid var(--wot-color-border, #f0f0f0);
border-radius: 8rpx;
}
.color-input {
flex: 1;
}
}
.input-tip {
margin-left: 80rpx;
font-size: 24rpx;
color: var(--wot-color-text-placeholder, #999);
}
}
.popup-actions {
display: flex;
gap: 20rpx;
}
.action-section {
padding: 0 30rpx 30rpx;
}
</style>
+158 -276
View File
@@ -1,128 +1,117 @@
<template>
<view class="workbench-container">
<!-- 顶部统计卡片 -->
<view class="stats-section">
<view class="app-container theme-adaptive">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title theme-text-primary">工作台</text>
<text class="page-subtitle theme-text-secondary">欢迎回来</text>
</view>
<!-- 统计卡片 -->
<wd-card class="stats-section">
<view class="stats-grid">
<view v-for="(stat, index) in statsData" :key="index" class="stat-card">
<view class="stat-icon" :style="{ backgroundColor: stat.color + '20' }">
<wd-icon :name="stat.icon" :color="stat.color" size="24" />
<view v-for="(item, index) in statsData" :key="index" class="stats-card theme-card"
@click="handleStatClick(item)">
<view class="stats-icon" :style="{ background: item.iconBgColor }">
<wd-icon :name="item.icon" size="24" />
</view>
<view class="stat-content">
<text class="stat-number">{{ stat.number }}</text>
<text class="stat-label">{{ stat.label }}</text>
<view class="stats-content">
<text class="stats-number theme-text-primary">{{ item.number }}</text>
<text class="stats-label theme-text-secondary">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</wd-card>
<!-- 快捷入口 -->
<view class="quick-actions-section">
<view class="section-header">
<text class="section-title">快捷入口</text>
<text class="section-subtitle">常用功能一键直达</text>
</view>
<view class="actions-grid">
<view
v-for="(action, index) in quickActions"
:key="index"
class="action-item"
@tap="handleQuickAction(action)"
>
<view class="action-icon" :style="{ backgroundColor: action.color + '15' }">
<wd-icon :name="action.icon" :color="action.color" size="28" />
</view>
<text class="action-name">{{ action.name }}</text>
</view>
</view>
</view>
<!-- 快捷操作 -->
<wd-card title="快捷操作">
<wd-grid :column="4" border>
<wd-grid-item v-for="(action, index) in quickActions" :key="index" :icon="action.icon" :text="action.name"
icon-size="20" custom-class="custom-item" @click="handleActionClick(action)">
</wd-grid-item>
</wd-grid>
</wd-card>
<!-- 最近动态 -->
<wd-card title="最近动态">
<wd-cell-group>
<wd-cell v-for="(activity, index) in recentActivities" :key="index" :title="activity.title"
:label="activity.label" :value="activity.value" custom-class="activity-cell">
</wd-cell>
</wd-cell-group>
</wd-card>
<!-- 待办事项 -->
<view class="todo-section">
<view class="section-header">
<text class="section-title">待办事项</text>
<text class="section-subtitle">{{ pendingTodos.length }} 项待处理</text>
</view>
<view class="todo-list">
<view
v-for="(todo, index) in pendingTodos"
:key="index"
class="todo-item"
@tap="handleTodoItem(todo)"
>
<view class="todo-priority" :class="'priority-' + todo.priority">
<view class="priority-dot"></view>
</view>
<view class="todo-content">
<text class="todo-title">{{ todo.title }}</text>
<text class="todo-time">{{ todo.time }}</text>
</view>
<wd-icon name="arrow-right" color="#999" size="16" />
</view>
</view>
<view v-if="pendingTodos.length === 0" class="empty-todos">
<wd-icon name="smile" color="#999" size="48" />
<text class="empty-text">暂无待办事项</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
//
const statsData = ref([
{ icon: "user", number: "1,234", label: "用户总数", color: "#165DFF" },
{ icon: "chart-bar", number: "856", label: "今日活跃", color: "#00B42A" },
{ icon: "calendar", number: "42", label: "待处理订单", color: "#FF7D00" },
{ icon: "warning", number: "8", label: "系统告警", color: "#F53F3F" },
]);
{
number: '1,234',
label: '用户总数',
color: '#165DFF',
iconBgColor: '#e1efff',
icon: 'user',
path: '/pages/user/index'
},
{
number: '856',
label: '今日活跃',
color: '#00B42A',
iconBgColor: '#e1fcef',
icon: 'app',
path: '/pages/user/active'
},
{
number: '42',
label: '待处理',
color: '#FF7D00',
iconBgColor: '#fff0e1',
icon: 'chart',
path: '/pages/order/pending'
},
{
number: '8',
label: '系统消息',
color: '#F53F3F',
iconBgColor: '#ffe1e1',
icon: 'warning',
path: '/pages/message/index'
}
])
//
//
const quickActions = ref([
{ name: "用户管理", icon: "user", color: "#165DFF", path: "/pages/work/user/index" },
{ name: "角色管理", icon: "usergroup", color: "#00B42A", path: "/pages/work/role/index" },
{ name: "菜单管理", icon: "app", color: "#FF7D00", path: "/pages/work/menu/index" },
{ name: "部门管理", icon: "fork", color: "#FFC53D", path: "/pages/work/department/index" },
{ name: "岗位管理", icon: "user-avatar", color: "#52C41A", path: "/pages/work/job/index" },
{ name: "日志管理", icon: "link", color: "#13C2C2", path: "/pages/work/log/index" },
{ name: "系统配置", icon: "setting", color: "#722ED1", path: "/pages/work/settings/index" },
{ name: "字典管理", icon: "books", color: "#FA541C", path: "/pages/work/dictionary/index" },
{ name: "任务管理", icon: "clock", color: "#F5222D", path: "/pages/work/task/index" },
{ name: "通知公告", icon: "notification", color: "#FAAD14", path: "/pages/work/notice/index" },
{ name: "帮助中心", icon: "help", color: "#2F54EB", path: "/pages/work/help/index" },
{ name: "系统监控", icon: "dashboard", color: "#B37FEB", path: "/pages/work/monitor/index" },
]);
{ name: '用户管理', icon: 'user', path: '/pages/user/index' },
{ name: '角色管理', icon: 'usergroup', path: '/pages/role/index' },
{ name: '部门管理', icon: 'fork', path: '/pages/dept/index' },
{ name: '系统设置', icon: 'setting', path: '/pages/settings/index' }
])
//
const pendingTodos = ref([
{ title: "审核新用户注册申请", time: "2小时前", priority: "high", type: "user" },
{ title: "处理订单退款请求", time: "3小时前", priority: "medium", type: "order" },
{ title: "更新系统权限配置", time: "1天前", priority: "low", type: "config" },
{ title: "回复用户反馈", time: "2天前", priority: "medium", type: "feedback" },
]);
//
const recentActivities = ref([
{ title: '新用户注册', label: '李四', value: '刚刚' },
{ title: '订单完成', label: '宫保鸡丁', value: '3分钟前' },
{ title: '系统更新', label: '版本v2.1.1', value: '8分钟前' },
{ title: '用户反馈', label: '界面优化建议', value: '12分钟前' }
])
//
const handleQuickAction = (action: any) => {
uni.navigateTo({
url: action.path,
});
};
const handleTodoItem = (todo: any) => {
const handleStatClick = (item: any) => {
uni.showToast({
title: `处理:${todo.title}`,
icon: "none",
});
};
title: `点击了 ${item.label}`,
icon: 'none'
})
}
//
const loadWorkbenchData = async () => {
// API
console.log("加载工作台数据...");
};
onMounted(() => {
loadWorkbenchData();
});
const handleActionClick = (action: any) => {
uni.showToast({
title: `跳转到 ${action.name}`,
icon: 'none'
})
}
</script>
<route lang="json">
@@ -139,181 +128,74 @@ onMounted(() => {
</route>
<style lang="scss" scoped>
.workbench-container {
min-height: 100vh;
padding: 20rpx;
background-color: #f5f5f5;
.page-header {
margin-bottom: 40rpx;
text-align: center;
.page-title {
margin-bottom: 8rpx;
font-size: 36rpx;
font-weight: bold;
}
.page-subtitle {
font-size: 28rpx;
}
}
.stats-section {
margin-bottom: 30rpx;
.stats-grid {
padding: 12rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12rpx;
}
.stats-card {
display: flex;
align-items: center;
padding: 30rpx;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
background: var(--card-bg);
border: 1rpx solid var(--border-color);
&:active {
transform: scale(0.95);
}
.stats-icon {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
border-radius: 12rpx;
}
.stats-content {
flex: 1;
.stats-number {
display: block;
font-size: 32rpx;
font-weight: bold;
color: var(--text-color);
margin-bottom: 4rpx;
}
.stats-label {
font-size: 24rpx;
color: var(--text-color-2);
}
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
.quick-actions {
margin-bottom: 40rpx;
}
.stat-card {
display: flex;
align-items: center;
padding: 30rpx;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
border-radius: 12rpx;
}
.stat-content {
flex: 1;
}
.stat-number {
display: block;
margin-bottom: 8rpx;
font-size: 36rpx;
font-weight: bold;
}
.stat-label {
font-size: 24rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
}
.section-subtitle {
font-size: 24rpx;
}
.quick-actions-section,
.todo-section,
.recent-section {
padding: 30rpx;
margin-bottom: 30rpx;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.actions-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0;
}
.action-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin-bottom: 16rpx;
border-radius: 16rpx;
}
.action-name {
font-size: 24rpx;
text-align: center;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.todo-item {
display: flex;
align-items: center;
padding: 30rpx;
border-radius: 12rpx;
}
.todo-priority {
margin-right: 20rpx;
}
.priority-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
.priority-high .priority-dot {
background-color: #f53f3f;
}
.priority-medium .priority-dot {
background-color: #ff7d00;
}
.priority-low .priority-dot {
background-color: #00b42a;
}
.todo-content {
flex: 1;
}
.todo-title {
display: block;
margin-bottom: 8rpx;
font-size: 28rpx;
}
.todo-time {
font-size: 24rpx;
}
.empty-todos {
padding: 60rpx 0;
text-align: center;
}
.empty-text {
display: block;
margin-top: 20rpx;
font-size: 28rpx;
}
//
:deep(.dark) .workbench-container {
background-color: #1a1a1a;
}
:deep(.dark) .stat-card,
:deep(.dark) .quick-actions-section,
:deep(.dark) .todo-section {
background-color: #2a2a2a;
}
:deep(.dark) .todo-item {
background-color: #333;
}
</style>
</style>
+16 -2
View File
@@ -32,7 +32,19 @@ const router = createRouter({
// 全局前置守卫
router.beforeEach((to, from, next) => {
console.log("🚀 路由守卫触发:", {
to: to.path,
from: from.path,
requireAuth: to.meta?.requireAuth,
});
if (to.meta && to.meta.requireAuth && !isLoggedIn()) {
console.log("🔒 需要登录,但用户未登录");
// 先阻止当前导航
next(false);
// 然后显示登录提示
uni.showModal({
title: "提示",
content: "该功能需要登录后使用",
@@ -53,9 +65,11 @@ router.beforeEach((to, from, next) => {
});
}
},
// 确保在取消弹窗时也能调用 next
fail: () => {
next(false);
// 失败时也返回首页
uni.switchTab({
url: "/pages/index/index",
});
},
});
} else {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

+1 -8
View File
@@ -1,14 +1,7 @@
import { defineStore } from "pinia";
import UserAPI, { type UserInfo } from "@/api/user";
import AuthAPI, { type LoginFormData, type LoginResult, type LogoutBody } from "@/api/auth";
import {
getAccessToken,
setAccessToken,
setRefreshToken,
clearAll,
getUserInfo,
setUserInfo,
} from "@/utils/auth";
import { getAccessToken, setAccessToken, setRefreshToken, clearAll, getUserInfo, setUserInfo } from "@/utils/auth";
export const useUserStore = defineStore("appUserInfo", () => {
const userInfo = ref<UserInfo | undefined>(getUserInfo());
+258 -216
View File
@@ -1,3 +1,8 @@
/**
* 主样式文件
* 统一管理所有样式保持简洁清晰
*/
html,
body,
#app {
@@ -7,256 +12,293 @@ body,
}
.app-container {
padding: 10rpx 10rpx;
padding: 16rpx;
:deep(.custom-item) {
height: 80px !important;
height: 100px !important;
}
:deep(.wd-card) {
margin: 10rpx 0 !important;
margin: 16rpx 0 !important;
}
}
/**
* 全局样式变量
*/
/*
* 主题颜色 - 会被theme.ts中的动态设置覆盖
* 这里作为默认值和IDE提示
*/
/* ===== 全局主题变量 ===== */
:root {
/* 主 */
/* 主题色系 */
--primary-color: #165dff;
--primary-color-light: #94bfff;
--primary-color-dark: #0e3c9b;
--wot-color-theme: #165dff;
/* 功能色 */
--success-color: #0fc6c2;
/* 功能色 */
--success-color: #07c160;
--warning-color: #ff7d00;
--danger-color: #f5222d;
--danger-color: #f53f3f;
--info-color: #86909c;
/* 文字颜色 */
--text-primary: #1d2129;
--text-regular: #4e5969;
--text-secondary: #86909c;
--text-placeholder: #c9cdd4;
--text-inverse: #ffffff;
--text-color: #1d2129;
--text-color-2: #4e5969;
--text-color-3: #86909c;
--text-color-placeholder: #c9cdd4;
--text-color-inverse: #ffffff;
/* 背景颜色 */
--bg-color: #ffffff;
--bg-color-2: #f8f8f8;
--bg-color-3: #f2f3f5;
--card-bg-color: #ffffff;
/* 边框颜色 */
--border-color: #e5e6eb;
--border-light: #f2f3f5;
--border-color-light: #f2f3f5;
/* 背景颜色 */
--bg-white: #ffffff;
--bg-light: #f2f3f5;
--bg-gray: #f7f8fa;
/* 阴影 */
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
--shadow-medium: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
--shadow-heavy: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
/* 圆角 */
--radius-sm: 4rpx;
--radius-md: 8rpx;
--radius-lg: 16rpx;
--radius-xl: 24rpx;
--radius-full: 9999rpx;
/* 间距 */
--spacing-xs: 8rpx;
--spacing-sm: 16rpx;
--spacing-md: 24rpx;
--spacing-lg: 32rpx;
--spacing-xl: 48rpx;
--spacing-xxl: 64rpx;
/* 字体大小 */
--font-xs: 24rpx;
--font-sm: 28rpx;
--font-md: 32rpx;
--font-lg: 36rpx;
--font-xl: 40rpx;
--font-xxl: 48rpx;
--font-xxxl: 56rpx;
/* 动画 */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
}
/**
* 主题相关的通用类
*/
/* ===== 暗黑模式变量 ===== */
.dark {
--text-color: #ffffff;
--text-color-2: #e0e0e0;
--text-color-3: #a0a0a0;
--text-color-placeholder: #606060;
--text-color-inverse: #1d2129;
/* 主色文本 */
--bg-color: #0f0f0f;
--bg-color-2: #1a1a1a;
--bg-color-3: #242424;
--card-bg-color: #1a1a1a;
--border-color: #2f2f2f;
--border-color-light: #3d3d3d;
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
--shadow-medium: 0 4rpx 16rpx rgba(0, 0, 0, 0.4);
--shadow-heavy: 0 8rpx 32rpx rgba(0, 0, 0, 0.5);
}
/* ===== 主题适配类 ===== */
.theme-adaptive {
color: var(--text-color);
background-color: var(--bg-color);
border-color: var(--border-color);
transition:
color var(--transition-normal),
background-color var(--transition-normal),
border-color var(--transition-normal);
}
.theme-card {
background-color: var(--card-bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-light);
transition: all var(--transition-normal);
&:hover {
box-shadow: var(--shadow-medium);
}
}
.theme-text-primary {
color: var(--text-color);
}
.theme-text-secondary {
color: var(--text-color-2);
}
.theme-text-tertiary {
color: var(--text-color-3);
}
.theme-text-muted {
color: var(--text-color-placeholder);
}
.theme-text-inverse {
color: var(--text-color-inverse);
}
/* ===== 常用工具类 ===== */
.text-primary {
color: var(--primary-color) !important;
}
.text-success {
color: var(--success-color) !important;
}
.text-warning {
color: var(--warning-color) !important;
}
.text-danger {
color: var(--danger-color) !important;
}
.text-info {
color: var(--info-color) !important;
}
.text-muted {
color: var(--text-color-3) !important;
}
/* 主色背景 */
.bg-primary {
color: #fff;
color: var(--text-color-inverse);
background-color: var(--primary-color) !important;
}
/* 主色边框 */
.border-primary {
border-color: var(--primary-color) !important;
.bg-card {
background-color: var(--card-bg-color) !important;
}
.bg-light {
background-color: var(--bg-color-2) !important;
}
/* 主色按钮样式 */
.btn-primary {
color: #fff;
background-color: var(--primary-color);
border: none;
border-radius: 8rpx;
transition: opacity 0.3s;
&:active {
opacity: 0.8;
}
}
/* 圆角按钮 */
.btn-rounded {
border-radius: 45rpx !important;
}
/* 次级按钮 */
.btn-secondary {
color: var(--primary-color);
background-color: #fff;
border: 1px solid var(--primary-color);
border-radius: 8rpx;
transition: background-color 0.3s;
&:active {
background-color: rgba(22, 93, 255, 0.05);
}
}
/**
* 字体大小
*/
.font-xs {
font-size: 24rpx;
}
.font-sm {
font-size: 28rpx;
}
.font-md {
font-size: 32rpx;
}
.font-lg {
font-size: 36rpx;
}
.font-xl {
font-size: 40rpx;
}
/**
* 边距辅助类
*/
.mt-10 {
margin-top: 10rpx;
}
.mt-20 {
margin-top: 20rpx;
}
.mt-30 {
margin-top: 30rpx;
}
.mt-40 {
margin-top: 40rpx;
}
.mb-10 {
margin-bottom: 10rpx;
}
.mb-20 {
margin-bottom: 20rpx;
}
.mb-30 {
margin-bottom: 30rpx;
}
.mb-40 {
margin-bottom: 40rpx;
}
.ml-10 {
margin-left: 10rpx;
}
.ml-20 {
margin-left: 20rpx;
}
.ml-30 {
margin-left: 30rpx;
}
.ml-40 {
margin-left: 40rpx;
}
.mr-10 {
margin-right: 10rpx;
}
.mr-20 {
margin-right: 20rpx;
}
.mr-30 {
margin-right: 30rpx;
}
.mr-40 {
margin-right: 40rpx;
}
.p-10 {
padding: 10rpx;
}
.p-20 {
padding: 20rpx;
}
.p-30 {
padding: 30rpx;
}
.p-40 {
padding: 40rpx;
}
/**
* 布局辅助类
*/
.flex {
display: flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-column {
flex-direction: column;
}
.items-center {
align-items: center;
}
.items-start {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.justify-start {
justify-content: flex-start;
}
.justify-end {
justify-content: flex-end;
}
/**
* 其他辅助类
*/
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.rounded-sm {
border-radius: 4rpx;
}
.rounded {
border-radius: 8rpx;
border-radius: var(--radius-md);
}
.rounded-lg {
border-radius: 16rpx;
}
.rounded-xl {
border-radius: 24rpx;
border-radius: var(--radius-lg);
}
.rounded-full {
border-radius: 9999rpx;
border-radius: var(--radius-full);
}
.shadow {
box-shadow: var(--shadow-light);
}
.shadow-lg {
box-shadow: var(--shadow-medium);
}
.transition {
transition: all var(--transition-normal);
}
.flex {
display: flex !important;
}
.items-center {
align-items: center !important;
}
.justify-center {
justify-content: center !important;
}
.justify-between {
justify-content: space-between !important;
}
.text-center {
text-align: center !important;
}
.font-bold {
font-weight: 700 !important;
}
/* ===== 间距工具类 ===== */
.p-xs {
padding: var(--spacing-xs) !important;
}
.p-sm {
padding: var(--spacing-sm) !important;
}
.p-md {
padding: var(--spacing-md) !important;
}
.p-lg {
padding: var(--spacing-lg) !important;
}
.p-xl {
padding: var(--spacing-xl) !important;
}
.m-xs {
margin: var(--spacing-xs) !important;
}
.m-sm {
margin: var(--spacing-sm) !important;
}
.m-md {
margin: var(--spacing-md) !important;
}
.m-lg {
margin: var(--spacing-lg) !important;
}
.m-xl {
margin: var(--spacing-xl) !important;
}
.mt-sm {
margin-top: var(--spacing-sm) !important;
}
.mt-md {
margin-top: var(--spacing-md) !important;
}
.mt-lg {
margin-top: var(--spacing-lg) !important;
}
.mt-xl {
margin-top: var(--spacing-xl) !important;
}
.mb-sm {
margin-bottom: var(--spacing-sm) !important;
}
.mb-md {
margin-bottom: var(--spacing-md) !important;
}
.mb-lg {
margin-bottom: var(--spacing-lg) !important;
}
.mb-xl {
margin-bottom: var(--spacing-xl) !important;
}
/* ===== 字体大小类 ===== */
.text-xs {
font-size: var(--font-xs) !important;
}
.text-sm {
font-size: var(--font-sm) !important;
}
.text-md {
font-size: var(--font-md) !important;
}
.text-lg {
font-size: var(--font-lg) !important;
}
.text-xl {
font-size: var(--font-xl) !important;
}
.text-2xl {
font-size: var(--font-xxl) !important;
}
+10
View File
@@ -10,6 +10,7 @@ declare global {
const EffectScope: typeof import('vue')['EffectScope']
const Storage: typeof import('../utils/storage')['Storage']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const applyCSSVariables: typeof import('../utils/theme')['applyCSSVariables']
const applyThemeOnPageShow: typeof import('../utils/theme')['applyThemeOnPageShow']
const applyThemeToMiniProgram: typeof import('../utils/theme')['applyThemeToMiniProgram']
const auth: typeof import('../api/auth')['default']
@@ -35,25 +36,33 @@ declare global {
const effectScope: typeof import('vue')['effectScope']
const extendedColorOptions: typeof import('../composables/useTheme')['extendedColorOptions']
const file: typeof import('../api/file')['default']
const generateCSSVariables: typeof import('../utils/theme')['generateCSSVariables']
const generateColorVariants: typeof import('../utils/theme')['generateColorVariants']
const generateGradientBackground: typeof import('../utils/theme')['generateGradientBackground']
const getAccessToken: typeof import('../utils/auth')['getAccessToken']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getContrastColor: typeof import('../utils/theme')['getContrastColor']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getDarkerColor: typeof import('../utils/colorUtils')['getDarkerColor']
const getLighterColor: typeof import('../utils/colorUtils')['getLighterColor']
const getRefreshToken: typeof import('../utils/auth')['getRefreshToken']
const getThemeClassName: typeof import('../utils/theme')['getThemeClassName']
const getThemeColor: typeof import('../utils/theme')['getThemeColor']
const getThemeColors: typeof import('../utils/theme')['getThemeColors']
const getToken: typeof import('../utils/storage')['getToken']
const getUserInfo: typeof import('../utils/auth')['getUserInfo']
const guessSerializerType: typeof import('@uni-helper/uni-use')['guessSerializerType']
const h: typeof import('vue')['h']
const initTheme: typeof import('../composables/useTheme')['initTheme']
const inject: typeof import('vue')['inject']
const isDarkMode: typeof import('../utils/theme')['isDarkMode']
const isLoggedIn: typeof import('../utils/auth')['isLoggedIn']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isValidColor: typeof import('../utils/theme')['isValidColor']
const log: typeof import('../api/log')['default']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
@@ -63,6 +72,7 @@ declare global {
const markRaw: typeof import('vue')['markRaw']
const menu: typeof import('../api/menu')['default']
const nextTick: typeof import('vue')['nextTick']
const normalizeColor: typeof import('../utils/theme')['normalizeColor']
const notice: typeof import('../api/notice')['default']
const onActivated: typeof import('vue')['onActivated']
const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
-26
View File
@@ -1,26 +0,0 @@
{
"light": {
"bgColor": "#F8F8F8",
"bgColorBottom": "#F8F8F8",
"bgColorTop": "#F8F8F8",
"bgTxtStyle": "dark",
"navBgColor": "#FFF",
"navTxtStyle": "black",
"tabBgColor": "#ffffff",
"tabBorderStyle": "black",
"tabColor": "#bfbfbf",
"tabSelectedColor": "#0165FF"
},
"dark": {
"bgColor": "#000",
"bgColorBottom": "#000",
"bgColorTop": "#000",
"bgTxtStyle": "light",
"navBgColor": "#000000",
"navTxtStyle": "white",
"tabBgColor": "#1a1a1a",
"tabBorderStyle": "white",
"tabColor": "#bfbfbf",
"tabSelectedColor": "#0165FF"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 199 KiB

+13
View File
@@ -0,0 +1,13 @@
<template>
<dev>
开发中...
</dev>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
</style>