普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月1日首页

Iceberg在图灵落地应用

作者 百度Geek说
2025年6月30日 15:33

导读

百度MEG上一代大数据产品存在平台分散、易用性差等问题,导致开发效率低下、学习成本高,业务需求响应迟缓。为了解决这些问题,百度MEG内部开发了图灵3.0生态系统,包括Turing Data Engine(TDE)计算&存储引擎、Turing Data Studio(TDS)数据开发治理平台和Turing Data Analysis(TDA)可视化BI产品。依托图灵3.0生态,我们引入了数据湖表格式:Apache Iceberg,利用其特性并在多种业务场景下进行优化实践,解决图灵数仓业务实时数据入湖,数据表历史记录更新效率低等多个痛点问题。

01 背景

1.1 图灵3.0生态概述

由于百度MEG上一代大数据产品存在平台多、易用性差及数据流转繁琐等问题。这些问题导致开发人员研发效率低及多平台间高昂的学习成本;业务部门的感知则是需求交付迟缓、数据产出延迟及数据质量低等问题。为了解决上述问题,我们构建了新一代大数据解决方案——"图灵3.0",旨在覆盖数据全生命周期,支持全链路数据操作,提供高效敏捷且统一的强大数据生态系统,其中包括数据计算引擎、数据开发和数据分析三个核心部分:

1. TDE(Turing Data Engine):图灵生态的计算引擎,包含基于Hive、Iceberg进行数据处理的Spark和ClickHouse高性能计算引擎

2. TDS(Turing Data Studio):一站式数据开发治理平台

3. TDA(Turing Data Analysis):新一代可视化BI产品

本文主要介绍数据湖表格式Iceberg在图灵3.0生态下的应用与实践。

1.JPG

△图灵3.0生态产品

1.2 问题

MEG数据中台基于Hive构建了离线数据仓库,已支持手百,搜索,商业,贴吧,小说,用增架构,销售等多个业务需求,但随着业务的发展,业务对数据的实时性以及查询性能等有更高要求,当前主要存在以下几个问题:

1. 商业、电商、销售等业务,周期性地更新行业等信息,单次更新数据量占比小、字段少,但是基于Hive的数据更新(以下简称:数据回溯)只能通过全量覆盖写的方式实现,数据回溯周期长、效率低、成本高。

2. 由于Hive在实时数据更新以及事务支持上存在一定局限性,无法有效满足业务构建实时数仓的需求。

3. 在处理大规模数据集上,Hive的查询性能受到如元数据的加载解析以及每次访问数据都需通过分布式文件系统listFile遍历文件列表等问题的影响,导致性能降低。

基于上述问题,我们通过技术调研,最终引入了开源的数据湖表格式Iceberg,构建数据湖存储服务,并借助大数据生态的Spark、Flink等计算引擎来实现数据湖的分析,将其无缝集成到图灵生态中,帮助业务提效降本,构建更快速、更高效、更低成本的数据中台产品。

1.3 Hive和Iceberg对比

Hive作为一个基于Hadoop生态系统的开源数据仓库工具,主要用于对大规模结构化数据进行存储、查询和分析。而Iceberg作为新一代数据湖表格式,提供了类似传统数据库的事务性,保证和数据一致性,并支持复杂的数据操作,如行级更新和删除等,更加适合实时更新,流批一体数据场景,下表列出Hive和Iceberg一些主要特性对比:

特性

Hive

Iceberg

行级更新

不支持

支持merge into、upsert等语法进行行级别更新能力

时效性

小时级别/天级

分钟级

事务

非完整的ACID事务

支持完整的ACID事务,同时使用多快照提供了读写分离的特性

元数据管理方式

基于Mysql进行元数据存储

通过文件组织管理,直接存储数据文件元数据

数据版本控制

支持时间旅⾏(Time travel)特性,可基于快照进行历史数据版本管理和访问

1.4 Iceberg的组织结构

Iceberg文件组织分为元数据层和数据层,主要包含version-hint,metadata file、snapshot file、manifest file和data file文件类型,具体如下:

  • metadata元数据层

a. version-hint:该文件作为元数据索引初始文件,记录了Iceberg表的版本号,通过版本号找到对应的metadata file。

b. metadata file:记录了Iceberg表的schemas、properties以及快照等信息。

c. snapshot file(manifest-list):每次数据 commit 会生成一个新的快照,保存了该快照下每个manifest file路径及对应的分区范围。

d. manifest file:记录数据文件元信息,包含每个数据文件的路径、文件的大小等一系列统计信息(如文件每列的最大最小值、空值数等),实现元数据和数据文件的关联。

  • data数据层

data file:实际的数据文件,以 parquet 等列存格式存储数据。

2.JPG

△Iceberg表结构

图片

△Iceberg文件组织结构

通过上述Iceberg元数据文件组织结构,Iceberg实现了文件级的元信息统计及版本化管理。

02 Iceberg能力建设与应用

2.1 图灵生态能力适配

2.1.1 统一元数据服务

由于原生iceberg缺少元数据的可视化管理能力,我们通过构建统一的元数据微服务,将Iceberg表和Hive表元数据进行管理,对应用层提供相关表和分区的增删改查等接口,统一数据存储的元数据操作入口。

该微服务主要包含常驻SparkSession模块,EngineMetaService模块和元数据模块,通过将SparkSession常驻,为用户提供Iceberg表和Hive表元数据和分区数据的增删改查功能,以及可视化的元数据管理界面。

图片

△统一元数据服务架构

2.1.2 打通Iceberg和Hive联邦查询

为了兼容历史业务存量Hive表,同时降低用户使用Iceberg的成本。我们在计算引擎层面打通Iceberg和Hive联邦查询能力,并保证了Iceberg表与原有方式语法一致。

通常在一条SQL执行过程中,主要可简化以下Parse、Analyzer、Optimizer、CBO四个流程。通过在Analyzer和Plan阶段进行改进优化,来打通Iceberg和Hive表联邦查询。

  • Analyzer阶段:该阶段主要是将spark未解析的逻辑计划进行解析,我们通过对SparkSessionCatalog加载方式改造,优先加载iceberg表使用的catalog类型,如果用户SQL使用的是Iceberg表,则对应会使用IcebergCatalog和iceberg数据源访问,否则使用SessionCatalog与Hive数据源访问。

  • Optimizer阶段:为加强数据安全管理,我们进一步打通Iceberg表鉴权能力,在基于逻辑计划生成物理计划阶段,解析注入表、字段信息以及表操作类型规则,并与公司内数管平台交互,实现对Iceberg表和字段的鉴权

图片

△Iceberg和Hive联邦查询适配流程

2.2 存量Hive低成本迁移Iceberg

现有数仓业务数据主要存储于Hive表,为支持业务快速切换Iceberg应用新技术,我们建设了存量Hive表低成本迁移至Iceberg表的能力。

以下是在实践过程中的两种迁移方案对比:

方式1:使用Iceberg功能migrate进行原地迁移,通过社区提供的CALL migrate语法,直接执行如下示例的SQL语句,即可将Hive表升级为Iceberg表。

CALL catalog_name.system.migrate('db.sample', map('foo''bar'));

该方案操作简单且可回滚,但这种方式在图灵生态落地过程中也存在一些问题:

该方式会基于原Hive表的数据信息构建Iceberg元数据信息,并将原Hive表名重命名为sample_backup_,同时数据路径也进行重命名。

  • 下游无法读:在执行迁移过程中,原Hive表对应的路径已经被重命名,进而导致下游业务无法正常读取正在迁移中的表。

  • 多表挂载冲突:在业务的使用场景中,存在同一份物理数据被多个Hive表挂载可能,直接修改路径会导致其他表失效。

方式2:基于上述问题,我们进一步对现有方案进行优化,不改变Hive表原有的数据路径,来实现Hive低成本迁移Iceberg,具体流程如下:

  • 构建Iceberg元数据:直接复用Hive的分区数据,新建同名的Iceberg表,并重建Iceberg元数据,最终新Iceberg表的元数据信息实际指向是Hive分区数据存储位置。

  • 数据校验:当Iceberg元数据构建完成后,查询Iceberg表中字段数据,和迁移之前Hive表字段数据,进行一致性校验,验证迁移是否符合预期。

  • 读写切换:数据校验完成后,我们只需要将对应表的属性更新为Iceberg。因为我们已经打通了Iceberg和Hive的查询,且迁移后表名未变,业务可正常使用原有表名及语法进行查询和写入,降低迁移成本。

图片

△Hive迁移Iceberg整体实现流程

2.3 Iceberg在图灵的应用和性能优化

2.3.1 图灵实时数仓应用

在图灵数仓大部分场景中,用户主要依托天级或小时级运行的离线Spark任务来完成数据入仓。在这种模式下,难以满足部分对数据实时性要求较高的需求。

为解决该问题,我们基于Iceberg+Flink构建的图灵实时湖仓架构,整体重构流程如下图所示。该架构模式实现了数据分钟级别实时入仓,显著提升了数据入仓的时效性。进一步扩展了整个图灵的应用场景。

  • 针对数据分析和case排查等场景,业务可基于图灵常驻计算引擎进行实时查询,快速获取所需要的数据支持业务分析决策;

  • 针对策略迭代、特征生产以及机器学习等复杂计算场景,可基于spark例行任务进行加工生产;

  • 针对策略数据调研分析、科学计算等复杂场景通过数据交互计算引擎Jupyter进行数据计算。通过构建图灵实时湖仓架构,既保证了数据分析的时效性又兼顾了复杂计算任务的处理能力,有效提升了业务的数据处理效率和分析决策能力。

图片

△图灵实时湖仓架构演变

2.3.2 行级更新策略

在图灵数仓业务场景下,商业、搜索、电商、销售等业务,周期性地更新行业等信息。而Hive在该场景下支持相对较弱,需通过全量覆盖写方式刷新数据,这种方式在大数据量场景下,回溯数据周期长,消耗资源大,所需要的人力时间成本也高。我们通过利用Iceberg行级更新的特性,基于update、merge into等方式回溯进行字段变更,能够很大程度的提高回溯效率,降低资源和人力成本。

针对数据行级更新,Iceberg提供了两种策略,分别为COW(Copy on Write: 写时复制) 或 MOR (Merge on Read:读时合并),其中MOR根据其标记删除文件的区别又细分了两种方式(Equality Delete File和Position Delete File)。

更新策略

更新后的读取效率

更新时写入效率

适用场景

备注

COW

最快

最慢

读多写少场景

