谈谈快速构建全栈小型应用(从开发到部署)

文章章节

谈全栈小应用程序

本人理解的全栈小应用程序定义如下:
(1)需要实现WEB网站,业务并不复杂如简单的数据录入系统、博客系统、数据存储平台;
(2)需要实现对应的小程序端或APP端展示系统数据以及简单的数据交互;
(3)较稳定的后端服务支撑,并发量峰值在1000左右,服务器运行内存控制在4G以下可支撑;
对各个实现端都可开发并选择和设计框架,成为全栈。
PS.本文章会长期更新,因为需求可能会增加更多的功能组件。

谈技术选型

(1)对于WEB网站端,vue在国内依然是首选。vue对于vue我觉得有如下实用性的优势:
a.性能和体积方面官网有很好的说明以及实验,在绝大多数情况下性能优于vue2;
b.vue3 引入了 Composition API(组合式),对比vue2使用的选项式更加容易编写代码(特别是我选用服务端渲染VUE的Nuxt3技术的时候,优点越发明显);
c.对typescript的支持,这个对弥补了JavaScript一直缺乏的类型检查,其实说白了就是为构建大型WEB应用前端最好的支持,但是小应用程序上typescript刚开始仍然会有些不习惯并且会比较JavaScript开发起来开发时间更长,不过对于使用过java、C#等对类型要求很强的开发者来说操作typescript上手其实很简单;
d.使用全局的API即composables,更简单的维护和使用各个自定义封装的API,而不是使用vue2的mixins的那种方式
(2)选择服务端渲染Nuxt3的原因,先看看官网的介绍:
a.Nuxt is an open source framework that makes web development intuitive and powerful.
Create performant and production-grade full-stack web apps and websites with confidence.(Nuxt是一个开源框架,使web开发直观而强大。满怀信心地创建高性能和生产级的全栈web应用程序和网站。)
b.通过服务端渲染,更加有利于被SEO抓取到
c.拥有所有vue3的功能并内置的各个功能模块更加快速的开发以及拥有很多的插件可以快速集成
ps.虽然nuxt3可以构建后端,它的后端是基于nitro(h3.js),官网地址是https://nitro.unjs.io/,我了解到的可以说是增强版的express.js(对typescript支持),我并不是说它不友好,对我的认知nodejs作为后端操作数据库的时候,虽然有很多ORM框架或者模块如TypeOrm,可能我使用java中的mybatis或Hibernate习惯了以至于对nodejs操作关系型数据库存在一些不习惯的写法。但是nodejs的操作no-sql如mongodb的时候确实行云流水般,可以参考Mongoose.js这个框架,我也使用过nodejs+Mongoose全栈开发的项目或样例,在这儿本文介绍的是关系型数据库mysql,故而不选型nuxt3直接开发后端。
(3)对于小程序端选择uniapp就不用再谈其优势了,不用说编译到多端应用,但是使用它来开发某一段如微信小程序的优势也比微信原生的开发强吧
(4)最后就是服务端的选择,这里选择python的fastapi这个框架,不选择java的原因简单:由于采用java设计和开发较大型项目的时候,编写的框架和模块封装都是基于分布式和集群的那一套,目前没有精力剥离它们作为单体小项目,有兴趣的看看我的java是怎么整合kubernetes那些分布式中间件和数据库的。
PS.这里只是做一些简单的描述,在具体实现的时候会对比出各个技术的优劣势。这里只是选取以上技术架构针对小型应用构建全栈的方案。

Python+FastAPI后端篇

python的版本3.10+
开发软件PyCharm 2023.1.2

源码目录介绍

在这里插入图片描述
– api # 各个模块的接口路由
– config # 配置文件,如dev、test、prod等各个环境对应的配置文件
– dto # 前端请求后端的数据结构模型(用过java的都知道DTO吧)
– res # 资源文件如上传图片、文档的目录位置(由于是小型应用,不做分布式直接文件存储MinIO、fastDfs等那些)
– schema # 数据库ORM模型
– utils # 基础的工具模块
– ecosystem.config.cjs # pm2部署启动文件
– main.py # 主程序入口
– README.md # 介绍文件
– requirements.txt # python模块包管理文件
– test.py # 测试代码
– test_main.http # 接口测试工具文件

多环境自适应

在实际的开发、测试以及生产的环境中,都需要区分不同环境下的一些配置例如:启动的后端端口号、数据库的地址以及其他配置相关的内容,需要使得程序在不同的环境下自动加载这些配置。(类似springboot中的spring.profiles.active)
在config目录下新建各个环境的配置文件如:
– config.dev.toml
– config.test.toml
– config.prod.toml
然后我们按需加载他们,在utils/config.py文件中实现:

import os
import toml
'''
根据当前系统环境变量PYTHON_ENV的值去读取项目各个环境下的系统配置参数。
注意的是PYTHON_ENV的值为['dev','test','prod']中的一个,
若未设置PYTHON_ENV环境变量的值则默认视为dev。
那么在本地开发的时候不需要配置PYTHON_ENV环境变量,在部署到生产服务器下设置即可,
到时候会在部署篇中介绍如何采用pm2的脚本部署python应用程序以及指定其运行的环境变量等。
'''
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PYTHON_ENV = os.environ.get('PYTHON_ENV', 'dev')
CONFIG = toml.load(ROOT_DIR + f'/config/config.{PYTHON_ENV}.toml')
MYSQL = CONFIG['mysql']
LOG = CONFIG['log']
WEB = CONFIG['web']
MYSQL_URI = MYSQL['uri']
MYSQL_GENERATE_SCHEMAS = MYSQL['generate_schemas']
LOG_FILE = LOG['file']
LOG_BACKUP_COUNT = LOG['backup_count']
WEB_PORT = WEB['port']
WEB_PATH = WEB['path']
WEB_JWT_SECRET = WEB['jwt_secret']
WEB_JWT_EXP_SECOND = WEB['jwt_exp_second']
WEB_PUBLIC_API_LIST = WEB['public_api_list']

def is_dev():
    return PYTHON_ENV == 'dev'

config.dev.toml文件内容样例为:

[mysql]
# mysql数据库连接地址,注意这里有个坑:密码中携带特殊符号如@#$这些需要转义#->%23
uri = "mysql://root:Mysql%232023@localhost:3306/dev"
# 这个是标记是否自动根据数据库ORM实体类生成数据库表,说到ORM框架的时候再看
generate_schemas = false

[log]
# 日志相关
file = "/var/log/server/log.log"
backup_count = 100

[web]
# 暴露的端口
port = 5100
# 暴露的后端接口前缀,这个有点玄学为什么这些写是为了兼容后期的部署(部署方案nginx做代理的时候用到)
path = "/server/api"
# 用户身份验证采用基础的jwt(不做jwt续命延长,需要延长业务需要上Redis实现)加密秘钥
jwt_secret = "jwt#@2023!"
# jwt身份过期时间(秒)
jwt_exp_second = 86400
# 这里定义可以游客身份访问的api列表
public_api_list = [
    "/user/login_admin",
    "/base/find_by_id",
    "/base/page",
    "/base/query",
    "/file/get_image",
    "/file/get_doc",
    "/index/main",
]

服务端日志

对于我接触到的java生态有log4j或log4j2,甚至nodejs都有log4js,目前使用Python,有什么比较好的日志管理技术呢?简单的答案:不需要,使用Python原生的logging模块结合pm2部署即可对日志进行管理,以下代码即可实现在程序运行中自动记录每天的日志到服务器文件里。
config/logger.py代码如下:

