Egg Vue 搭建全栈博客

Vue 开发记录及探讨 同时被 3 个专栏收录
6 篇文章 0 订阅
10 篇文章 0 订阅
10 篇文章 0 订阅

Egg

使用Egg建立后台,应用mongoose对数据库进行操作,利用中间件进行用户的鉴权,将私密接口通过token鉴别分离出来。
同时,巧妙应用中间件可以从url中将数据类型和数据id等分离出来,提高后台代码整洁性

本项目中,使用了mongodb数据库,在一开始做数据模型定义时,没有考虑太多情形,导致数据模型设计得不太好,读者可以考虑更改数据模型,改成类似关系型数据库那种类型

model

// model/user
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const schema = new mongoose.Schema({
    username: {
        type: String,               // 用户名用于登录注册
        required: true
    },
    password: {
        type: String,
        required: true,
        select: false,            // 查询不会返回
        set(value) {
            // 设置或更改时,加密
            return bcrypt.hashSync(value, 10);
        }
    },
    imgUrl: {
        type: String
    },
    fav: [{
        type: mongoose.SchemaTypes.ObjectId,
        ref: 'Article'
    }]
});

module.exports = mongoose.model('User', schema);

// model/article
const mongoose = require('mongoose');

const schema = new mongoose.Schema({
    title: { type: String },
    isTop: {
        type: Boolean,
        default: false
    },
    summary: { type: String },
    body: { type: String },             // 编译后的html内容
    MdContent: { type: String },     // md值
    read: {
        type: Number,
        default: 1
    },
    fav: {
        type: Number,
        default: 0
    },
    categories: [{
        type: mongoose.SchemaTypes.ObjectId,       // 指向 外键
        ref: 'Category'
    }],
    comments: [{
        type: mongoose.SchemaTypes.ObjectId,        // 指向 外键
        ref: 'Comment'
    }]
}, {
    timestamps: true                            // 自动添加 建立和更改的时间
});

module.exports = mongoose.model('Article', schema, 'articles');

以上先给出用户和文章数据的表,其他请看源码~~

然后还有连接数据库的代码:

module.exports = app => {
    const mongoose = require('mongoose')

    // 连接数据库
    mongoose.connect('mongodb://127.0.0.1:27017', {
        useNewUrlParser: true,    //如果在用户遇到 bug 时,允许用户在新的解析器中返回旧的解析器
        useUnifiedTopology: true,   //选择使用 MongoDB 驱动程序的新连接管理引擎
        dbName: 'myblog'         // 数据库名
    });

    //__dirname:    获得当前执行文件所在目录的完整目录名
    const models = require('require-all')(__dirname + '/../models')
}

middleware

首先看一下token鉴权的中间件:

const jwt = require('jsonwebtoken');
const User = require('../models/User');


module.exports = options => {


    return async (ctx, next) => {

        //从请求头中拿出token
        const token = String(ctx.request.headers.authorization || '').split(" ").pop()

        // const token = String(ctx.cookies.get('user_token', {
        //     encrypt: true
        // }));

        //console.log(token);

        //console.log(ctx.request.url);



        //console.log(ctx.app.config.keys.split('_')[1]);

        // 拿到key
        const key = ctx.app.config.keys.split('_')[1];

        // 如果没有设置token,返回
        if (!token) {
            ctx.status = 401;
            ctx.body = 'token 不存在';
            return
        }

        try {

            // 根据token解析出用户id
            const { id } = jwt.verify(token, key);

            // 解析token错误,返回
            if (!id) {
                ctx.status = 401;
                ctx.body = 'token 错误';
                return
            }

            // 根据解析出来的id,获取用户
            const user = await User.findById(id);

            // 找不到用户,返回
            if (!user) {
                ctx.status = 401;
                ctx.body = 'token 错误 , 用户不存在';
                return
            }

        } catch (e) {            // 出错,返回
            ctx.status = 401;
            ctx.body = 'jwt token error,解析token错误'
        }


        await next()
    }

}

然后可以看看两个从url中分理出变量的中间件:

// middleware/getId
module.exports = options => {
    return async (ctx, next) => {

        // 取得 id
        const id = ctx.params.id;

        // 赋予资源 id
        ctx.resource_id = id;

        await next();
    }
}

// middle/resource
const inflection = require('inflection')
module.exports = options => {
    return async (ctx, next) => {

        //console.log(ctx.params);

        // 从 params 的 resource 中获取要操作的model
        const modelName = inflection.classify(ctx.params.resource);

        // 绑定要操作的 Model
        ctx.Model = require(`../models/${modelName}`);

        await next()
    }
}

route

然后看到路由文件,根据egg对路由的定义,如下:

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */

const db = require('./db/db');

module.exports = app => {
  const { router, controller } = app;

  db(app);

  // 引入中间件
  const resource = app.middleware.resource();
  const response = app.middleware.response();
  const get_id = app.middleware.getId();
  const login = app.middleware.login();
  const auth = app.middleware.auth();
  const indexlogin = app.middleware.indexlogin();

  router.get('/', controller.home.index);
  
  // 其他路由看源码~~
};

以上只是路由文件的结构,具体路由定义请 git clone 后查看源码

controller

由于代码过多,以下只给出关键的代码
在博客操作之中,我们需要上传图片,通过查阅其他大佬的博客才解决了问题,代码如下:

async uploadMarkdownImg() {
        let parts = this.ctx.multipart({ autoFields: true });
        let stream
        let fileUrl
        while ((stream = await parts()) != null) {
            if (!stream.filename) {
                break;
            }
            let filename = (new Date()).getTime() + Math.random().toString(36).substr(2) + path.extname(stream.filename).toLocaleLowerCase();
            let target = 'app/public/markdown/' + filename;
            fileUrl = 'http://127.0.0.1:7001/public/markdown/' + filename
            let writeStream = fs.createWriteStream(target);
            await pump(stream, writeStream);
        };
        this.ctx.body = fileUrl
    }

在前端已表单form的形式提交数据,在后面的vue讲解之中会提到

至此,后台Egg部分就先讲这些,具体的实现请下载源码来查看:
https://github.com/li-car-fei/Vue-Eggjs-Blog

Vue admin

先通过几张图看一下基本的功能:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
vue后台使用了element-ui进行搭建,用组件库的效率提升了很多~~

http

后台管理系统对于所有接口都需要鉴权,所以可以设置http拦截器,当拦截到后台返回token错误,就可以跳转到登录页面进行登录:

import axios from 'axios'
import Vue from 'vue'
import router from './router/index'

const http = axios.create({
    baseURL: 'http://127.0.0.1:7001/admin/api'
});

// request 拦截器,设置请求头中的token
http.interceptors.request.use((request) => {
    // 获取token
    const token = sessionStorage.getItem('token') || '';

    if (token) {
        request.headers.Authorization = 'Carfied ' + token;
    }

    return request
}, (error) => {
    window.alert('token 错误');
    return Promise.reject(error);
});


// response 拦截器 , 通过判断 status 执行操作
http.interceptors.response.use((response) => {
    return response
}, (error) => {
    if (error.response.status === 500) {
        Vue.prototype.$message({
            type: 'error',
            message: error.response.data
        });
        return
    }

    if (error.response.status === 401) {
        // 跳转到登录界面
        router.push('/login');
        return
    }

    // 其他错误
    return Promise.reject(error);
});

export default http

router

路由管理利用子路由的方法管理页面,同时,通过路由参数来判定数据的编辑或者新建,如下:

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import CategoryList from '../views/CategoryList.vue'
import CategoryEdit from '../views/CategoryEdit.vue'

import ArticleList from '../views/ArticleList.vue'
import ArticleEdit from '../views/ArticleEdit.vue'

import UserList from '../views/UserList.vue'
import UserEdit from '../views/UserEdit.vue'

import CommentEdit from '@/views/CommentEdit'
import CommentList from '@/views/CommentList'

import Login from '../views/Login.vue'

Vue.use(VueRouter)

