Koa 手把手用做一个聊天室(koa+vue)

目录结构:

imserver

controllers

index.js

app.js

controller.js

package.json

node_modules

vuesrcapiindex.js
assetscssindex.css

config

index.js

dev.js

prod.js

lang

index.js

en.js

zh.js

routerindex.js
serviceindex.js

store

modules

index.js

login.js

utillocale.js
views

home.vue

login.vue

App.vue
main.js
static

css

normalize.css

img

0.png

1.png

2.png

3.png

4.png

5.png

6.png

7.png

8.png

9.png

package.json

im\server\controllers\index.js:

var userIndex = 0
async function loginName (ctx, next) {
  let locale = ctx.cookies.get('locale')
  let langMap = {
    zh: '甲乙丙丁戊己庚辛壬癸',
    en: 'ABCDEFGHIJ',
  }
  let names = langMap[`${locale}`]
  let name = names[userIndex % 10]
  ctx.response.body = JSON.stringify({
    code: 1,
    data: {name},
    message: 'ok'
  })
  ctx.response.type = 'json'
}
async function login (ctx, next) {
  let name = ctx.request.body.name
  let user = {
    id: ++userIndex,
    name: name,
    time: new Date().getTime(),
    img: userIndex % 10
  }
  let value = Buffer.from(JSON.stringify(user)).toString('base64')
  console.log(`Set cookie value: ${value}`)
  ctx.cookies.set('name', value)
  ctx.response.body = JSON.stringify({
    code: 1,
    data: user,
    message: 'ok'
  })
  ctx.response.type = 'json'
}
async function logout (ctx, next) {
  ctx.cookies.set('name', '')
}
module.exports = [
  {
    method: 'GET',
    path: '/loginName',
    fn: loginName
  },
  {
    method: 'POST',
    path: '/login',
    fn: login
  },
  {
    method: 'POST',
    path: '/logout',
    fn: logout
  }
]

im\server\app.js:

const Koa = require('koa')

const app = new Koa()

const Cookies = require('cookies');

const bodyParser = require('koa-bodyparser')

const controller = require('./controller')

const server = app.listen(3000)

// 导入WebSocket模块
const WebSocket = require('ws')

// 引用Server类
const WebSocketServer = WebSocket.Server

// parse user from cookie:
app.use(async (ctx, next) => {
  ctx.state.user = parseUser(ctx.cookies.get('name') || '')
  ctx.state.userLocale = ctx.cookies.get('locale') || ''
  console.log('ctx.state.user: ', ctx.state.user)
  console.log('ctx.state.userLocale: ', ctx.state.userLocale)
  await next()
})

function createWebSocketServer(server, onConnection, onMessage, onClose, onError) {
  let wss = new WebSocketServer({server}) // 实例化
  wss.broadcast = data => {
    wss.clients.forEach(client => {
      client.send(data)
    })
  }
  wss.on('connection', ws => {
    onConnection = onConnection || function () {
      console.log('[WebSocket] connected.')
    }
    onMessage = onMessage || function (msg) {
      console.log('[WebSocket] message received: ' + msg)
    }
    onClose = onClose || function (code, message) {
      console.log(`[WebSocket] closed: ${code} - ${message}`)
    }
    onError = onError || function (err) {
      console.log('[WebSocket] error: ' + err)
    }
    ws.on('message', onMessage)
    ws.on('close', onClose)
    ws.on('error', onError)
    let user = parseUser(ws.upgradeReq)
    if (!user) {
      ws.close(4001, 'Invalid user')
    }
    ws.user = user
    ws.wss = wss
    onConnection.apply(ws)
  })
  console.log('WebSocketServer was attached.')
  return wss
}

var messageIndex = 0

function createMessage(type, user, data) {
  return JSON.stringify({
    id: ++messageIndex,
    type: type,
    time: new Date().getTime(),
    user: user,
    data: data
  })
}