import logging
from logging.handlers import TimedRotatingFileHandler
# config目录下定义的tom文件,参考上一节,以下全文将不做介绍
from utils.config import LOG_FILE, LOG_BACKUP_COUNT
# 这里主要使用了tortoise-ORM框架,这里是记录数据库执行的语句、参数、结果等
# 到日志(如果在生产环境中需要更高的性能将)
logger = logging.getLogger('tortoise')
# logger.setLevel(logging.DEBUG)设置问logger.setLevel(logging.INFO)即可
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
# 日志格式
formatter = logging.Formatter(
    fmt="%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
ch.setFormatter(formatter)
# 定义存储日志为文件的方式,LOG_FILE指定文件的路径
# (PS.这个路径需要先创建,如果希望不手工创建,编写以下判断文件目录是否存在,不存在则创建的语句吧)
# LOG_BACKUP_COUNT指定最大存储日志文件的数量,我设置100即三个月多够用了,
# 应该每隔一个月至少要巡检一下服务器吧(这儿设计到一些运维的方向)
save_handler = TimedRotatingFileHandler(LOG_FILE, when="midnight", interval=1, backupCount=LOG_BACKUP_COUNT)
# 日志文件会自动按yyyy-MM-dd后缀命名
save_handler.suffix = "%Y-%m-%d"
save_handler.setFormatter(formatter)

logger.addHandler(ch)
logger.addHandler(save_handler)

如何使用?在任意的Python文件位置导入logger,然后logger.info(‘info’)或logger.error(‘error’)即可打印了,PS.虽然没有实现log4j那种强大,但是目前对于小应用足以了。

接口响应体规范化

如果不去管理接口返回的响应体格式,我觉得整个项目都是遗憾的。A开发喜欢返回{‘code’: ‘success’, ‘data’: ‘…’},B开发喜欢返回{‘status’: 1, ‘data’:‘…’, ‘msg’:‘操作成功’},那么问题就大了,虽然程序运行没有问题,但是还是考虑过前端的感受已经规范,只有返回的响应体内容规范了,前端的axios等才可能更好的封装,何况nuxt3前端的请求有点特殊,到时候前端篇封装请求会说明。
utils/http.py文件内容如下:

HTTP_STATUS_SUCCESS = 1
HTTP_STATUS_ERROR = 0
HTTP_STATUS_AUTH = 2
# 有更多的业务需求扩展这儿即可

def http_ok(data=None, msg='操作成功'):
    return {
        'data': data,
        'msg': msg,
        'status': HTTP_STATUS_SUCCESS,
    }


def http_fail(msg='操作失败'):
    return {
        'data': None,
        'msg': msg,
        'status': HTTP_STATUS_ERROR,
    }


def http_auth(msg='身份认证失败'):
    return {
        'data': None,
        'msg': msg,
        'status': HTTP_STATUS_AUTH,
    }

数据库交互ORM

这儿使用tortoise-ORM这个模块,官网地址https://tortoise.github.io/
先稍微的封装一下,具体的使用其实也很简单,阅读一下官网基本的入门了。
utils/sql.py内容如下:

import os
from datetime import datetime

from tortoise import Tortoise
from tortoise.exceptions import DoesNotExist
from tortoise.models import Model

from utils.config import MYSQL_URI, MYSQL_GENERATE_SCHEMAS, ROOT_DIR
from utils.jwt_payload import get_user_id
from utils.logger import logger
# 数据库表记录数据更新的时间
UPDATE_TIME = 'update_time'
# 数据库表记录数据更新人的ID
# PS.这个有点意思,因为Python中并没有
# ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
# 可以在任何位置获取到springmvc中的request,从而根据request的header解析token获取到当前访问者的用户ID
# 在Python的fastApi中使用contextvars包下的ContextVar函数创建上下文变量结合fastapi的中间件来实现
UPDATE_BY = 'update_by'


async def init_db():
    """
        fastapi整合tortoise-orm,注意db_url中密码如果有特殊字符串需要使用urllib.parse.quote_plus('#')获取特殊字符的转义值,
        然后将转义后的值编写入db_url中即可
        :return:
        """
    # ps.使用__init__.py文件也无法自动导入models,故而采取如下的方式构造
    models = []
    for filename in os.listdir(ROOT_DIR + '/schema'):
        if filename != '__pycache__' and filename != '__init__.py' and filename != 'base.py':
            models.append('schema.' + filename.replace('.py', ''))
    # 连接数据库和加载模块
    await Tortoise.init(db_url=MYSQL_URI, modules={'models': models})
    # 同步数据库表结构
    if MYSQL_GENERATE_SCHEMAS is True:
        await Tortoise.generate_schemas()
    logger.info('init db success..')


async def close_db():
    """
    关闭数据库连接
    :return:
    """
    await Tortoise.close_connections()
    logger.info('close db success..')


class BaseSchema(Model):
	"""
	所有的业务实体继承这个类,享有封装的这些功能吧
	"""
    class Meta:
    	# 这个的作用指定当前类是不匹配任何数据库表或视图的,即虚拟映射类
        abstract = True

    @classmethod
    async def find_by_id(cls, data_id):
        """
        根据数据的主键id获取单条数据
        :param data_id:  数据的主键id
        :return: 数据记录存在返回数据,否则返回None
        """
        try:
            return await cls.filter(id=data_id).first()
        except DoesNotExist:
            # PS.这儿tortoise-orm查询不到数据会报DoesNotExist异常,我并不想这样
            return None

    @classmethod
    async def insert(cls, **kwargs):
        """
        创建数据
        注意:如果外部传入的参数中包含id字段将会强制删除,使用自增值作为主键id
        :param kwargs: 数据字段值参数列表值为create(name='name', script="script")或dict_data = {'name': 'name', 'script': 'script'} 然后create(**dict_data)
        :return:
        """
        data_dict = dict(kwargs)
        data_dict.pop('id', None)
        # 当前派生类中定义的所有字段名
        fields_in_derived_class = cls._meta.fields_map.keys()
        # 处理更新时间
        if UPDATE_TIME in fields_in_derived_class:
            # 更新时间设置为默认值
            data_dict[UPDATE_TIME] = datetime.now()
        # 处理操作人
        if UPDATE_BY in fields_in_derived_class:
            # 更新人字段设置为当前操作者的ID
            data_dict[UPDATE_BY] = get_user_id()
        return await cls.create(**data_dict)

    @classmethod
    async def update_by_id(cls, **kwargs):
        """
        更新数据
        :param kwargs: 数据字段值参考create方法,但必须携带id字段的值并数据存在才能有效更新
        :return: 更新后的数据,未执行更新返回None
        """
        data_dict = dict(kwargs)
        data_id = data_dict.get('id')
        if data_id is not None:
            instance = await cls.find_by_id(data_id)
            if instance is not None:
                for key, value in kwargs.items():
                    setattr(instance, key, value)
                # 当前派生类中定义的所有字段名
                fields_in_derived_class = cls._meta.fields_map.keys()
                # 处理更新时间
                if UPDATE_TIME in fields_in_derived_class:
                    # 更新时间设置为默认值
                    setattr(instance, UPDATE_TIME, datetime.now())
                # 处理操作人
                if UPDATE_BY in fields_in_derived_class:
                    # 更新人字段设置为当前操作者的ID
                    setattr(instance, UPDATE_BY, get_user_id())
                await instance.save()
                return instance
        return None

    @classmethod
    async def insert_or_update(cls, **kwargs):
    	"""
    	更新或新增数据,根据数据的ID是否存在处理
    	"""
        data_dict = dict(kwargs)
        data_id = data_dict.get('id')
        if data_id is None:
            return await cls.insert(**kwargs)
        else:
            return await cls.update_by_id(**kwargs)

    @classmethod
    async def delete_by_id(cls, data_id):
        """
        根据数据的主键id删除单条数据。\n
        注意:数据不存在不会触发异常,因视为已删除。
        :param data_id: 数据的主键id
        :return: None
        """
        await cls.filter(id=data_id).delete()

    @classmethod
    async def delete_by_ids(cls, data_ids):
    	"""
    	根据ID数组删除多条数据。\n
    	:param data_ids: 数据的主键数组
        :return: None
    	"""
        await cls.filter(id__in=data_ids).delete()

    @classmethod
    async def page(cls, page, page_size, query=None, order_by=None):
    	"""
    	分页实现,具体如何使用会在路由层给出。
    	:param page: 分页页码
    	:param page_size: 页的大小
    	:param query: 查询内容
    	:param order_by: 排序内容
        :return: ...
    	"""
        offset = (page - 1) * page_size
        results = []
        total = 0
        if query is not None:
            total = await cls.filter(**query).count()
        else:
            total = await cls.filter().count()
        if total > 0:
            if query is not None:
                results = cls.filter(**query)
            else:
                results = cls.filter()
            if order_by is not None:
                if ',' in order_by:
                    order_by = order_by.split(',')
                    results = results.order_by(*tuple(order_by))
                else:
                    results = results.order_by(order_by)
            results = await results.all().offset(offset).limit(page_size)
        return {
            "page": page, "page_size": page_size, "total": total, "offset": offset, "results": results
        }

    @classmethod
    async def query(cls, limit, query=None, order_by=None):
    	"""
    	快速查询,兼容前端的业务:根据查询条件和排序方式获取某表的前limit数据数组
    	"""
        if query is not None:
            results = cls.filter(**query)
        else:
            results = cls.filter()
        if order_by is not None:
            if ',' in order_by:
                order_by = order_by.split(',')
                results = results.order_by(*tuple(order_by))
            else:
                results = results.order_by(order_by)
        results = await results.all().offset(0).limit(limit)
        return results

接下来是看实体类如何编写:
按schema/user.py为例子:

from enum import IntEnum
from tortoise import fields
from utils.sql import BaseSchema

class UserStatus(IntEnum):
	"""
	定义用户状态的枚举
	"""
    ACTIVATE = 0
    DISABLE = 1


class User(BaseSchema):
    """
    系统用户
    """
    class Meta:
    	# 对应的数据库表名称,我喜欢表前缀为t_,视图的前缀为v_。
        table = 't_user'
        # table_description 会自动生成数据库表的注释,mysql是肯定支持其他的看官网吧
        table_description = '系统用户表'
	# 主键、自动增长,要使用UUID类型看官网
    id = fields.BigIntField(pk=True, description="主键")
    # 下边就是各个字段的类型,当然还有很多支持的类型
    account = fields.CharField(max_length=20, description="账号")
    password = fields.CharField(max_length=64, description="密码")
    name = fields.CharField(max_length=10, description="姓名")
    # 这个指定字段的类型是枚举类型还是有点好处的
    status = fields.IntEnumField(UserStatus, description="状态", default=UserStatus.ACTIVATE)
    admin = fields.BooleanField(default=False, description="是否是管理员")
    update_time = fields.DatetimeField(default=None, description="数据更新时间", null=True)
    update_by = fields.BigIntField(default=None, description="数据更新者", null=True)

    @classmethod
    async def find_user_by_account(cls, account):
    	'''
    	扩展的业务方法,有点类似于java中的service层代码,我这儿把Python视为脚本语言,就不再分层了
    	'''
        return await User.filter(account=account).first()

PS.如果开启自动生成表,那么运行代码后数据库表t_user不存在则会自动生成表,我觉得这一块可以继续改进。害怕生产下出现问题!!

jwt身份验证

即使是小系统,也需要身份验证。至少需要控制一下某些功能(接口或页面)是登陆者才能访问的,网大的可以做到用户-用户组-角色-权限(包括页面、按钮、接口)等权限流,我之前使用java做过,有兴趣的可以留言或私信,有时间的话我可以写一篇完整的全栈权限控制文章(由于要控制前端页面、按钮以及后端接口,也是必备全栈的知识的)。
utils/jwt_payload.py内容如下:

from contextvars import ContextVar
from datetime import datetime, timezone, timedelta
import jwt
from utils.config import WEB_JWT_SECRET, WEB_JWT_EXP_SECOND

def jwt_encode(data, exp_second=WEB_JWT_EXP_SECOND, secret=WEB_JWT_SECRET):
	# 对数据进行jwt加密
    data["exp"] = datetime.now(tz=timezone.utc) + timedelta(seconds=exp_second)
    return jwt.encode(data, secret)


def jwt_decode(token, secret=WEB_JWT_SECRET):
	# 对数据进行jwt解密
    try:
        return jwt.decode(token, secret, leeway=10, algorithms=["HS256"])
    except:
        return None


def jwt_user_id(token, secret=WEB_JWT_SECRET):
	# 通过token进行jwt解密,然后取出用户ID
    payload = jwt_decode(token, secret)
    if payload is not None:
        return payload.get('id', None)
    return None


# 创建上下文变量以存储 userId 即当前操作者的ID(通过token解析)
user_id_ctx = ContextVar("user_id")

def set_user_id(user_id):
	# 注入用户ID到当前会话请求,在fastapi的中间件具体实现
    user_id_ctx.set(user_id)

def get_user_id():
	# 这儿就可以在任何位置获取到当前操作者的ID了
    try:
        return user_id_ctx.get()
    except:
        return None

用户模块接口

api/user_api.py内容如下:

from fastapi import APIRouter
from dto.user_dto import UserCreateDTO, UserStatusDTO, UserLoginDTO, UserModifyPwdDTO
from schema.user import User, UserStatus
from utils.common import copy_bean, str_md5
from utils.http import http_ok, http_fail
from utils.jwt_payload import jwt_encode, get_user_id
router = APIRouter()
# 创建用户的时候默认密码
DEFAULT_PASSWORD = 'pwd@2023'

@router.post("/create")
async def create(dto: UserCreateDTO):
    user = copy_bean(dto, User)
    exist_user = await User.find_user_by_account(user.account)
    if exist_user is not None:
        return http_fail('账号已经存在,请更换账号后再操作')
    user.password = str_md5(DEFAULT_PASSWORD)
    await User.insert(**dict(user))
    return http_ok()


@router.post("/update_status")
async def create(dto: UserStatusDTO):
    exist_user = await User.find_by_id(dto.id)
    if exist_user is None:
        return http_fail('用户不存在,操作失败')
    exist_user.status = dto.status
    await User.update_by_id(**dict(exist_user))
    return http_ok()


@router.post("/login_admin")
async def login_admin(dto: UserLoginDTO):
    user = await User.find_user_by_account(dto.account)
    if user is None:
        return http_fail('账号或密码错误,登录失败')
    if user.password != dto.password:
        return http_fail('账号或密码错误,登录失败')
    if user.status == UserStatus.DISABLE:
        return http_fail('账户已被禁用,登录失败')
    # 登录返回令牌给前端
    token = jwt_encode({"id": user.id, "name": user.name})
    return http_ok({
        "id": user.id,
        "name": user.name,
        "admin": user.admin,
        "token": token
    }, '登录成功')


@router.post("/modify_password")
async def modify_password(dto: UserModifyPwdDTO):
    user_id = get_user_id()
    user = await User.find_by_id(user_id)
    if user is None:
        return http_fail('账号有误,修改密码失败')
    if user.password != dto.password:
        return http_fail('旧密码有误,修改密码失败')
    user.password = dto.new_password
    await User.update_by_id(**dict(user))
    return http_ok(None, '修改密码成功')

给一下dto/user_dto.py的样例,其他模块皆可以参考该脚本:

from pydantic import BaseModel, Field
from schema.user import UserStatus
# 主要涉及到pydantic的知识,这儿一一说明
class UserCreateDTO(BaseModel):
    account: str = Field(..., min_length=1)
    name: str = Field(..., min_length=1)

class UserStatusDTO(BaseModel):
    id: int
    status: UserStatus

class UserLoginDTO(BaseModel):
    account: str = Field(..., min_length=1)
    password: str = Field(..., min_length=1)

class UserModifyPwdDTO(BaseModel):
    password: str = Field(..., min_length=1)
    new_password: str = Field(..., min_length=1)

资源图片的上传和下载

先看看schema/file.py的数据库模型定义:

import os
from datetime import datetime
from enum import IntEnum
from uuid import uuid1
from fastapi import UploadFile
from tortoise import fields
from tortoise.transactions import atomic
from utils.config import ROOT_DIR
from utils.http import http_fail, http_ok
from utils.jwt_payload import get_user_id, get_x_server
from utils.logger import logger
from utils.sql import BaseSchema

class FileType(IntEnum):
	# 文件类型定义,还可以有其他...
    IMAGE = 0
    DOC = 1
    VIDEO = 2

class File(BaseSchema):
    """
    上传文件表
    """

    class Meta:
        table = 't_file'
        table_description = '上传文件表'

    id = fields.BigIntField(pk=True, description="主键")
    original_file_name = fields.CharField(max_length=512, description="原始文件名")
    save_file_name = fields.CharField(max_length=512, description="保存的文件名")
    save_file_path = fields.CharField(max_length=512, description="保存的文件相对路径")
    content_type = fields.CharField(max_length=512, description="文件内容类型")
    file_type = fields.IntEnumField(FileType, description="文件类型")
    size = fields.BigIntField(description="文件大小")
    suffix = fields.CharField(max_length=64, description="文件后缀名")
    upload_time = fields.DatetimeField(description="文件上传时间")
    upload_by = fields.BigIntField(description="文件上传者")
    update_time = fields.DatetimeField(default=None, description="数据更新时间", null=True)
    update_by = fields.BigIntField(default=None, description="数据更新者", null=True)

    @classmethod
    async def upload_image(cls, file: UploadFile):
    	'''
    	上传图片
    	'''
        original_file_name = file.filename
        size = file.size
        suffix_index = original_file_name.rfind('.')
        content_type = file.content_type
        # 防止上传其他类型的文件,别人传一个.php或.py文件上传可能会出大事!!!
        if not content_type.startswith('image') or suffix_index == -1:
            return http_fail('上传图片类型格式不合法')
        if size > 2 * 1024 * 1024:
            return http_fail('上传图片大小超出限制')
        suffix = original_file_name[suffix_index:]
        current_datetime = datetime.now()
        year = current_datetime.year
        month = current_datetime.month
        day = current_datetime.day
        # 文件名更换为随机uuid+源后缀名
        save_file_name = f'{str(uuid1())}{suffix}'
        # 保存的服务端文件目录
        folder_path = ROOT_DIR + f'/res/image/{year}/{month}/{day}'
        if not os.path.exists(folder_path):
            os.makedirs(folder_path)
        save_file_path = f'image/{year}/{month}/{day}/{save_file_name}'
        file_path = f'{folder_path}/{save_file_name}'
        with open(file_path, "wb") as f:
        	# 保存文件
            f.write(file.file.read())
        # 保存数据到DB
        data = File(original_file_name=original_file_name, save_file_name=save_file_name, save_file_path=save_file_path,
                    content_type=content_type, file_type=FileType.IMAGE, size=size, suffix=suffix,
                    upload_time=datetime.now(),
                    upload_by=get_user_id())
        data = await File.insert(**dict(data))
        return http_ok(data)

看看api/file_api.py的实现:

import os
from fastapi import APIRouter, UploadFile
from starlette.responses import FileResponse, Response
from schema.file import File
from utils.config import ROOT_DIR
from utils.http import http_ok

router = APIRouter()

# 上传图片
@router.post("/upload_image")
async def upload_image(file: UploadFile):
    return await File.upload_image(file)

# 请求图片
@router.get("/get_image")
async def get_image(path: str):
    file_path = ROOT_DIR + f'/res/{path}'
    if os.path.exists(file_path) and len(path) > 0:
        return FileResponse(file_path)
    else:
        return Response(status_code=404)

核心启动文件

main.py内容如下:

import uvicorn
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
import api
from utils.config import WEB_PORT, WEB_PATH, WEB_PUBLIC_API_LIST
from utils.http import http_ok, http_auth
from utils.jwt_payload import jwt_decode, jwt_user_id, set_user_id, set_x_server
from utils.logger import logger
from utils.sql import init_db, close_db

app = FastAPI()

# 自动引入api模块下的所有接口路由
module_elements = dir(api)
# 如果你没有自动扫描api/*.py的功能,那么你需要每一个路由文件都要引入一遍,
# 在开发的时候路由文件越多你要引入就越多
for element_name in module_elements:
    element = getattr(api, element_name)
    if isinstance(element, type(api)):
        api_name = element_name.replace('_api', '')
        app.include_router(element.router, prefix=WEB_PATH + "/" + api_name, tags=[api_name])

# index router
@app.get(WEB_PATH + "/")
async def root():
    return http_ok()

# 核心中间件(类似于)
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
    path = request.url.path.replace(WEB_PATH, '')
    headers = request.headers
    # 前端请求的时候携带请求头authorization的token
    token = headers.get('authorization')
    # 非公开的路由需要认证用户身份
    if path not in WEB_PUBLIC_API_LIST:
        valid = jwt_decode(token)
        if valid is None:
            error_response = JSONResponse(content=http_auth(), status_code=200)
            return error_response
	# 认证通过解析出用户ID并注入到当前请求的上下文中,便于在任何地方获取到
    set_user_id(jwt_user_id(token))
    # 执行原来的方法
    response = await call_next(request)
    return response

# fastapi解决跨域,小程序访问需要
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# fastapi在启动的时候将ORM框架初始化
async def on_startup():
    logger.info("server starting...")
    await init_db()

# falstapi在停止的时候将ORM框架的数据链接释放
async def on_shutdown():
    await close_db()
    logger.info("server shutdown...")

# 绑定startup和shutdown
app.add_event_handler("startup", on_startup)
app.add_event_handler("shutdown", on_shutdown)

if __name__ == '__main__':
    uvicorn.run('main:app', host='0.0.0.0', port=WEB_PORT, reload=False, log_level="debug")

PS.运行main.py即可启动后端程序,注意当修改任何处的python代码,都会秒级别的自动重启后端。这是java做不到的吧,即使可以springboot热部署也达不到秒级别或者eclipse的debug在修改非方法内部代码的时候也是需要手动重启的。如果按照分布式那一套架构,启动一次约半分钟,我觉得脚本语言的优势就体现出来的(当然对小应用最佳,大型应用java绝对首选)。

聊聊臭了的CURD

CURD就是数据库的增删改查,有些项目能用封装解决,有些项目不能用封装解决,取决于客户、技术管理者的决定。如果处于企业内部网络或者安全性要求不高的系统可快速封装单表甚至多表的CURD操作,但是并非是所有的业务表都可以无业务的增删改查那么简单,在这里我提出了一个适用于如下功能的python版本的CURD的单表封装:
1.先通过权限保证并非所有的CURD接口都能公开访问参考auth_middleware的WEB_PUBLIC_API_LIST定义逻辑
2.然后采用手动定义允许哪些表(实体)可以快速CURD
参考api/base_api.py和dto/base_dto.py代码:

# base_dto.py
from typing import Optional, List

from pydantic import BaseModel

"""
Tortoise-ORM 支持各种查询操作,包括以下一些查询条件:
    exact: 等于(精确匹配)。
    iexact: 不区分大小写的等于(精确匹配)。
    contains: 包含特定字符串。
    icontains: 不区分大小写的包含特定字符串。
    startswith: 以特定字符串开头。
    istartswith: 不区分大小写的以特定字符串开头。
    endswith: 以特定字符串结尾。
    iendswith: 不区分大小写的以特定字符串结尾。
    in: 在给定列表或查询集合中。
    lt: 小于。
    lte: 小于等于。
    gt: 大于。
    gte: 大于等于。
    range: 在指定范围内。
    isnull: 为 null 值。
    regex: 正则表达式匹配。
    iregex: 不区分大小写的正则表达式匹配。
    not: 非操作。
    and: 与操作。
    or: 或操作。
"""

class QueryDTO(BaseModel):
    query: Optional[dict] = None
    order_by: Optional[str] = None
    limit: int = 10

class PageDTO(BaseModel):
    page: int = 1
    page_size: int = 10
    query: Optional[dict] = None
    order_by: Optional[str] = None

class IdsDTO(BaseModel):
    ids: List[int]

# base_api.py
import importlib
from fastapi import APIRouter
from dto.base_dto import PageDTO, IdsDTO, QueryDTO
from utils.common import copy_bean
from utils.http import http_ok
router = APIRouter()
# 自动import出schema/base.py中的实体,即开发者定义哪些实体类是可以基础CURD的,不能CURD的如
# user、role、sys_config等这些敏感的数据
# 例如现在我们希望实现news新闻模块、student学生模块可以快速实现CURD接口则base.py内容为:
# from schema.news import News
# from schema.student import Student 
def get_model_class(model):
    module = importlib.import_module('schema.base')
    if hasattr(module, model):
        model_class = getattr(module, model)
        return model_class

# 快速根据数据ID快速获取单条数据记录,样例为:
# 获取学生ID=10的记录:http://127.0.0.1:5100/server/api/base/find_by_id?model=Student&id=10
# 获取新闻ID=11的记录:http://127.0.0.1:5100/server/api/base/find_by_id?model=News&id=11
@router.get("/find_by_id")
async def find_by_id(id: int, model: str):
    data = None
    model_class = get_model_class(model)
    if model_class:
        data = await model_class.find_by_id(id)
    return http_ok(data)

# 快速分页
# http://127.0.0.1:5100/server/api/base/page?model=Student
# request body:
# {page: 1, page_size: 10, query: {age: 20, name__contains: '张'}, order_by: '-update_time'}
# 以上的样例分页获取到学生中年龄等于20并且姓名中包含'张'的第一页数据,并按照更新时间倒序排序取10条数据
# ps.name__contains的写法参考base_dto中我列出来的,是原生Tortoise-ORM支持的并不需要自行封装
@router.post("/page")
async def page(dto: PageDTO, model: str):
    data = None
    model_class = get_model_class(model)
    if model_class:
    	# 防止不开心的人搞事情,拖垮数据库
        if dto.page_size > 1000:
            dto.page_size = 1000
        data = await model_class.page(dto.page, dto.page_size, dto.query, dto.order_by)
    return http_ok(data)

# 根据查询条件和排序方式获取某表的前limit数组数据
# http://127.0.0.1:5100/server/api/base/query?model=News
# request body:
# {limit: 5, query: {column: 1}, order_by: '+update_time'}
# 以上样例查询出新闻栏目值等于1的并按照更新时间正序排序的5条新闻数组数据
@router.post("/query")
async def query(dto: QueryDTO, model: str):
    data = None
    model_class = get_model_class(model)
    if model_class:
    	# 防止不开心的人搞事情,拖垮数据库
        if dto.limit > 1000:
            dto.limit = 1000
        data = await model_class.query(dto.limit, dto.query, dto.order_by)
        pass
    return http_ok(data)

# 执行数据的新增和保存(根据ID是否存在)
# http://127.0.0.1:5100/server/api/base/insert_or_update?model=News
# request body:
# {id: null, column: 2, title: '新闻标题'}
# 以上样例将快速新增新闻数据
# PS.这个接口我并未放在公开访问的接口中。按照各项目的需求而定
@router.post("/insert_or_update")
async def insert_or_update(dto: dict, model: str):
    model_class = get_model_class(model)
    if model_class:
    	# 这里有个小坑,python中并未明确给出反射的实现,如果是java中非常好实现或者用hutool的包
    	# 主要是根据实体类的类型model_class,以及请求的dto的json数据转换为对应的ORM类(必须要)
    	# 代码下一个章节给出
        data = copy_bean(dto, model_class)
        await model_class.insert_or_update(**dict(data))
    return http_ok()

# 根据id数组批量删除数据
# http://127.0.0.1:5100/server/api/base/delete_by_ids?model=News
# request body:
# {ids: [1,2,3]}
# 以上样例将批量删除新闻ID等于1,2,3的三条数据
# PS.这个接口我并未放在公开访问的接口中。按照各项目的需求而定
@router.post("/delete_by_ids")
async def delete_by_ids(dto: IdsDTO, model: str):
    model_class = get_model_class(model)
    if model_class:
        await model_class.delete_by_ids(dto.ids)
    return http_ok()

补充封装的工具模块

utils/common.py代码

import hashlib

def copy_field(source, target):
    source_dict = dict(source)
    for key, value in source_dict.items():
        if hasattr(target, key):
            setattr(target, key, value)

def copy_list(sources, target_type):
    results = []
    for source in sources:
    	# 这个很关键target_type()会自动将前端的json->python的dict->orm的bean转换
    	# 有兴趣的可以多研究一下python的这样的特性
        target = target_type()
        copy_field(source, target)
        results.append(target)
    return results

def copy_bean(source, target_type):
    target = target_type()
    copy_field(source, target)
    return target

def str_md5(text):
    md5 = hashlib.md5()
    md5.update(text.encode('utf-8'))
    return md5.hexdigest()

ORM的事务

web系统开发不可避免的就是事务,在tortoise-orm中的事务按下边的写法去实现(官方推荐很多写法,我中意的是使用装饰器也就是类似java springboot的注解去自动管理事务)

# schema/user.py实现事务的样例:
from tortoise import fields
from utils.sql import BaseSchema
from tortoise.exceptions import OperationalError

class User(BaseSchema):
    """
    系统用户
    代码省略...
    """

	# 事务:创建用户和用户其他基础信息
    @classmethod
    # 这个装饰器定义的函数会自动管理事务
    @atomic()
    async def create_user_role(cls, account):
        user = await User.insert_or_update(name="张三",account="zhangsan") # 省略其他属性
        user_info = await UserInfo.insert_or_update(age=20,address="xxx路yyy号",user_id=user.id) # 省略其他属性
        # 如果要回滚事务,使用下面的代码抛出错误
        # raise OperationalError()

总结

按照以上的章节即可构建出一个小型应用的后端程序,基础功能完善后就看项目的业务去编写代码了,能用基础CURD就用,不能用的业务就愉快的码吧。
最后补一下模块依赖文件requirements.txt:

tortoise-orm~=0.20.0
python-multipart~=0.0.6
fastapi~=0.104.0
pydantic~=2.4.2
toml~=0.10.2
uvicorn~=0.20.0
pyjwt~=2.8.0
starlette~=0.27.0

Nuxt3(Vue3)前端篇

源码目录介绍

nuxt3中对目录的划分比较讲究,可以参考官网https://nuxt.com/docs/guide/directory-structure/nuxt的解释,我这里几乎和官网的标准一致,就增加了一个pm2部署的文件ecosystem.config.cjs和eslint、prettier代码格式化的插件,如下:
在这里插入图片描述

前端代码格式化

使用nuxt3的"@nuxtjs/eslint-module"这个插件即可快速配置eslint,参考https://nuxt.com/modules/eslint,我这里还使用了prettier。
在这里插入图片描述
在这里插入图片描述
这样保存代码将会自动格式化js,ts,vue,css,scss这些代码了(vscode的话自己装插件然后配置吧)

集成element-plus

参考https://nuxt.com/modules/element-plus
首选的vue3的UI框架了吧,不做其他介绍。具体使用在nuxt3中有些差异后面会详细说

集成lodash

参考https://nuxt.com/modules/lodash
前端有用的工具模块,使用这个插件将不用手动导入lodash的模块,自动引入isEmpty,useToUpper等这些,只是收到一些编码前缀的控制。

集成pinia和persistedstate状态持久层

vuex是vue2使用的会话状态管理,而vue3使用pinia这个了,作用就是保存web上的会话状态甚至持久化例如登录的状态和信息等。
参考https://nuxt.com/modules/pinia和https://nuxt.com/modules/pinia-plugin-persistedstate
使用的步骤:
先按照以上的官网地址集成pinia和persistedstate,
在nuxt.config.ts文件中使用如下代码片段:

export default defineNuxtConfig({
  // 引入插件
  modules: [
    "@pinia/nuxt",
    "@pinia-plugin-persistedstate/nuxt",
  ],
  // 配置持久化
  piniaPersistedstate: {
    // 使用cookie实现持久化
    cookieOptions: {
      sameSite: "strict",
      // 存储的时间一天和后端定义的jwt令牌过期时间一致,即登录后一天身份失效(不做续命延长)
      maxAge: 24 * 60 * 60,
      // cookie保存的目录
      path: "/server",
    },
    // 指定使用cookie实现持久化
    storage: "cookies",
  },
});

然后就是组合式写法
composables/useMainStore.ts内容如下:

import { defineStore } from "pinia";

export const useMainStore = defineStore("main", {
  state: () => {
    return {
      isLogin: false,
      userId: 0,
      userName: "",
      userAdmin: false,
      token: "",
    };
  },
  actions: {
    login(id: number, name: string, admin: boolean, token: string) {
      this.userId = id;
      this.userName = name;
      this.userAdmin = admin;
      this.token = token;
      this.isLogin = true;
    },
    logout() {
      this.userId = 0;
      this.userName = "";
      this.userAdmin = false;
      this.token = "";
      this.isLogin = false;
    },
  },
  persist: true,
});

然后看是怎么登录和登出的:

// 登录,假设result是请求后端登录接口http://127.0.0.1:5100/server/api/user/login_admin返回的数据
const { id, admin, name, token } = result;
// 执行登录
useMainStore().login(id, name, admin, token);
// 获取当前登录者的信息
const { isLogin, userName, userId} = useMainStore();
// 退出登录
useMainStore().logout()

页面布局

nuxt3中可以使用layouts进行布局,也可以使用vue3的组件式写法进行布局,都有些差异和写法。由于项目是采用门户网站的风格即顶部导航、中间页面内容、底部内容,实现起来的效果按照需求一致。
app.vue内容:

<div class="app-page">
    <!-- 页面过度动画,当目标页面采用useFetch请求的时候接口访问需要时间的时候不使用 NuxtLoadingIndicator则会出现卡顿  -->
    <NuxtLoadingIndicator />
    <NuxtPage />
  </div>

page/index.vue首页:

<template>
  <div>
    <NavMenu></NavMenu>
    <div class="app-page-content">
      <!-- 页面具体的内容 -->
    </div>
    <NavFooter></NavFooter>
  </div>
</template>

NavMenu组件中就可以编写页面导航的菜单了。
NavFooter组件中就可以编写页面底部的内容了。

封装element-ui的弹出层

为什么封装弹出层,还是项目使用统一风格的弹窗,不要再乱使用其他弹窗即采用固定的格式和用户交互。
composables/useMessage.ts

import { ElMessage, ElMessageBox } from "element-plus";

export function useMessage() {
  const success = (msg: string) => {
    ElMessage({
      message: msg,
      type: "success",
    });
  };

  const error = (msg: string) => {
    ElMessage({
      message: msg,
      type: "error",
    });
  };

  const warning = (msg: string) => {
    ElMessage({
      message: msg,
      type: "warning",
    });
  };

  const valid = (exp: boolean, msg: string) => {
    if (exp) {
      warning(msg);
      throw new Error(msg);
    }
  };

  const confirm = (
    msg: string,
    confirmButtonText: string = "确定",
    cancelButtonText: string = "取消",
  ) => {
    return new Promise<void>((resolve, reject) => {
      ElMessageBox.confirm(msg, "系统提示", {
        confirmButtonText,
        cancelButtonText,
        type: "warning",
      })
        .then(() => {
          resolve();
        })
        .catch(() => {
          // eslint-disable-next-line prefer-promise-reject-errors
          reject();
        });
    });
  };

  return {
    success,
    error,
    warning,
    valid,
    confirm,
  };
}

集成nprogress进度条插件

首先nuxt3中NuxtLoadingIndicator的进度条只会在页面A跳转到页面B的时候展示进度条,假设用户在页面A,触发NuxtLink标签进行跳转到页面B,页面B中有一个请求后端数据然后渲染到页面的工作,那么NuxtLoadingIndicator会在请求开始和结束的时候显示顶部滚动条。
但是,如果我们是通过按钮交互发起的后端请求呢?就是点击删除按钮,然后询问用户是否确定删除数据,用户确定后,将确定按钮设置为禁用并旋转loading的状态,然后请求后端的时候显示nprogress进度条。

# 安装
yarn add nprogress -D

编写客户端插件plugins/progress.client.ts:

import NProgress from "nprogress";
// 这个进度条插件仅在客户端实现,当页面发起异步请求获取数据的时候显示顶部进度条,和NuxtLoadingIndicator没有冲突,各尽其责
export default defineNuxtPlugin(() => {
  return {
    provide: {
      startLoading: () => {
        NProgress.start();
      },
      doneLoading: () => {
        NProgress.done();
      },
    },
  };
});

后续如何使用?参考下一个章节的请求后端接口的封装。

封装请求和后端交互

nuxt.config.ts配置后端接口地址:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      baseURL:
        // 这个环境变量的作用在打包部署阶段的作用就体现出来了
        process.env.NUXT_SERVER_API_BASE_URL || "http://127.0.0.1:5100/server/",
    },
  },
})

