基于Springboot2+Vue3的Partner交友项目(七)

Vue3在线聊天室

聊天,即前端与后端通信,基于 websocket 通道,前端可以实时从 websocket 通道来获取后台推送的消息,就不需要刷新网页了。

后端

pom加入websocket依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

配置文件

1 WebSocketConfig

// 1.WebSocketConfig 
package com.partner.boot.common;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration    // 配置文件必备
@EnableWebSocket  // 开启服务
public class WebSocketConfig {
    /**
     * 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
     * ServerEndpointExporter 是依赖所提供的的类
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

2 WebSocketServer.java

package com.partner.boot.service.impl;

import cn.hutool.core.lang.Dict;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.partner.boot.entity.Im;
import com.partner.boot.entity.User;
import com.partner.boot.service.IImService;
import com.partner.boot.service.IUserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author websocket服务
 */

@ServerEndpoint(value = "/imserver/{uid}")       // 定义路由  websocket 也需要路由
@Component                                       // 注册为springbootservice 服务才能生效
public class WebSocketServer {

    private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);
    public static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();  // 记录当前在线连接数

    @Resource
    IUserService userService;

    @Resource
    IImService imService;

    private static IUserService staticUserService;
    private static IImService staticImService;


    /**
     * 0.页面初始化
     * 程序初始化的时候触发这个方法  赋值
     */
    @PostConstruct
    public void setStaticUser() {
        // 在程序初始化时把 加载到内存,变成静态的成员变量
        // 也可以通过 SpringBeanUtil ,但需要自己封装spring获取bean的类
        staticUserService = userService;
        staticImService = imService;
    }

    /**
     * 1.页面开启
     * 连接建立成功调用的方法 可以获取前端页面的 session (一个客户端和一个服务端建立的连接) 通过session往客户端发消息
     * 当新的用户连接成功后,会广播给每一个用户当前有多少个人
     * 并把新用户的 session 存到  sessionMap
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("uid") String uid) {
        // uid 作为key:一个用户只有一个客户端,只有一个页面。
        sessionMap.put(uid, session);
        log.info("有新用户加入,uid={}, 当前在线人数为:{}", uid, sessionMap.size());
        // 将当前所有用户个数传给客户端。Dict,Hutool 提供的 字典。
        Dict dict = Dict.create().set("nums", sessionMap.size());
        // 后台发送消息给所有的客户端
        sendAllMessage(JSONUtil.toJsonStr(dict));
    }

    /**
     * 2.页面关闭
     * 连接关闭调用的方法
     * 刷新(session会变),离开都算关闭
     */
    @OnClose
    public void onClose(Session session, @PathParam("uid") String uid) {
        // 从后台缓存去掉
        sessionMap.remove(uid);
        log.info("有一连接关闭,uid={}的用户session, 当前在线人数为:{}", uid, sessionMap.size());
        Dict dict = Dict.create().set("nums", sessionMap.size());
        // 后台发送消息给所有的客户端
        sendAllMessage(JSONUtil.toJsonStr(dict));
    }