const router = new VueRouter({
  // 设置 H5 history 模式
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home,
      children: [
        { path: '/', component: CategoryList, name: 'list' },

        { path: '/categories/create', component: CategoryEdit, name: 'create' },
        { path: '/categories/list', component: CategoryList, name: 'list' },
        { path: '/categories/create/:id', component: CategoryEdit, props: true, name: 'create' },

        { path: '/articles/list', component: ArticleList, name: "articles list" },
        { path: '/articles/create', component: ArticleEdit, name: "articles create" },
        { path: '/articles/create/:id', component: ArticleEdit, props: true, name: 'articles create' },

        { path: '/users/list', component: UserList, name: 'users list' },
        { path: '/users/create', component: UserEdit, name: "users create" },
        { path: '/users/create/:id', component: UserEdit, props: true, name: "users create" },

        { path: '/comments/list', component: CommentList, name: 'comments list' },
        { path: '/comments/create', component: CommentEdit, name: "comments create" },
        { path: '/comments/create/:id', component: CommentEdit, props: true, name: "comments create" },
      ]
    },
    {
      path: '/login',
      component: Login,
      name: "login"
    },
  ]
});

//跳转前设置title
router.beforeEach((to, from, next) => {
  window.document.title = to.name;
  next();
});

export default router

home

主页面主要是有一个侧边栏进行路由的跳转,使用element-ui的组件进行搭建:

<template>
  <div>
    <el-container style="height: 100vh; border: 1px solid #eee">
      <el-aside width="200px" style="background-color: rgb(238, 241, 246)">
        <el-menu router :default-openeds="['1', '3']">
          <el-submenu index="1">
            <template slot="title">
              <i class="el-icon-message"></i>内容管理
            </template>
            <el-menu-item-group>
              <template slot="title">分类</template>
              <el-menu-item index="/categories/create">新建分类</el-menu-item>
              <el-menu-item index="/categories/list">分类列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template slot="title">文章</template>
              <el-menu-item index="/articles/create">新建文章</el-menu-item>
              <el-menu-item index="/articles/list">文章列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template slot="title">用户</template>
              <el-menu-item index="/users/create">新建用户</el-menu-item>
              <el-menu-item index="/users/list">用户列表</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group>
              <template slot="title">评论</template>
              <el-menu-item index="/comments/create">新建评论</el-menu-item>
              <el-menu-item index="/comments/list">评论列表</el-menu-item>
            </el-menu-item-group>
          </el-submenu>
        </el-menu>
      </el-aside>

      <el-container>
        <el-header style="text-align: right; font-size: 12px">
          <el-dropdown>
            <span>{{username}}</span>
            <el-dropdown-menu slot="dropdown">
              <el-dropdown-item>
                <a @click="logout">退出</a>
              </el-dropdown-item>
            </el-dropdown-menu>
          </el-dropdown>
        </el-header>

        <el-main>
          <router-view :key="$route.path"></router-view>
        </el-main>
      </el-container>
    </el-container>
  </div>
</template>

<style>
.el-header {
  background-color: #b3c0d1;
  color: #333;
  line-height: 60px;
}

.el-aside {
  color: #333;
}
</style>

<script>
export default {
  data() {
    return {
      username: ""
    };
  },
  methods: {
    logout() {
      sessionStorage.clear();
      this.$router.push("/login");
    }
  },
  created() {
    this.username = sessionStorage.getItem("username") || "";
  }
};
</script>

Article

在文章的编辑页面中,回顾前面egg对文章图片的处理,进行如下处理:

handleEditorImgAdd(pos, $file) {
      var formdata = new FormData();
      formdata.append("file", $file);
      this.$http.post("/markdown/upload/img", formdata).then(res => {
        var url = res.data;
        this.$refs.md.$img2Url(pos, url); //这里就是引用ref = md 然后调用$img2Url方法即可替换地址
      });
    }

而由于其他编辑页面都是一样的搭建,这里给出文章编辑页面完整代码,其他页面请读者git clone后查看