function onConnect() {
  let tip = {
    zh: '加入聊天室',
    en: 'Join the chat room'
  }
  let msg = createMessage('add', this.user, tip)
  this.wss.broadcast(msg)
  // build user list:
  let users = this.wss.clients.map(client => client.user)
  msg = createMessage('list', this.user, users)
  this.wss.broadcast(msg)
}

function onMessage(message) {
  console.log(message)
  let msg = createMessage('chat', this.user, message)
  this.wss.broadcast(msg)
}

function onClose() {
  let tip = {
    zh: '退出聊天室',
    en: 'Quit chat room'
  }
  let msg = createMessage('del', this.user, tip)
  this.wss.broadcast(msg)
}

function parseUser(obj) { // 解析用户
  if (!obj) {
    return
  }
  console.log('try parse: ' + obj)
  let s = ''
  if (typeof obj === 'string') {
    s = obj
  } else if (obj.headers) {
    let cookies = new Cookies(obj, null)
    s = cookies.get('name')
  }
  if (s) {
    try {
      let user = JSON.parse(Buffer.from(s, 'base64').toString())
      console.log(`User: ${user.name}, ID: ${user.id}`)
      return user
    } catch (e) {
      console.log(e)
    }
  }
}
app.use(bodyParser())
app.use(controller())
app.wss = createWebSocketServer(server, onConnect, onMessage, onClose)
console.log('http://127.0.0.1:3000')

im\server\controller.js:

// 导入fs
const fs = require('fs')

function addControllers(router, dir) {
  fs.readdir(dir, (err, files) => {
    if (err) throw err
    files.filter(f => f.endsWith('.js')).forEach(f => {
      let mappingList = require('./' + dir + '/' + f)
      console.log(`Loading file: ${f} for router`)
      mappingList.forEach(params => addMapping({router, ...params}))
    })
  })
}

function addMapping({router, method, path, fn}) {
  switch(method) {
    case 'GET':
      router.get(path, fn)
      console.log(`register URL mapping: GET ${path}`)
      return
    case 'POST':
      router.post(path, fn)
      console.log(`register URL mapping: POST ${path}`)
      return
    case 'PUT':
      router.put(path, fn)
      console.log(`register URL mapping: PUT ${path}`)
      return
    case 'DELETE':
      router.del(path, fn)
      console.log(`register URL mapping: DELETE ${path}`)
      return
    default:
      console.log(`Invalid method: ${method} with path: ${path}`)
      return
  }
}

module.exports = function (dir) {
  let controllers_dir = dir || 'controllers',
  router = require('koa-router')()
  addControllers(router, controllers_dir)
  return router.routes()
}

im\server\package.json:

{
  "name": "my-koa",
  "version": "1.0.0",
  "description": "koa",
  "main": "app.js",
  "scripts": {
    "dev": "node app.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Gulinling",
  "license": "ISC",
  "dependencies": {
    "ws": "1.1.1",
    "koa": "2.0.0",
    "koa-bodyparser": "3.2.0",
    "koa-router": "7.0.0",
    "nunjucks": "2.4.2",
    "mime": "1.3.4",
    "mz": "2.4.0"
  }
}

im\vue\src\api\index.js:

import service from '@/service'
import config from '@/config'
export default {
  login (params) {
    return service.loop(config.login, params, 'post')
  },
  loginName () {
    return service.loop(config.loginName, {}, 'get')
  }
}

m\vue\src\assets\css\index.css:

html, body, #app{
  height: 100%;
}
.container{
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
}

im\vue\src\config\dev.js:

export const dev = {
  login: 'login',
  loginName: 'loginName'
}

im\vue\src\config\index.js:

import {dev} from './dev'
import {prod} from './prod'
const env = process.env.NODE_ENV
function getConfig (env) {
  let config = null
  switch (env) {
    case 'development':
      config = dev
      break
    case 'production':
      config = prod
      break
    default:
      config = dev
  }
  return config
}
export default {
  ...getConfig(env)
}

im\vue\src\config\prod.js:

export const prod = {
  login: 'login',
  loginName: 'loginName'
}

im\vue\src\lang\en.js:

export default {
  home: {
    title: 'chat room',
    userList: 'User list',
    chatRecord: 'Chat record'
  },
  login: {
    title: 'Your Name'
  }
}

im\vue\src\lang\index.js:

import Vue from 'vue'
import Element from 'element-ui'
import VueI18n from 'vue-i18n'
import locale from '@/util/locale'
import en from './en'
import zh from './zh'
import 'element-ui/lib/theme-chalk/index.css'
import './../../static/css/normalize.css'
import './../assets/css/index.css'
document.cookie = `locale = ${locale}`
Vue.use(VueI18n)
Vue.use(Element)

const i18n = new VueI18n({
  locale,
  messages: {
    en,
    zh
  }
})
export default i18n

im\vue\src\lang\zh.js:

export default {
  home: {
    title: '聊天室',
    userList: '用户列表',
    chatRecord: '聊天记录'
  },
  login: {
    title: '你的名字'
  }
}

im\vue\src\router\index.js:

import Vue from 'vue'
import Router from 'vue-router'
import home from '@/views/home'
import login from '@/views/login'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: home.name,
      component: home
    },
    {
      path: '/login',
      name: login.name,
      component: login
    }
  ]
})

im\vue\src\service\index.js:

import axios from 'axios' // 导入axios

const service = axios.create({ // 创建实例
  baseURL: '/api/', // url 前缀
  timeout: 3000 // 请求超时时间
})

service.interceptors.request.use( // 请求拦截器
  config => {
    config.headers['User-Token'] = 'toKen' // 让每个请求携带token
    return config
  },
  error => {
    Promise.reject(error) // 请求错误处理
  }
)

service.interceptors.response.use( // 响应拦截器
  response => { // 统一处理状态
    const res = response.data
    if (res.code !== 1 || response.status !== 200) { // 需自定义
      return Promise.reject({ // 返回异常
        code: res.code,
        message: res.message
      })
    } else {
      return res
    }
  },
  error => { // 处理处理
    return Promise.reject(error)
  }
)

function get (url, params = {}) { // get请求
  return new Promise((resolve, reject) => {
    service({
      url,
      params,
      method: 'get'
    })
    .then(res => {
      resolve(res)
    })
    .catch(err => {
      reject(err)
    })
  })
}

function post (url, data = {}) { // post请求
  return new Promise((resolve, reject) => {
    service({
      url,
      data,
      method: 'post'
    })
    .then(res => {
      resolve(res)
    })
    .catch(err => {
      reject(err)
    })
  })
}

function loop (url, params = {}, method = 'get') { // 循环请求(get post)
  return new Promise((resolve, reject) => {
    serviceLoop({url, params, method, resolve, reject})
  })
}

function serviceLoop ({url, params, method, resolve, reject}) {
  let option = {
    url,
    method
  }
  method = method.toLocaleLowerCase()
  if (method === 'get') {
    option.params = params
  } else if (method === 'post') {
    option.data = params
  } else {
    throw new Error('目前只支持get和post')
  }
  service(option)
  .then(res => {
    resolve(res)
  })
  .catch(err => {
    setTimeout(() => {
      serviceLoop({url, params, method, resolve, reject})
    }, 1000)
  })
}

function fileUpload(url, data = {}) { // 文件上传
  return new Promise((resolve, reject) => {
    service({
      url,
      data,
      method: 'post',
      headers: { 'Content-Type': 'multipart/form-data' }
    })
    .then(res => {
      resolve(res)
    })
    .catch(err => {
      reject(err)
    })
  })
}

export default {
  get,
  post,
  loop,
  fileUpload
}

im\vue\src\store\index.js:

// 引入vue
import Vue from 'vue'
// 引入vuex
import Vuex from 'vuex'
// 导入仓库模块
import {login} from './modules/login.js'

// 使用vuex
Vue.use(Vuex)