composables/useHttpFetch.ts

import type { UseFetchOptions } from "nuxt/app";
import { defu } from "defu";
// 仅在客户端运行的过程中开启nprogress进度条
function startLoading() {
  if (process.client) {
    const { $startLoading } = useNuxtApp();
    // @ts-ignore
    $startLoading();
  }
}
// 仅在客户端运行的过程中结束nprogress进度条
function doneLoading() {
  if (process.client) {
    const { $doneLoading } = useNuxtApp();
    // @ts-ignore
    $doneLoading();
  }
}
// 发起Fetch请求
export function useHttpFetch<T>(url: string, options: UseFetchOptions<T> = {}) {
  // 取令牌
  const { token } = useMainStore();
  const config = useRuntimeConfig();
  // 取到runtimeConfig中的baseURL 后端接口地址
  const { baseURL } = config.public;
  const headers = { };
  if (!isEmpty(token)) {
    headers["Authorization"] = token;
  }
  const defaults: UseFetchOptions<T> = {
    baseURL,
    key: url,
    headers,
    onRequest(_ctx) {
      startLoading();
    },
    onRequestError(_ctx) {
      doneLoading();
    },
    onResponse(_ctx) {
      doneLoading();
    },
    onResponseError(_ctx) {
      doneLoading();
    },
  };
  // 官方推荐使用defu构造的proxy请求参数
  const params = defu(options, defaults);
  // 发起请求
  return useFetch(url, params);
}