MOR 标记条件删除(Equality Delete File

较快

最快

写入多、读取少场景

读开销:每次读取数据需要额外读取标记删除列数据进行比较。

写开销:只需要存储标记过滤数据的条件,写入成本极低。

MOR 标记位置删除(Position Delete File)

快(依赖更新数据量)

较快

少量数据更新、读取少场景

读开销:加载每个文件需过滤的数据行号。(删除行过多,影响性能)

写开销:需要扫描一遍原数据,找出待删除数据的行号。

关于COW和MOR更新策略的文件表现形式如下图所示,我们针对不同场景采用不同更新策略:

  • 对于日常数据查询分析场景,小时级&天级离线例行生成加工场景,由于查询次数会远多于数据更新次数,可默认采用COW策略;

  • 针对一些业务更新少量字段进行长周期回溯场景,以及实时场景,写入频繁,通过使用MOR策略,来支持用户进行数据回溯变更字段信息,以提升数据更新效率并节省资源。

图片

△COW和MOR两种更新策略对比

图片

△MOR两种删除文件类型&更新字段示例

在业务进行数据回溯应用过程中,我们采用MOR(Position Delete File)进行行级数据更新,通过原Hive回溯和新Iceberg回溯两种方式对比,在一天24小时不同分区上,验证了Hive和Iceberg新旧的回溯效率,如下图所示,业务回溯效率整体可平均提升50%+;进一步地对比单次回溯一年数据消耗的计算资源量对比,平均整体降低70%+的计算资源消耗,整体上极大提升回溯效率,并降低资源成本。

图片

△ Hive 和 Iceberg 回溯效率对比

2.3.3 Iceberg表生命周期管理和性能优化

在Iceberg应用实践的过程中,针对不同业务场景遇到的问题,我们汇总如下:

  • 小文件过多:在实时湖仓业务场景,为了要保证数据的时效性,通常是分钟级别的commit操作,在这种场景下,单个作业执行一天,则需要1440 个 commit,如果执行时间更长,则会产生更多的commit,随着时间的累积,元数据以及数据文件等都会产生大量的小文件,对于整体查询的性能会产生一定的影响。

  • 存储资源增加:如果iceberg表的快照不及时进行清理,可能会造成数据存储增加,导致存储账号资源紧张。

  • 缺乏分区数据统一管理:在一些业务场景,只需要保存一定天数的分区数据,针对无用数据需要进行删除处理。

  • 数据文件组织不均衡且无序:由于表数据写入是随机无序,且针对表数据文件大小会存在不均衡的情况。

针对上述问题,我们通过对Iceberg表进行全生命周期管理,并结合Iceberg特性优化表查询性能,保障整个数据链路的稳定性,整体框架如下图所示:

图片

△Iceberg表生命周期管理和性能优化流程

以上流程主要包含表数据生命周期管理和表性能优化两部分。

一方面,对于表数据生命周期管理,我们通过在线服务执行定时任务,来实现对表数据和元数据进行全生命周期监控,具体如下:

  • 数据分区过期:基于用户配置的表生命周期,进行分区数据删除,保证数据文件按期清理。

  • 元数据快照清理:为用户提供按照时间维度天级别和按照个数维度小时级别两种快照过期策略,精细化元数据快照过期处理,实现存储资源的高效利用。

  • 元数据孤儿文件清理:通过天级例行任务来触发清理由于计算引擎执行任务失败等情况产生的一些没有被引用的孤儿文件,避免元数据累积影响性能。

另一方面,在表性能优化方面,我们结合图灵数仓表使用情况,并基于Iceberg原生特性,为用户在平台侧提供Iceberg表优化算子(如下图示例),主要包含以下两种能力:

  • 小文件合并:通过制定合并文件大小,对表数据文件进行重写合并,避免产生大量小文件。

  • z-order排序优化:实现对表相关字段进行重排序,提升查询性能。

图片

△Iceberg表优化算子任务创建示例

我们通过对Iceberg表整体的生命周期管理,实现了数据和元数据的统一治理,表元数据小文件数万个降低到数百级别,合理控制了元数据产生的数量,并解决了数据频繁回溯场景下存储快速增加的问题。而在表查询优化方面,通过在一些表的数据重分布和字段重排序应用,在部分业务表查询性能提速50%。

03 未来规划

Iceberg作为图灵3.0生态中的重要组成部分,基于其高时效性、行级更新能力、小文件合并以及Z-order等成体系的数据优化的技术解决方案,为MEG数据中台业务提供构建湖仓一体,解决数据回溯等痛点问题的能力。目前Iceberg的应用已覆盖搜索,商业,销售,用增架构等多个业务线,通过低成本助力业务将存量Hive迁移Iceberg表,为业务提供高性能数据查询,同时实现对业务的降本增效。此外,我们也在不断完善Iceberg数据存储引擎的各项能力,包含表数据智能治理、查询优化、智能索引以及特定场景的性能问题等,并不断扩大Iceberg的业务覆盖范围。

昨天以前首页

离职 20 天,我的新系统终于研发完成上线

2025年6月30日 07:23

大家好,我是兔兔,今天分享一篇关于兔兔答题新系统的安装部署教程。我们的系统也升级到 1.1.0 的版本。

1、升级了答题的 UI 界面,支持顺序练习、章节练习等多种练习界面左右切换试题、答题卡功能,新增题库激活码功能,同时优化了大量试题,页面渲染卡顿问题,从实际的测试情况,2000道试题,页面的渲染也不会出现卡顿问题。

2、同时新增了微信支付和余额支付两种模式。新增用户钱包功能,以及钱包余额的充值。用户下单时可以选择钱包支付。

3、完善订单模块,题库模块和资源模块的付费支付功能。管理端支持订单查看、订单退款、退款记录等功能。

4、新增系统配置,将用户端的固定的内容,可以实现管理端配置,实现实时动态显示。

关于更多的功能,可以访问 www.tutudati.com/doc/8.html 进行阅读。

服务器配置

本章适合私有化部署或者源码购买的用户了解,关于兔兔答题系统对服务器的要求,要遵循入下的版本即可:

1、PHP的版本必须在8.0.x,至于更高的版本,例如8.4x版本以及更高,系统还未完全测试过,生产环境不建议直接使用。

2、MySQL的版本必须是>= 5.7.x的版本,低于5.7.0的版本100%是无法使用的。

3、Redis的版本没有过多的要求,不过推荐使用7.0以及更高的版本。如何在后端代码框架(ThinkPHP)中缓存的配置采用的默认驱动,就不需要安装 Redis 服务。兔兔答题默认采用的是文件存储,如果没有特别的配置,可以不用安装 Redis 服务。

4、管理端使用的Vue3的版本开发,同时使用了Element Plus的版本,所以需要使用Node.js的版本推荐是>= 16.x的版本。同时推荐生产环境,编译打包成静态文件运行。

上述为兔兔答题系统系统部署所需软件环境。要满足这样的环境,服务器的最低配置在 2 核 2G 的配置。

前期准备

1、本系统采用域名授权的方式进行商业售卖,因此你在购买系统之后,请提供授权域名。授权域名需要提供两个。假设你注册的域名是 dati.com 的域名,则你需要提供一个 admin.dati.com 的域名和一个 cx4vmw1d.dati.com 的域名。

2、注册并且备案好你的微信小程序,并且生成好 APPID 和 APPSECRET。

3、购买一台配置不低于 2 核 2G 的服务器,系统的版本自己根据需要来安装,并且安装好宝塔。

安装说明

本教程为兔兔答题 saas 系统的环境搭建与系统安装教程,请仔细阅读本教程中的内容。避免折腾许久,也无法系统搭建成功,如果你不会技术相关的知识,请联系我们协助搭建。

[!CAUTION] 如果你不会技术,或者是对 PHP 部署不熟悉,请联系我们协助部署。不要个人在独自折腾,自己解决不掉,最后说系统有问题。如果仍选择自己部署的,请仔细阅读文档,不要文档都没看清楚,就吐槽一堆,在大量实践证明中,很多人连文档都不看,或者看一部分就自己去操作,最后出现问题,还怪系统的问题。本文档采用宝塔的方式进行部署。

安装流程

安装的流程,请严格按照下面的几点进行部署,不要随机的顺序去安装,除非你对 PHP 很熟悉。

1、安装PHP8.3,并且安装Redis、fileinfo扩展,如果涉及到函数禁用,请开启这几个相关的函数(redis、fileinfo, eval, mysqli,一般来说在使用一些集成方式安装PHP这些扩展都会带有)。

2、安装 MySQL5.7 服务+ Redis7.x 服务,这两种数据库服务。

3、安装 NGINX 服务,对于NGINX没有版本要求,直接安装即可。

4、安装Node.js,这里推荐安装18.x版本,因为一些依赖库需要使用到。如果你的服务器不支持安装其他的版本,并且服务器当前安装的版本不满足兔兔答题系统的要求,可以选择在其他设备上安装,编译好管理端的包之后,在上传到服务器上即可。

安装完成之后,根据如下截图,去核实一下,自己的环境是否安装正确。由于商户端,我采用的是本地电脑编译,然后上传到 server 目录中,因此服务器就没有安装 Node.js。这里根据自己的需要来。

兔兔答题安装环境截图

扩展配置截图

[!CAUTION] 默认兔兔答题没有使用 Redis 作为缓存系统,所以截图中没有安装 Redis 扩展,如果你采用了 Redis 作为缓存系统,则一定要把截图中的扩展安装上。

程序文件

要实现完成的安装过程,必须知道系统的源代码目录分别是哪些。

platform: 总平台端的前端文件
server: 商户端、总平台和用户端的后端 API 接口服务文件
tenant: 商户端的前端文件
user_client_003: 用户端源代码

用户端源代码的目录并不一定是user_client1,实际的目录都是以user_client开头的目录。

[!CAUTION] 默认的情况,我们在发送程序源代码时,会根据你提供的授权域名,将商户端和总平台端编译好,并自动添加到 server/public 目录下面,因此你只需要关注 server 目录的部署即可,也就是下文中提到的 API 部署章节。如果你是完整的前后端部署,需要关注下文中的商户端和总平台端章节。

API部署

1、将 server 目录上传到服务器运行目录,宝塔的目录是/www/wwwroot,只要放到该目录下即可,如果你需要在添加一层目录也是可以的,只要保证在/www/wwwroot/xxx/server/public目录下的就可以了。

2、导入数据库,在宝塔的数据库管理里面创建一个数据库,然后导入tcloud_prod.sql文件。

3、配置系统环境变量,找到 server 目录,将该文件夹下的.example.env复制一份,并命名位.env,根据下面的提示进行配置,其他没有说明的就不要修改。

APP_DEBUG=true

[APP]
DEFAULT_TIMEZONE=Asia/Shanghai

[DATABASE]
TYPE=mysql
# 数据库的 IP 地址
HOSTNAME=
# 数据库的名称,也就是第 2 点中创建的数据库名
DATABASE=
# 数据库用户名称
USERNAME=
# 数据库用户密码
PASSWORD=
HOSTPORT=3306
CHARSET=utf8mb4
DEBUG=true
PREFIX=t_

[LANG]
default_lang=zh-cn

[PROJECT]
# 这里的 tutu 不要改,固定为该内容。
UNIQUE_IDENTIFICATION=tutu
DEFAULT_PASSWORD=123456

DEMO_ENV=false

4、添加域名配置,按照下图配置。

域名配置截图

5、安装目录设置,在执行完第4步后,进入到server/public目录下面,你会发现多了一个.user.ini的文件,打开该文件,在文件内容的最前面添加一个#,起到注释作用,也可以将该文件删除。不推荐删除该文件。

6、在配置完域名之后,还需要对 Nginx 文件做指定的配置。在宝塔的站点菜单,找到你刚才添加的域名,右侧有一个设置按钮,点击设置按钮。 nginx 配置

nginx 配置

nginx 配置

nginx 配置

6、设置文件目录权限,在 server 下面应该有个runtime目录,如果没有该目录,就创建一个 runtime 的目录。然后选中该目录右键权限选项,按照如下设置权限。 目录文件授权

兔兔答题

7、操作完上面的步骤,此时系统就算搭建完成了。可以通过访问 https://cx4vmw1d.dati.com/admin, 就可以登录到商户端,商户端的默认账号是shuati,123456。

[!CAUTION] 切记在登录成功之后,修改密码,建议设置复杂的密码。如果没有修改密码,带来的损失,与兔兔答题无关,自行承担损失。

平台端部署

在拿到源代码之后,将 platform 和 server 两个文件夹放在相同目录的位置,也不要去改这两个文件目录的名称。

1、安装 Node.js 依赖包,在 platform 目录下面执行npm install --legacy-peer-deps命令,前提是你已经安装好 Node.js 环境。

2、设置后端域名,在 platform 目录新建一个.env.production文件(没有则新建),按照如下的内容填写文件。

NODE_ENV = 'production'
# Base API
VITE_APP_BASE_URL='https://admin.dati.com/'

3、编译打包文件,一定是在 platform 目录下执行该命令。

npm run build

4、待命令完成之后,出现如下的内容,则表示编译成功,然后就不用管理。

✓ built in 49.99s                                                                                                                                 22:34:58
文件正在复制 ==> ../server/public/admin
文件已复制 ==> ../server/public/admin

商户端部署

商户端的文件夹是 tenant,商户端部署和平台端的部署方式步骤完全一样,只是在.env.production文件中的域名配置,要改成https://cx4vmw1d.dati.com/。当执行完 npm install --legacy-peer-deps 出现如下的内容,则表示编译成功。

✓ built in 49.99s                                                                                                                                 22:34:58
文件正在复制 ==> ../server/public/tenant
文件已复制 ==> ../server/public/tenant

[!CAUTION] 在平台端和商户端编译完成之后,要将程序部署到服务器,直接参考 API 部署章节就可以了。记住,平台端和商户端的编译是在本地电脑执行的。如果你是要在服务器上执行,只需要按照上面的操作步骤执行对应的配置和编译命令就可以了。

小程序部署

小程序端的目录是user_client_003,下面的所有操作都是在该目录下执行。

1、执行npm i 来安装依赖包。

2、第二步找到 uitils 下的 request.js,替换后端api域名。讲 baseUrl 的值配置成 https://cx4vmw1d.dati.com/

3、修改manifest.json配置信息,根据里面的下面的中文标注修改,其他的参数可以采用默认的配置,也可以根据自己的需要进行配置。

{
    "name" : "yuanshuati",
    "appid" : "DCloud开发者中心的应用appid",
    "description" : "yunshuati",
    "versionName" : "1.0.0",
    "versionCode" : "100",
    "transformPx" : false,
    /* 小程序特有相关 */
    "mp-weixin" : {
        "appid" : "微信小程序appid",
        "setting" : {
            "urlCheck" : true,
            "minified" : true,
            "postcss" : true,
            "es6" : true
        },
        "resizable" : false,
        "usingComponents" : true,
        "LazyCodeLoading" : true,
        "__usePrivacyCheck__" : true
    },
}

上述配置,可以通过 uniapp 官方的编辑器来执行,uniapp开发者工具下载地址。其次需要下载并安装微信开发者工具,请自行下载并安装,微信开发者工具下载地址

安装完之后,使用uniapp编辑器,按照下图编译运行即可。 uniapp编辑器编译

常见问题

1、如何注册 DCloud 应用?

首先登录该网址 Dcloud 开发者中心,没有账号的自己根据界面操作注册一个账号。

DCloud开发者账号注册

DCloud开发者账号注册

DCloud开发者账号注册

最后将 Appid 复制下来,填写到小程序端部署中第 3 点中的DCloud开发者中心的应用appid对应配置位置。

基于uniapp+nodejs实现小程序登录功能

2025年6月29日 19:20

本系列教程,以【中二少年工具箱】小程序为案例demo,具体效果请在微信中搜索该小程序查看。或在微信输入框输入 【#小程序://中二少年工具箱/6buitXgPnjHV21r】


一、概述

1.1 技术选型:

小程序端:uniapp

后端:nodejs+midwayjs+typeorm

数据库:mysql

1.2 登录功能实现方案:

1.小程序端调用接口uni.login获取随机code

2.将随机code传递给后端接口,后端接口中调用小程序官方api,获取用户信息

3.后端将用户信息保存到数据库中用户信息表,并将保存结果返回给前端

4.前端缓存用户信息,并显示

二、小程序端实现

代码实现:

 function getUserInfoByWx() {
        isLoad.value = true

        uni.login({
            provider: 'weixin', //使用微信登录
            success: function (loginRes) {
                const userData = {
                    code: loginRes.code
                }
                // console.log('userData',userData);
                getUserInfoByWxApi(userData).then(res => {
                    console.log(res)
                    if (res.success) {
                        userInfoStore.setUserInfo({
                            userName: res.data.userName,
                            openidWx: res.data.openidWx
                        })
                    } else {
                        openMessage({
                            text: '自动创建用户出错,请点击登录手动创建'
                        })
                    }
                }).catch(err => {
                    console.log('eeeeeeeeeeeeeee', err)
                    openMessage({
                        text: '登录失败,请联系开发者'
                    })
                })
                    .finally(() => {
                        // debugger
                        isLoad.value = false
uni.$emit('loginFinish');
                    })
            },
            fail() {
                isLoad.value = false
            }
        });
    }

代码解释:

1.isLoad:前端是否显示正在登录的动画。

2.uni.login:uniapp提供的登录api,可以生成各平台的临时code。

3.getUserInfoByWxApi:调用后端接口,将临时code作为参数传递给后端,后端再调用官方接口完成登录。

4.userInfoStore.setUserInfo登录成功后,在全局状态管理中保存用户信息

上面的代码,大部分都是和前端登录相关的业务代码,真正核心的是生成了临时code并传递给后端,因为调用官方接口只能在后端代码中运行。

三、后端实现

后端代码实现可分为两步,一是调用官方接口,获取小程序官方返回的用户信息;二是根据业务需求,将用户信息保存到我们的数据库中。

controller层代码实现;

  @Post('/getUserInfoByWx')
  async getUserInfoByWx(@Body() userData: { code: string }) {
    const openidRs = await this.loginService.getOpenidWx(userData)
    const openidKey = 'openidWx'
    const rs = await this.loginService.getUserInfoByPlat(openidRs, openidKey)
    return rs
  }

上面代码的code就是小程序端传入的临时code,主要用于getOpenidWx方法中,获取调用官方接口后的返回结果。

3.1 调用官方接口

上面代码中的getOpenidWx方法即是调用官方接口:

 const openidRs = await this.loginService.getOpenidWx(userData)

具体的service实现:

  /*根据临时code,获取wx返回的登录信息*/
  async getOpenidWx(userData:{code:string} | any): Promise<any> {
    const url = 'https://api.weixin.qq.com/sns/jscode2session';
    const data = {
      appid: 'wx9cxxxxxxxxxxxxx',
      secret: '66bxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      js_code: userData.code,
      grant_type: 'authorization_code'
    }
    const result = await this.httpService.get(url, {params: data})
    return result
  }

官方的文档如下:

在这里插入图片描述 结合文档和我写的接口示例,我为大家总结了关键点:

1.接口通过GET方式访问

2.参数包括appid、secret、js_code、grant_type都是通过url参数的方式传递。

3.appid+secret:开发者的身份认证,通过开发管理平台获取:

在这里插入图片描述

4.js_code:小程序端传递来的临时code

5.grant_type:照官网写,别问为什么。

返回结果中我们需要重点关注的参数是openid,这是每一个用户的唯一标识。

3.2 保存用户信息到数据库

上面controller层一共调用了两个方法,一个是上面的调用官方接口,另一个就是保存用户信息到数据库并返回用户信息:

  const rs = await this.loginService.getUserInfoByPlat(openidRs, openidKey)

上面是兼容各平台的写法,我们可以忽略openidKey参数,只以微信小程序为例,具体的service实现为:

/**
   * 根据各平台的openid获取用户信息,可用于首次登录时,自动注册*/
  async getUserInfoByPlat(openidRs,openidKey:string){
    // debugger
    // 通过三方平台的
    let rs:any={}
    let findWxUserRs = new WxUser()
    if (openidRs.status == 200 && (openidRs.data.openid || openidRs.data.data.openid)) {
      const userInfo={}
      userInfo[openidKey]=openidRs.data.openid || openidRs.data.data.openid
      findWxUserRs = await this.wxUserService.getWxUserByUserInfo(userInfo) || new WxUser()
    }

    if (findWxUserRs && findWxUserRs.id) {
      //用户已注册,获取用户信息
      if(!findWxUserRs.userExtraEntity){
        // 兼容旧数据,若没有extra信息,则创建
        rs = await this.wxUserService.saveWxUser(findWxUserRs)
      }else{
        rs=findWxUserRs
      }
    } else {
      //用户未注册,则保存并登录
      // /*TODO:tt和wxopenid的层级不同,需要改造*/
      findWxUserRs[openidKey] = openidRs.data.openid || openidRs.data.data.openid
      rs = await this.wxUserService.saveWxUser(findWxUserRs)
    }
    return rs
  }

代码解释:

1.openidRs是调用官方接口的方法返回的用户信息,如果它的status为200,并且openid有值,则说明调用官方接口成功。判断里之所以有两种层级,可能是因为某个平台的返回结果比较奇葩,代码过于久远,我也记不清了。

2.findWxUserRs是以openid作为筛选条件,筛选数据库的用户表,第一版代码不用像我写这么麻烦,我的openid可能分为openidWX,openidTT等等。第一次做这个功能,就在用户表里增加字段openid即可,然后根据这个字段筛选用户表。

3.如果以openid为筛选条件查到了用户信息,说明用户已注册,返回查询到的数据库中的用户信息。不用关心我的userExtraEntity对象判断,我的用户表结构发生过变化,为了兼容旧数据,这里做了判断。

4.如果以openid为筛选条件未查询到信息,则说明用户未注册,应该主动注册用户。注册用户的逻辑就因人而异了,我生成了随机的用户名称和用户id,再加上openid字段,保存了基本的用户信息。

5.保存成功后,返回用户信息。

我们要理清楚一个概念:用户信息。

通过官方接口获取的用户信息,是小程序官方提供返回结果,现阶段对我们最重要的是openid。

通过我们自己业务代码获取的用户信息,是返回数据库中的用户表信息。现阶段最重要的是用户名(userName)+id(表id,在我们业务中的唯一标识)+openid(用户在某小程序中的唯一标识)。

为什么不能用openid代替id成为我们业务表中的唯一标识,因为以后有可能还会集成其他小程序平台,openid在某小程序的各个场景中是唯一标识,但对于我们系统而言,它只是一个业务字段。

三、后端返回结果,前端显示

前端调用接口最终返回的结果,是保存在数据表中的用户信息:用户名+id+openid。

在上面前端代码中,成功调用接口后,主要做了两个操作:

      userInfoStore.setUserInfo({
                            userName: res.data.userName,
                            openidWx: res.data.openidWx
                        })

...省略代码
uni.$emit('loginFinish');

代码解释:

1.userInfoStore.setUserInfo:是维护全局状态管理中的用户信息,并且使用pinia做成响应式,只要改变,小程序端的页面就会显示对应用户名。

在这里插入图片描述

2.uni.$emit('loginFinish'):是一个事件通知机制。当登录模块所有操作完成,再触发loginFinish。其他组件中需要等待登录操作完成后才能执行的代码,需要严格控制执行顺序的代码,就在合适的位置插入uni.$on()监听。


总结

博主的大部分demo示例都会放到:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.uni.login获取登录随机code,传递给后端接口。

2.后端代码中,调用官方接口获取平台返回的openid

3.将openid更新到数据库的用户信息表,如果没有该用户,则创建。

4.后端接口返回用户信息,在前端显示并执行后续操作

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~

Java爬虫实战指南:按关键字搜索京东商品

作者 onejason
2025年6月29日 17:11

在电商领域,京东作为国内知名的电商平台,提供了丰富的商品信息。通过 Java 爬虫技术,我们可以高效地按关键字搜索京东商品,并获取其详细信息。这些信息对于市场分析、选品上架、库存管理和价格策略制定等方面具有重要价值。本文将详细介绍如何利用 Java 爬虫按关键字搜索京东商品,并提供完整的代码示例。

一、准备工作

(一)注册京东开放平台账号

要使用京东商品详情 API,首先需要在京东开放平台注册账号,并创建应用以获取 App KeyApp Secret。这些是调用 API 所必需的凭证。

(二)安装必要的 Java 库

确保你的项目中已经添加了以下依赖库:

  • Jsoup:用于解析 HTML 文档。
  • Apache HttpClient:用于发送 HTTP 请求。

可以通过 Maven 来管理这些依赖。以下是 Maven 的依赖配置示例:

xml

<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.10.2</version>
</dependency>

二、编写爬虫代码

(一)发送 HTTP 请求并解析 HTML

使用 Jsoup 库发送 HTTP 请求,获取商品详情页的 HTML 内容。然后使用 Jsoup 解析 HTML,提取商品详情数据。

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

public class JDProductCrawler {
    public static Document getHtml(String url) {
        try {
            return Jsoup.connect(url)
                    .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3")
                    .get();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

(二)解析搜索结果

解析搜索结果页面,提取商品标题、价格和链接。

import java.util.ArrayList;
import java.util.List;

public class JDProductParser {
    public static List<Product> parseHtml(Document doc) {
        List<Product> products = new ArrayList<>();
        Elements items = doc.select("li.gl-item");
        for (Element item : items) {
            String title = item.select("div.p-name").text();
            String price = item.select("div.p-price").text();
            String link = item.select("a").attr("href");
            products.add(new Product(title, link, price, ""));
        }
        return products;
    }
}

(三)按关键字搜索商品

根据关键字构建搜索 URL,并获取多页搜索结果的 HTML 内容。

import java.io.IOException;
import java.util.List;

public class JDProductSearch {
    public static List<Product> searchProducts(String keyword, int maxPages) {
        List<Product> allProducts = new ArrayList<>();
        String baseUrl = "https://search.jd.com/Search?keyword=" + keyword + "&enc=utf-8&wq=" + keyword;

        for (int page = 1; page <= maxPages; page++) {
            String url = baseUrl + "&page=" + page;
            try {
                Document doc = JDProductCrawler.getHtml(url);
                List<Product> products = JDProductParser.parseHtml(doc);
                allProducts.addAll(products);
                Thread.sleep(2000); // 避免请求过于频繁
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }

        return allProducts;
    }
}

(四)整合代码

将上述功能整合到主程序中,实现完整的爬虫程序。

import java.util.List;

public class Main {
    public static void main(String[] args) {
        String keyword = "耳机";
        int maxPages = 3;

        List<Product> products = JDProductSearch.searchProducts(keyword, maxPages);
        for (Product product : products) {
            System.out.println(product);
        }
    }
}

(五)Product 类

定义一个简单的 Product 类来存储商品信息。

public class Product {
    private String title;
    private String img;
    private String price;
    private String shop;

    public Product(String title, String img, String price, String shop) {
        this.title = title;
        this.img = img;
        this.price = price;
        this.shop = shop;
    }

    @Override
    public String toString() {
        return "Product{" +
                "title='" + title + ''' +
                ", img='" + img + ''' +
                ", price='" + price + ''' +
                ", shop='" + shop + ''' +
                '}';
    }
}

三、注意事项与优化建议

(一)遵守法律法规

在进行爬虫操作时,必须严格遵守相关法律法规,尊重网站的 robots.txt 文件规定。

(二)合理设置请求频率

避免过高的请求频率导致对方服务器压力过大,甚至被封禁 IP。

(三)应对反爬机制

京东等大型电商平台通常有较为复杂的反爬虫机制,可能需要使用更高级的技术,如代理 IP、模拟浏览器等。

(四)数据存储与分析

获取到的商品信息需要妥善存储和分析。可以将数据存储在数据库中,如 MySQL、MongoDB 等,方便后续的数据查询和分析。

四、总结

通过上述步骤和代码示例,你可以轻松地使用 Java 爬虫按关键字搜索京东商品,并获取其详细信息。希望这个教程对你有所帮助!如果你对爬虫开发有更多兴趣,可以尝试探索更复杂的功能,如多线程爬取、数据可视化等。

vue3+node后台管理系统实战——1.利用小程序实现web登录

2025年6月29日 12:04

有任何问题,都可以私信博主,共同探讨学习。

项目示例地址:中二少年学编程的示例项目

本项目适合熟悉vue3、nodejs基础,并希望了解实战应用的同学;适合想要学习web全栈开发的同学;适合大学生作业、毕业设计参考,有任何问题,请随时联系博主。


一、技术选型

前端:vue3+viewui 后端:nodejs+midwayjs+typeorm 数据库:mysql

二、设计方案

大部分网站的登录模块包含两个功能:注册和登录。

注册主要目的是在系统中申请一个账户和密码,作为后续登录的凭证。但是出于防止恶意注册、便于找回密码、防脚本批量注册等多方面考虑,大部分注册行为还会考虑邮箱验证、手机号验证等功能。

如果同学们开发的网站是面向普通用户(to C),并且有巨大市场潜力的项目,那么可以按照常规注册模式开发项目。

但是很多后台管理系统其实面向的是特定的企业用户(to B),并不需要开发注册功能,只需要超级管理员创建并管理用户即可。

我开发的demo网站,属于后台管理系统,是解决面向企业用户的场景,但是我做出这个demo又是为了让所有同学都可以体验参考。所以我为demo网站设计了两种登录模式:一是便捷的注册登录功能;二是通过超管创建用户后,常规的登录功能。

注册功能想要便捷,就不能沿用手机号验证那套规则,那样可能会吓跑大部分嫌麻烦的同学,利用微信登录又需要每年支付300元认证费用,所以我就设计实现了利用微信扫码跳转小程序,通过小程序验证后,实现登录的功能。

这套方案非常适合想要低成本、便捷地通过微信实现扫码登录的中小企业、学生、个人开发者等群体。

至于登录的逻辑就很简单了,不管利用哪种方式实现,本质都是将前端采集的用户和密码发送到后端,后端与数据库中保存的用户信息匹配,如果匹配成功,则登录成功,如果匹配失败,则返回登录失败。

三、小程序扫码登录

小程序的实现方式略微复杂,而且这是博主自己思考的方案,网上应该是没有太多参考资料。如果仅仅是学习基础的登录技术原理,来应付企业管理系统开发,并不需要学习本章节。

3.1 后端调用小程序官方api生成小程序码

后端调用小程序的官方api生成带参数的小程序码,参数的key值我们设定为cId,后面要用。生成小程序码,可以指定扫描后进入的页面,我们设置为扫码进入“我的”页面。

在这里插入图片描述

代码实现:

 /**
   * 根据token获取微信小程序码
   * @Param access_token - 微信token
   * @Param env_version - 环境版本:develop,release
   * */
  async getWXACodeUnlimited(access_token: string, env_version: string) {
    const url = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
    const cId = nanoid()
    const data = {
      scene: 'cId=' + cId,
      page: 'pages/about/about',
      env_version
    }
    const params = {
      access_token
    }
    const result = await this.httpService.request({
      url,
      method: 'POST',
      responseType: 'arraybuffer', // 指定响应类型为二进制数据
      data,
      params
    })
    return {
      imgData: result.data,
      cId
    }
  }

代码讲解:

1.api.weixin.qq.com/wxa/getwxac…

2.const cId = nanoid():使用nanoid插件生成随机的参数

3.await this.httpService.request:使用内置的服务调用接口,同学们不论使用什么工具都是可以的。

3.2 前端请求接口,获取小程序码

3.1章节返回的是小程序码的图片数据和cId参数,前端请求对应的后端接口,获取小程序码图片并显示。

在这里插入图片描述转存失败,建议直接上传图片文件

代码实现:

  /**
     * 获取小程序码
     * 返回值-小程序码参数*/
    const miniAppSceneCodeUrl = ref('') //带参数的二维码
    async function getWXACodeUnlimited() {
        miniAppSceneCodeUrl.value = ''
        let imgRs = {}
        try {
            imgRs = await getWXACodeUnlimitedApi({
                env_version: 'release',
            });
        } catch (e) {
            imgRs = {
                success: false
            }
        }
        // 将 Buffer 数据转换为图片 URL
        // 1. 将数组转换为 Uint8Array
        const uint8Array = new Uint8Array(imgRs.data.imgData.data);

        // 2. 将 Uint8Array 转换为 Blob
        const blob = new Blob([uint8Array], {type: 'image/png'}); // 根据实际图片类型设置 MIME 类型

        // 3. 生成图片 URL
        miniAppSceneCodeUrl.value = URL.createObjectURL(blob);
        return imgRs.data.cId
    }

对应的html代码:

           <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
            </div>

代码解释:

1.getWXACodeUnlimitedApi:调用后端接口,获取图片信息和cId参数值。

2.后面操作buffer的三行代码可以省略,我的后端返回的图片数据是buffer二进制,如果同学们直接返回base64数据,会更简单,直接在前端src中赋值即可显示。之所以返回buffer,是因为我的服务器带宽很差,只能尽量压缩前后端交互数据的大小。

3.miniAppSceneCodeUrl:小程序码的url,获取后直接在前端html中显示。

3.3 小程序监听扫码登录行为

用户扫描小程序码时,会进入小程序并直接跳转“我的”页面,小程序的“我的”页面监听当页面渲染后,是否携带了参数cId。

如果小程序监听用户跳转行为携带了cId,说明用户在触发扫码登录行为,则保存cId到用户信息,如果没有携带cId,属于普通跳转行为,不做任何操作。

代码示例:

onLoad(async (option) => {
if (option.scene) {
const scene = decodeURIComponent(option.scene)
const params = parseScene(scene); // 解析为键值对
saveCId(params.cId)
}

})

代码解释:

1.onLoad:小程序端是用uniapp开发的,onLoad是页面加载的生命周期。

2.option.scene :监听页面加载后,页面携带的参数。

3.saveCId:保存cId到数据库中的用户信息表中。这部分代码涉及小程序部分功能,略微复杂,涉及很多复杂的判断。比如如果用户第一次登录小程序,还需要先等待小程序创建新用户后,再保存cId到数据库中的用户信息。这些属于小程序的功能开发了,不在本次登录功能的介绍中,所以不再赘述。同学们如果想要实现类似效果,只要能做到在这一步将cId保存到数据库中用户信息即可。

3.4 以cId为筛选条件轮询用户信息

前端在3.2章节中获取小程序码时,getWXACodeUnlimited方法同时返回了cId,详见3.2章节中的代码。

获取到cId后,前端即可以cId为筛选条件,对用户信息表执行轮询操作。因为每一个cId都是随机生成的,当发现用户信息表中出现符合的数据时,说明用户已经扫码登录成功,前端页面就可以放行,显示登录成功了。

代码实现:

    const showRefreshCode = ref(false)
 /**
     *根据小程序码参数轮询用户信息 */
    function getUserInfoBySceneCode(sceneCode) {
        if (!sceneCode) return
        let getApiCount = 0 //当前轮询次数
        let maxApiCount = 3  //最大的轮询次数
        let getApiSuccess = false //轮询是否成功

        getUserInfoInterval.value = setInterval(async () => {
            if (getApiCount >= maxApiCount) {
                // 超过10次,停止轮询
                clearInterval(getUserInfoInterval.value);
                showRefreshCode.value = true
                if (getApiCount === maxApiCount && !getApiSuccess) {
                    // 十次轮询都未成功,显示错误信息
                    console.error('十次轮询均失败:', getApiSuccess);
                    Message.error('扫码登录失败,请重试');
                    showRefreshCode.value = true;
                }
            }
            getApiCount++
            try {
                const userInfoRs = await getUserInfoBySceneCodeApi({
                    sceneCode: sceneCode
                })
                if (userInfoRs.success) {
                    getApiSuccess = true
                    Message.success('扫码登录成功')
                    // 登录成功后,维护全局变量
                    await handleLogIn(userInfoRs)
                    router.push({name: 'home'})
                    // 成功后停止轮询
                    clearInterval(getUserInfoInterval.value);
                }
            } catch (error) {
                console.error('根据小程序码参数获取用户信息失败:', error);
            }
        }, 3000); // 每3秒轮询一次
    }

上面代码就是简单的轮询代码,并没有什么技术点需要讲解。

3.5增加小程序码过期机制

前端无限制地轮询请求,可能会影响性能,所以应该设置一个机制,轮询一定次数后,则认定本次登录行为过期。停止轮询,并隐藏过期小程序码,用户点击刷新后,重新开启新的轮询。

在这里插入图片描述

3.4章节中的showRefreshCode变量就是显示刷新图标的开关。当轮询次数超出限制,则显示刷新图标,并且阻止用户扫码。因为已经不再轮询小程序码中携带的cId参数,再扫码已经没有意义。

当用户点击刷新图标,则重新获取小程序码,并重新开启轮询。

代码实现:

在前面显示小程序码的html代码中,增加刷新图标的判断:

            <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
              <div v-if="showRefreshCode" class="refresh-icon">

                <Icon style="cursor: pointer;opacity: 0.8" @click="loginBySceneCode" type="md-refresh" size="80"/>
              </div>
            </div>

代码解释:

1.showRefreshCode:是否显示刷新图标

2.loginBySceneCode:点击刷新图标的方法,包含请求小程序码、刷新图标隐藏、开启轮询用户信息等操作。

四、常规的用户+密码登录

除了小程序扫码登录,示例项目还提供了普通的用户+密码的登录方式。用户由超级管理员创建,创建后的用户使用默认密码登录。现在的示例项目角色管理功能尚未完善,所以所有用户均可创建用户。

4.1 账户登录前端实现

账户登录页面的前端主要由一个Form表单构成,表单包含用户名和密码的输入,登录按钮的实现。并且为账户和密码的输入框增加不能为空的规则。

效果如下:

在这里插入图片描述

代码实现:

<Form ref="loginForm" :model="form" :rules="rules" @keydown.enter.native="handleSubmit">
    <FormItem prop="userCode">
      <Input v-model="form.userCode" placeholder="请输入用户名">
        <span slot="prepend">
          <Icon :size="16" type="ios-person"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem prop="password">
      <Input type="password" v-model="form.password" password  placeholder="请输入密码">
        <span slot="prepend">
          <Icon :size="14" type="md-lock"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem>
      <Button @click="handleSubmit" class="lz-btn-primary" long>登录</Button>
    </FormItem>
  </Form>

大部分ui框架都提供了表单校验规则的功能,viewui的表单校验通过rules定义。示例项目仅仅设置了必填验证,实际项目中还应该增加长度验证。

登录按钮功能代码实现:

async function handleSubmit() {
  const validRs=await loginForm.value.validate()
  if (validRs) {
    const data = {
      userCode: form.value.userCode,
      password: form.value.password
    };
    const res = await login(data)
    if (res.success) {
      // 登录成功后,维护全局变量
      await handleLogIn(res)
      // 路由跳转
      router.push({
        name: '_home'
      })
    }

  }
}

代码解释:

1.validRs:判断表单校验是否通过,通过校验后,才能执行登录逻辑

2.await login(data):调用后端接口,判断用户名和密码是否合法,后端通过检查数据库信息,返回判断结果。

3.await handleLogIn(res):如果成功后,维护全局变量。这个方法是pinia中定义的,主要将一些后续会经常使用的关键信息维护在全局状态管理。下文详细讲解。

4.router.push:上面所有方法都实现后,则跳转到网站首页。

handleLogin方法代码实现:

       /**
         * 登录时维护全局变量
         * @param loginRs 登录成功后的返回值
         * @param loginRs.accessToken token
         * @param loginRs.refreshTokenId refreshTokenId
         * @param loginRs.data 用户信息*/
        async handleLogIn(loginRs) {
            // debugger
            const {accessToken, refreshTokenId, data} = loginRs
            console.log('userInfo', data)
            setToken(accessToken, refreshTokenId)
            this.token = accessToken
            // 维护全局用户信息
            this.userInfo = data
            setUserInfoLocal(data)
            //     维护路由信息
            await this.setRouters()
        },

如果同学的项目也采用了token鉴权,那么就在这里维护token信息,我的demo项目采用了双token,但是这部分内容对于前端初学者来说过于复杂,学习曲线过陡不利于长期学习,所以不打算在此赘述。

如果只是简单的项目,不涉及token,则采用下面的代码:

        async handleLogIn(loginRs) {
            // debugger
            const { data} = loginRs
            console.log('userInfo', data)
            // 维护全局用户信息
            this.userInfo = data
            localStorage.setItem('userInfo', JSON.stringify(userInfoData))
            //     维护路由信息
            await this.setRouters()
        },

代码解释:

1.userInfo :保存到全局状态管理的用户信息。userInfo必须要同步保存到缓存中,因为全局状态管理中的userInfo在刷新页面后会失效,很多场景仍然需要缓存中的userInfo。

2.setRouters:从远端获取路由,并维护路由信息到全局状态管理时,才需要此方法。路由信息只需要保存到全局状态管理,并不需要localstorage缓存,因为如果路由是从远端获取的,则说明在做权限和路由的管理,路由与权限有关,属于变化相对频繁的数据,就不能从缓存中简单获取。而应该当监控到失去路由信息时,均重新获取路由。如果项目路由全部由前端维护,不需要做权限管理,则不需要此方法。

4.2 账户登录后端实现

如果不考虑token鉴权,那么最简单的用户密码验证,就是简单的增删改查。后端接口根据前端发送的用户名,查询数据库用户信息表中是否存在该用户,如果存在,再比对密码,如果用户和密码都合法,则用户成功登录。

代码实现:

async login(entity: { userCode: string, password: string }): Promise<any> {
    let userData = await this.baseUserModel.findOne({
      where: {userCode: entity.userCode}
    });
    if(!userData){
      return {
        success: false,
        msg: '用户不存在',
      }
    }
    let hasAuth = false
    if(userData && userData.userCode.trim()==='test'){
      // 判断游客用户-test登录
      hasAuth =  entity.password===this.commonService.getPassCode()
    }else{
      hasAuth = bcrypt.compareSync(entity.password, userData?.password)
    }

    if (!hasAuth) {
      return {
        success: false,
        msg: '用户名或密码错误',
        data:userData
      }
    }
    return {
      success: true,
      data: userData,
    }
  }

代码解释:

1.this.baseUserModel.findOne:这是typeorm的语法,根据userCode查找用户信息。如果不存在,则返回前端结果,如果存在,则继续。

2.我的项目里存在test特殊账户,同学们查看项目示例中二少年学编程的示例项目就能看到,test账户的密码就是个我自己加盐加密的随机数,没有什么知识点。在同学们的实战项目中,这部分可以省略。

3.bcrypt.compareSync:判断传入的密码和用户信息中保存的密码是否相同。bcrypt是一个加解密的插件,如果我们保存到用户信息表中的密码为123456,那么一旦被人攻破服务器,造成的损失就会非常大,所以在创建用户的时候,一般都会使用加密工具进行加密后,再保存。加密后的密码是一串看不出意义的字符串,只要再用bcrypt比对这个字符串和123456,就能确定它俩是否一致。


总结

项目示例地址:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.注册和登录的原理

2.小程序扫码登录实现

3.普通账户密码登录实现

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~

定时同步订单信息

作者 Jimmy
2025年6月28日 19:34

有这么一个场景👇

由于网络的问题,我们创建订单并付费成功了。但是,在调用同步订单接口的时候,出现了网络的问题等不可抗拒的因素而同步不成功。这个时候,就会造成订单的状态不正确,那么,我们有什么措施解决或者减少这种情况的?

嗯~

我们可以采用下面的方案:

  1. 使用浏览器存储,将订单信息存储在 IndexedDB
  2. 将信息存储在电脑本地

使用 IndexedDB 在之前的文章中,我们已经提及 前端使用 IndexedDB 存储

这里,我们采取第二种方法 - 将信息存储在电脑磁盘。下面是我们的思路👇

这里我们使用的开发环境 👇

版本:Windows 10 专业版

版本号:22H2

电脑上拆分了多个磁盘 C, D, E, F

  1. 在同步订单的接口执行前,先将订单机器信息记录到本地
  2. 在应用的首页,每隔 * 分钟轮询本地的记录,同步到远程服务
  3. 如果同步成功,则删除本地的信息记录;同步失败的订单下次轮询

👌。思路有了,我们来实现下,这里假设你已经安装了对应的 Node 环境。

写入文件

我们将订单信息记录在 D 盘下:

export const LOCAL_DISK_ORDER_INFO_PATH = "d:\\app-name\order-info"; // 订单信息存放的路径

export interface DiskSaveOrderInfo {
  billNo: string;
  billPayTime: string;
  payWay: string
  // ... other properties
}

我们将执行下面的操作进行保存。

// 获取订单的文件路径
export const getOrderFilePath = (orderId: string) => {
  return path.join(LOCAL_DISK_ORDER_INFO_PATH, `${orderId}.json`); // 存储为 json 文件
};

// 保存订单到文件
export const saveOrderToFile = async (orderId: string, order: DiskSaveOrderInfo) => {
  const filePath = getOrderFilePath(orderId);
  // 创建文件路径
  if (!fs.existsSync(LOCAL_DISK_ORDER_INFO_PATH)) {
    try {
      // 创建对应的文件夹
      await fs.promises.mkdir(LOCAL_DISK_ORDER_INFO_PATH, { recursive: true });
    } catch (error) {
      console.error("fs.promises.mkdir", "创建目录失败", error);
    }
  } else {
    fs.writeFileSync(filePath, JSON.stringify(order, null, 2)); // 写入数据
  }
};

读取文件

我们从磁盘中读取保存的订单信息👇

// 从文件中读取订单 - 读取单个文件
export const readOrderFromFile = (orderId: string): DiskSaveOrderInfo | null => {
  const filePath = getOrderFilePath(orderId);
  try {
    const data = fs.readFileSync(filePath, "utf8");
    return JSON.parse(data);
  } catch (error) {
    console.error("fs.readFileSync", `readOrderFromFile - 读取订单 ${orderId} 失败`, error);
    return null;
  }
}

// 读取指定文件夹下的所有订单信息
export const getAllOrderFiles = (): DiskSaveOrderInfo[] => {
  try {
    const orderFiles = fs.readdirSync(LOCAL_DISK_ORDER_INFO_PATH);
    const orderFilesData = orderFiles
      .filter((file) => file.endsWith(".json"))
      .map((file) => {
        // 获取订单号
        const orderId: string = file.replace(/\.json$/, "");
        const data: DiskSaveOrderInfo | null = readOrderFromFile(orderId);
          return data;
        });
    const result = orderFilesData.filter((data) => data !== null);
    return [...result] as DiskSaveOrderInfo[];
  } catch (error) {
    console.error("getAllOrderFiles", "获取所有订单文件失败", error);
    return [];
  }
}

删除文件

我们删除磁盘上保存的订单,如下👇

// 删除订单文件
export const deleteOrderFile = (orderId: string): boolean => {
  const filePath = getOrderFilePath(orderId);
  try {
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
        return true;
    }
    return false;
  } catch (error) {
    console.error("fs.unlinkSync", `删除订单 ${orderId} 文件失败`, error);
    return false;
  }
};

执行保存

在同步订单接口之前,我们将相关的信息记录下

export function usePayDone() {
  const billNo = useStore(SelectorBillNo);
  
  const notifyPaySuccessFn = async (payWay: PayWayEnum) => {
    try {
      // 保存订单信息到本地
      await saveOrderToFile(billNo, {
        billNo,
        billPayTime: `${new Date().getTime()}`,
        payWay
      })
    } catch (error) {
      console.error("saveOrderToFile()", `保存订单 ${billNo} 信息到本地失败`, error);
    }
    // 执行同步订单到远程的代码 ...
  }
  
  return {
    notifyPaySuccessFn
  }
}

同步到远程

我们将本地的订单信息读取出来,然后依次向远程发起请求同步👇

// 定时同步本地订单的信息 - 这里可以自行实现是间隔多久触发下面的代码
export const useSyncOrders = () => {
  const handleSyncOrderFn = async () => {
    // 获取本地磁盘所有的订单信息
    const orderFiles = getAllOrderFiles();
    
    if (orderFiles.length === 0) {
      return Promise.resolve();
    }
    
    const syncPromises = orderFiles.map((file) => {
      return new Promise(async (resolve, reject) => {
        try {
          // 同步订单信息接口
          await postNotifyPaySuccess({
            billNo: file.billNo,
            payWay: file.payWay,
          });
          // 如果同步成功,则删除订单
          deleteOrderFile(file.billNo);
          resolve(true);
        } catch (error){
          console.error("deleteOrderFile()", `删除本地订单 ${file.billNo} 失败`, error);
          reject(error);
        }
      });
    });
    
    return Promise.all(sysnPromises);
  }
  
  return {
    hanleSyncOrderFn,
  };
}

至此,我们已经完成了订单信息的存储,订单的读取和订单的同步。

拜拜,Happy Coding!

参考

从0到1:不文明现象随手拍小程序开发日记(一)

作者 CC小程序
2025年6月28日 17:12

前期调研

不文明现象随手拍小程序:在城市的快速发展进程中,不文明现象时有发生,为了有效解决这一问题,提升城市文明程度, 市民若发现不文明行为,如乱扔垃圾、随地吐痰、破坏公共设施、违规停车等,只需点击“上报不文明现象”按钮,即可将这些不文明行为记录下来,并附上简短的文字描述,如事件发生的具体地点、时间以及对周围环境或他人造成的影响等。市民可以在小程序的“我的上报记录”页面中随时查看自己的上报; 为了进一步调动市民参与的积极性,小程序设置了积分激励机制。每当市民成功上报一条不文明现象并被后台审核通过后,即可获得相应的积分奖励。 同时提供积分商城模块,可以让市民使用通过参与任务所获得的积分来兑换各种奖励或福利。

功能规划

在这里插入图片描述

数据设计

ActivityModel.DB_STRUCTURE = {
_pid: 'string|true',
ACTIVITY_ID: 'string|true',

ACTIVITY_TITLE: 'string|true|comment=标题',
ACTIVITY_STATUS: 'int|true|default=1|comment=状态 0=未启用,1=使用中',
ACTIVITY_CHECK_REASON: 'string|false|comment=审核理由',

ACTIVITY_CATE_ID: 'string|true|default=0|comment=分类',
ACTIVITY_CATE_NAME: 'string|false|comment=分类冗余',

ACTIVITY_CANCEL_SET: 'int|true|default=1|comment=撤销设置 0=不允,1=允许,2=仅上报截止前可撤销',  

ACTIVITY_MAX_CNT: 'int|true|default=20|comment=每人次数上限 0=不限',
ACTIVITY_START: 'int|false|comment=项目时间',
ACTIVITY_START_DAY: 'string|false',

ACTIVITY_BEGIN: 'int|true|default=0|comment=开始时间',
ACTIVITY_STOP: 'int|true|default=0|comment=截止时间',


ACTIVITY_ADD_MONTH: 'string|false',

ACTIVITY_ORDER: 'int|true|default=9999',
ACTIVITY_VOUCH: 'int|true|default=0',

ACTIVITY_FORMS: 'array|true|default=[]',
ACTIVITY_OBJ: 'object|true|default={}',

ACTIVITY_JOIN_FORMS: 'array|true|default=[]',

ACTIVITY_ADDRESS: 'string|false|comment=详细地址',
ACTIVITY_ADDRESS_GEO: 'object|false|comment=详细地址坐标参数',

ACTIVITY_QR: 'string|false',
ACTIVITY_VIEW_CNT: 'int|true|default=0',
ACTIVITY_JOIN_CNT: 'int|true|default=0',
ACTIVITY_COMMENT_CNT: 'int|true|default=0',

ACTIVITY_ADD_TIME: 'int|true',
ACTIVITY_EDIT_TIME: 'int|true',
ACTIVITY_ADD_IP: 'string|false',
ACTIVITY_EDIT_IP: 'string|false',
};
ActivityJoinModel.DB_STRUCTURE = {
_pid: 'string|true',
ACTIVITY_JOIN_ID: 'string|true',
ACTIVITY_JOIN_ACTIVITY_ID: 'string|true|comment=上报PK',
ACTIVITY_JOIN_ACTIVITY_TITLE: 'string|true',

ACTIVITY_JOIN_IS_ADMIN: 'int|true|default=0|comment=是否管理员添加 0/1',
 

ACTIVITY_JOIN_USER_ID: 'string|true|comment=用户ID',
ACTIVITY_JOIN_SCORE: 'int|true|default=0|comment=获取积分',

ACTIVITY_JOIN_FORMS: 'array|true|default=[]|comment=表单',
ACTIVITY_JOIN_OBJ: 'object|true|default={}',

ACTIVITY_JOIN_STATUS: 'int|true|default=0|comment=状态 1=成功, 99=系统撤销',
ACTIVITY_JOIN_REASON: 'string|false|comment=撤销理由',

ACTIVITY_JOIN_ADD_MONTH: 'string|false',

ACTIVITY_JOIN_ADD_TIME: 'int|true',
ACTIVITY_JOIN_EDIT_TIME: 'int|true',
ACTIVITY_JOIN_ADD_IP: 'string|false',
ACTIVITY_JOIN_EDIT_IP: 'string|false',
};

核心实现

class ActivityService extends BaseProjectService {

// 获取当前项目状态
getJoinStatusDesc(activity) {
let timestamp = this._timestamp;

if (activity.ACTIVITY_STATUS == ActivityModel.STATUS.UNUSE)
return '项目停止';
else if (activity.ACTIVITY_START > timestamp)
return '项目未开始';
else if (activity.ACTIVITY_STOP <= timestamp)
return '项目结束';
else
return '进行中';
}

/** 浏览信息 */
async viewActivity(userId, id) {

let fields = '*';

let where = {
_id: id,
ACTIVITY_STATUS: ActivityModel.STATUS.COMM,
}
let activity = await ActivityModel.getOne(where, fields);
if (!activity) return null;

ActivityModel.inc(id, 'ACTIVITY_VIEW_CNT', 1);


return activity;
}

/** 取得分页列表 */
async getActivityList(type = 'run', {
cateId, //分类查询条件
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {

orderBy = orderBy || {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_START': 'asc',
'ACTIVITY_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_ADDRESS,ACTIVITY_STOP,ACTIVITY_JOIN_CNT,ACTIVITY_OBJ,ACTIVITY_VIEW_CNT,ACTIVITY_TITLE,ACTIVITY_MAX_CNT,ACTIVITY_START_DAY,ACTIVITY_START,ACTIVITY_ORDER,ACTIVITY_STATUS,ACTIVITY_CATE_NAME,ACTIVITY_OBJ.cover,ACTIVITY_OBJ.score';

let where = {};

if (cateId && cateId !== '0') where.ACTIVITY_CATE_ID = cateId;

where.ACTIVITY_STATUS = ActivityModel.STATUS.COMM; // 状态  

// 进行状态
let day = timeUtil.time('Y-M-D');
if (type == 'run') {
where.ACTIVITY_STOP = ['>=', this._timestamp];
}
else {
where.ACTIVITY_STOP = ['<=', this._timestamp];
orderBy = {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_START': 'desc',
'ACTIVITY_ADD_TIME': 'desc'
};
}

if (util.isDefined(search) && search) {
where['ACTIVITY_TITLE'] = ['like', search];

} else if (sortType && util.isDefined(sortVal)) {

// 搜索菜单
switch (sortType) {
case 'cateId': {
if (sortVal) where.ACTIVITY_CATE_ID = String(sortVal);
break;
}
case 'sort': {
// 排序
orderBy = this.fmtOrderBySort(sortVal, 'ACTIVITY_ADD_TIME');
break;
}
}
}

let ret = await ActivityModel.getList(where, fields, orderBy, page, size, isTotal, oldTotal);
if (ret) ret.type = type;
return ret;
}


/** 取得我的上报分页列表 */
async getMyActivityJoinList(userId, {
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {
orderBy = orderBy || {
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_JOIN_REASON,ACTIVITY_JOIN_ACTIVITY_ID,ACTIVITY_JOIN_ACTIVITY_TITLE,ACTIVITY_JOIN_STATUS,ACTIVITY_JOIN_ADD_TIME';

let where = {
ACTIVITY_JOIN_USER_ID: userId
};

if (util.isDefined(search) && search) {
where['activity.ACTIVITY_TITLE'] = {
$regex: '.*' + search,
$options: 'i'
};
} else if (sortType) {
// 搜索菜单
switch (sortType) {
case 'timedesc': { //按时间倒序
orderBy = {
'activity.ACTIVITY_START': 'desc',
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
break;
}
case 'timeasc': { //按时间正序
orderBy = {
'activity.ACTIVITY_START': 'asc',
'ACTIVITY_JOIN_ADD_TIME': 'asc'
};
break;
}
case 'status': {
where.ACTIVITY_JOIN_STATUS = Number(sortVal)
break;
}
}
}


let result = await ActivityJoinModel.getList(where, fields, orderBy, page, size, isTotal, oldTotal);

return result;
}

/** 取得我的上报详情 */
async getMyActivityJoinDetail(userId, activityJoinId) {

let fields = '*';

let where = {
_id: activityJoinId,
ACTIVITY_JOIN_USER_ID: userId
};
let activityJoin = await ActivityJoinModel.getOne(where, fields);

return activityJoin;
}
 


async statActivityJoin(id) {
// 上报数
let where = {
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let cnt = await ActivityJoinModel.count(where);


await ActivityModel.edit(id, { ACTIVITY_JOIN_CNT: cnt });
}

/**  上报前获取关键信息 */
async detailForActivityJoin(userId, activityId) {
let fields = 'ACTIVITY_JOIN_FORMS, ACTIVITY_TITLE';

let where = {
_id: activityId,
ACTIVITY_STATUS: ActivityModel.STATUS.COMM,
}
let activity = await ActivityModel.getOne(where, fields);
if (!activity)
this.AppError('该项目不存在');

if (activity.ACTIVITY_MAX_CNT > 0) {
let cnt = await ActivityJoinModel.count({ ACTIVITY_JOIN_USER_ID: userId });
if (cnt >= activity.ACTIVITY_MAX_CNT)
this.AppError('该项目您已经上报' + cnt + '次,已超过可提交上限~');
}


let myForms = [];

if (myForms.length == 0) {

let user = await UserModel.getOne({ USER_MINI_OPENID: userId, USER_STATUS: UserModel.STATUS.COMM });
if (!user) this.AppError('用户异常');

// 取得我的上报信息
myForms = [
{ mark: 'name', type: 'text', title: '姓名', val: user.USER_NAME },
{ mark: 'phone', type: 'mobile', title: '手机', val: user.USER_MOBILE },
]

}

activity.myForms = myForms;

return activity;
}

/** 撤销我的上报 只有成功可以撤销 取消即为删除记录 */
async cancelMyActivityJoin(userId, activityJoinId) {
let where = {
ACTIVITY_JOIN_USER_ID: userId,
_id: activityJoinId,
ACTIVITY_JOIN_STATUS: 0
};
let activityJoin = await ActivityJoinModel.getOne(where);

if (!activityJoin) {
this.AppError('未找到可撤销的记录');
}

let activity = await ActivityModel.getOne(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID);
if (!activity)
this.AppError('该项目不存在');

if (activity.ACTIVITY_STATUS == ActivityModel.STATUS.UNUSE)
this.AppError('该项目已停止,不能撤销');

if (activity.ACTIVITY_CANCEL_SET == 0)
this.AppError('该项目设置了不能撤销');

if (activity.ACTIVITY_CANCEL_SET == 2 && activity.ACTIVITY_STOP < this._timestamp)
this.AppError('该项目已经截止上报,不能撤销');

await ActivityJoinModel.del(where);

// 上报数量统计
await this.statActivityJoin(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID);

}


/** 按天获取上报项目 */
async getActivityListByDay(day) {
let start = timeUtil.time2Timestamp(day);
let end = start + 86400 * 1000 - 1;
let where = {
ACTIVITY_STATUS: ActivityModel.STATUS.COMM,
ACTIVITY_START: ['between', start, end],
};

let orderBy = {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_ADD_TIME': 'desc'
};

let fields = 'ACTIVITY_TITLE,ACTIVITY_START,ACTIVITY_OBJ.cover';

let list = await ActivityModel.getAll(where, fields, orderBy);

let retList = [];

for (let k = 0; k < list.length; k++) {

let node = {};
node.timeDesc = timeUtil.timestamp2Time(list[k].ACTIVITY_START, 'h:m');
node.title = list[k].ACTIVITY_TITLE;
node.pic = list[k].ACTIVITY_OBJ.cover[0];
node._id = list[k]._id;
retList.push(node);

}
return retList;
}

/**
 * 获取从某天开始可报名的日期
 * @param {*} fromDay  日期 Y-M-D
 */
async getActivityHasDaysFromDay(fromDay) {
let where = {
ACTIVITY_START: ['>=', timeUtil.time2Timestamp(fromDay)],
};

let fields = 'ACTIVITY_START';
let list = await ActivityModel.getAllBig(where, fields);

let retList = [];
for (let k = 0; k < list.length; k++) {
let day = timeUtil.timestamp2Time(list[k].ACTIVITY_START, 'Y-M-D');
if (!retList.includes(day)) retList.push(day);
}
return retList;
}


}

UI设计

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

后台管理

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

在这里插入图片描述在这里插入图片描述

git代码下载

点击下载

从0到1:文旅小程序开发笔记(上)

作者 CC小程序
2025年6月27日 17:55

可行性调查

涵盖活动报名、景点介绍、旅行攻略以及游记分享等功能,满足用户在旅游过程中的多种需求,提升旅游体验,同时助力文旅发展。

  • 活动报名:分类呈现各类文旅活动,如文化节庆、户外探险、亲子研学、美食体验等,每项活动展示活动名称、时间、地点、活动亮点)、参与人数上限以及报名截止日期等关键信息,并配以精美的活动海报图片,方便用户快速了解活动概况;点击后进入报名表单填写页面,用户填写完成后可提交报名,系统自动生成报名订单。
  • 景点攻略:将景点按照不同类型进行分类,用户可通过分类导航快速进入相应类别页面浏览景点。
  • 我的游记:展示用户发布的精彩的游记案例,用户也可以自行发布自己的游记。

概要设计

在这里插入图片描述

数据库设计


ActivityModel.DB_STRUCTURE = {
_pid: 'string|true',
ACTIVITY_ID: 'string|true',

ACTIVITY_TITLE: 'string|true|comment=标题',
ACTIVITY_STATUS: 'int|true|default=1|comment=状态 0=未启用,1=使用中',

ACTIVITY_CATE_ID: 'string|true|default=0|comment=分类',
ACTIVITY_CATE_NAME: 'string|false|comment=分类冗余',

ACTIVITY_CANCEL_SET: 'int|true|default=1|comment=取消设置 0=不允,1=允许,2=仅截止前可取消',
ACTIVITY_CHECK_SET: 'int|true|default=0|comment=审核 0=不需要审核,1=需要审核', 
ACTIVITY_IS_MENU: 'int|true|default=1|comment=是否公开展示名单',

ACTIVITY_MAX_CNT: 'int|true|default=20|comment=人数上限 0=不限',
ACTIVITY_START: 'int|false|comment=活动开始时间',
ACTIVITY_END: 'int|false|comment=活动截止时间',
ACTIVITY_STOP: 'int|true|default=0|comment=报名截止时间 0=永不过期',

ACTIVITY_ORDER: 'int|true|default=9999',
ACTIVITY_VOUCH: 'int|true|default=0',

ACTIVITY_FORMS: 'array|true|default=[]',
ACTIVITY_OBJ: 'object|true|default={}',

ACTIVITY_JOIN_FORMS: 'array|true|default=[]',

ACTIVITY_ADDRESS: 'string|false|comment=详细地址',
ACTIVITY_ADDRESS_GEO: 'object|false|comment=详细地址坐标参数',

ACTIVITY_QR: 'string|false',
ACTIVITY_VIEW_CNT: 'int|true|default=0',
ACTIVITY_JOIN_CNT: 'int|true|default=0',
ACTIVITY_COMMENT_CNT: 'int|true|default=0',

ACTIVITY_USER_LIST: 'array|true|default=[]|comment={name,id,pic}',

ACTIVITY_ADD_TIME: 'int|true',
ACTIVITY_EDIT_TIME: 'int|true',
ACTIVITY_ADD_IP: 'string|false',
ACTIVITY_EDIT_IP: 'string|false',
};
ActivityJoinModel.DB_STRUCTURE = {
_pid: 'string|true',
ACTIVITY_JOIN_ID: 'string|true',
ACTIVITY_JOIN_ACTIVITY_ID: 'string|true|comment=报名PK',

ACTIVITY_JOIN_IS_ADMIN: 'int|true|default=0|comment=是否管理员添加 0/1',

ACTIVITY_JOIN_CODE: 'string|true|comment=核验码15',
ACTIVITY_JOIN_IS_CHECKIN: 'int|true|default=0|comment=是否签到 0/1 ',
ACTIVITY_JOIN_CHECKIN_TIME: 'int|false|default=0|签到时间',

ACTIVITY_JOIN_USER_ID: 'string|true|comment=用户ID',


ACTIVITY_JOIN_FORMS: 'array|true|default=[]|comment=表单',
ACTIVITY_JOIN_OBJ: 'object|true|default={}',

ACTIVITY_JOIN_STATUS: 'int|true|default=1|comment=状态  0=待审核 1=报名成功, 99=审核未过',
ACTIVITY_JOIN_REASON: 'string|false|comment=审核拒绝或者取消理由',

ACTIVITY_JOIN_ADD_TIME: 'int|true',
ACTIVITY_JOIN_EDIT_TIME: 'int|true',
ACTIVITY_JOIN_ADD_IP: 'string|false',
ACTIVITY_JOIN_EDIT_IP: 'string|false',
};

核心实现

class ActivityService extends BaseProjectService {

// 获取当前活动状态
getJoinStatusDesc(activity) {
let timestamp = this._timestamp;

if (activity.ACTIVITY_STATUS == 0)
return '活动停止';
else if (activity.ACTIVITY_END <= timestamp)
return '活动结束';
else if (activity.ACTIVITY_STOP <= timestamp)
return '报名结束';
else if (activity.ACTIVITY_MAX_CNT > 0
&& activity.ACTIVITY_JOIN_CNT >= activity.ACTIVITY_MAX_CNT)
return '报名已满';
else
return '报名中';
}

/** 浏览信息 */
async viewActivity(userId, id) {

let fields = '*';

let where = {
_id: id,
ACTIVITY_STATUS: ActivityModel.STATUS.COMM
}
let activity = await ActivityModel.getOne(where, fields);
if (!activity) return null;

ActivityModel.inc(id, 'ACTIVITY_VIEW_CNT', 1);

// 判断是否有报名
let whereJoin = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ['in', [ActivityJoinModel.STATUS.WAIT, ActivityJoinModel.STATUS.SUCC]]
}
let activityJoin = await ActivityJoinModel.getOne(whereJoin);
if (activityJoin) {
activity.myActivityJoinId = activityJoin._id;
activity.myActivityJoinTag = (activityJoin.ACTIVITY_JOIN_STATUS == ActivityJoinModel.STATUS.WAIT) ? '待审核' : '已报名';
}

else {
activity.myActivityJoinId = '';
activity.myActivityJoinTag = '';
}


return activity;
}

/** 取得分页列表 */
async getActivityList({
cateId, //分类查询条件
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {

orderBy = orderBy || {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_CATE_NAME,ACTIVITY_USER_LIST,ACTIVITY_STOP,ACTIVITY_JOIN_CNT,ACTIVITY_OBJ,ACTIVITY_VIEW_CNT,ACTIVITY_TITLE,ACTIVITY_MAX_CNT,ACTIVITY_START,ACTIVITY_END,ACTIVITY_ORDER,ACTIVITY_STATUS,ACTIVITY_CATE_NAME,ACTIVITY_OBJ';

let where = {};
where.and = {
_pid: this.getProjectId() //复杂的查询在此处标注PID
};
if (cateId && cateId !== '0') where.and.ACTIVITY_CATE_ID = cateId;

where.and.ACTIVITY_STATUS = ActivityModel.STATUS.COMM; // 状态  


if (util.isDefined(search) && search) {
where.or = [{
ACTIVITY_TITLE: ['like', search]
},];
} else if (sortType && util.isDefined(sortVal)) {
// 搜索菜单
switch (sortType) {
case 'cateId': {
if (sortVal) where.and.ACTIVITY_CATE_ID = String(sortVal);
break;
}
case 'sort': {
// 排序
orderBy = this.fmtOrderBySort(sortVal, 'ACTIVITY_ADD_TIME');
break;
}
case 'today': { //今天
let start = timeUtil.getDayFirstTimestamp();
let end = start + 86400 * 1000 - 1;
where.and.ACTIVITY_START = ['between', start, end];
break;
}
case 'tomorrow': { //明日
let start = timeUtil.getDayFirstTimestamp() + 86400 * 1000;
let end = start + 86400 * 1000 - 1;
where.and.ACTIVITY_START = ['between', start, end];
break;
}
case 'month': { //本月
let day = timeUtil.time('Y-M-D');
let start = timeUtil.getMonthFirstTimestamp(day);
let end = timeUtil.getMonthLastTimestamp(day);

where.and.ACTIVITY_START = ['between', start, end];
break;
}
}
}

return await ActivityModel.getList(where, fields, orderBy, page, size, isTotal, oldTotal);
}


/** 取得某一个报名分页列表 */
async getActivityJoinList(activityId, {
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {
orderBy = orderBy || {
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_JOIN_OBJ,ACTIVITY_JOIN_IS_CHECKIN,ACTIVITY_JOIN_REASON,ACTIVITY_JOIN_ACTIVITY_ID,ACTIVITY_JOIN_STATUS,ACTIVITY_JOIN_ADD_TIME,user.USER_PIC,user.USER_NAME,user.USER_OBJ';

let where = {
ACTIVITY_JOIN_ACTIVITY_ID: activityId,
ACTIVITY_JOIN_STATUS: ActivityModel.STATUS.COMM
};

let joinParams = {
from: UserModel.CL,
localField: 'ACTIVITY_JOIN_USER_ID',
foreignField: 'USER_MINI_OPENID',
as: 'user',
};

let result = await ActivityJoinModel.getListJoin(joinParams, where, fields, orderBy, page, size, isTotal, oldTotal);

return result;
}


/** 取得我的报名分页列表 */
async getMyActivityJoinList(userId, {
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {
orderBy = orderBy || {
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_JOIN_IS_CHECKIN,ACTIVITY_JOIN_REASON,ACTIVITY_JOIN_ACTIVITY_ID,ACTIVITY_JOIN_STATUS,ACTIVITY_JOIN_ADD_TIME,activity.ACTIVITY_END,activity.ACTIVITY_START,activity.ACTIVITY_TITLE';

let where = {
ACTIVITY_JOIN_USER_ID: userId
};

if (util.isDefined(search) && search) {
where['activity.ACTIVITY_TITLE'] = {
$regex: '.*' + search,
$options: 'i'
};
} else if (sortType) {
// 搜索菜单
switch (sortType) {
case 'timedesc': { //按时间倒序
orderBy = {
'activity.ACTIVITY_START': 'desc',
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
break;
}
case 'timeasc': { //按时间正序
orderBy = {
'activity.ACTIVITY_START': 'asc',
'ACTIVITY_JOIN_ADD_TIME': 'asc'
};
break;
}
case 'succ': {
where.ACTIVITY_JOIN_STATUS = ActivityJoinModel.STATUS.SUCC;
break;
}
case 'wait': {
where.ACTIVITY_JOIN_STATUS = ActivityJoinModel.STATUS.WAIT;
break;
}
case 'cancel': {
where.ACTIVITY_JOIN_STATUS = ActivityJoinModel.STATUS.ADMIN_CANCEL;
break;
}
}
}

let joinParams = {
from: ActivityModel.CL,
localField: 'ACTIVITY_JOIN_ACTIVITY_ID',
foreignField: '_id',
as: 'activity',
};

let result = await ActivityJoinModel.getListJoin(joinParams, where, fields, orderBy, page, size, isTotal, oldTotal);

return result;
}

/** 取得我的报名详情 */
async getMyActivityJoinDetail(userId, activityJoinId) {

let fields = '*';

let where = {
_id: activityJoinId,
ACTIVITY_JOIN_USER_ID: userId
};
let activityJoin = await ActivityJoinModel.getOne(where, fields);
if (activityJoin) {
activityJoin.activity = await ActivityModel.getOne(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID, 'ACTIVITY_TITLE,ACTIVITY_START,ACTIVITY_END');
}
return activityJoin;
}
 
async statActivityJoin(id) {
// 报名数
let where = {
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ['in', [ActivityJoinModel.STATUS.WAIT, ActivityJoinModel.STATUS.SUCC]]
}
let cnt = await ActivityJoinModel.count(where);


// 用户列表
where = {
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let joinParams = {
from: UserModel.CL,
localField: 'ACTIVITY_JOIN_USER_ID',
foreignField: 'USER_MINI_OPENID',
as: 'user',
};
let orderBy = {
ACTIVITY_JOIN_ADD_TIME: 'desc'
}
let list = await ActivityJoinModel.getListJoin(joinParams, where, 'ACTIVITY_JOIN_ADD_TIME,user.USER_MINI_OPENID,user.USER_NAME,user.USER_PIC', orderBy, 1, 6, false, 0);
list = list.list;

for (let k = 0; k < list.length; k++) {
list[k] = list[k].user;
}

await ActivityModel.edit(id, { ACTIVITY_JOIN_CNT: cnt, ACTIVITY_USER_LIST: list });
}

/**  报名前获取关键信息 */
async detailForActivityJoin(userId, activityId) {
let fields = 'ACTIVITY_JOIN_FORMS, ACTIVITY_TITLE';

let where = {
_id: activityId,
ACTIVITY_STATUS: ActivityModel.STATUS.COMM
}
let activity = await ActivityModel.getOne(where, fields);
if (!activity)
this.AppError('该活动不存在');


// 取出本人最近一次的填写表单

let whereMy = {
ACTIVITY_JOIN_USER_ID: userId,
}
let orderByMy = {
ACTIVITY_JOIN_ADD_TIME: 'desc'
}
let joinMy = await ActivityJoinModel.getOne(whereMy, 'ACTIVITY_JOIN_FORMS', orderByMy);


let myForms = joinMy ? joinMy.ACTIVITY_JOIN_FORMS : [];
activity.myForms = myForms;

return activity;
}

/** 取消我的报名 只有成功和待审核可以取消 取消即为删除记录 */
async cancelMyActivityJoin(userId, activityJoinId) {
let where = {
ACTIVITY_JOIN_USER_ID: userId,
_id: activityJoinId,
ACTIVITY_JOIN_STATUS: ['in', [ActivityJoinModel.STATUS.WAIT, ActivityJoinModel.STATUS.SUCC]]
};
let activityJoin = await ActivityJoinModel.getOne(where);

if (!activityJoin) {
this.AppError('未找到可取消的报名记录');
}

if (activityJoin.ACTIVITY_JOIN_IS_CHECKIN == 1)
this.AppError('该活动已经签到,无法取消');

let activity = await ActivityModel.getOne(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID);
if (!activity)
this.AppError('该活动不存在');

if (activity.ACTIVITY_END <= this._timestamp)
this.AppError('该活动已经结束,无法取消');

if (activity.ACTIVITY_CANCEL_SET == 0)
this.AppError('该活动不能取消');

if (activity.ACTIVITY_CANCEL_SET == 2 && activity.ACTIVITY_STOP < this._timestamp)
this.AppError('该活动已经截止报名,不能取消');

await ActivityJoinModel.del(where);

// 统计
await this.statActivityJoin(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID);
}


/** 用户自助签到 */
async myJoinSelf(userId, activityId) {
let activity = await ActivityModel.getOne(activityId);
if (!activity)
this.AppError('活动不存在或者已经关闭');

let day = timeUtil.timestamp2Time(activity.ACTIVITY_START, 'Y-M-D');

let today = timeUtil.time('Y-M-D');
if (day != today)
this.AppError('仅在活动当天可以签到,当前签到码的日期是' + day);

let whereSucc = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let cntSucc = await ActivityJoinModel.count(whereSucc);

let whereCheckin = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_IS_CHECKIN: 1,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let cntCheckin = await ActivityJoinModel.count(whereCheckin);

let ret = '';
if (cntSucc == 0) {
ret = '您没有本次活动报名成功的记录,请在「个人中心 - 我的活动报名」查看详情~';
} else if (cntSucc == cntCheckin) {
// 同一活动多次报名的情况
ret = '您已签到,无须重复签到,请在「个人中心 - 我的活动报名」查看详情~';
} else {
let where = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_IS_CHECKIN: 0,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let data = {
ACTIVITY_JOIN_IS_CHECKIN: 1,
ACTIVITY_JOIN_CHECKIN_TIME: this._timestamp,
}
await ActivityJoinModel.edit(where, data);
ret = '签到成功,请在「个人中心 - 我的活动报名」查看详情~'
}
return {
ret
};
}

/** 按天获取报名项目 */
async getActivityListByDay(day) {
let start = timeUtil.time2Timestamp(day);
let end = start + 86400 * 1000 - 1;
let where = {
ACTIVITY_STATUS: ActivityModel.STATUS.COMM,
//ACTIVITY_START: ['between', start, end], //for demo
};

let orderBy = {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_ADD_TIME': 'desc'
};

let fields = 'ACTIVITY_TITLE,ACTIVITY_START,ACTIVITY_OBJ.cover';

let list = await ActivityModel.getAll(where, fields, orderBy);

let retList = [];

for (let k = 0; k < list.length; k++) {

let node = {};
node.timeDesc = timeUtil.timestamp2Time(list[k].ACTIVITY_START, 'h:m');
node.title = list[k].ACTIVITY_TITLE;
node.pic = list[k].ACTIVITY_OBJ.cover[0];
node._id = list[k]._id;
retList.push(node);

}
return retList;
}

/**
 * 获取从某天开始可报名的日期
 * @param {*} fromDay  日期 Y-M-D
 */
async getActivityHasDaysFromDay(fromDay) {
let where = {
ACTIVITY_START: ['>=', timeUtil.time2Timestamp(fromDay)],
};

let fields = 'ACTIVITY_START';
let list = await ActivityModel.getAllBig(where, fields);

let retList = [];
for (let k = 0; k < list.length; k++) {
let day = timeUtil.timestamp2Time(list[k].ACTIVITY_START, 'Y-M-D');
if (!retList.includes(day)) retList.push(day);
}

return [timeUtil.time('Y-M-D'), timeUtil.time('Y-M-D', 86400), timeUtil.time('Y-M-D', 86400 * 2), timeUtil.time('Y-M-D', 86400 * 3), timeUtil.time('Y-M-D', 86400 * 4), timeUtil.time('Y-M-D', 86400 * 5), timeUtil.time('Y-M-D', 86400 * 6)]; //for demo
return retList;
}


}

UI设计

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

后台管理系统设计

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

git代码下载

点击下载

前端调试太痛苦?这 7 个技巧直接解决 90% 问题!| 掘金一周 6.26

作者 掘金一周
2025年6月26日 16:57

本文字数2000+ ,阅读时间大约需要 6分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

🏄公司报销,培养我成一名 WebGL 工程师⛵️ @curdcv_po

文章围绕WebGL开发展开,介绍了Web 3D技术应用场景,推荐从Three.js库入门。阐述需掌握的知识,如基础概念、材质光照等。还给出代码示例,涵盖物体操作、动画实现、界面控制等,总结了动画、时间、响应式等关键点。

Vue实现一个“液态玻璃”效果登录卡片 @前端不端钱

本文介绍了用Vue实现“液态玻璃”效果登录卡片的方法。先解析技术原理,包括多层叠加、3D灵动倾斜等;接着详解实现步骤,含结构搭建、SVG滤镜等;还给出常见问题建议,总结技术要点,助你打造高端玻璃UI。

前端调试太痛苦?这 7 个技巧直接解决 90% 问题! @程序员大卫

文章分享了 7 个前端调试实用技巧,包括用 Chrome 替换功能、Proxyman 本地/远程映射、手动添加 Source Map,还介绍了 Select 下拉框及不同实现方式下 Hover 样式的调试方法,助于提升问题排查效率。

10000+ 各点位轻松展示,使用 Leaflet 实现地图海量标记点聚类 @前端梦工厂

本文围绕 Leaflet.markercluster 展开,介绍其为 Leaflet 地图库标记点聚类插件。阐述使用该插件的必要性及优势,给出安装、使用方法,含 Vue 项目示例,还提供性能优化建议,凸显其提升地图性能与用户体验的作用。

后端

从回调地狱👹到优雅飞升 👼@有才叔

文章围绕 Java 异步编程难题展开,介绍回调地狱、异常黑洞等五大问题及对应解法,如用 CompletableFuture 解决回调,科学配置线程池等。还提及响应式编程大招,最后给出避坑口诀助力驯服并发 Bug。

一次生产故障引发的JVM垃圾回收器选型思考:彻底掌握垃圾回收原理及通用配置!@神码小Z

本文围绕JVM垃圾回收器展开,先介绍回收基础,包括原理与三色标记算法。接着解析主流回收器特点、适用场景与配置。给出场景化选择策略、调优实践、故障排查案例及高级技巧,最后展望未来并给出选择建议。

@Async的六大常见坑,今天给你盘明白@一只叫煤球的猫

本文围绕@Async注解展开,指出其六大常见坑,包括默认线程池问题、异常处理难、内部方法调用无效等。针对各问题给出解决方案,还提及业务线程池隔离、监控状态等更好实践,助开发者稳定可靠地进行异步编程。

为啥需要一把Anaconda"瑞士军刀"?@有才叔

本文介绍了Anaconda的优势、安装方法及使用示例。它是Python科学计算“全家桶”,能打包常用库、管理环境。按步骤在官网下载安装,验证成功后,用Pandas和Matplotlib在Jupyter Notebook完成小Demo,可继续深入探索。

Android

React Native 0.80 开始支持 iOS 预构建 @恋猫de小郭

React Native 0.80 开始支持 iOS 预构建,先对 Folly、GLog 等基础库预构建,后续将迁移核心库。此举可提升构建速度,M4 芯片机器上快约 12%,还能减少因环境差异致构建失败,目前需手动启用。

现在的需求这么花哨了吗,文本都能拼上自定义组件啦? @Coffeeee

文章围绕富中自定义组件与文案拼接的需求展开。先实现了宽度自适应的胶囊自定义视图,通过Path和Paint绘制。接着用ReplacementSpan子类将胶囊组件与文案拼接,解决宽高异步获取和垂直居中问题,最终完成需求。

人工智能

AI 应用开发入门:前端也可以学习 AI @唐某人丶

本文是前端工程师学习 AI 应用开发的入门指南。介绍两条学习路线,阐述 AI 应用结构与基础概念,通过实现编程助手展示开发逻辑,还讲解提示词工程、Memory、RAG、Tools 等技术,助读者重塑认知。

IOS

Flutter 应该如何实现 iOS 26 的 Liquid Glass ,它为什么很难?@恋猫de小郭

文章围绕在 Flutter 实现 iOS 26 的 Liquid Glass 效果展开。介绍其核心特征与传统毛玻璃不同,复刻需实现着色器。以两个项目为例说明实现细节,虽含大量运算,但复刻 80% 左右可能性高,未来或通过独立包推进。

社区活动日历

掘金官方 文章头图 1303x734.jpg

活动日历

活动名称 活动时间
🎆代码为剑,万元现金大奖等你瓜分 2025年5月26日-2025年6月30日
创作者训练营:老友带新+新人冲榜,全员参与,双倍快乐! 2025年5月27日-2025年6月30日
“寻找地表最强Trae Agent ”征文活动火热开启!iPhone16 等你来拿! 2025年5月26日-2025年6月30日

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

亲测上百款MCP后,我发现这10款才是真正能提升生产力的神器。

作者 ConardLi
2025年6月25日 22:14

本期视频教程:www.bilibili.com/video/BV1GU…

大家好,欢迎来到 code秘密花园,我是花园老师(ConardLi)。

在之前的教程中,我们尝试了 MCP + 数据库的案例,并且系统性学习了 MCP 的原理和通信过程,以及对 MCP 的安全性进行了系统性分析:

这段时间我个人在学习和工作中使用 MCP 非常多,体验了各种场景,有些工具浮于表面,根本不实用,但是有些工具确实是真的让我感受到效率提升。所以今天这一期主要是跟大家来介绍一下我这段时间的使用体验,给大家分享一些借助 MCP 能够真正能够提升我们打工人生产力的场景。

MCP 基础回顾

MCP(Model Context Protocol,模型上下文协议)是由 Anthropic 公司推出的一个开放标准协议,目的就是为了解决 AI 模型与外部数据源、工具交互的难题。

MCP就像是一个 “通用插头” 或者 “USB 接口”,制定了统一的规范,不管是连接数据库、第三方 API,还是本地文件等各种外部资源,都可以通过这个 “通用接口” 来完成,让 AI 模型与外部工具或数据源之间的交互更加标准化、可复用。相当于给本来只具备基础对话能力的大模型插上了翅膀,能够完成很多之前做不到的事。

这次我们使用到的 MCP Host 是 Chatbox ,Chatbox 是我一直在本地使用的一款本地 AI 聊天软件,但是之前一直不支持 MCP,前几天发现在最新的 1.14 版本中终于支持上了 MCP ,于是立马来体验了一下,效果非常不错:

下面我们直接来使用一下,先从一个最简单的 MCP 开始:

小试牛刀:AI 自动整理文件夹

场景:最近下载了好多文件,太乱了,让 AI 帮我们自动整理一下****

使用到的 MCP: @modelcontextprotocol/server-filesystem:可检索和管理本地文件系统

我们先进入【设置 - MCP - 自定义 MCP 服务器】

直接在官方 MCP 列表中就可以检索到 Filesystem:

点击之后,将 YOUR_ALLOWED_PATH_HERE 替换为你允许 AI 访问的本机目录

点击测试,如果能成功拉到工具列表,说明配置成功:

点击保存,然后打开聊天窗口,启用 Filesystem,后续就可以直接调用这个工具啦:

提示词:下载目录下有哪些文件?

这是下载目录下的情况:

输入以上提示词后,AI 可以读取到这些文件信息,说明配置成功:

提示词:Downloads 目录下的文件,帮我按文件类型分类整理对应文件夹

AI 自动分析了文件内容,给出分类建议:

然后调用创建文件夹和移动文件的工具:

完成之后,Downloads 文件夹已经被完美的整理好啦:

教你讨好女朋友:AI 生成满分旅游攻略

场景:女朋友最近想出去玩,我们借助 AI 来帮我们做一份满分的旅游攻略 ~

使用到的 MCP:

  • @amap/amap-maps-mcp-server:可调用高德地图的各种能力
  • @modelcontextprotocol/server-sequential-thinking:让模型具备有序、连贯的推理过程

进入【设置 - MCP - 自定义 MCP 服务器】选择添加自定义服务器,选择远程(http/sse),然后粘贴以下 URL:

https://mcp.amap.com/sse?key=您在高德官网上申请的key

这里我们需要一个高德地图的 API Key,可以参考高德地图的官方文档(lbs.amap.com/api/mcp-ser… Key(Web 服务):

然后我们测试连接(可以获取到工具说明配置成功):

另外一个需要用到的 MCP 是 Sequential Thinking,Chatbox 默认就内置了这个 MCP:

Sequential Thinking 是几乎是当前最热门,使用频率最高的 MCP 服务器,它可以让普通模型具备一定的思考能力,本质是一种结构化思维协议,可以指导模型进行有序、连贯的推理过程。能够将复杂问题分解为可管理的步骤、随着理解的加深,修改和完善想法、分支出其他推理路径、动态调整想法总数、生成并验证解决方案假设等等。

在我之前的教程有讲过 MCP 的交互原理:《全网最细,一文带你弄懂 MCP 的核心原理》,让模型结合 MCP 去完成一个任务时,一般中间需要多轮的交互,模型一般需要先完成一个步骤后再考虑下一个步骤需要做什么,任务如果比较复杂,这个过程会不太稳定,所以当我们的任务较为复杂时,建议结合 Sequential Thinking 使用,它会先帮模型规划好完整一个任务一共要分几步,然后分步骤指导模型后续的动作,可以让整个任务完成的有规划、更稳定,比如我们本次的任务通过下面的提示词一次就完成了:

提示词:

  • 我 6.21 计划去北京游玩,我需要制作一个 3 天的旅行攻略。
  • 帮制作旅行攻略,考虑出行时间和路线,以及天气状况路线规划。
  • 制作一个网页地图能够合理展示规划(给女朋友看),要求页面设计非常美观,景区图片以卡片展示。
  • 行程规划结果希望能在高德地图 APP 展示,并集成到网页中。
  • 同一天行程景区之间我想打车前往,必须包含的景点:故宫、颐和园,其他景点你可以自由安排。
  • 你先调用 Sequential Thinking 拆解我的任务,然后按照规划一步步完成。

Sequential Thinking 的任务分解结果:

  • 第一步:查询北京天气(6月17-19日)
  • 第二步:选择和安排景点(故宫+颐和园必须,再选择其他经典景点)
  • 第三步:获取各景点的地理坐标
  • 第四步:规划每日行程路线(考虑地理位置和游览时间)
  • 第五步:计算景点间打车路线和时间
  • 第六步:生成高德地图展示链接
  • 第七步:整理完整攻略信息
  • 第八步:生成一份精美的网页

在期间,模型依次调用了:查询天气、查询目标位置、关键字搜索点位、查询点位间的距离和时间,生成高德地图展示、生成打车规划路线等工具,整个过程非常丝滑:

最终生成了一份旅行攻略:

然后把旅行攻略编写为网页(因为要制作的网页比较大,这一步输出可能中断,引导 AI 继续完成即可):

最终生成了一份非常美观的网页,包括天气、景点信息、时间安排、打车规划、旅行贴士等信息:

后续你还可以在这个提示词的基础上继续完善,比如每天吃什么、住在什么地方等等,可以让 AI 生成更完美的规划路线。这下应该能给你的女朋友交上一份 “满分答卷” 了。

科研党的福音:AI 助你轻松检索和分析论文

场景:arXiv 这些平台上的检索功能不太好用,让 AI 帮我们自动检索、下载、分析符合需求的论文。

使用到的 MCP:

  • arxiv-mcp-server:可以自动检索、下载、分析 arxiv 论文
  • @modelcontextprotocol/server-sequential-thinking:让模型具备有序、连贯的推理过程

来到 arxiv-mcp-server 的 Github github.com/blazickjp/a… ,可以看到具体的配置文档:

这里建议指定一下 Python 版本,大家可以直接复制我这个命令:

{
    "mcpServers": {
        "arxiv-mcp-server": {
            "command": "uv",
            "args": [
                "tool",
                "run",
                "--python 3.11",
                "arxiv-mcp-server",
                "--storage-path", "替换为你自己的本机目录"
            ]
        }
    }
}

然后进入【设置 - MCP - 自定义 MCP 服务器】选择从剪贴板中的JSON导入:

将 --storage-path 这个参数替换为你的本机路径(用户存储下载后的论文):

提示词:我想查找一些关于大模型领域的论文(2025 年以后的),请使用 Sequential Thinking 帮我规划并完成任务。

AI 帮我们自动分析好需要检索的关键词,并且检索了 2025-01-01 后符合相关主题的论文,并且生成了中文摘要:

提示词:帮我下载第四篇论文,然后分析这个论文的具体内容,总结一份中文分析文档。

成功将指定论文下载到本地:

并且生成了完整的论文分析报告:

解决世纪难题:AI 告诉你今天吃什么

场景:我们每天都要考虑的一个问题,今天吃什么,让 AI 来帮我们做决定吧!

使用到的 MCP:

  • @amap/amap-maps-mcp-server:可调用高德地图的各种能力
  • @modelcontextprotocol/server-sequential-thinking:让模型具备有序、连贯的推理过程

提示词:我现在在北京知春路附近,我今天又不知道吃什么了,你现在可以调用高德地图的相关工具检索到周边的美食,我需要你先调用 Sequential Thinking 拆解我的任务,然后调用 高德地图的相关工具 检索美食,最后帮我生成一个美观的 HTML,做一个今天吃什么小游戏,方便我来使用。

Sequential Thinking 首先帮我将任务拆解成了四个步骤,然后引导模型调用高德地图 MCP 一步步完成执行:

  1. 确认您在“北京知春路”附近,通过高德地图获取周边美食清单。
  2. 将获得的美食店信息(名称、地址、图片)收集成适合网页的数据源。
  3. 设计一个“今天吃什么”小游戏,用户点击按钮后随机显示附近餐厅信息,可“一键换一家”。
  4. 最后为您生成一个美观、简洁、易用的HTML页面源码,随时可保存使用。

生成的 HTML 代码我们可以直接点击右上角的运行按钮:

然后我们就可以愉快的使用啦:

开发者效率起飞:AI 帮你管理 Github

场景:作为一名开发者,每天都要关注自己负责的仓库是否有新增 Issue 和 PR,可以让 AI 帮助我们更高效的完成这些工作。

使用到的 MCP:

  • github-mcp-servcer:可以让 AI 操纵 Github
  • @larksuiteoapi/lark-mcp:可以让 AI 操纵飞书:发消息、写文档

我们先配置一下 github-mcp-servcer , 在官方文档可以看到 Github MCP Server 是以 Docker 来运行的(需要你本地有 Docker 环境):

我们直接复制这段命令:

docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server

然后配置一下 环境变量:

GITHUB_PERSONAL_ACCESS_TOKEN=GITHUB_PERSONAL_ACCESS_TOKEN

注意这里的环境的值要替换为你个人的 GITHUB_PERSONAL_ACCESS_TOKEN:

在GitHub获取 GITHUB_PERSONAL_ACCESS_TOKEN 的步骤如下:

1. 访问设置页面

登录GitHub后,点击右上角头像→选择Settings(设置)。

2. 进入开发者设置

在左侧菜单最底部找到 Developer settings(开发者设置)并点击。

3. 生成新令牌

  • 点击 Personal access tokens(个人访问令牌)
  • 选择 Tokens (classic)(经典令牌)
  • 点击 Generate new token → Generate new token (classic)(生成新令牌→生成经典令牌)

4. 配置令牌权限

  • Note(备注):为令牌命名(如:MyAppToken)
  • Expiration(有效期):选择适当的过期时间(建议设置较短有效期)
  • Select scopes(选择权限)**:根据需求勾选,常见需要的权限:
    • repo(访问仓库)
    • workflow(访问工作流)
    • user(访问用户信息)

5. 生成并保存令牌

  • 点击Generate token(生成令牌)
  • 立即复制生成的令牌(形如:ghp_xxxxxxxxxxxxxx)
  • 令牌仅显示一次,丢失后需重新生成

自动 Review PR

提示词:查看 ConardLi/easy-dataset 有哪些未合并的 PR

提示词:433 PR 帮我详细 Review 其中的代码变更。

AI 生成了详细的代码变更总结:

提示词:这其中你觉得可能有质量问题的代码位置,帮我仔细分析,并生成评论,调用 get_pull_request_comments 工具提交上去。

AI 自动生成了整个 PR 的评审结论,以及在具体的代码位置生成了评论:

自动整理 Issue

下面我们希望让 AI 对我们仓库的 Issue 进行分类整理,然后保存到飞书文档,并且通过聊天窗口发送给我们,首先我们要根据文档安装飞书的 MCP ,具体可以看这个文档

进入【设置 - MCP - 自定义 MCP 服务器】选择添加自定义服务器,选择本地,粘贴如下配置

npx -y @larksuiteoapi/lark-mcp mcp -a cli_a8d8eaeebde5100e -s 5YIikp0YoPVMjfqatlylLfpr2pbrndQl -u u-u-fSdCzY19lcmXl_kuUsE.0Zl55vH1h4yro0w0ggKw0C3y (注意更新一下)

{
  "mcpServers": {
    "lark-mcp": {
     "command": "npx",
      "args": [
        "-y",
        "@larksuiteoapi/lark-mcp",
        "mcp",
        "-a",
        "<your_app_id>", // 需要替换为你的应用的 App ID。
        "-s",
        "<your_app_secret>", // 需要替换为你的应用的 App Secret。
        "-u",
        "<your_user_token>" // 需要替换为用户访问凭证(user_access_token)
      ]
    }
  }
}

注意,这里的 appId、appSecret、user_token ,大家要根据上面的文档,在飞书开发者后台(open.feishu.cn/app)创建好应用,并…

提示词:帮我详细分析一下,ConardLi/easy-dataset 最新增加的 10 条 Issue ,按照清晰的分类、友好的格式进行整理。并且将以上信息创建一个飞书文档,然后将文档链接发送给邮箱为 1009903985@qq.com 的飞书用户的聊天窗口。

AI 首先分类整理好了 Issue:

然后通过飞书机器人发送到你的聊天窗口:

以上的流程完全也可以搞成定时任务,那么你每天早上不用再手动去打开你的代码仓库,挨个翻看新增的 Issue 和 PR 了,只需要等着飞书消息就可以看到整理好的信息啦。

汇报不用愁:AI 自动处理表格并生成图表

场景:老板交给你一堆文件(非表格文档,如 Markdown),让你做数据分析?手动整理太累了,让 AI 从中提取数据,自动整理为 Excel,然后生成丰富的可视化图表

使用到的 MCP:

  • excel-mcp-server:轻松创建、分析、编辑 Excel
  • @modelcontextprotocol/server-filesystem:检索和管理本地文件
  • @antv/mcp-server-chart:可以生成 20 多种不同风格的图表

创建表格

来到 excel-mcp-server 的 Github github.com/haris-musa/… ,可以看到具体的配置文档:

这里我们通过 SSE 的方式进行连接,复制以下命令到终端执行(注意把 EXCEL_FILES_PATH 替换为你本机路径):

EXCEL_FILES_PATH=EXCEL_FILES_PATH FASTMCP_PORT=8080 uvx excel-mcp-server sse

终端出现以下日志说明启动成功:

进入【设置 - MCP - 自定义 MCP 服务器】选择添加自定义服务器,选择远程(http/sse),然后粘贴以下 URL:

http://127.0.0.1:8080/sse

我们本地在一些非 Excel 文档中(比如 Word、Mardkown)中保存了一些结构化数据:

提示词:帮我读取并分析 data.md,将其中的结构化数据转换为 Excel 并保存到本地。

模型先调用了 Filesystem 工具,读取并分析了 data.md 文件,然后调用 create_workbook、create_worksheet、write_data_to_excel,将文件写入 excel:

打开新创建的 Excel 文件,可以看到写入后的数据,并且合理划分了多个 Sheet:

分析表格

提示词:帮我分析表格中的数据,然后帮我生成销售额的变化曲线

打开图表发现已经生成好了曲线:

表格可视化

如果觉得 Excel 自带的图表不够丰富,我们可以借助 @antv/mcp-server-chart 帮我们生成更多维度的图表 github.com/antvis/mcp-… 它支持生成 20 种不同风格的图表:

配置方式比较简单,直接到 【设置 - MCP - 自定义 MCP 服务器】选择添加自定义服务器,选择本地 (stdio),然后在命令处直接粘贴 npx -y @antv/mcp-server-chart :

提示词:借助 Sequential Thinking 进行思考,详细分析图表里的各种特征,选择 mcp-server-chart 中合适的图表,帮我将数据进行全面可视化,将生成的图表最终用一个 HTML 进行展示,要求页面美观,展示维度丰富、全面(至少包含 10 个维度的图表展示)。

以下是生成的 HTML 页面:

文档有救了:AI 帮你轻松绘制架构图

场景:在写文档的过程中,我们经常需要添加一些架构图,来让文档显得更专业、更高大上一点,借助 AI,可以快速根据我们的想法和指定内容绘制架构图。

使用到的 MCP:

  • @peng-shawn/mermaid-mcp-server:可以绘制 Mermaid 图表
  • @antv/mcp-server-chart:可以生成 20 多种不同风格的图表
  • excalidraw-mcp:可以生成手绘风格的图表

Mermaid

我们先来尝试生成 Mermaid 语法的架构图:首先配置: @peng-shawn/mermaid-mcp-server (输入命令 npx -y @peng-shawn/mermaid-mcp-server) 然后将环境变量配置为:CONTENT_IMAGE_SUPPORTED=false(将图片文件保存到本地目录)

提示词:生成一个微服务架构系统的架构图,需包含以下组件:

  • 前端层:Web 客户端、移动客户端
  • API 网关:负责请求路由、认证
  • 服务集群:用户服务、订单服务、支付服务、商品服务
  • 数据层:MySQL 数据库、Redis 缓存、Elasticsearch 搜索引擎
  • 运维工具:Prometheus 监控、Kubernetes 容器编排

要求:使用分层架构展示,标注组件间的通信协议(如 REST、gRPC),并用不同颜色区分服务类型。

将图像保存到 /Users/Downloads 目录,文件名 arc.png

当然,这里我们也可以直接让 AI 帮我们生成 Mermaid 语法,然后我们自己粘贴到一些可视化 Mermaid 平台。

思维导图

下面我们尝试生成思维导图:我们这里借助 @antv/mcp-server-chart 它也支持生成思维导图:

提示词:设计「电商平台开发」项目思维导图,结构需包含:

  • 阶段划分(需求分析、UI 设计、后端开发、测试上线)
  • 各阶段任务(如需求分析:用户调研、竞品分析、功能列表)
  • 资源分配(开发人员、设计工具、测试环境)
  • 风险点(工期延误、技术兼容性、安全漏洞)

手绘风格

下面我们使用 excalidraw-mcp(github.com/i-tozer/exc… - MCP - 自定义 MCP 服务器】选择添加自定义服务器,选择本地,粘贴如下配置:npx -y excalidraw-mcp :

为了保证输出更稳定,我们添加一个全局提示词,在 Chatbox 中可以创建一个搭档来约束最终的输出格式:

提示词:创建一个标准微服务架构图(调用 Excalidraw 工具),该技术栈应包括一个 Web 前端、结合使用 AWS Lambda 和微服务的服务层,以及两种不同的数据库。要求手绘风格、线条清晰、颜色分明,最终将一个符合规范的 Excalidraw 配置,直接输出出来。

注意在发送消息前先选中搭档:

模型输出:

最终会生成一个 Excalidraw 特有的 JSON 文件,我们将其复制:

粘贴到 excalidraw.com/ ,既可以得到一个手绘风格的架构图:

自媒体神器:AI 帮你自动分析整理热点

场景:做自媒体的同学,可能需要各种 “蹭热点” ,让 AI 帮你每天自动收集和整理最新热点信息。

使用到的 MCP:

  • @browsermcp/mcp:可以让 AI 操纵浏览器
  • @larksuiteoapi/lark-mcp:可以让 AI 操纵飞书:发消息、写文档

自动抓取内容

首先我们配置 @browsermcp/mcp( github.com/BrowserMCP/… ,进入【设置 - MCP - 自定义 MCP 服务器】选择添加自定义服务器,选择本地,粘贴如下配置:npx -y @browsermcp/mcp :

然后这个 MCP 需要配合一个 Chrome 插件使用,进入 chromewebstore.google.com/detail/brow… Add to Chrome:

然后点击插件图表,点击连接,连接成功后就可以在客户端中调用这个 MCP 了:

提示词:帮我分析今天的知乎热榜和微博热榜,然后将从两个网站得到的信息进行分类、汇总、去重,最终生成一份内容丰富的今日热点内容(去除政治、营销相关内容,不少于 10 条),要求每个内容必须有,分类、短标题、简单描述、详情链接。

这里的热榜站点大家可以根据自己的需求再进行增加:

AI 将自动打开浏览器,浏览指定内容,并抓取网页信息:

按规定格式完成整理:

自动整理文档

下面,我们借助飞书的 MCP( @larksuiteoapi/lark-mcp)帮我们将爬取到的任务整理为飞书文档和表格,并且通过机器人发送消息给我们:

提示词:帮我分别创建一个飞书文档和多维表格,飞书文档按照标准格式写入完整的热点内容,多维表格按照类型、标题、描述、链接的格式进行录入,创建完成后。创建一个群聊,群聊名称为今日热点,然后将邮箱为 1009903985@qq.com 的用户拉进群,把这两个链接发到群里。

AI 执行结果:

自动创建好的飞书文档和多维表格:

自动拉群并发送消息:

基于这个流程,大家完全可以自己封装一个定时任务,每天固定时间让 AI 完成热点信息的分析和整理,并且将整理好的内容发送给你,大家可以自己去扩展 ~

最后

关注《code秘密花园》从此学习 AI 不迷路,code秘密花园 AI 教程完整的学习资料汇总在这个文档:ai.mmh1.top/#/ai-tutori…

如果本期对你有所帮助,希望得到一个免费的三连,感谢大家支持,我们下期见!

打造极致聊天体验:uz-chat——全端AI聊天组件来了!

作者 伊泽瑞尔
2025年6月24日 21:47

🚀 打造极致聊天体验:uz-chat——全端AI聊天组件来了!

🌟 插件介绍

uz-chat是一款基于uni-app开发的全端AI聊天组件,可无缝对接DeepSeek、OpenAI等主流AI服务。它不仅支持基础的消息展示,还内置了打字机效果、Markdown渲染和平滑滚动等高级特性,让你的应用瞬间拥有专业级聊天体验!

插件效果

✨ 核心功能亮点

1️⃣ 全端兼容,一次开发多端运行

  • 完美支持H5、小程序、App等多平台
  • 基于uni-app生态,无缝集成现有项目

2️⃣ 流畅的消息交互体验

  • 🎉 实时滚动:新消息自动平滑滚动到底部
  • ⌨️ 打字机效果:模拟AI思考和输入过程
  • 📋 消息操作:支持复制、编辑消息内容

3️⃣ 强大的内容渲染

  • ✍️ Markdown支持:代码高亮、表格、列表等格式化展示
  • 💻 代码块展示:支持多种编程语言语法高亮
  • 📝 富文本内容:满足复杂消息展示需求

4️⃣ 灵活的自定义能力

  • 支持自定义头像、昵称
  • 可扩展的消息类型插槽
  • 丰富的样式定制选项

🚀 快速上手

安装方式

在DCloud插件市场导入聊天消息组件uni_modules版本,无需额外import即可使用。

基础用法

<template>
  <uz-chat 
    @sendMessage="sendMessage"
    :isSending="isSending"
    :messages="messages"
    v-model:modelValue="inputMessage"
    :offset-height="topHeight + 'rpx'"
  ></uz-chat>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const isSending = ref(false)
const messages = ref([])
const inputMessage = ref('')

// 发送消息处理
const sendMessage = async (msg: string) => {
  // 实现消息发送逻辑
}
</script>

对接AI服务

// 对接DeepSeek等AI服务示例
async function createChatCompletion(messages) {
  const openai = new OpenAI({
    baseURL: 'https://api.deepseek.com',
    apiKey: process.env.DEEPSEEK_API_KEY
  })
  
  return openai.chat.completions.create({
    messages: messages,
    model: 'deepseek-chat',
    stream: true
  })
}

🛠️ 技术特性

  • 高效渲染:采用虚拟列表技术,支持大量消息展示
  • 性能优化:消息滚动节流处理,避免卡顿
  • 类型安全:完整的TypeScript类型定义
  • 轻量化设计:核心功能打包体积小

📈 未来规划

  • 支持上拉加载更多历史消息
  • 支持语音消息
  • 自定义表情包功能
  • 暗黑模式

🤝 如何获取

💡 写在最后

uz-chat致力于为开发者提供开箱即用的高质量聊天组件,无论是构建AI助手、在线客服还是社交聊天应用,它都能满足你的需求。现在就集成uz-chat,为你的应用增添专业的聊天体验吧!

如果觉得这个组件对你有帮助,欢迎在掘金、CSDN等平台分享你的使用体验,也欢迎提交issue和PR参与项目贡献!

❌
❌