const store = new Vuex.Store({
  state: { // 1、state:创建初始化状态
    count: 1 // 放置初始状态
  },
  mutations: { // 2、mutations:创建改变状态的方法
    ADD (state, n) { // 状态变更函数一般大写
      state.count += n
    }
  },
  getters: { // 3、getters:提供外部获取state
    count (state) {
      return state.count
    }
  },
  actions: { // 4、actions:创建驱动方法改变mutations
    add ({commit}, data) {  // 触发mutations中相应的方法一般小写
      commit('ADD', data)
    }
  },
  modules: {
    login
  }
})

// 5、输出store
export default store

im\vue\src\store\modules\login.js:

export const login = {
  state: { // 1、state:创建初始化状态
    user: {} // 放置初始状态
  },
  mutations: { // 2、mutations:创建改变状态的方法
    updateUser (state, newVal) {
      state.user = newVal
    }
  },
  getters: { // 3、getters:提供外部获取state
    user (state) {
      return state.user
    }
  },
  actions: { // 4、actions:创建驱动方法改变mutations
    updateUser ({commit}, data) {  // 触发mutations中相应的方法
      commit('updateUser', data)
    }
  }
}

im\vue\src\util\locale.js:

function getBrowserLang () {
  let locale = null
  if (navigator.languages) {
    locale = navigator.languages[0].toLocaleLowerCase()
  } else {
    locale = (navigator.language || navigator.userLanguage || navigator.browserLanguage || navigator.systemLanguage).toLocaleLowerCase()
  }
  if (~locale.indexOf('zh')) {
    locale = 'zh'
  } else if (~locale.indexOf('en')) {
    locale = 'en'
  } else {
    locale = 'zh'
  }
  return locale
}
export default getBrowserLang()

im\vue\src\views\home.vue:

<style scoped>
  .home {
    height: 100%;
  }
  .content {
    width: 98%;
    height: 98%;
  }
  .content::after,
  .user::after,
  .list::after {
    display: table;
    content: '';
    clear: both;
  }
  .left {
    width: 30%;
  }
  .right {
    width: 70%;
  }
  .left,
  .right {
    height: calc(100% - 81px);
    float: left;
  }
  .content >>> .el-card {
    height: 100%;
  }
  .content >>> .el-card__body {
    padding: 0;
    height: calc(100% - 55px);
    position: relative;
    overflow: hidden;
  }
  .list,
  .chat {
    width: calc(100% + 26px);
    margin: 0;
    padding: 20px;
    list-style: none;
    overflow: scroll;
    text-align: left;
  }
  .list {
    height: calc(100% - 14px);
  }
  .chat {
    height: calc(100% - 54px);
  }
  .chat-input {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 40px;
    border: 1px solid #ebeef5;
    box-sizing: border-box;
  }
  .chat-input >>> .el-input__inner {
    height: 100%;
    resize: none;
  }
  .user {
    position: relative;
    min-height: 48px;
    padding-left: 60px;
    margin: 20px 0;
    padding-right: 46px;
  }
  .user.self {
    padding-left: 0;
    padding-right: 106px;
  }
  .user.self .user-info{
    left: auto;
    right: 46px;
  }
  .user.self .user-content{
    float: right;
    background-color: #cef;
  }
  .user.self .user-content::after{
    content: '';
    position: absolute;
    left: 100%;
    top: 0px;
    bottom: 0;
    margin: auto;
    width: 16px;
    height: 16px;
    border-width: 0;
    border-style: solid;
    border-color: transparent;
    border-bottom-width: 10px;
    border-bottom-color: currentColor;
    border-radius: 0 0 32px 0;
    color:#cef;
  }
  .user.self .user-info{
    text-align: center;
  }
  .user-img {
    display: block;
    width: 30px;
    height: 30px;
    margin: auto;
  }
  .user-content{
    position: relative;
    display: table;
    float: left;
    min-height: 48px;
    line-height: 27px;
    padding: 10px;
    background-color: #eee;
    border-radius: 4px;
    box-shadow: 0 0 1px 0 #b4bbbf;
    box-sizing: border-box;
  }
  .user-content::after{
    content:'';
    position:absolute;
    right: 100%;
    top: 0px;
    bottom: 0;
    margin: auto;
    width: 16px;
    height: 16px;
    border-width: 0;
    border-style: solid;
    border-color: transparent;
    border-bottom-width: 10px;
    border-bottom-color: currentColor;
    border-radius: 0 0 0 32px;
    color: #ddd;
  }
  .user-info {
    display: table;
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    margin: auto;
    white-space: nowrap;
  }
  .user-name {
    color: #7c868d;
  }
  .user-id {
    color: #b4bbbf;
  }
  .user-text {
    margin: 0;
    color: #000;
    word-break: break-all;
    white-space: pre-wrap;
  }