    /**
     * 3. 发送消息。核心功能。
     * 收到客户端消息后调用的方法
     * 后台收到客户端发送过来的消息
     * onMessage 是一个消息的中转站
     * 接受 浏览器端 socket.send 发送过来的 json数据
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, Session fromSession, @PathParam("uid") String uid) throws JsonProcessingException {

        log.info("服务端收到用户uid={}的消息:{}:", uid, message);

        if (staticUserService == null) {
            return;
        }

        // 查询用户
        User user =  staticUserService.getOne(new QueryWrapper<User>().eq("uid", uid));
        if (user == null) {
            log.error("获取用户信息失败,uid={}", uid);
            return;
        }

        // 4
        // 直接存到数据库里面
        Im im = Im.builder().uid(uid).username(user.getNamex()).avatar(user.getAvatar()).sign(user.getSign())
                .createTime(LocalDateTime.now()).text(message).build();

        // 2
        // // 前端代码改了后 message 就不再是 json 了,而是消息字符串,需要自己构建DTO
        // ImMessageDTO messageDTO = ImMessageDTO.builder().uid(uid).username(user.getNamex()).avatar(user.getAvatar()).sign(user.getSign())
        //        .createTime(new Date()).text(message).build();

        // 1
        // // Json 字符串转化为数据类
        // ImMessageDTO imMessageDTO = JSONUtil.toBean(message, ImMessageDTO.class);
        // imMessageDTO.setCreateTime(new Date());

        // 3
        // // 存数据到数据库
        // Im im = new Im();
        // BeanUtil.copyProperties(messageDTO,im);


        staticImService.save(im);

        // // message 处理好后再转回json;处理后的消息体;
        // 刚发消息时显示不出来,原因:实体类加了注解@Alias("xx"),json的key会变成中文
        // String jsonStr = JSONUtil.toJsonStr(im);

        // 处理后的消息体
        String jsonStr = new ObjectMapper().writeValueAsString(im);

        // 广播
        // 消息体是后端构建的 前端需要使用数据时,前端自己不构建,依赖于后端发送的数据
        // 所以自己也要接受到后端返回的消息 所以这里应该用 sendAllMessage 而不是 sendMessage
        this.sendAllMessage(jsonStr);
        log.info("发送消息:{}:",  jsonStr);
    }



    /**
     * 3.1
     * 广播 服务端发送消息给除了自己的其他客户端
     */
    private void sendMessage(Session fromSession, String message) {
        sessionMap.values().forEach(session -> {
            // 把发送消息的自己排除
            if (fromSession != session) {
                log.info("服务端给客户端[{}]发送消息{}:", session.getId(), message);
                try {
                    session.getBasicRemote().sendText(message); // 发消息
                } catch (IOException e) {
                    log.error("服务端发送消息给客户端异常", e);
                }
            }
        });
    }

    /**
     * 3.2
     * 服务端发送消息给所有客户端
     */
    private void sendAllMessage(String message) {
        try {
            for (Session session : sessionMap.values()) {
                log.info("服务端给客户端[{}]发送消息{}:", session.getId(), message);
                session.getBasicRemote().sendText(message);
            }
        } catch (Exception e) {
            log.error("服务端发送消息给客户端失败", e);
        }
    }