还没有结束,继续封装常用的GET和POST请求:
composables/useHttpGet.ts

import type { UseFetchOptions } from "nuxt/app";

export function useHttpGet<T>(url: string, options: UseFetchOptions<T> = {}) {
  options.method = "GET";
  return useHttpFetch(url, options);
}

composables/useHttpPost.ts

import type { UseFetchOptions } from "nuxt/app";

export function useHttpPost<T>(url: string, options: UseFetchOptions<T> = {}) {
  options.method = "POST";
  return useHttpFetch(url, options);
}

下边的封装很关键,如果在VUE3项目已经结束了,但是在Nuxt3还需要下边的控制。
首先要理解Nuxt3中错误的展示形式有定义的error.vue文件,当代码执行showError(…)的时候会跳转到error这个页面,先上代码。

<template>
  <div>
    <NavMenu></NavMenu>
    <div class="app-page-content">
      <div class="app-error-page w1240">
        <h1>您访问的页面出现错误,请联系网站管理员。</h1>
        <el-collapse v-model="activeNames">
          <el-collapse-item title="详细的错误原因" name="1">
            <div>
              {{ error }}
            </div>
          </el-collapse-item>
        </el-collapse>
        <br />
        <el-button type="info" @click="toIndex">返回首页</el-button>
      </div>
    </div>
    <NavFooter></NavFooter>
  </div>
</template>

<script setup lang="ts">
defineProps({
  error: Object,
});
const activeNames = ref("1");
function toIndex() {
  navigateTo("/");
}
</script>
<style scoped lang="scss">
.app-error-page {
  padding-top: 3vw;
  padding-bottom: 3vw;
  h1 {
    padding-bottom: 2vw;
  }
}
</style>

经过分析,试用于这种错误的情况有:当页面A跳转到页面B,但是页面B发起的后端请求,但是这个请求出现了错误,那么将会跳转到error页面。
还有另一种情况,当页面的交互按钮发起后端请求,后端请求出现错误或者业务错误例如登录的时候账号或密码错误提示、删除数据的时候提示数据不存在等等,我们需要弹出错误的提示框给用户。
那么就需要下边的两种封装请求错误的方式:
方式一(自动跳转到error页面)composables/useHttpValidAction.ts:

import type { _AsyncData } from "#app/composables/asyncData";
import { FetchError } from "ofetch";

export function useHttpValidAction(
  result: _AsyncData<unknown, FetchError | null>,
) {
  const { data, error } = result;
  if (error.value) {
    showError(error.value);
  } else {
    // @ts-ignore
    const { status, data: resultData, msg } = data.value;
    if (status === 1) {
      // 接口调用成功
      result.data.value = resultData;
      // 把外一层data去掉,有利于页面操作
      return result;
    } else if (status === 2) {
      // 身份认证失败,强制跳转到登录页面
      navigateTo("/login");
    } else {
      // 接口调用失败,ssr渲染页面抛出错误即自动跳转访问到error.vue页面并显示后端给的错误提示消息
      showError(msg);
    }
  }
}

方式二(错误弹窗提示)composables/useHttpAction.ts:

import type { _AsyncData } from "#app/composables/asyncData";
import { FetchError } from "ofetch/dist/node";

export function useHttpAction(
  result: _AsyncData<unknown, FetchError | null>,
  success: boolean = true,
) {
  let flag = false;
  const { data, error } = result;
  if (error.value) {
  	// 这种情况是后端500的错误
    useMessage().error("系统繁忙,请稍后再试。");
  } else {
    // @ts-ignore
    const { status, data: resultData, msg } = data.value;
    if (status === 1) {
      flag = true;
      result.data.value = resultData;
      // 默认的显示交互:操作成功
      if (success) {
        useMessage().success(msg);
      }
      return result;
    } else {
      // 这种请求是后端业务错误即http_fail()的时候错误提示
      useMessage().error(msg);
    }
  }
  // 这里的异常需要注意:是为了停止异步代码的继续向下,具体的业务逻辑使用中就会体会到。
  if (!flag) throw new Error("useHttpAction");
}

使用的例子一(进入页面获取新闻的数据):

const { data } = useHttpValidAction(await useHttpGet("/api/index/news"));

使用的例子二(交互按钮触发请求如修改密码):

async function handleModifyPwd() {
  const { password, newPassword, repPassword } = form;
  valid(isEmpty(password), "请输入您的当前密码");
  valid(isEmpty(newPassword), "请输入您的新的密码");
  valid(isEmpty(repPassword), "请输入您的重复密码");
  valid(!isEqual(newPassword, repPassword), "两次输入的新密码不一致");
  valid(
    newPassword.length < 6 || newPassword.length > 16,
    "密码长度必须是6~16位之间",
  );
  try {
    // 按钮动画开启
    modifyPwdLoading.value = true;
    useHttpAction(
      await useHttpPost("/api/user/modify_password", {
        body: useConvertRequest({
          password: md5(password),
          newPassword: md5(newPassword),
        }),
      }),
    );
    // 关闭修改密码模态框
    dialogVisible.value = false;
    // 清除登录状态
    useMainStore().logout();
    // 跳转到登录页面
    await navigateTo("/login");
  } finally {
    // 按钮动画关闭、
    modifyPwdLoading.value = false;
  }
}

集成vue-clipboard3插件

vue-clipboard3插件为了可以实现将文本复制到电脑的剪切板,项目需要使用到,在这儿记录一下,因为是服务端渲染不能在服务端使用。并不是所有的项目都需要。

# 安装
yarn add vue-clipboard3 -D

编写插件plugins/clipboard.client.ts

import useClipboard from "vue-clipboard3";

export default defineNuxtPlugin(() => {
  const { toClipboard } = useClipboard();

  const copyToClipboard = async (text: string) => {
    await toClipboard(text);
  };
  return {
    provide: {
      copyToClipboard,
    },
  };
});

在vue页面使用

const handleCopyText = async () => {
  const text= `复制文本到剪切板`;
  if (process.client) {
    const { $copyToClipboard } = useNuxtApp();
    await $copyToClipboard(text);
    useMessage().success("内容已复制");
  }
};

集成echarts

很多项目都需要使用到图表的功能,甚至大屏数据页也需要使用,在这集成到nuxt3中

# 安装
yarn add echarts -D
yarn add vue-echarts -D

编写插件plugins/echarts.client.ts

import { use } from "echarts/core";

// 需要什么类型的图表插件就import什么
import { CanvasRenderer } from "echarts/renderers";
import { BarChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";

export default defineNuxtPlugin(() => {
  use([CanvasRenderer, BarChart, GridComponent, TooltipComponent]);
});

vue页面中使用:

import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LabelLayout } from "echarts/features";
import { PieChart } from "echarts/charts";
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent,
} from "echarts/components";
import VChart from "vue-echarts";

use([
  CanvasRenderer,
  PieChart,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  LabelLayout,
]);
const { data } = useHttpValidAction(await useHttpGet("/api/index/big_data"));
const { optionNewsData }= data.value;
// 这里就是构造echarts的数据内容了
const optionNews = ref({
  series: [
    {
      type: "pie",
      radius: "50%",
      label: {
        color: "#FFA500",
      },
      data: optionNewsData,
    },
  ],
});
// <v-chart class="chart" :option="optionNews" /> 这样使用就可以了

封装图片组件

因为后端数据库存储的图片的相对路径地址如image/2023/11/30/19f2736a-8f1e-11ee-a0dc-4515df8de332.jpg这样的格式,现在需要统一封装一个加载服务端图片的组件:
components/AppImage.vue

<script setup lang="ts">
import { Picture } from "@element-plus/icons-vue";
// 组件参数
const props = defineProps({
  // v-model值,传入服务端存储的图片路径
  modelValue: { type: String, default: () => null },
});
const { modelValue } = props;
// 请求图片接口地址
const config = useRuntimeConfig();
const { baseURL } = config.public;
// no-image.png需要存储在public/img/empty/no-image.png
const url =
  isNil(modelValue) || isEmpty(modelValue)
    ? "img/empty/no-image.png"
    : `${baseURL}api/file/get_image?path=${modelValue}`;
</script>

<template>
  <el-image class="app-image" :src="url" :fit="'fill'">
    <template #error>
      <div class="image-slot">
      	<!-- 图片加载错误显示element-icon的图标 -->
        <el-icon><Picture /></el-icon>
      </div>
    </template>
  </el-image>
</template>

<style scoped lang="scss">
.app-image {
  width: 100%;
  height: 100%;
}
.image-slot {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  background: var(--el-fill-color-light);
  color: var(--el-text-color-secondary);
  font-size: 30px;
}
.image-slot .el-icon {
  font-size: 30px;
}
</style>

那么在页面上使用就很简单了:

<AppImage v-model="'image/2023/11/30/19f2736a-8f1e-11ee-a0dc-4515df8de332.jpg'"></AppImage>

封装图片上传组件

element-plus的上传图片的组件并没有实现到我希望的样子,我希望的组件的样子是如下:
页面只管v-model双向绑定表单的数据值form.photo,以及限制最多选择的图片张数,组件内部如何上传、如何移除图片,页面不关心。具体的封装代码参考如下,由于编写许多注释,这里不再做文字说明

<el-form-item label="学生照片">
    <AppUploadImage
      v-model="form.photo"
      :limit="1"
    ></AppUploadImage>
</el-form-item>

components/AppUploadImage.vue如下:

<script setup lang="ts">
import { Plus } from "@element-plus/icons-vue";
import type { UploadUserFile } from "element-plus";
import zhCN from "element-plus/dist/locale/zh-cn.mjs";
// v-model数据双向绑定
const emits = defineEmits(["update:modelValue"]);
// 组件参数
const props = defineProps({
  // v-model值(单选图片返回图片地址,多选返回图片地址数组)
  modelValue: { type: [String, Array], default: () => null },
  // 图片最多上传数量
  limit: { type: Number, default: () => 1 },
  // 上传图片单张大小限制(默认1MB)
  size: { type: Number, default: () => 1 },
});
// loading动画
const loading = ref(false);
// 支持的图片格式
const accept = ".jpg,.jpeg,.png,.gif,.bmp,.JPG,.JPEG,.PBG,.GIF,.BMP";
// 弹窗提示
const { warning, error, success } = useMessage();
// 取父组件传递的参数
const { limit, modelValue, size } = props;
// 标记是否可以多选图片
const multiple = limit > 1;
// 上传图片接口地址
const config = useRuntimeConfig();
const { baseURL } = config.public;
const action = `${baseURL}api/file/upload_image`;
// 身份认证令牌
const { token } = useMainStore();
// 构造请求头
const headers = !isEmpty(token) ? { Authorization: token } : {};
// el-upload组件展示的图片数据域
const fileList = ref<UploadUserFile[]>([]);
// 过滤主动控制fileList值之后图片仅展示服务端图片地址
const filterServerImage = (imageList) =>
  imageList.filter((row) => isEmpty(row.raw));