</style>

<template>
  <div class="home">
    <div class="container">
      <div class="content">
        <h1>{{$t('home.title')}}</h1>
        <div class="left">
          <el-card class="box-card">
            <div slot="header">
              <span>{{$t('home.userList')}}</span>
            </div>
            <ul class="list">
              <li v-for="item in list" class="user">
                <p class="user-info">
                  <span class="user-name">{{item.name}}</span>
                  <span class="user-id">{{item.id}}</span>
                  <img class="user-img" :src="imgSrc(item.img)" alt="img">
                </p>
              </li>
            </ul>
          </el-card>
        </div>
        <div class="right">
          <el-card class="box-card">
            <div slot="header">
              <span>{{$t('home.chatRecord')}}</span>
            </div>
            <ul class="chat" ref="chat">
              <li v-for="item in chat" :class="['user', {'self': item.user.id === user.id}]">
                <p class="user-info">
                  <span class="user-name">{{item.user.name}}</span>
                  <span class="user-id">{{item.user.id}}</span>
                  <img class="user-img" :src="imgSrc(item.user.img)" alt="img">
                </p>
                <div class="user-content">
                  <p class="user-text">{{item.data}}</p>
                </div>
              </li>
            </ul>
            <el-input class="chat-input" v-model="text" @keyup.enter.native="send" clearable></el-input>
          </el-card>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
  import api from '@/api'
  import {mapGetters} from 'vuex'
  export default {
    name: 'home',
    data () {
      return {
        ws: null,
        chat: [],
        list: [],
        text: ''
      }
    },
    mounted () {
      this.init()
    },
    computed: {
      ...mapGetters(["user"])
    },
    methods: {
      imgSrc (name) {
        return `/static/img/${name}.png`
      },
      init () {
        if (this.user.id) {
          this.ws = new WebSocket(`ws://${location.hostname}:3000`)
          this.ws.addEventListener('message', this.message)
        } else {
          this.$router.push({name: 'login'})
        }
      },
      send () { // 发送输入的消息
        this.ws.send(this.text)
        this.text = ''
      },
      message (res) { // 响应收到的消息
        let msg = JSON.parse(res.data)
        switch (msg.type) {
          case 'list':
            this.list = msg.data
            break
          case 'add':
            if (!~this.list.indexOf(msg.user)) {
              this.list.push(msg.user)
            }
            msg.data = msg.data[this.$i18n.locale] || msg.data
          case 'del':
            let index = this.list.findIndex(item => item.id === msg.user.id)
            this.list.splice(index, 1)
            msg.data = msg.data[this.$i18n.locale] || msg.data
          case 'chat':
            if (this.chat.length) {
              let lastTime = msg.time
              let lastTime2 = this.chat[this.chat.length - 1].time
              if (lastTime - lastTime2 > 60000) {
                this.$refs.chat.innerHTML += `<li style="text-align: center; margin-right: 26px;">${new Date(msg.time).toLocaleString()}</li>`
              }
            }
            this.chat.push(msg)
        }
        this.$nextTick(() => {
          this.$refs.chat.scrollTop = this.$refs.chat.scrollHeight
        })
      }
    }
  }
</script>

im\vue\src\views\login.vue:

<style scoped>
  .login {
    height: 100%;
    overflow: hidden;
  }
</style>

<template>
  <div class="login">
    <div class="container">
      <div class="content">
        <h1>{{$t('login.title')}}</h1>
        <el-input v-model="text" @keyup.enter.native="submit" clearable></el-input>
      </div>
    </div>
  </div>