<template>
  <div class="page-cat-create">
    <h3>{{id ? "编辑" : "新建"}}文章</h3>
    <el-form label-width="80px">
      <el-form-item label="标题">
        <el-input v-model="model.title"></el-input>
      </el-form-item>
      <el-form-item label="摘要">
        <el-input type="textarea" v-model="model.summary"></el-input>
      </el-form-item>
      <el-form-item label="阅读量">
        <el-input-number v-model="model.read"></el-input-number>
      </el-form-item>
      <el-form-item label="点赞量">
        <el-input-number v-model="model.fav"></el-input-number>
      </el-form-item>
      <el-form-item label="分类">
        <el-select multiple v-model="model.categories" placeholder="请选择文章分类">
          <el-option
            v-for="item in categories"
            :key="item._id"
            :label="item.title"
            :value="item._id"
          ></el-option>
        </el-select>
      </el-form-item>
      <el-form-item label="评论">
        <el-select multiple v-model="model.comments" placeholder="请选择评论">
          <el-option
            v-for="item in comments"
            :key="item._id"
            :label="item.content"
            :value="item._id"
          ></el-option>
        </el-select>
      </el-form-item>
      <el-form-item label="置顶">
        <el-switch v-model="model.isTop" active-text="" inactive-text=""></el-switch>
      </el-form-item>
      <el-form-item label="正文">
        <mavon-editor
          :toolbars="toolbars"
          @imgAdd="handleEditorImgAdd"
          @save="saveDoc"
          style="height:600px"
          v-model="model.MdContent"
          ref="md"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="save">保存</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script>
export default {
  props: {
    id: { require: true }
  },
  data() {
    return {
      categories: [],
      model: {},
      comments: [],

      // markdown 工具栏参数设置
      toolbars: {
        bold: true, // 粗体
        italic: true, // 斜体
        header: true, // 标题
        underline: true, // 下划线
        strikethrough: true, // 中划线
        mark: true, // 标记
        superscript: true, // 上角标
        subscript: true, // 下角标
        quote: true, // 引用
        ol: true, // 有序列表
        ul: true, // 无序列表
        link: true, // 链接
        imagelink: true, // 图片链接
        code: false, // code
        table: true, // 表格
        fullscreen: true, // 全屏编辑
        readmodel: true, // 沉浸式阅读
        htmlcode: true, // 展示html源码
        help: true, // 帮助
        undo: true, // 上一步
        redo: true, // 下一步
        trash: true, // 清空
        save: true, // 保存(触发events中的save事件)
        navigation: true, // 导航目录
        alignleft: true, // 左对齐
        aligncenter: true, // 居中
        alignright: true, // 右对齐
        subfield: true, // 单双栏模式
        preview: true // 预览
      }
    };
  },

  methods: {
    async save() {
      // 先保存markdown原本的内容以及编译后的内容
      this.model.body = this.$refs.md.d_render;
      let res;
      if (!this.id) {
        res = await this.$http.post("/article", this.model);
      } else {
        res = await this.$http.put(`/article/${this.id}`, this.model);
      }
      if (res.status === 200) {
        this.$message({
          type: "success",
          message: res.data
        });

        this.$router.push("/articles/list");
      }
    },
    async fetchDetail() {
      const res = await this.$http.get(`/article/${this.id}`);
      this.model = res.data;
    },
    async fetchCategories() {
      const res = await this.$http.get("/category");
      this.categories = res.data;
    },
    async fetchComments() {
      const res = await this.$http.get("/comment");
      this.comments = res.data;
    },

    saveDoc(value, render) {
      this.model.MdContent = value;
      this.model.body = render;
    },

    //上传图片接口pos 表示第几个图片
    handleEditorImgAdd(pos, $file) {
      var formdata = new FormData();
      formdata.append("file", $file);
      this.$http.post("/markdown/upload/img", formdata).then(res => {
        var url = res.data;
        this.$refs.md.$img2Url(pos, url); //这里就是引用ref = md 然后调用$img2Url方法即可替换地址
      });
    }
  },
  created() {
    this.fetchComments();
    this.fetchCategories();
    this.id && this.fetchDetail();
  }
};
</script> 

main

vue 的入口文件:

// 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 Element from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import http from './http'
import dayjs from 'dayjs'

import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'

// 定义一个对时间的过滤器
Vue.filter('date', (val, type) => {
  if (!val) {
    return '';
  }

  return dayjs(val).format(type)
})


Vue.config.productionTip = false

Vue.use(Element);
Vue.use(mavonEditor);
Vue.prototype.$http = http;

/* eslint-disable no-new */
new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

对于登录以及用户状态的处理,本例使用了session:

async login() {
      const model = {
        username: this.username,
        password: this.password
      };

      const res = await this.$http.post("/login", model);
      console.log(res);
      if (res.status === 200) {
        // 设置token以及username
        sessionStorage.setItem("token", res.data.token);
        sessionStorage.setItem("username", res.data.username);
        // 提示信息
        this.$message({
          type: "success",
          message: "登录成功"
        });
        // 路由跳转到主页
        this.$router.push("/");
      }
    }

Vue Index

Vue 搭建主页的代码结构与管理页面差不多,有分页等功能,而由于主页的请求只有部分需要鉴权,本例中使用session本地储存信息,当进行需要鉴权的请求时先判定,若有token才进行请求,若没有则提醒登录:

async login() {
      const model = {
        username: this.username,
        password: this.password
      };

      const res = await this.$http.post("/login", model);
      console.log(res);
      if (res.status === 200) {
        // 设置token以及username
        sessionStorage.setItem("token", res.data.token);
        //sessionStorage.setItem("user_id", res.data.user);
        //sessionStorage.setItem("username", res.data.username);
        // 提示信息
        this.$message({
          type: "success",
          message: "登录成功"
        });
        console.log(this.islogin);
        this.$router.go(0);
      }
    }

类似于收藏文章这样的操作:

// 检查登录状态
computed: {
    // 是否登陆了
    islogin() {
      return !!(sessionStorage.getItem("token") || "");
    }
  },

// 收藏文章
    async user_fav_push() {
      if (!this.islogin) {
        this.$message({
          type: "info",
          message: "请先登录再收藏文章"
        });
        return;
      }
      const res = await this.$http.put(`/article/user/fav`, {
        article_id: this.$route.params.id,
        user_fav_change: !this.user_fav_in
      });
      //console.log(res.data);
      this.$message({
        type: "info",
        message: res.data.message
      });
      this.check_user_fav();
    },

    // 用户评论
    async post_comment() {
      const islogin = this.islogin;
      if (islogin) {
        const post_comment = {
          //user: sessionStorage.getItem("user_id"),
          article: this.$route.params.id,
          content: this.comment
        };
        this.loading = true;
        const res = await this.$http.post("/comment", post_comment);

        this.$message({
          type: "info",
          message: res.data.message
        });

        this.detail = res.data.data;

        this.comment = "";
        this.loading = false;

        //this.get_art_detail(this.$route.params.id);
      } else {
        this.$message({
          type: "warning",
          message: "请先登录再发表评论"
        });
      }
    }

对于markdown文章的显示,由于上文用到的组件已经把markdown编译成html代码并且存在数据库中,我们使用<article></article>标签进行展示即可,使用vue的v-html属性赋予标签html内容,完整的界面代码如下:

<template>
  <div>
    <h2 style="color:rgb(39, 147, 219)">{{detail.title}}</h2>
    <span style="padding-right:20px" v-for="category in detail.categories" :key="category._id">
      <el-tag style="cursor:pointer">{{category.title}}</el-tag>
    </span>
    <div style="color:rgb(85, 133, 165);margin-top:8px">
      <span style="padding-right:80px">阅读: {{detail.read}}</span>
      <span>
        <span style="padding-right:80px">
          <el-button type="primary" size="small" @click="favin">
            点赞:
            <span v-if="fav_in">{{detail.fav}} +1</span>
            <span v-else>{{detail.fav}}</span>
          </el-button>
        </span>
        <span>
          <el-button type="primary" size="small" @click="user_fav_push">
            <span v-if="user_fav_in">取消收藏</span>
            <span v-else>收藏</span>
            <i v-if="!user_fav_in" class="el-icon-star-off"></i>
          </el-button>
        </span>
      </span>
    </div>
    <div style="color:rgb(85, 133, 165);margin-top:8px">
      <span style="padding-right:80px">createdAt: {{detail.createdAt|date}}</span>
      <span>updatedAt: {{detail.updatedAt|date}}</span>
    </div>
    <hr />
    <article v-html="detail.body"></article>
    <hr />
    <div style="color:rgb(85, 133, 165);margin-top:8px">评论区:</div>
    <el-card
      v-for="comment in detail.comments"
      :key="comment._id"
      :body-style="{ background: 'rgba(39, 129, 182, 0.4)' }"
    >
      <time style="font-weight: 200">{{comment.createdAt|date}}</time>
      <div>{{comment.user.username}}说:</div>
      <div style="padding-left:70px">{{comment.content}}</div>
    </el-card>
    <el-divider>
      <i class="el-icon-mobile-phone"></i>
    </el-divider>
    <div>
      <el-input
        style="margin-bottom:8px"
        v-model="comment"
        placeholder="在此可输入评论"
        clearable
        @change="post_comment"
      ></el-input>
      <el-button type="primary" @click="post_comment" :loading="loading">发送</el-button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      fav_in: false, //是否点赞
      detail: {}, //内容详情
      comment: "", //评论内容
      loading: false, //评论过程post
      user_fav_in: undefined //当前用户是否收藏了此文章
    };
  },

  computed: {
    // 是否登陆了
    islogin() {
      return !!(sessionStorage.getItem("token") || "");
    }
  },

  methods: {
    // 获取文章详情信息
    async get_art_detail(id) {
      const res = await this.$http.get(`/article/${id}`);
      //console.log(res.data);
      this.detail = res.data;
    },

    // 点赞
    async favin() {
      const id = this.$route.params.id;
      const res = await this.$http.get(`/article/fav/${id}`);
      this.fav_in = true;
      //console.log(res.data);
      this.$message({
        type: "info",
        message: res.data
      });
    },

    // 检查此文章是否被当前用户收藏了
    async check_user_fav() {
      //const islogin=!!(sessionStorage.getItem('token')||"");
      if (!this.islogin) {
        return;
      }
      const article_id = this.$route.params.id;
      const res = await this.$http.get(`/article/favinuser/${article_id}`);
      //console.log(res.data.result);
      this.user_fav_in = res.data.result;
    },

    // 收藏文章
    async user_fav_push() {
      if (!this.islogin) {
        this.$message({
          type: "info",
          message: "请先登录再收藏文章"
        });
        return;
      }
      const res = await this.$http.put(`/article/user/fav`, {
        article_id: this.$route.params.id,
        user_fav_change: !this.user_fav_in
      });
      //console.log(res.data);
      this.$message({
        type: "info",
        message: res.data.message
      });
      this.check_user_fav();
    },

    // 用户评论
    async post_comment() {
      const islogin = this.islogin;
      if (islogin) {
        const post_comment = {
          //user: sessionStorage.getItem("user_id"),
          article: this.$route.params.id,
          content: this.comment
        };
        this.loading = true;
        const res = await this.$http.post("/comment", post_comment);

        this.$message({
          type: "info",
          message: res.data.message
        });

        this.detail = res.data.data;

        this.comment = "";
        this.loading = false;

        //this.get_art_detail(this.$route.params.id);
      } else {
        this.$message({
          type: "warning",
          message: "请先登录再发表评论"
        });
      }
    }
  },
  created() {
    this.get_art_detail(this.$route.params.id); //获取详细信息
    this.check_user_fav(); //检查是否被当前用户收藏
  }
};
</script>

<style>
</style>

结语

上文大概介绍了如何运用egg和vue搭建出全栈博客,还有许多功能没有实现,希望大家可以完善

如果希望查看源码或者git fork,这是github地址:
https://github.com/li-car-fei/Vue-Eggjs-Blog

希望能给我个star~~

  • 2
    点赞
  • 3
    评论
  • 3
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值