// 当图片选择器是多选的时候用于存储临时的已上传的图片服务端路径
let modelValueList = [];
// 是否隐藏上传的+号按钮
const hideBtn = ref(false);
// 根据后端返回的图片服务端路径构造最终的请求服务端图片的完整的地址
const getImageUrl = (path) => `${baseURL}api/file/get_image?path=${path}`;
// 展示图片
const showImageList = (newVal) => {
  if (isString(modelValue)) {
    // 单选图片仅显示一张服务端路径的图片
    const imageUrl = getImageUrl(newVal);
    fileList.value = [{ name: imageUrl, url: imageUrl }];
    // 隐藏+号按钮
    hideBtn.value = true;
  } else if (isArray(modelValue)) {
    // 赋值给临时存储服务端图片路径的数组(最好去重)
    modelValueList = [...new Set(newVal)];
    for (const item of newVal) {
      // 多选图片
      const imageUrl = getImageUrl(item);
      // 注意这儿不能重复添加图片
      const index = fileList.value.findIndex((row) =>
        isEqual(imageUrl, row.url),
      );
      // 只添加不重复的图片
      if (index === -1) fileList.value.push({ name: imageUrl, url: imageUrl });
    }
    // 最终仅仅展示服务端的图片
    fileList.value = filterServerImage(fileList.value);
    // 显示或隐藏+号按钮
    hideBtn.value = fileList.value.length >= limit;
  }
};
// 深度监听,v-model数据双向绑定
watch(
  () => props.modelValue,
  (newVal) => {
    if (!isNil(newVal) && !isEmpty(newVal)) {
      showImageList(newVal);
    } else {
      // 显示+按钮
      hideBtn.value = false;
    }
  },
  { immediate: true },
);
// el-upload ref对象
const appUploadImage = ref<any>();
// 用于预览图片的图片地址
const dialogImageUrl = ref("");
// 用于预览图片的对话框展示
const dialogVisible = ref(false);

// 限制最多上传图片数量
const handleExceed = () => {
  warning(`最多上传${limit}张图片`);
};
// 文件上传失败(后端异常)
const handleError = () => {
  error("文件上传失败,请联系网站管理员");
};
// 移除文件
const handleRemove = (uploadFile) => {
  if (isString(modelValue)) {
    // 单选的时候直接设置v-model的值为空
    emits("update:modelValue", null);
  } else if (isArray(modelValue)) {
    // 获取当前删除的文件的服务端路径
    const path = uploadFile.url.replace(
      `${baseURL}api/file/get_image?path=`,
      "",
    );
    const index = modelValueList.findIndex((row) => isEqual(row, path));
    if (index !== -1) {
      // 从临时的服务端图片路径存储集合中删除当前图片
      modelValueList.splice(index, 1);
      // v-model值绑定,PS.这儿不能直接使用emits("update:modelValue", modelValueList); 不会触发watch
      emits("update:modelValue", [...modelValueList]);
    }
  }
};
// 服务端响应图片上传http status = 200
const handleSuccess = (response, uploadFile) => {
  const { name } = uploadFile;
  const { status, data, msg } = response;
  // 根据后端数据域中的status必须是1才视为成功
  if (status === 1) {
    // 服务端返回的图片相对路径
    const { saveFilePath } = useConvertResponse(data);
    if (isString(modelValue)) {
      // 单选直接返回这个服务端相关路径即可
      emits("update:modelValue", saveFilePath);
    } else if (isArray(modelValue)) {
      // 多选的时候,存储到临时数组(在展示的图片需要去除图片重复)
      modelValueList.push(saveFilePath);
      // 触发v-model双向绑定
      // PS.这儿一定要使用setTimeout延迟执行,否则多选图片同时上传给服务端,响应的时候速度较快导致emits无法正常执行(内部防抖)
      setTimeout(() => {
        // v-model值绑定,PS.这儿不能直接使用emits("update:modelValue", modelValueList); 不会触发watch
        emits("update:modelValue", [...modelValueList]);
      }, 100);
    }
    success(`图片${name}上传成功`);
  } else {
    error(`图片${name}上传失败,原因:${msg}`);
    // 服务端返回status=0的时候如文件不合法等,手动从图片域中移除当前图片
    appUploadImage.value.handleRemove(uploadFile);
  }
};
// 图片上传大小和类型限制
const handleBeforeUpload = (rawFile) => {
  if (rawFile.size > size * 1024 * 1024) {
    warning(`图片${rawFile.name}上传失败,超出限制单张大小:${size}MB`);
    return false;
  }
  if (!rawFile.type.startsWith("image")) {
    warning(`图片${rawFile.name}上传失败,不合法的图片类型`);
    return false;
  }
  return true;
};

// 点击预览图片
const handlePreview = (uploadFile) => {
  dialogImageUrl.value = uploadFile.url!;
  dialogVisible.value = true;
};
</script>

<template>
  <ClientOnly>
    <el-config-provider :locale="zhCN">
      <el-upload
        ref="appUploadImage"
        v-model:file-list="fileList"
        v-loading="loading"
        :action="action"
        :accept="accept"
        :headers="headers"
        list-type="picture-card"
        :on-preview="handlePreview"
        :multiple="multiple"
        :limit="limit"
        :on-exceed="handleExceed"
        :on-error="handleError"
        :on-success="handleSuccess"
        :on-remove="handleRemove"
        :before-upload="handleBeforeUpload"
        :class="{ hide: hideBtn }"
      >
        <el-icon><Plus /></el-icon>
      </el-upload>
      <el-dialog v-model="dialogVisible">
        <img w-full :src="dialogImageUrl" alt="预览图片" />
      </el-dialog>
    </el-config-provider>
  </ClientOnly>
</template>

<style scoped lang="scss">
.hide {
  ::v-deep(.el-upload--picture-card) {
    display: none !important;
  }
}
</style>

封装分页表格组件

需要封装出和业务无关的表格分页组件,内部的实现不关心,在页面传入请求的接口地址、请求参数、排序参数、表格列构造等等即可快速渲染。
components/AppTable.vue如下:

<script setup lang="ts">
import zhCN from "element-plus/dist/locale/zh-cn.mjs";
import { Delete, Plus, Refresh, Search } from "@element-plus/icons-vue";
// 触发父组件的事件:新增按钮、编辑按钮、删除按钮的点击事件、查看按钮点击事件
const emits = defineEmits(["add", "edit", "delete", "detail"]);
// 组件参数
const props = defineProps({
  // 分页表格请求的接口地址
  url: { type: String, default: () => null },
  // 当前页码
  page: { type: Number, default: () => 1 },
  // 页的记录数
  pageSize: { type: Number, default: () => 10 },
  // 是否显示多选选择的列(checkbox)
  selection: { type: Boolean, default: () => true },
  // 分页接口查询参数构造器,通过父组件中传入并自动执行
  query: { type: Function, default: () => null },
  // 分页接口默认排序构造器,通过父组件中传入并自动执行
  orderBy: { type: String, default: () => null },
  // 分页接口重置参数构造器,通过父组件中传入并自动执行
  reset: { type: Function, default: () => null },
  // 新增页面跳转地址,传入则点击新增按钮则自动跳转到该地址(不携带任何参数)
  // PS.如果传入该值将不会触发@add事件
  addUrl: { type: String, default: () => null },
  // 编辑页面跳转地址,传入则点击编辑按钮则自动跳转到该地址(并携带数据ID参数)
  // PS.如果传入该值将不会触发@edit事件
  editUrl: { type: String, default: () => null },
  // 批量删除接口地址,传入则执行默认的删除数据的业务逻辑
  // PS.如果传入该值将不会触发@delete事件
  deleteUrl: { type: String, default: () => null },
  // 详情页面跳转地址,传入则点击详情按钮则自动跳转到该地址(并携带数据ID参数)
  // PS.如果传入该值将不会触发@detail事件
  detailUrl: { type: String, default: () => null },
  // 按钮权限控制,更深的权限控制由后端返回(这儿不做,仅前端控制)
  buttonPermission: {
    type: Array,
    default: () => ["search", "reset", "add", "edit", "delete", "detail"],
  },
  // 默认操作列的宽度
  columnActionWidth: { type: Number, default: () => 120 },
});
// 取父组件传入的参数
const {
  url,
  page,
  pageSize,
  selection,
  query,
  reset,
  addUrl,
  deleteUrl,
  orderBy,
  editUrl,
  detailUrl,
} = props;
// 默认直接显示分页表格的加载动画
const loading = ref(true);
// 分页表格列表数据
const tableData = ref([]);
// 数据的总记录数(后端控制返回)
const total = ref(0);
// 当前页码
const currentPage = ref(page);
// 当前页的大小
const pageSizeData = ref(pageSize);
// 当前表格选择的数据列表
let selectionList = [];
// 删除按钮动画
const deleteLoading = ref(false);
// 执行查询数据
async function handleSearch() {
  try {
    // 显示加载动画
    loading.value = true;
    // 构造查询条件(默认兼容Tortoise ORM的filter参数写法)
    let queryJSON = null;
    if (query) queryJSON = await query();
    if (typeof url === "string" && !isEmpty(url)) {
      // 构造查询报文
      const body = useConvertRequest({
        page: currentPage.value,
        pageSize: pageSizeData.value,
        query: queryJSON,
        orderBy,
      });
      // 获取数据
      const { data } = useHttpValidAction(await useHttpPost(url, { body }));
      // 解析数据
      const response = useConvertResponse(data.value);
      // 对表格赋值
      tableData.value = response?.results;
      // 对分页器赋值
      total.value = response?.total;
    }
  } finally {
    // 关闭加载动画
    loading.value = false;
  }
}

// 执行重置
async function handleReset() {
  // 父组件控制重置业务逻辑
  if (reset) await reset();
  // 执行查询
  await handleSearch();
}

// 点击新增按钮执行
function handleAdd() {
  if (isEmpty(addUrl)) {
    // 当不传入addUrl新增页面的地址值的时候主动触发父组件的@add事件
    emits("add");
  } else {
    // 默认跳转
    navigateTo(addUrl);
  }
}
// 点击详情按钮执行
function handleDetail(id) {
  if (isEmpty(detailUrl)) {
    // 当不传入detailUrl详情页面的地址值的时候主动触发父组件的@detail事件
    emits("detail");
  } else {
    // 默认跳转
    navigateTo({
      path: detailUrl,
      query: {
        id,
      },
    });
  }
}
// 点击删除按钮执行
async function handleDelete() {
  if (isEmpty(deleteUrl)) {
    emits("delete");
  } else {
    // 默认删除逻辑
    // eslint-disable-next-line no-lonely-if
    if (selectionList.length === 0) {
      useMessage().warning("请选择数据后再删除!");
    } else {
      // 获取选择的数据的ID集合
      const ids = selectionList.map((row) => row.id);
      await useMessage().confirm(
        `即将删除所选择的${ids.length}条数据并且无法恢复,是否继续?`,
      );
      // 执行删除
      try {
        deleteLoading.value = true;
        if (typeof deleteUrl === "string") {
          useHttpAction(await useHttpPost(deleteUrl, { body: { ids } }));
          await handleSearch();
        }
      } finally {
        deleteLoading.value = false;
      }
    }
  }
}

function handleSelectionChange(val) {
  selectionList = val;
}

function handleEdit(id) {
  if (isEmpty(editUrl)) {
    // 当不传入editUrl编辑页面的地址值的时候主动触发父组件的@edit事件
    emits("edit");
  } else {
    // 默认跳转
    navigateTo({
      path: editUrl,
      query: {
        id,
      },
    });
  }
}

defineExpose({ handleSearch, handleReset });
</script>

<template>
  <ClientOnly>
    <div class="app-table">
      <div class="app-table-form">
        <el-form :inline="true" class="app-table-form-inline">
          <slot name="form"></slot>
          <el-form-item>
            <el-button
              v-if="buttonPermission.includes('search')"
              type="primary"
              :icon="Search"
              @click="handleSearch"
              >搜索</el-button
            >
            <el-button
              v-if="buttonPermission.includes('reset')"
              type="primary"
              :icon="Refresh"
              @click="handleReset"
              >重置</el-button
            >
            <el-button
              v-if="buttonPermission.includes('add')"
              type="primary"
              :icon="Plus"
              @click="handleAdd"
              >新增</el-button
            >
            <el-button
              v-if="buttonPermission.includes('delete')"
              type="primary"
              :icon="Delete"
              @click="handleDelete"
              >删除</el-button
            >
            <slot name="formButton"></slot>
          </el-form-item>
        </el-form>
      </div>
      <div class="app-table-content">
        <el-table
          v-loading="loading"
          :data="tableData"
          style="width: 100%"
          stripe
          empty-text="无数据"
          @selection-change="handleSelectionChange"
        >
          <el-table-column v-if="selection" type="selection" width="55" />
          <el-table-column type="index" width="80" label="序号" />
          <slot name="columns"></slot>
          <el-table-column
            fixed="right"
            label="操作"
            :width="columnActionWidth"
          >
            <template #default="scope">
              <el-button
                v-if="buttonPermission.includes('detail')"
                link
                type="primary"
                size="small"
                @click="handleDetail(scope.row.id)"
                >查看</el-button
              >
              <el-button
                v-if="buttonPermission.includes('edit')"
                link
                type="primary"
                size="small"
                @click="handleEdit(scope.row.id)"
                >编辑</el-button
              >
              <slot name="columnButton" :row="scope.row"></slot>
            </template>
          </el-table-column>
        </el-table>
      </div>
      <div class="app-table-pagination">
        <el-config-provider :locale="zhCN">
          <el-pagination
            v-model:current-page="currentPage"
            v-model:page-size="pageSizeData"
            :page-sizes="[10, 20, 100, 200, 500]"
            :disabled="loading"
            layout="total, sizes, prev, pager, next, jumper"
            :total="total"
            @size-change="handleSearch"
            @current-change="handleSearch"
          />
        </el-config-provider>
      </div>
    </div>
  </ClientOnly>