</template>

<script>
  import api from '@/api'
  import {mapActions} from 'vuex'
  export default {
    name: 'login',
    data () {
      return {
        text: ''
      }
    },
    created () {
      this.loginName()
    },
    methods: {
      ...mapActions(['updateUser']),
      loginName () {
        api.loginName()
        .then(res => {
          this.text = res.data.name
        })
        .catch(err => {
          console.log(err)
        })
      },
      submit () {
        api.login({name: this.text})
        .then(res => {
          this.updateUser(res.data)
          this.$router.push({name: 'home'})
        })
        .catch(err => {
          console.log(err)
        })
      }
    }
  }
</script>

im\vue\src\App.vue:

<style>
</style>

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

<script>
  export default {
    name: 'App'
  }
</script>

im\vue\src\main.js:

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store'
import i18n from './lang'
Vue.config.productionTip = false

/* eslint-disable no-new */
window.vue = new Vue({
  el: '#app',
  router,
  store,
  i18n,
  components: { App },
  template: '<App/>'
})

im\vue\static\css\normalize.css:Normalize.css: Make browsers render all elements more consistently.

im\vue\static\img:0-9图片自己网上下载一些头像喽

im\vue\package.json:

{
  "name": "vue",
  "version": "1.0.0",
  "description": "A Vue.js project",
  "author": "",
  "private": true,
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js --host 10.17.143.11",
    "start": "npm run dev",
    "e2e": "node test/e2e/runner.js",
    "test": "npm run unit && npm run e2e",
    "lint": "eslint --ext .js,.vue src test/unit test/e2e/specs",
    "build": "node build/build.js"
  },
  "dependencies": {
    "axios": "^0.18.0",
    "element-ui": "^2.4.7",
    "vue": "^2.5.2",
    "vue-i18n": "^8.1.0",
    "vue-router": "^3.0.1",
    "vuex": "^3.0.1"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.2",
    "babel-core": "^6.22.1",
    "babel-eslint": "^7.1.1",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-loader": "^7.1.1",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-plugin-transform-vue-jsx": "^3.5.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
    "babel-register": "^6.22.0",
    "chalk": "^2.0.1",
    "copy-webpack-plugin": "^4.0.1",
    "cross-spawn": "^5.0.1",
    "css-loader": "^0.28.0",
    "eslint": "^4.15.0",
    "eslint-config-standard": "^10.2.1",
    "eslint-friendly-formatter": "^3.0.0",
    "eslint-loader": "^1.7.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-node": "^5.2.0",
    "eslint-plugin-promise": "^3.4.0",
    "eslint-plugin-standard": "^3.0.1",
    "eslint-plugin-vue": "^4.0.0",
    "extract-text-webpack-plugin": "^3.0.0",
    "file-loader": "^1.1.4",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "html-webpack-plugin": "^2.30.1",
    "nightwatch": "^0.9.12",
    "node-notifier": "^5.1.2",
    "optimize-css-assets-webpack-plugin": "^3.2.0",
    "ora": "^1.2.0",
    "portfinder": "^1.0.13",
    "postcss-import": "^11.0.0",
    "postcss-loader": "^2.0.8",
    "postcss-url": "^7.2.1",
    "rimraf": "^2.6.0",
    "selenium-server": "^3.0.1",
    "semver": "^5.3.0",
    "shelljs": "^0.7.6",
    "uglifyjs-webpack-plugin": "^1.1.1",
    "url-loader": "^0.5.8",
    "vue-loader": "^13.3.0",
    "vue-style-loader": "^3.0.1",
    "vue-template-compiler": "^2.5.2",
    "webpack": "^3.6.0",
    "webpack-bundle-analyzer": "^2.9.0",
    "webpack-dev-server": "^2.9.1",
    "webpack-merge": "^4.1.0"
  },
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ]
}

需要你切换到server目录和vue目录安装依赖,运行需要先切换server执行npm run dev再切换vue执行同样的操作,至此完结撒花

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值