    /**
     * 4.
     * 发生错误
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("发生错误");
        error.printStackTrace();
    }

}


前端

安装表情包依赖 npm i vue3-emoji@1.3.0 表情包依赖文档:https://github.com/ADKcodeXD/Vue3-Emoji

im.vue

<script setup>
import {nextTick, onMounted, ref} from "vue";
import V3Emoji from 'vue3-emoji'
import 'vue3-emoji/dist/style.css'
import {useUserStore} from "@/stores/user";
import request from "@/utils/request";

const messages = ref([])

const userStore = useUserStore()
const user = userStore.getUser

const text = ref('')  // 聊天输入的内容
const divRef = ref()         // 聊天框的引用。发送消息后,需要把滚动条滚动到最新位置,需要用到这个引用

// 页面滚动到最新位置的函数
const scrollBottom = () => {
  // 数据渲染通过v-if, scrollBottom函数触发时,页面的DOM不一定渲染好了
  // 等到页面元素出来之后再去滚动
  nextTick(() => {
    divRef.value.scrollTop = divRef.value.scrollHeight
  })
}

// 页面加载完成触发此函数
onMounted(() => {
  // 数据加载完再滚动
  request.get("/im/init/10").then(res => {
    messages.value = res.data

    // // 1.刷新滚动
    // scrollBottom()
  })
})

// 后台 WebSocketServer.java 提供的路由
const client = new WebSocket(`ws://localhost:9090/imserver/${user.uid}`)

// 2.发送消息触发滚动条滚动
const send = () => {
  if (client) {
    // 之前在前端写死数据 目的是 明白实体类应该包括哪些字段
    // message.value.push({uid: user.id, username: user.name, avatar: user.avatar,text: text.value})
    // 现在是改造的更简单
    client.send(text.value)
  }
  text.value = ''  // 清空文本框

  // send 之后不能立马滚动,要接收到消息后再滚动
  // 消息要转到后台存到数据库再发送给其它用户
  // scrollBottom()
}

// 获取当前文件状态
client.onopen = () => {
  console.log('open')
}

// 页面刷新的时候 和 后台websocket服务关闭的时候
client.onclose = () => {
  console.log('close')
}

// 获取后台消息
client.onmessage = (msg) => {
  if (msg.data) {
    let json = JSON.parse(msg.data)
    // 有聊天消息
    if (json.uid && json.text) {
      messages.value.push(json)
      // 消息接收到后,滚动页面到最底部
      scrollBottom()
    }
  }
}

const optionsName = {
  'Smileys & Emotion': '笑脸&表情',
  'Food & Drink': '食物&饮料',
  'Animals & Nature': '动物&自然',
  'Travel & Places': '旅行&地点',
  'People & Body': '人物&身体',
  Objects: '物品',
  Symbols: '符号',
  Flags: '旗帜',
  Activities: '活动'
}


</script>

<template>
  <div style="width: 80%; margin: 10px auto">
    <!-- 聊天框-->
    <div ref="divRef" style="background-color: white; padding: 20px; border: 1px solid #ccc; border-radius: 10px; height: 400px; overflow-y: scroll;">
      <!-- 循环获取消息 -->
      <!-- item.uid + item.createTime + Math.random() 消息的唯一id  -->
      <!-- 创建了im表后,表里的id是唯一的 -->
      <div v-for="item in messages" :key="item.id">

        <!-- 1 别人给我发的消息 -->
        <div style="display: flex; margin: 20px 0;" v-if="user.uid !== item.uid">
          <!-- 点击头像所显示的框框 -->
          <el-popover
              placement="top-start"
              :width="100"
              trigger="hover"
          >
            <!-- 头像 -->
            <template #reference>
              <img :src="item.avatar" alt="" style="width: 30px; height: 30px; border-radius: 50%; margin-right: 10px">
            </template>
            <!-- 弹出框类容 -->
            <div style="line-height: 20px">
              <div style="font-size: 16px">{{ item.username }}</div>
              <div style="font-size: 12px;">{{ item.sign }}</div>
            </div>
          </el-popover>
          <!-- 聊天类容  width:fit-content 不加,消息会占满整行 -->
          <!-- <div style="width: 50px; line-height: 30px; margin-left: 5px; color: #888; overflow: hidden; font-size: 14px">{{ item.username }}</div> -->
          <div style="line-height: 30px; background-color: aliceblue; padding: 0 10px; width:fit-content; border-radius: 10px">{{ item.text }}</div>
        </div>

        <!-- 2  我给别人发的消息  justify-content: flex-end:靠右显示-->
        <div style="display: flex; justify-content: flex-end; margin: 20px 0;" v-else>
          <div style="line-height: 30px; background-color: lightyellow; padding: 0 10px; width:fit-content; border-radius: 10px;">{{ item.text }}</div>
          <el-popover
              placement="top-start"
              :width="100"
              trigger="hover"
          >
            <template #reference>
              <img :src="item.avatar" alt="" style="width: 30px; height: 30px; border-radius: 50%; margin-left: 10px">
            </template>
            <div style="line-height: 20px">
              <div style="font-size: 16px">{{ item.username }}</div>
              <div style="font-size: 12px;">{{ item.sign }}</div>
            </div>
          </el-popover>
        </div>

      </div>
    </div>

    <!-- 聊天输入框 第三方插件 -->
    <div style="margin: 10px 0; width: 100%">
      <!--  :keep="true" 标签框关闭不会销毁组件。页面性能会好点,不用重复渲染。-->
      <!-- :textArea="true"  显示聊天框; v-model="text"  聊天框内容  -->
      <V3Emoji default-select="recent" :recent="true" :options-name="optionsName" :keep="true"  :textArea="true" size="mid" v-model="text" />
      <div style="text-align: right"><el-button @click="send" type="primary">发送</el-button></div>
    </div>

  </div>
</template>

ImMessageDTO.java 后台接收前台的实体,作为数据传输用,本身没有太大价值,与数据库没关系。

发送的消息 --> 需要把数据发送给后台 --> 后端接收消息,存到数据库表 im 里去 im 表 --> ImMessageDTO就没用了。

管理端

创建im权限(页面)管理 、 为admin分配权限。

存储数据、取数据。

    // ImController 
    @GetMapping("/init/{limit}")
    // @PathVariable 接受花括号形式的参数 
    public Result findAllInit(@PathVariable Integer limit) {
        // 默认取limit条数据
        return Result.success(imService.list(new QueryWrapper<Im>().last("limit " + limit)));
    }
问题

问题:在 WebSocketServer.java 的 onMessage() 方法里 获取 user 失败
在这里插入图片描述
在这里插入图片描述

解决:

为什么消息会出现null?因为消息是多线程,前端每构建的消息通道,都是一个单独的线程,在多线程里面不能通过注入 @Resource IUserService userService; 从容器里拿到bean,可以通过 static 初始化进来,也可以通过 SpringBeanUtil ,但需要自己封装spring获取bean的类/方法,然后再从容器里拿bean。

  // 把 userService 初始化进来
  private static IUserService staticUserService;  

    /**
     * 0.页面初始化
     * 程序初始化的时候触发这个方法  赋值
     */
    @PostConstruct
    public void setStaticUser() {
        staticUserService = userService;
    }

首页动态展示

换代码生成器了

增加 个人主页,修改密码,两个页面和后端接口。

前端:person.vue password.vue

后端:

   // 1 WebController
   
   // 修改密码
    @PostMapping("/password/change")
    public Result passwordChange(@RequestBody UserRequest userRequest) {
        userService.passwordChange(userRequest);
        return Result.success();
    }

    // 更新个人信息
    @PutMapping("/updateUser")
    public Result updateUser(@RequestBody User user) {
        Object loginId = StpUtil.getLoginId();
        if (!loginId.equals(user.getUid())) {
            Result.error("无权限");
        }
        userService.updateById(user);
        return Result.success(user);
    }
// 2 IUserService
void passwordChange(UserRequest userRequest);

// 3  UserRequest
 private String uid;
 private String newPassword;

// 4 UserServiceImpl
public void passwordChange(UserRequest userRequest) {
    User dbUser = getOne(new UpdateWrapper<User>().eq("uid", userRequest.getUid()));
    if (dbUser == null) {
        throw new ServiceException("未找到用户");
    }
    boolean checkpw = BCrypt.checkpw(userRequest.getPassword(), dbUser.getPassword());
    if (!checkpw) {
        throw new ServiceException("原密码错误");
    }
    String newPass = userRequest.getNewPassword();
    dbUser.setPassword(BCrypt.hashpw(newPass));
    updateById(dbUser);   // 设置到数据库
}

动态展示首页

在后台获取用户信息 User user = (User) StpUtil.getSession().get(Constants.LOGIN_USER_KEY);

   // Dynamic 
   @TableField(exist = false)
    private User user;
    @GetMapping("/hot")
    @SaCheckPermission("dynamic.list.hot")
    public Result hot( @RequestParam Integer pageNum,
                       @RequestParam Integer pageSize) {
        // 用户存在动态表里的是uid 这里获取信息时需要的是完整的用户id
        // 实际业务时需要在不同系统交互数据 这里的话单个系统比较简单 直接根据用户id查数据即可
        // 但用户比较多,需要分页了,否则前端可能会卡了
        QueryWrapper<Dynamic> queryWrapper = new QueryWrapper<Dynamic>().orderByDesc("id");
        Page<Dynamic> page = dynamicService.page(new Page<>(pageNum, pageSize), queryWrapper);

        // 1 通过sql 2 直接从一个很大的用户接口里筛选需要的数据
        // 这里使用 2
        List<User> users = userService.list();
        for (Dynamic record : page.getRecords()) {
            // 从users里面找到uid跟当前动态里面的uid一样的数据
            // ifPresent 表示数据筛选有结果的时候
            users.stream().filter(user -> user.getUid().equals(record.getUid())).findFirst().ifPresent(record::setUser);
        }
        return Result.success(page);
    }

前端

<script setup>
import {ChatLineRound, Compass, Pointer, View} from '@element-plus/icons-vue'
import request from "@/utils/request";
import {reactive} from "vue";

function filterTime(time) {
  const date = new Date(time)
  const Y = date.getFullYear()
  const M = date.getMonth() + 1 < 10 ? '0'+(date.getMonth()+1) : date.getMonth()+1
  const D = date.getDate()
  return `${Y}-${M}-${D}`
}

const state = reactive({
  hotDynamics: []
})
const load = () => {
  request.get('/dynamic/hot', {
    params: {
      pageNum: 1,
      pageSize: 5
    }
  }).then(res => {
    state.hotDynamics = res.data.records
    console.log(res.data.records)
  })
}
load()
</script>

<template>
  <div style="background-color: white; border-radius: 10px; margin-bottom: 10px" class="container-height;">
    <div style="display: flex; padding: 10px">
      <!--      话题动态-->
      <div class="dynamic-box" style="width: 60%; ">
<!--        :src="item.user.avatar"    {{ item.user.namex }} -->
        <div style="padding: 20px; border: 1px solid #ccc; border-radius: 10px; margin-bottom: 10px" v-for="item in state.hotDynamics" :key="item.id">
          <div style="display:flex;">
            <img style="width: 50px; height: 50px; margin-right: 20px; border-radius: 50%"
                 :src="item.user.avatar"
                 alt="">
            <div style="flex: 1; line-height: 25px">
              <div style="font-weight: bold"> {{ item.user.namex }} </div>
              <div style="font-size: 12px; color: #999">{{ filterTime(item.createTime) }} · 来自 {{ item.user.address }}</div>
            </div>
            <el-button>关注</el-button>
          </div>