</template>

<style scoped lang="scss">
.app-table-pagination {
  width: 100%;
  padding-top: 1vw;
  padding-bottom: 1vw;
  display: flex;
  flex-direction: row;
  justify-content: flex-end;
}
.app-table-form-inline .el-input {
  --el-input-width: 220px;
}
</style>

页面使用如下:

<script setup lang="ts">
const form = reactive({
  title: "",
  column: "",
});
// 栏目常量定义不做篇幅列出。定义一些键值对标识:值-文本
const { newsColumn, validDef } = useDefConstant();
const appTable = ref<any>();
nextTick(() => {
  // 一定要等待页面渲染后再执行请求表格数据的操作。由于是服务端渲染
  appTable.value?.handleSearch();
});

function handleQuery() {
  const data = {};
  if (!isEmpty(form.title)) {
    data["title__contains"] = form.title;
  }
  if (validDef(newsColumn, form.column)) {
    data["column"] = form.column;
  }
  return data;
}

function handleReset() {
  form.title = "";
  form.column = "";
}
</script>
<AppTable
          ref="appTable"
          :url="'/api/base/page?model=News'"
          :delete-url="'/api/base/delete_by_ids?model=News'"
          :add-url="'/data/news_edit'"
          :edit-url="'/data/news_edit'"
          :detail-url="'/news_detail'"
          :order-by="'-update_time'"
          :query="handleQuery"
          :reset="handleReset"
        >
          <template #form>
            <el-form-item label="新闻标题">
              <el-input v-model="form.title" placeholder="请输入新闻标题搜索" />
            </el-form-item>
            <el-form-item label="新闻栏目">
              <el-select
                v-model="form.column"
                placeholder="请选择新闻栏目搜索"
                clearable
              >
                <el-option
                  v-for="(row, index) in newsColumn"
                  :key="index"
                  :label="row.label"
                  :value="row.value"
                />
              </el-select>
            </el-form-item>
          </template>
          <template #columns>
            <el-table-column prop="title" label="新闻标题">
              <template #default="scope">
                <div
                  class="app-table-column-html"
                  v-html="scope.row.title"
                ></div>
              </template>
            </el-table-column>
            <el-table-column prop="column" label="新闻栏目" width="180">
              <template #default="scope">
                <el-tag v-if="scope.row.column === 0">栏目A</el-tag>
                <el-tag v-else-if="scope.row.column === 1" type="success"
                  >栏目B</el-tag
                >
                <el-tag v-else-if="scope.row.column === 2" type="info"
                  >栏目C</el-tag
                >
              </template>
            </el-table-column>
            <el-table-column prop="updateTime" label="更新时间" width="180">
              <template #default="scope">
              	<!-- 格式化时间,这儿不列篇幅了键入后端解析即可 -->
                {{ useDataFormat().dateTime(scope.row.updateTime) }}
              </template>
            </el-table-column>
          </template>
        </AppTable>

权限控制

在前端,我们可以指定一些目录下的页面或指定的页面必须要是登录的状态才能进入,当然只是前端做控制并不能防止有心人,需要结合后端的接口开放权限实现。
middleware/auth.global.ts内容如下:

export default defineNuxtRouteMiddleware((to, _from) => {
  const { path } = to;
  const { isLogin } = useMainStore();
  // 未登录无法进行数据管理下的页面
  if (path.startsWith("/data") && !isLogin) {
    return navigateTo("/login");
  }
});

聊聊在nuxt3服务端渲染下的element-ui的模态框和表单

之前封装的分页表格组件中的新增、编辑按钮默认是做跳转页而不是使用模态框打开的方式,如果项目有需要到使用模态框+表单的方式怎么办,nuxt3的服务端渲染有没有影响?先看element-plus官方给的提示
在这里插入图片描述
很明显,使用后同样对渲染的form有影响,我直接上代码如何解决吧。
先封装组件components/DialogForm.vue

<script setup lang="ts">
const { warning } = useMessage();
// 是否显示模态框,默认不显示
const dialogVisible = ref(false);
// 提交按钮的禁用动画
const loading = ref(false);
// 传递的参数
const props = defineProps({
  // 数据的主键(我们视为打开编辑类模态框要么是新增数据要么是编辑数据)
  id: { type: [String, Number], default: () => null },
  // 模态框的标题
  dialogTitle: { type: String, default: () => null },
  // 模态框的宽度
  dialogWidth: { type: [String, Number], default: () => 600 },
  // 绑定表单的数据域
  formModel: { type: Object, default: () => null },
  // 绑定表单的验证规则
  formRules: { type: Object, default: () => null },
  // 组件外部定义整个表单的保存逻辑
  save: { type: Function, default: () => null },
});
// 取参
const { id, formModel, formRules, dialogTitle, dialogWidth, save } = props;
// 对模态框的标题加上“新增”还是"编辑"
const title = computed(() => {
  return (isEmpty(id) ? "新增" : "编辑") + `${dialogTitle}`;
});
// 表单ref
const formRef = ref();
// 打开模态框
function handleOpen() {
  dialogVisible.value = true;
}
// 保存表单按钮触发
async function handleSave() {
  // 验证表单数据规则
  await formRef.value.validate(async (valid: boolean) => {
    if (valid) {
      if (save) {
        try {
          // 保存按钮动画开启
          loading.value = true;
          // 执行外部定义的保存方法
          await save();
          // 关闭模态框
          handleClose();
        } finally {
          // 保存按钮动画关闭
          loading.value = false;
        }
      }
    } else {
      warning("请验证表单数据项的完整性");
    }
  });
}
// 关闭模态框
function handleClose() {
  dialogVisible.value = false;
  handleReset();
}
// 重置表单:这是坑,需要把思路转为到组件中实现,在组件外部由于增加了ClientOnly使得无法正常获取到formRef对象
function handleReset() {
  formRef.value.resetFields();
}
// 暴露的方法
defineExpose({ handleOpen, handleClose, handleReset });
</script>

<template>
  <ClientOnly>
    <el-dialog
      v-model="dialogVisible"
      :title="title"
      :width="dialogWidth"
      :close-on-click-modal="false"
      @close="handleClose"
    >
      <el-form
        ref="formRef"
        :model="formModel"
        label-width="80px"
        :rules="formRules"
        :label-position="'left'"
      >
      	<!-- 使用插槽的方式在父组件中自定义表单项内容 -->
        <slot></slot>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="handleClose">关闭</el-button>
          <el-button type="primary" :loading="loading" @click="handleSave">
            提交
          </el-button>
        </span>
      </template>
    </el-dialog>
  </ClientOnly>
</template>

<style scoped lang="scss"></style>

然后看看具体如何使用这个封装的对话框+表单组件?

<!-- 某编辑页也定义为组件如DictEdit.vue组件 -->
<script setup lang="ts">
const { msg, post } = useRequest();
// 暴露的保存按钮执行完毕的emit很重要,否则无法重置表单
const emits = defineEmits(["done"]);
// 模态框+表单组件对象
const dialogForm = ref();
// 表单数据域
const form: Record<string, any> = reactive({
  name: "",
  key: "",
});
// 表单验证规则
const rules: Record<string, any> = reactive({
  name: [{ required: true, message: "请输入字典名称", trigger: "blur" }],
  key: [{ required: true, message: "请输入字典标识", trigger: "blur" }],
});
// 打开模态框,供父组件使用
async function open(id: string) {
  dialogForm.value.handleOpen();
  if (!isEmpty(id)) {
    useHttpAction(await useHttpPost("/dict/detail", { body: { id } }));
  }
}
// 保存表单
async function save() {
  useHttpAction(await useHttpPost("/dict/save", { body: form }));
  // 保存成功通知父组件的done事件,让父组件关闭模态框和重置表单的功能(一定要这样,否则会出现问题)
  emits("done");
}
defineExpose({ open });
</script>

<template>
  <DialogForm
    ref="dialogForm"
    dialog-title="字典数据"
    :form-model="form"
    :form-rules="rules"
    :save="save"
  >
    <el-form-item label="字典名称" prop="name">
      <el-input
        v-model="form.name"
        clearable
        maxlength="10"
        placeholder="请输入字典名称(最大长度10)"
      />
    </el-form-item>
    <el-form-item label="字典标识" prop="key">
      <el-input
        v-model="form.key"
        clearable
        maxlength="10"
        placeholder="请输入字典标识(最大长度10)"
      />
    </el-form-item>
  </DialogForm>
</template>

<style scoped lang="scss"></style>

然后继续看如何在分页表格列表页的新增或者编辑按钮使用编辑页组件

<script setup lang="ts">
const dictEdit = ref();
function handleOpen(id: string) {
  dictEdit.value?.open(id);
}
function handleDone() {
  // 保存结束了,组件内部自动重置表单和关闭模态框了。
  // 现在要刷新分页表格还是怎么样看你的业务了
}
</script>
<BusiDictEdit ref="dictEdit" @done="handleDone"></BusiDictEdit>

封装动态表单(子表单)

上一章说到表单,现在来看子表单以及动态表单的验证。
先看看效果图,要不这样说有朋友不理解
在这里插入图片描述
就是把表格里的动态表单封装为一个组件,组件内部的新增按钮和删除按钮以及表格的表头和列如何渲染都交给组件内部实现,组件外部只管传递参数构造表头和表列,然后v-model可以双向取到动态表单的数组数据即可。上代码

<script setup lang="ts">
import { Delete, Plus } from "@element-plus/icons-vue";
// 定义一组类型,表示父组件传递进来的构造子表单的项的类型可以是"input" | "select" | "radio"...
// 需要更多就自行扩展
type ColumnFieldType = "input" | "select" | "radio"; 
// 定义每一表单项(表格列)传递过来的数据格式(通过一下格式去构造表单的每一项)
interface ColumnField {
  // 列宽度
  width?: string | number;
  // 列显示的文字
  label: string;
  // 列对应的数据域字段名
  prop: string;
  // 提示文字
  placeholder?: string;
  // 类型,根据具体的类型去渲染不同的列(我这儿只给出input类型,其他类型可以自己扩展)
  type: ColumnFieldType;
}
// 数据双向绑定
const emits = defineEmits(["update:modelValue"]);
// 传参
const props = defineProps({
  // 当前子表单的数据域在父表单的字段名
  mainField: { type: String, default: () => null },
  // 数据双向绑定
  modelValue: { type: Array<any>, default: () => [] },
  // 表格列渲染
  tableField: { type: Array<ColumnField>, default: () => [] },
  // 子表单规则
  formRules: { type: Object, default: () => {} },
  // 动态表单为空的时候提示的表格文字
  emptyText: { type: String, default: () => "暂无数据" },
});
const { modelValue, tableField, mainField, formRules } = props;
const tableData = ref(modelValue);
function handleAddRow() {
  tableData.value.push({});
  emits("update:modelValue", tableData.value);
}
function handleDelRow(index: number) {
  tableData.value.splice(index, 1);
  emits("update:modelValue", tableData.value);
}
</script>

<template>
  <el-table
    ref="tableRef"
    class="table-class"
    :data="tableData"
    border
    style="width: 100%"
    :empty-text="emptyText"
  >
    <el-table-column
      v-for="(item, index) in tableField"
      :key="index"
      :width="item.width"
    >
      <template #header>
        <span>
          {{ item.label }}
        </span>
      </template>
      <template #default="scoped">
        <el-form-item
          :prop="`${mainField}[${scoped.$index}][${item.prop}]`"
          :rules="formRules[item.prop]"
        >
          <el-input
            v-if="item.type === 'input'"
            v-model="scoped.row[item.prop]"
            type="text"
            :placeholder="item.placeholder"
            :title="scoped.row[item.prop]"
          ></el-input>
        </el-form-item>
      </template>
    </el-table-column>
    <el-table-column fixed="right">
      <template #header>
        <el-button type="primary" :icon="Plus" circle @click="handleAddRow" />
      </template>
      <template #default="scoped">
        <el-button
          type="danger"
          :icon="Delete"
          circle
          @click="handleDelRow(scoped.$index)"
        />
      </template>
    </el-table-column>
  </el-table>
</template>
<!-- 下边的样式解决在表格中的表单元素无法左对齐的问题 -->
<style scoped lang="scss">
.table-class {
  ::v-deep(.el-form-item__content) {
    margin-left: 0 !important;
  }
}
</style>

看看如何使用的?

<script setup lang="ts">
const { msg, post } = useRequest();
const emits = defineEmits(["done"]);
const dialogForm = ref();
// 整个表单数据域
const form: Record<string, any> = reactive({
  name: "",
  key: "",
  // 注意这里是动态子表单的数据域
  values: [],
});
// 父表单排除子表单的验证规则
const rules: Record<string, any> = reactive({
  name: [{ required: true, message: "请输入字典名称", trigger: "blur" }],
  key: [{ required: true, message: "请输入字典标识", trigger: "blur" }],
});
// 子表单的验证规则
const tableRules: Record<string, any> = reactive({
  name: [
    {
      required: true,
      message: "请输入字典项名",
      trigger: "blur",
    },
  ],
  key: [
    {
      required: true,
      message: "请输入字典项值",
      trigger: "blur",
    },
  ],
});
// 子表单渲染参数
const tableField: any[] = reactive([
  {
    label: "字典项名",
    width: "210",
    prop: "name",
    placeholder: "请输入字典项名",
    type: "input",
  },
  {
    label: "字典项值",
    width: "210",
    prop: "key",
    placeholder: "请输入字典项值",
    type: "input",
  },
]);
async function open(id: string) {
  dialogForm.value.handleOpen();
  if (!isEmpty(id)) {
    useHttpAction(await useHttpPost("/dict/detail", { body: { id } }));
  }
}
async function save() {
  useHttpAction(await useHttpPost("/dict/save", { body: form }));
  emits("done");
}
defineExpose({ open });
</script>

<template>
  <DialogForm
    ref="dialogForm"
    dialog-title="字典数据"
    :form-model="form"
    :form-rules="rules"
    :save="save"
  >
    <el-form-item label="字典名称" prop="name">
      <el-input
        v-model="form.name"
        clearable
        maxlength="10"
        placeholder="请输入字典名称(最大长度10)"
      />
    </el-form-item>
    <el-form-item label="字典标识" prop="key">
      <el-input
        v-model="form.key"
        clearable
        maxlength="10"
        placeholder="请输入字典标识(最大长度10)"
      />
    </el-form-item>
    <!-- 这里知道main-field的值了吧,这个很有用,传不对了整个子表单的验证就失效 -->
    <TableForm
      v-model="form.values"
      main-field="values"
      :table-field="tableField"
      :form-rules="tableRules"
    ></TableForm>
  </DialogForm>
</template>

<style scoped lang="scss"></style>

其他

(1)由于前端的JavaScript/typescript的变量命名风格是驼峰,而后端的python的变量命名风格是下划线,那么在前后端请求或者响应数据的时候需要对数据格式进行转换即驼峰转下划线、下划线转驼峰。下边是两个工具api。

// composables/useConvertRequest.ts
// Helper 函数:将对象的键从驼峰命名法转换为下划线命名法
// @ts-ignore
function convertToSnakeCase(obj: any) {
  if (obj === null || typeof obj !== "object") {
    return obj;
  }
  if (Array.isArray(obj)) {
    return obj.map((item) => convertToSnakeCase(item));
  }
  const result = {};
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      const newKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
      // @ts-ignore
      result[newKey] = convertToSnakeCase(obj[key]);
    }
  }
  return result;
}
export function useConvertRequest(data: any) {
  if (isArray(data) && data.length > 0) {
    return data.map((row) => convertToSnakeCase(row));
  } else if (isObject(data)) {
    return convertToSnakeCase(data);
  }
  return data;
}

// composables/useConvertResponse.ts
// Helper 函数:将对象的键从下划线命名法转换为驼峰命名法
// @ts-ignore
function convertToCameCase(obj: any) {
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  if (Array.isArray(obj)) {
    return obj.map((item) => convertToCameCase(item));
  }

  const result = {};
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      const newKey = key.replace(/_([a-z])/g, function (match, letter) {
        return match ? letter.toUpperCase() : "";
      });
      // @ts-ignore
      result[newKey] = convertToCameCase(obj[key]);
    }
  }
  return result;
}
export function useConvertResponse(data: any) {
  if (isArray(data) && data.length > 0) {
    return data.map((row) => convertToCameCase(row));
  } else if (isObject(data)) {
    return convertToCameCase(data);
  }
  return data;
}

(2)给出我这的完整的nuxt.config.ts配置内容

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  app: {
    // PS.注意这里的前端访问的前缀设定要和部署的时候nginx代理的路径一致,否则无法访问。
    baseURL: "/client",
    head: {
      title: "网站标题",
      meta: [
        {
          name: "viewport",
          content: "width=achievement-width, initial-scale=1",
        },
        {
          charset: "utf-8",
        },
      ],
    },
  },
  css: [
    "~/assets/css/style.css",
    "~/assets/css/responsive.css",
    "~/assets/scss/main.scss",
  ],
  devtools: { enabled: true },
  modules: [
    "@nuxtjs/eslint-module",
    "@element-plus/nuxt",
    "nuxt-lodash",
    "@pinia/nuxt",
    "@pinia-plugin-persistedstate/nuxt",
  ],
  eslint: {},
  elementPlus: {},
  piniaPersistedstate: {
    cookieOptions: {
      sameSite: "strict",
      maxAge: 24 * 60 * 60,
      path: "/server",
    },
    storage: "cookies",
  },
  runtimeConfig: {
    public: {
      baseURL:
        process.env.NUXT_SERVER_API_BASE_URL|| "http://127.0.0.1:5100/server/",
    },
  },
  build: {
    transpile: [/echarts/],
  },
});

总结

前端的涉及到的东西太多了,在这儿不能全部覆盖到位。

服务器部署篇

为什么把这个部署篇放在小程序或APP移动端篇之前?因为有些项目并不都是需要移动端应用程序的,并且本篇幅内容比较重要,可能涉及部署环境以及编译、运行各端代码甚至内网穿透等对独立开发者或者初创公司对服务器资源的经济管理等解决方案。

服务器配置

(1)最低配置
CUP:2核
内存:4G
磁盘空间:20G

(2)推荐配置:
CPU:4核
内存:8G
磁盘空间:20G
ps.如果你看到内网穿透的章节以上的云服务器配置你会选择是采用云服务器还是本地服务器。

操作系统安装

如果采用云服务器配置的话这个步骤直接省略,如果是本地服务器安装的话由于我已经成功使用U盘安装好了Ubuntu Server 22,这里的安装步骤就没法截图和演示了,我查阅些网上的安装教程并不完善,主要缺失在磁盘分区的安装步骤,如果有机会或者有必要我会增加这一个章节如何在两块raid磁盘(500G SSD+1T 高性能机械)上安装Ubuntu Server 22。故采用Ubuntu Server 22作为服务器操作系统,如果有人疑问centos7或者8行不行,我肯定的告诉你最好不要centos系列,至少在centos7中很难安装到nodejs18(最高支持到nodejs16),以及python的默认支持只是python2。

软件环境安装

提示:一下的所有命令我将忽略所有的sudo
注意:可能一下的命令有些编码问题赋值到服务器运行失败,因为之前我记录到word文档中,空格编码出现了问题,可以删除空格重新打就可以了

nodejs18
# 拉取nodejs18源
curl -s https://deb.nodesource.com/setup_18.x | sudo bash
# 等待一分钟或者按照提示进行下一步
# 安装
apt install nodejs -y
# 验证
node -v
# v18.17.1
npm -v
# 9.6.7
# 配置npm镜像源
npm config set registry https://registry.npmmirror.com
# 安装yarn
npm install -g yarn
# 配置yarn镜像源
yarn config set registry https://registry.npmmirror.com
# 验证
yarn -v
# 1.22.21
# 安装pm2
npm install -g pm2
# 安装nodejs的环境变量管理模块(编译前端项目的用到)
npm install -g cross-env
python3

由于ubuntu22默认的python版本就是3.10,符合项目的要求,如果有朋友需要安装到3.11或更高就按照我下边的步骤安装吧。(默认3.10版本是可以稳定运行的,不想折腾就直接看下一个章节)

sudo apt update
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.11 -y
python3.11 --version
mysql8
# 安装mysql8
apt install mysql-server
apt install libmysqlclient-dev
# 进入mysql客户端,这时候默认不需要输入密码
mysql -uroot
# 更改root密码
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'Mysql#2023';
# 退出重新登录mysql
mysql -uroot -p
# 此时需要输入上面设置的密码
use mysql;
# 开放root用户远程连接权限
update user set user.Host='%' where user.User='root';
# 刷新权限
flush privileges;
# 退出后编辑mysql的配置文件
vi /etc/mysql/mysql.conf.d/mysqld.conf
# 可以修改很多参数,这里不再一一列出来,主要修改如下bind-address位置,使得可以远程连接mysql
bind-address		= 0.0.0.0
# 退出编辑后重启mysql服务就可以远程连接了
service mysql restart
nginx
apt install nginx
systemctl status nginx

后端python+fastapi程序

先给出pm2的运行配置文件ecosystem.config.cjs
PS.注意pm2虽然有热启动刷新的功能,我不建议在生产环境下使用,生产下还是人为手工控制程序的启动和关闭更加放心。

module.exports = {
    apps: [
        {
            name: 'server', // 应用程序名称可以根据需要更改
            interpreter: "python3", // 指定pm2使用python3来运行
            script: 'main.py', // 运行的脚本名称main.py需要和ecosystem.config.cjs目录同级
            env: {
                PYTHON_ENV: 'prod' // 指定PYTHON_ENV环境变量的值为“生产环境”,这样运行程序的时候就会自动加载config.prod.toml文件作为配置了
            },
        },
    ],
};

# 拉取依赖和镜像
pip3 install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
pip3 install tortoise-orm[asyncpg] -i https://mirrors.aliyun.com/pypi/simple/
pip3 install tortoise-orm[asyncmy] -i https://mirrors.aliyun.com/pypi/simple/
# 启动后端python
pm2 start ecosystem.config.cjs
# 验证
pm2 list
# 将会看到运行的程序的状态、内存、CPU等等使用情况

前端nuxt3编译和运行

依旧先给出pm2的配置文件ecosystem.config.cjs内容:

module.exports = {
  apps: [
    {
      name: 'client', // 应用程序名称可以根据需要更改
      // exec_mode: 'cluster', 可以使用集群的方式运行,由于这里并未使用nuxt3的server功能且项目并发量不大就不再使用集群的方式运行,采用默认的模式
      // instances: '2', // 指定集群节点数量
      script: '.output/server/index.mjs', // 运行的nuxt3的编译后的启动文件
      // 指定前端运行的端口
      port: 3001,
      // 指定日志文件目录,也可以不指定(nuxt3的server功能未使用记录日志的意义不大)
      error_file: '/var/log/laibin_client/error.log',
      out_file: '/var/log/laibin_client/out.log',
      access_file: '/var/log/laibin_client/access.log',
      // 日志保留的大小和天数
      logrotate: {
        max_size: '5M',
        retain: 365,
      },
    },
  ],
};

# 安装依赖
yarn install
# 编译nuxt3,将x.x.x.x更换为后端程序的IP和端口以及nginx代理的前缀(nginx配置看下一个章节)
# 注意这里的build比较吃服务器硬件,如果服务器配置不高最好在本地电脑build,然后上传.nuxt和.output目录的编译后的文件去服务器上运行也是可以的(nodejs同样是跨平台的)。
cross-env NUXT_SERVER_API_BASE_URL=http://x.x.x.x/server/ yarn build
# 运行
pm2 start ecosystem.config.cjs

nginx配置

编辑/etc/nginx/sites-available/default文件

vi /etc/nginx/sites-available/default
# 内容如下:
server {
        listen 80 default_server;
        listen [::]:80 default_server;

        # 如果是启用了https访问那么把下边三行代码注释放开,然后注释掉上边的两行
        # listen 443 ssl default_server;
        # listen [::]:443 ssl default_server;
        # include snippets/snakeoil.conf;
        root /var/www/html;
        index index.html index.htm index.nginx-debian.html;
        server_name _;
        location / {
                try_files $uri $uri/ =404;
        }

        location /server {
                # 后端的端口在config/config.prod.toml中配置
                proxy_pass http://localhost:4001;
        }
        location /client{
                # 前端的端口在pm2的配置文件中配置
                proxy_pass http://localhost:3001;
        }
}

由于小程序必须是要使用域名并且https访问,所以将下载的nginx的证书(各云厂商都有免费的https证书使用)上传到服务器上例如/etc/nginx下

# 编辑/etc/nginx/snippets/snakeoil.conf内容如下
ssl_certificate /etc/nginx/www.xxxxyyy.com.pem;
ssl_certificate_key /etc/nginx/www.xxxxyyy.com.key;

# 然后重启nginx
systemctl restart nginx

如果部署的是云服务器,到这里就结束了。如果希望使用内网穿透的方式部署和访问,那么请看下面的章节

frp内网穿透

frp是什么原理是什么概念我就不说,给出官网地址:https://github.com/fatedier/frp
我比较希望直接说为什么要使用frp这个免费的、高性能的内网穿透或者说什么场景下使用。
场景1、个人开发者或初创公司节约服务器成本
场景2、需要做一些演示demo给客户
场景3、异地网络办公,ssh远程连接到异地服务器等
场景4、其他…
先聊聊场景1,云服务器的费用有高有低取决于硬件配置,但是本地服务器使用较便宜的价格即可购买配置高的服务器。我先显示我的服务器的硬件(128G内存+24核心48线程),这里我就不打广告说具体多少价格了:
在这里插入图片描述
在这里插入图片描述
这样的硬件配置实惠,可以跑好几个项目的程序吧。要想穿透它需要有一个拥有公网IP的云服务器,这个云服务器的配置不需要很高,甚至2核2G足矣,但是带宽越高越高(网速、请求并发等都会提高),新人或者企业都有优惠力度,这里我就不打广告。

接下来给出详细的frp内网穿透到我们的项目的步骤,并且是基于HTTPS的(我查阅网上给出的许多教程并未详细给出HTTPS访问的配置),说白了不做HTTPS既不安全,又没法做小程序甚至APP意义不大。

前提条件:
以阿里云厂商为例子,
(1)购买一台云服务器
(2)购买域名(价格看个人)
(3)申请免费的HTTPS证书
(4)将域名解析到购买的服务器
如果不会的话就打电话请求客服吧。
步骤:
(1)去官网下载最新版的安装包如frp_0.52.3_linux_amd64.tar.gz(我的是ubantu amd架构的服务器)
(2)将安装包分别上传到云服务器和本地服务器
(3)分别解压到目录如/root/frp下
(4)配置云服务器上的nginx如下

vi /etc/nginx/sites-available/default
# 内容如下:
server {
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
        include snippets/snakeoil.conf;
        root /var/www/html;
        index index.html index.htm index.nginx-debian.html;
        server_name _;
        location / {
            # 这里将https请求代理到http://127.0.0.1:8888,然后http://127.0.0.1:8888
            # 自动通过frp代理到本地服务器的http
            proxy_pass http://127.0.0.1:8888;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;
            proxy_set_header X-NginX-Proxy true;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_max_temp_file_size 0;
            proxy_redirect off;
            proxy_read_timeout 240s;
        }
}

server {
        listen 80;
        listen [::]:80;
        # 将所有的http强制转到https去
        return 301 https://$http_host$request_uri;
}
# vi /etc/nginx/snippets/snakeoil.conf内容如下
ssl_certificate /etc/nginx/www.xxxxyyy.com.pem;
ssl_certificate_key /etc/nginx/www.xxxxyyy.com.key;

(5)编辑云服务器上的frps配置并启动