          <div style="" class="content">{{ item.description }}</div>

          <div style="margin: 10px 0">
            <el-row :gutter="10">
              <el-col :span="12" style="margin-bottom: 10px">
                <img style="width: 100%;"
                     :src="item.img"
                     alt="">
              </el-col>
              <el-col :span="12" style="margin-bottom: 10px">
                <img style="width: 100%;"
                     :src="item.img"
                     alt="">
              </el-col>
            </el-row>
          </div>

          <div style="margin: 10px 0; display: flex; line-height: 25px">
            <div style="width: 50%">
              <el-tag># 冬至到了</el-tag>
              <el-tag type="danger" style="margin-left: 10px">
                <el-icon style="top: 1px">
                  <Compass/>
                </el-icon>
                米粉杂谈
              </el-tag>
            </div>
            <div style="width: 50%; text-align: right; color: #999; font-size: 14px;">
              <el-icon size="20" style="top: 5px">
                <View/>
              </el-icon>
              20
              <el-icon size="20" style="margin-left: 10px; top: 5px">
                <Pointer/>
              </el-icon>
              10
              <el-icon size="20" style="margin-left: 10px; top: 5px">
                <ChatLineRound/>
              </el-icon>
              30
            </div>
          </div>
        </div>

      </div>


      <!-- 咨询-->
      <div style="width: 40%; ">

        <div style=" padding: 10px; margin-left: 10px; border: 1px solid #ccc; border-radius: 10px; margin-bottom: 10px">
          <div style="font-size: 18px; padding: 10px; color: salmon"><b>交友资讯</b></div>

          <div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
          <div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
          <div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
          <div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
          <div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
        </div>

        <div style=" padding: 10px; margin-left: 10px; border: 1px solid #ccc; border-radius: 10px">
          <div style="font-size: 18px; padding: 10px; color: #8ec5fc"><b>推荐圈子</b></div>

          <el-row :gutter="10" style="margin: 10px 0">
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
            <el-col :span="12">
              <el-card style="margin-bottom: 10px; cursor: pointer">
                <div style="padding: 5px; text-align: center">米粉圈子</div>
              </el-card>
            </el-col>
          </el-row>
        </div>

      </div>



    </div>
  </div>
</template>

<style scoped>
/* 只显示2行文本,多余的用省略号代替 */
.content {
  margin: 10px 0;
  line-height: 25px;
  text-align: justify;
  word-break: break-all;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
</style>

问题集

问题一:不用登陆也可以看首页

解决:

1
在这里插入图片描述

2

在这里插入图片描述

3

// 直接进入首页,缓存里没用任何用户信息,会报错
const avatar = ref('')
const user = store.getUser  //  store.getUser 默认是 {},if({})是true
// 有值再赋值
if (user.avatar) {
  avatar.value = user.avatar
}

问题二:
在这里插入图片描述

解决:

// role.vue
const handleEdit = (raw) => {
  dialogFormVisible.value = true
  // 未来元素渲染
  nextTick(() => {
    ruleFormRef.value.resetFields()
    state.form = JSON.parse(JSON.stringify(raw))

    // 初始化,默认不选择任何节点
    permissionTreeRef.value.setCheckedKeys([])
    raw.permissionIds.forEach(v => {
      // 给权限树设置选中的节点
      permissionTreeRef.value.setChecked(v, true, false)
    })

  })
}

问题三:没登录 系统不知道是用户还是管理员 没法鉴权 。 需要把权限开放给用户

解决:

在这里插入图片描述

问题四:保存有数据,数据库有数据,但是前台未显示数据

解决:表单deleted字段未设置默认值

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值