# vi /root/frp/frps.toml内容如下
bindPort = 7000 # frps端口
token = "xxx" # 更改令牌,和本地服务器匹配
webServer.addr = "0.0.0.0"
webServer.port = 7500 # web面板端口
webServer.user = "admin" # 访问web面板用户
webServer.password = "yyy" # 更改为访问web面板密码
vhostHTTPPort = 8888 # 设定frps代理的http应用端口
# 和云nginx的proxy_pass http://127.0.0.1:8888;对应好
# 启动frp服务端
./frps -c ./frps.tom
# 也可以nohup后台运行、也可以注册开机自动启动,这儿就不列出来了

(6)本地服务器上安装nginx,并配置参考上一节的编辑/etc/nginx/sites-available/default文件内容使用本地服务器的nginx去代理本地服务器运行的python后端和nodejs前端
(7)本地服务器上编辑frpc的配置并启动

# vi /root/frp/frpc.toml内容如下
serverAddr = "8.134.x.y" # 这里更改为云服务器的IP
serverPort = 7000 # 这里更改为云服务器的frps程序的端口(注意需要云服务器开启端口安全组)
token = "xxx" # 令牌和云服务器的一致

[[proxies]]
name = "web"  # 名称随便天
type = "http" # 类型http即和frps.toml中的vhostHTTPPort = 8888绑定
localPort = 80 # 本地服务器http代理的nginx的端口
customDomains = ["www.xxxxyyy.top"] # 这里更换为你申请的域名,必填。

# 启动frp客户端
./frpc -c ./frpc.tom
# 也可以nohup后台运行、也可以注册开机自动启动,这儿就不列出来了

最后就可以直接使用https://www.xxxxyyy.top/client 访问到部署在本地服务器的网站了,同理https://www.xxxxyyy.top/server就可以访问到python后端了。以后需要再部署项目B、项目C…,不管是java、python、nodejs都可以直接在本地的服务器配置nginx的配置去代理就可以通过域名穿透访问了(是不是很方便了)。

PS.有朋友问我这种内网穿透的方式能不能应用在生产环境下,我的回答是:可以。
我的解释是:
(1)如果你的项目是博客、论坛、小程序是新闻系统、甚至DEMO演示系统,不需担心本地服务器断电或者网络故障的那点时间的话可以直接跑(目前的城市里很少断电断网了,即使断电断网恢复时间也不会很久对项目不造成什么影响,当然这种方式必须要配置所有程序依赖的东西如数据库、nginx、frp、以及开发的程序做服务器自启动,就不要人工干预维护了)
(2)如果你的项目是商城或其他需要保证24小时不间断(所有的东西没有绝对稳定,只有相对稳定)的情况,那么就组建本地网络吧,就是在一个城市或者多个城市组建2台或以上的本地服务器,然后在本地服务器上装上nginx+keepalived保证高可用(就是说任何一台服务器断电断网都不会影响到程序的运行),这样再去穿透虚拟的keepalived浮动IP即可。
但是这样做,你需要编写后端程序要求更高:支持分布式集群、分布式锁等等,你还需要搭建多台服务器间数据库主从切换或者双机切换。如果有朋友有兴趣可以私信我,我之前整理过高可用的部署方案如下,这里我就不给出来明显的部署步骤了,甚至有兴趣高可用部署的看我的kubernetes那些文章。
在这里插入图片描述
最终的选择依托于您的想法。

uniapp小程序篇

目前uniapp的进化速度我觉得此时仍然在vue2活跃的社区范围,所以我并没有将小程序端的工程代码升级到vue3。由于小程序目前的项目并未要求实现表单录入,对组件的封装较少,但是封装了一个很有用的’上拉刷新、下拉加载’的组件。

源码目录

标准的uniapp-cli项目,eslint代码格式化就不在详细介绍了。
在这里插入图片描述

封装弹出层

// common/toast.js
const time = 2000;
export class Toast {
  static fail(msg = "操作失败") {
    return new Promise((resolve, reject) => {
      if (msg === "操作失败" || msg == "身份认证失败") {
        uni.showToast({
          title: msg,
          icon: "error",
          duration: time,
          success: () => {
            setTimeout(() => {
              resolve();
            }, time);
          },
          fail: (e) => {
            reject(e);
          },
        });
      } else {
        uni.showModal({
          title: "系统提示",
          content: msg,
          showCancel: false, // 不显示取消按钮
          success: (res) => {
            if (res.confirm) {
              resolve();
            }
          },
          fail: (e) => {
            reject(e);
          },
        });
      }
    });
  }

  static success(msg = "操作成功") {
    return new Promise((resolve, reject) => {
      if (msg.length <= 5) {
        uni.showToast({
          title: msg,
          icon: "success",
          duration: time,
          success: () => {
            setTimeout(() => {
              resolve();
            }, time);
          },
          fail: (e) => {
            reject(e);
          },
        });
      } else {
        uni.showModal({
          title: "系统提示",
          content: msg,
          showCancel: false, // 不显示取消按钮
          success: (res) => {
            if (res.confirm) {
              resolve();
            }
          },
          fail: (e) => {
            reject(e);
          },
        });
      }
    });
  }

  static none(msg) {
    return new Promise((resolve, reject) => {
      uni.showToast({
        title: msg,
        icon: "none",
        duration: time,
        success: () => {
          setTimeout(() => {
            resolve();
          }, time);
        },
        fail: (e) => {
          reject(e);
        },
      });
    });
  }

  static loading(msg, time) {
    return new Promise((resolve, reject) => {
      uni.showLoading({
        title: msg || "加载中",
        fail: (error) => {
          reject(error);
        },
      });
      setTimeout(() => {
        uni.hideLoading();
        resolve();
      }, time || 2000);
    });
  }

  static confirm(
    msg,
    confirmText = "确定",
    cancelText = "取消",
    title = "系统提示"
  ) {
    return new Promise((resolve, reject) => {
      uni.showModal({
        title,
        content: msg,
        confirmText,
        cancelText,
        success: (res) => {
          if (res.confirm) {
            resolve();
          } else {
            reject();
          }
        },
        fail: (e) => {
          console.error(e);
        },
      });
    });
  }
}

封装请求和后端交互

// common/request.js
import { Toast } from "@/common/toast";

export default function () {
  // 接口请求拦截器
  uni.addInterceptor("request", {
    invoke(args) {
      args.url = process.env.VUE_APP_BASE_API + args.url;
      args.header = args.header || {};
    },
    complete(res) {
      const { statusCode, data } = res;
      if (statusCode === 200) {
        if (data.status !== 1) {
          Toast.none(data.msg()).then(() => {});
        }
      } else {
        Toast.fail().then(() => {});
      }
    },
  });
}

export function post({ url, data, header, loading }) {
  return new Promise((resolve, reject) => {
    if (loading)
      uni.showLoading({ title: "请稍后", mask: true }).then(() => {});
    uni.request({
      url,
      data,
      header,
      method: "POST",
      success: (res) => {
        if (res.data.status === 1) resolve(res.data.data);
        else reject(res.data.msg);
      },
      fail: (error) => {
        reject(error);
      },
      complete: () => {
        if (loading) uni.hideLoading();
      },
    });
  });
}

export function get({ url, data, header, loading }) {
  return new Promise((resolve, reject) => {
    if (loading)
      uni.showLoading({ title: "请稍后", mask: true }).then(() => {});
    uni.request({
      url,
      data,
      header,
      method: "GET",
      success: (res) => {
        if (res.data.status === 1) resolve(res.data.data);
        else reject(res.data.msg);
      },
      fail: (error) => {
        reject(error);
      },
      complete: () => {
        if (loading) uni.hideLoading();
      },
    });
  });
}

// 需要再入口文件App.vue中启用请求拦截器
<script>
import request from "@/common/request";
export default {
  onLaunch: function () {
    request();
    console.log("App Launch");
  },
  onShow: function () {
    console.log("App Show");
  },
  onHide: function () {
    console.log("App Hide");
  },
};
</script>

<style lang="scss">
@import "@/static/iconfont.css";
@import "@/assets/main.scss";
</style>

如何在页面上使用?

await post({
     url: "api/base/query?model=Student",
     data: {
       limit: 10,
       orderBy: "-update_time",
     },
   })

封装上拉刷新、下拉加载组件

需要结合后端返回的格式封装,我不希望大家拿我的组件代码去插件市场上赚取收益。

<!-- TablePage.vue组件 -->
<script>
import mixin from "@/common/minix";
import { post } from "@/common/request";
import { convertRequest, convertResponse } from "@/common/convert";

export default {
  name: "TablePage",
  mixins: [mixin],
  props: {
    // 外部设置组件内部的高度,不设置这个值的话下拉刷新不能正常使用
    // 通常的,这个值需要计算uni.getSystemInfoSync().windowHeight - 页面其他元素的高度总和
    height: {
      type: Number,
      default: 0,
    },
    // 接口请求地址==>必须是分页类型的接口
    url: {
      type: String,
      default: "",
    },
    // 每一页的数据长度
    length: {
      type: Number,
      default: 10,
    },
    // 组件外控制查询条件
    setQuery: {
      // eslint-disable-next-line vue/require-prop-type-constructor
      type: Function | Object | undefined,
      default: () => null,
    },
    // 组件外控制排序条件
    setSort: {
      // eslint-disable-next-line vue/require-prop-type-constructor
      type: Function | String | undefined,
      default: () => null,
    },
    auto: {
      type: Boolean,
      default: () => true,
    },
  },
  data() {
    return {
      // 设置高度
      scrollHeight: 300,
      // 加载更多状态控制
      more: "more",
      // 当前加载的数据集合
      list: [],
      // 当前数据的开始位置
      start: 1,
      // 自定义下拉刷新
      triggered: false, //下拉刷新是否被触发
      // eslint-disable-next-line vue/no-reserved-keys
      _freshing: false, // 是否正在刷新
    };
  },
  watch: {
    height: {
      handler(val) {
        if (val > 0) {
          this.scrollHeight = val;
        }
      },
      immediate: true,
    },
  },
  mounted() {
    if (this.auto) this.load();
  },
  methods: {
    async load() {
      this.start = 1;
      this.more = "more";
      await this.getList(1);
    },
    async getList(start) {
      // 参数校验
      if (!this.url) return;
      if (this.more === "loading" || this.more === "noMore") return;
      // 准备请求数据前的准备工作
      this.more = "loading";
      let query = null,
        sort = null;
      if (this.setQuery && typeof this.setQuery === "function")
        query = await this.setQuery();
      else if (this.setQuery && typeof this.setQuery === "object")
        query = this.setQuery;
      if (this.setSort && typeof this.setSort === "function")
        sort = await this.setSort();
      else if (this.setSort && typeof this.setSort === "string")
        sort = this.setSort;
      // 发起接口请求
      try {
        let { results } = await post({
          url: this.url,
          data: convertRequest({
            pageSize: this.length,
            page: start,
            query,
            orderBy: sort,
          }),
          loading: false,
        });
        results = convertResponse(results);
        this.list = JSON.parse(
          JSON.stringify(start > 1 ? [...this.list, ...results] : results)
        );
        this.start++;
        // 如果后端没有返回满页的数据,则视为没有更多数据了
        this.more =
          results.length < this.length || this.list.length === 0
            ? "noMore"
            : "more";
        this.$emit("end");
      } catch (e) {
        // 请求数据失败工作
        this.more = "more";
        this.$emit("end");
      }
    },
    scrolltolower() {
      this.getList(this.start);
    },

    onRefresh() {
      if (this._freshing) return;
      this._freshing = true;
      if (!this.triggered) this.triggered = true;
      this.load()
        .then(() => {
          this.triggered = false;
          this._freshing = false;
        })
        .catch(() => {
          this.triggered = false;
          this._freshing = false;
        });
    },
    onRestore() {
      // 需要重置
      this.triggered = "restore";
    },
  },
};
</script>

<template>
  <scroll-view
    :style="{ height: scrollHeight + 'px' }"
    enable-back-to-top="true"
    scroll-y="true"
    refresher-enabled="true"
    :refresher-triggered="triggered"
    class="app-page"
    @scrolltolower="scrolltolower"
    @refresherrefresh="onRefresh"
    @refresherrestore="onRestore"
  >
    <slot :data="list" name="pageList" />
    <uni-load-more :status="more" class="app-page-more" />
  </scroll-view>
</template>

<style scoped lang="scss">
.policy-item {
  margin-bottom: 10px;
  background-color: #fff;
}
</style>

// minix.js 所有页面都引入
import { Toast } from "@/common/toast";

export default {
  data() {
    return {
      pageHeight: 0,
    };
  },
  onReady() {
  	// 取页面高度(从而计算出上拉刷新或下拉加载组件的应有的高度)
  	// 全屏手机的话组件高度应该是pageHeight - 2,过大过小都会影响页面的操作
  	// 如果页面上还有其他元素,自行设定高度然后继续相减即可
    this.pageHeight = uni.getSystemInfoSync().windowHeight;
  },
  methods: {
  },
};

具体使用上拉刷新或下拉加载的页面(我的这个组件适用于局部性上拉刷新或下拉加载)

<template>
  <view class="page-content">
      <view class="padding-20">
      	<!-- height我为什么还要多减20因为我设置了页面内边距 -->
        <table-page
          ref="page"
          url="api/base/page?model=Student"
          :height="pageHeight - 2 - 20"
          :set-sort="'-update_time'"
          @end="pageLoading = false"
        >
          <template #pageList="{ data }">
            <!-- 这个学生组件就是每一项数据需要展示的样子的组件 -->
            <student
              v-for="(row, index) in data"
              :key="index"
              :data="row"
            />
          </template>
        </table-page>
      </view>
  </view>
</template>

<script>
// 只需要引入传参即可
import mixin from "@/common/minix";
import TablePage from "@/components/table-page/index.vue";
import Student from "@/components/student/index.vue";
export default {
  components: { Student , TablePage },
  mixins: [mixin],
  data() {
    return {};
  },
  onLoad() {},
  methods: {},
};
</script>
<style lang="scss" scoped></style>

结束语

编写文章一是为了记录,二是分享给有需要的朋友。
祝大家编码顺利、祝自由工作者更多的变现、祝在职工作者更少的加班、祝创业的老板前程似锦。
PS.开源与否取决于心态和环境。

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wangwj1006

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值