XieJava's blog

记录最好的自己


  • 首页

  • 分类

  • 标签

  • 归档

  • 关于

Django+Vue快速实现博客网站

发表于 2022-07-26 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 2.4k | 阅读时长 ≈ 12

Django是一个开放源代码的Web应用框架,由Python写成。采用了MTV的框架模式,即模型M,视图V和模版T。它最初是被开发来用于管理劳伦斯出版集团旗下的一些以新闻内容为主的网站的,即是CMS(内容管理系统)软件。对于博客网站来说是典型的CMS应用。本文介绍通过Django+Vue的博客模版快速实现一个可用的博客网站。

这里用的博客模板是Gblog是一款nice的基于 vue 的博客模板。界面简洁轻快,非常适合用作个人博客。https://gitee.com/fengziy/Gblog 后台的接口和管理界面就通过Django框架来实现了。

这里数据库用mysql,接口框架主要用到的是Django的djangorestframework,内容编辑器用的是markdown通过django-mdedior库实现。

一、依赖库

1
2
3
4
5
6
7
8
9
10
11
asgiref==3.5.2
Django==4.0.6
django-cors-headers==3.13.0
django-filter==22.1
django-mdeditor==0.1.20
djangorestframework==3.13.1
mysqlclient==2.1.1
Pillow==9.2.0
pytz==2022.1
sqlparse==0.4.2
tzdata==2022.1

二、工程目录组织结构

工程目录组织结构

三、代码实现

1、模型

模型很简单,根据Gblog前台要显示的内容包括有‘文章分类’、‘文章标签’、‘博客文章’、‘站点信息’、‘社交信息’、‘聚焦’,模型定义分别如下:
这里要说明的是因为博客文章内容准备用markdown编写,所以引入了mdeditor from mdeditor.fields import MDTextField
内容字段content=MDTextField(verbose_name='内容')
模型代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
from django.db import models
from common.basemodel import BaseModel
from mdeditor.fields import MDTextField
# Create your models here.
'''文章分类'''
class BlogCategory(BaseModel):
id = models.AutoField(primary_key=True)
title=models.CharField(max_length=50,verbose_name='分类名称',default='')
href=models.CharField(max_length=100,verbose_name='分类路径',default='')

def __str__(self):
return self.title

class Meta:
verbose_name='文章分类'
verbose_name_plural='文章分类'


'''文章标签'''
class Tag(BaseModel):
id=models.AutoField(primary_key=True)
tag=models.CharField(max_length=20, verbose_name='标签')

def __str__(self):
return self.tag

class Meta:
verbose_name='标签'
verbose_name_plural='标签'


'''博客文章'''
class BlogPost(BaseModel):
id = models.AutoField(primary_key=True)
title = models.CharField(max_length=200, verbose_name='文章标题', unique = True)
category = models.ForeignKey(BlogCategory, blank=True, null=True, verbose_name='文章分类', on_delete=models.DO_NOTHING)
isTop=models.BooleanField(default=False,verbose_name='是否置顶')
isHot=models.BooleanField(default=False,verbose_name='是否热门')
summary=models.CharField(max_length=500,verbose_name='内容摘要',default='')
content=MDTextField(verbose_name='内容')
viewsCount= models.IntegerField(default=0, verbose_name="查看数")
commentsCount=models.IntegerField(default=0, verbose_name="评论数")
tags=models.ManyToManyField(to=Tag, related_name="tag_post", blank=True, default=None,verbose_name="标签")


@property
def tag_list(self):
return ','.join([i.tag for i in self.tags.all()])

def __str__(self):
return self.title

class Meta:
verbose_name = '博客文章'
verbose_name_plural = '博客文章'


'''站点信息'''
class Site(BaseModel):
id = models.AutoField(primary_key=True)
name=models.CharField(max_length=50, verbose_name='站点名称', unique = True)
avatar=models.CharField(max_length=200, verbose_name='站点图标')
slogan=models.CharField(max_length=200, verbose_name='站点标语')
domain=models.CharField(max_length=200, verbose_name='站点域名')
notice=models.CharField(max_length=200, verbose_name='站点备注')
desc=models.CharField(max_length=200, verbose_name='站点描述')

def __str__(self):
return self.name

class Meta:
verbose_name = '站点信息'
verbose_name_plural = '站点信息'


'''社交信息'''
class Social(BaseModel):
id=models.AutoField(primary_key=True)
title=models.CharField(max_length=20, verbose_name='标题')
icon=models.CharField(max_length=200, verbose_name='图标')
color=models.CharField(max_length=20, verbose_name='颜色')
href=models.CharField(max_length=100, verbose_name='路径')

def __str__(self):
return self.title

class Meta:
verbose_name = '社交信息'
verbose_name_plural = '社交信息'

'''聚焦'''
class Focus(BaseModel):
id=models.AutoField(primary_key=True)
title=models.CharField(max_length=20, verbose_name='标题')
img=models.CharField(max_length=100, verbose_name='路径')

def __str__(self):
return self.title

class Meta:
verbose_name='聚焦'
verbose_name_plural='聚焦'

'''友链'''
class Friend(BaseModel):
id=models.AutoField(primary_key=True)
siteName=models.CharField(max_length=20, verbose_name='友链站点名称')
path=models.CharField(max_length=100, verbose_name='地址路径')
desc=models.CharField(max_length=200, verbose_name='描述')

def __str__(self):
return self.siteName

class Meta:
verbose_name='友链'
verbose_name_plural='友链'

2、admin管理

实际上只要把模型注册到admin就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from django.contrib import admin
from blog.models import *
# Register your models here.
@admin.register(BlogCategory)
class BlogCategoryAdmin(admin.ModelAdmin):
admin.site.site_title="ishareblog后台"
admin.site.site_header="ishareblog后台"
admin.site.index_title="ishareblog管理"

list_display = ['id', 'title', 'href']

@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ['title','category','isTop','isHot']
search_fields = ('title',)

@admin.register(Site)
class SiteAdmin(admin.ModelAdmin):
list_display = ['name','slogan','domain','desc']

@admin.register(Social)
class SocialAdmin(admin.ModelAdmin):
list_display = ['title','href']

@admin.register(Focus)
class FoucusAdmin(admin.ModelAdmin):
list_display = ['title','img']

@admin.register(Friend)
class FoucusAdmin(admin.ModelAdmin):
list_display = ['siteName','path','desc']

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ['id','tag']

3、接口

前端是Vue模板展示的,所以要为前端Vue提供相应的接口。通过djangorestframework将模型通过restful接口提供是非常easy的。

1)首先将需要暴露的模型通过序列化类序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
serializers.py

from blog.models import *
from rest_framework import serializers
class BlogCategoryModelSerializer(serializers.ModelSerializer):
class Meta:
model=BlogCategory
fields = "__all__"

class TagModelSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = "__all__"


class BlogPostModelSerializer(serializers.ModelSerializer):
create_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", required=False, read_only=True)
update_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", required=False, read_only=True)
category_id = serializers.CharField(max_length=32, source='category.id')
pubTime=update_time
category=BlogCategoryModelSerializer()
tags=serializers.SerializerMethodField()

# 多对多,钩子函数序列化,必须是以get_开头的
def get_tags(self, obj):
tags = obj.tags.all()
tag = TagModelSerializer(tags, many=True)
return tag.data

class Meta:
model=BlogPost
fields="__all__"

class SiteModelSerializer(serializers.ModelSerializer):
class Meta:
model = Site
fields = "__all__"

class SocialModelSerializer(serializers.ModelSerializer):
class Meta:
model = Social
fields = "__all__"

class FocusModelSerializer(serializers.ModelSerializer):
class Meta:
model = Focus
fields = "__all__"

class FriendModelSerializer(serializers.ModelSerializer):
class Meta:
model = Friend
fields = "__all__"

2)将序列化的对象通过视图类提供接口

custommodelviewset.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from rest_framework import status
from rest_framework import viewsets
from common.customresponse import CustomResponse

class CustomModelViewSet(viewsets.ModelViewSet):

#CreateModelMixin->create
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return CustomResponse(data=serializer.data, code=201,msg="OK", status=status.HTTP_201_CREATED,headers=headers)
#ListModelMixin->list
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(queryset, many=True)
return CustomResponse(data=serializer.data, code=200, msg="OK", status=status.HTTP_200_OK)

#RetrieveModelMixin->retrieve
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return CustomResponse(data=serializer.data, code=200, msg="OK", status=status.HTTP_200_OK)
#UpdateModelMixin->update
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)

if getattr(instance, '_prefetched_objects_cache', None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}

return CustomResponse(data=serializer.data, code=200, msg="OK", status=status.HTTP_200_OK)

#DestroyModelMixin->destroy
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
self.perform_destroy(instance)
return CustomResponse(data=[], code=204, msg="OK", status=status.HTTP_204_NO_CONTENT)

views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# Create your views here.
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, status
from rest_framework import filters
from api.myfilter import BlogPostFilter
from api.serializers import *
from blog.models import BlogCategory, BlogPost,Site,Social,Focus,Friend,Tag
from api.mypage import MyPage
from common.custommodelviewset import CustomModelViewSet
from common.customresponse import CustomResponse

class BlogCategoryViewset(CustomModelViewSet):
queryset = BlogCategory.objects.all()
serializer_class = BlogCategoryModelSerializer

class BlogsView(CustomModelViewSet):
queryset = BlogPost.objects.order_by('-isTop','-update_time')
serializer_class = BlogPostModelSerializer
pagination_class = MyPage
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
filterset_class = BlogPostFilter
#搜索
search_fields=('title',)
#排序
ordering_fields = ('isTop', 'update_time')
#自定义获取详情接口
def retrieve(self,request,*args, **kwargs):
instance=self.get_object()
instance.viewsCount+=1
instance.save()
serializer=self.get_serializer(instance)
return CustomResponse(data=serializer.data,code=200,msg="success",status=status.HTTP_200_OK)


class SiteView(CustomModelViewSet):
queryset = Site.objects.all()
serializer_class = SiteModelSerializer

class SocialView(CustomModelViewSet):
queryset = Social.objects.all()
serializer_class = SocialModelSerializer

class FocusView(CustomModelViewSet):
queryset = Focus.objects.all()
serializer_class = FocusModelSerializer

class FriendView(viewsets.ModelViewSet):
queryset = Friend.objects.all()
serializer_class = FriendModelSerializer

class TagView(viewsets.ModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagModelSerializer

3)通过路由来实现接口地址和视图的绑定和访问

urls.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# -*- coding: utf-8 -*-
"""
:author: XieJava
:url: http://ishareread.com
:copyright: © 2021 XieJava <xiejava@ishareread.com>
:license: MIT, see LICENSE for more details.
"""
from api import views
from django.urls import path,include
from rest_framework.routers import DefaultRouter
blogcategory_list=views.BlogCategoryViewset.as_view({'get':'list',})
blogcategory_detail=views.BlogCategoryViewset.as_view({ 'get': 'retrieve',})
blog_list=views.BlogsView.as_view({'get':'list',})
blog_detail=views.BlogsView.as_view({ 'get': 'retrieve',})
site_list=views.SiteView.as_view({'get':'list',})
site_detail=views.SiteView.as_view({'get':'retrieve',})
social_list=views.SocialView.as_view({'get':'list',})
social_detail=views.SocialView.as_view({'get':'retrieve',})
focus_list=views.FocusView.as_view({'get':'list',})
focus_detail=views.FocusView.as_view({'get':'retrieve'})
friend_list=views.FriendView.as_view({'get':'list',})
friend_detail=views.FriendView.as_view({'get':'retrieve'})
tags_list=views.TagView.as_view({'get':'list',})
# router=DefaultRouter()
# router.register('blogs',views.BlogsView)
urlpatterns = [
path('category/',blogcategory_list),
path('category/<pk>/',blogcategory_detail),
path('post/list',blog_list),
path('post/<pk>',blog_detail),
path('social/',social_list),
path('site/<pk>',site_detail),
path('focus/list',focus_list),
path('comment/',blog_list),
path('friend/',friend_list),
path('tags/',tags_list),
]

4)自定义接口返回格式

接口需要根据Glog定义的格式进行定义和返回,这里就需要自定义接口返回格式。
具体实现参见:https://xiejava.blog.csdn.net/article/details/125773730
–自定义返回响应类customresponse.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from rest_framework.response import Response
from rest_framework.serializers import Serializer

class CustomResponse(Response):
def __init__(self,data=None,code=None,msg=None,
status=None,
template_name=None, headers=None,
exception=False, content_type=None,**kwargs):
super().__init__(None, status=status)

if isinstance(data, Serializer):
msg = (
'You passed a Serializer instance as data, but '
'probably meant to pass serialized `.data` or '
'`.error`. representation.'
)
raise AssertionError(msg)
self.data={'code':code,'msg':msg,'data':data}
self.data.update(kwargs)
self.template_name=template_name
self.exception=exception
self.content_type=content_type

if headers:
for name, value in headers.items():
self[name] = value

–翻页实现类mypage.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from rest_framework import status
from rest_framework.pagination import PageNumberPagination
from common.customresponse import CustomResponse

class MyPage(PageNumberPagination):
page_size = 8 #每页显示数量
max_page_size = 50 #每页最大显示数量。
page_size_query_param = 'size' #每页数量的参数名称
page_query_param = 'page' #页码的参数名称

#自定义分页器的返回参数
def get_paginated_response(self, data):
ret_data = dict()
ret_data['items'] = data
# 加入自定义分页信息
ret_data['total'] = self.page.paginator.count
ret_data['hasNextPage'] = self.get_next_link()
ret_data['size'] = self.page_size
ret_data['page'] = self.page.number
return CustomResponse(data=ret_data,code=200,msg="OK",status=status.HTTP_200_OK)

全部代码:
后台代码:https://gitee.com/xiejava/ishareblog
前台代码:https://gitee.com/xiejava/Gblog

四、效果

1、后台管理

管理界面
管理界面
博客文章列表
博客文章列表
文章内容编辑,支持markdown
文章内容编辑,支持markdown
分类管理
文章分类
标签管理
标签管理
社交信息
社交信息

2、接口

接口清单
接口清单

文章列表接口,支持翻页

文章列表接口

文章详情接口
文章详情接口

3、前台展现

前台展现

文章列表
文章列表

文章详情,支持markdown显示及目录
文章详情

社交信息
社交信息

博客效果地址:http://blog.ishareread.com

后续考虑
1、django原生admin的管理界面还是简陋了一点,后续可能会用其他管理界面的UI给换掉
2、现在有了一个hexo的博客了,后续可能会考虑实现hexo生成的博客内容直接同步到django的博客,或者django博客编辑的内容直接生成hexo的.md文件
有兴趣的话可以关注本博客


博客:http://xiejava.ishareread.com


“fullbug”微信公众号

关注微信公众号,一起学习、成长!

Vue3解析markdown解析并实现代码高亮显示

发表于 2022-07-19 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 479 | 阅读时长 ≈ 2

Vue实现博客前端,需要实现markdown的解析,如果有代码则需要实现代码的高亮。
Vue的markdown解析库有很多,如markdown-it、vue-markdown-loader、marked、vue-markdown等。这些库都大同小异。这里选用的是marked,代码高亮的库选用的是highlight.js。

具体实现步骤如下:

一、安装依赖库

在vue项目下打开命令窗口,并输入以下命令

1
2
npm install marked -save    // marked 用于将markdown转换成html
npm install highlight.js -save //用于代码高亮显示

命令执行完后可以在控制台或package.json文件中看到有安装的版本号
package.json文件中看到有安装的版本号

二、在main.js文件中引入highlight.js及样式并创建一个自定义的全局指令

1
2
3
4
5
6
7
8
9
10
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css' //样式

//创建v-highlight全局指令
Vue.directive('highlight',function (el) {
let blocks = el.querySelectorAll('pre code');
blocks.forEach((block)=>{
hljs.highlightBlock(block)
})
})

这样就可以在vue组件中使用v-highlight引用代码高亮的方法了。

三、在Vue组件中应用marked解析及实现代码高亮

代码示例如下:

1
2
3
4
 <!-- 正文输出 -->
<div class="entry-content">
<div v-highlight v-html="article" id="content"></div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script>
// 将marked 引入
import { marked }from 'marked';
export default {
name: 'articles',
data(){
return{
article:''
}
},
methods: {
getPostDetail() {
console.log('getPostDetail()'+this.id)
fetchPostDetail(this.id).then(res => {
this.postdetail=res.data
// 调用marked()方法,将markdown转换成html
this.article= marked(this.postdetail.content);
console.log(res.data)
}).catch(err => {
console.log(err)
})

},
created() {
//调用获取文章内容的接口方法
this.getPostDetail()
},
}
</script>

四、显示效果

markdown解析及代码高亮显示效果
在这里插入图片描述

示例中引用的样式是 import 'highlight.js/styles/atom-one-dark.css'
实际highlight.js/styles中提供了很多样式,可以根据自己的喜好选用。

代码高亮样式


博客:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注:微信公众号,一起学习成长!

Python3.9环境安装mysqlclient报python setup.py egg_info did not run successfully错避坑

发表于 2022-07-16 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 434 | 阅读时长 ≈ 2

MySQL是常用的开源数据库,Python环境下django框架连接MySQL数据库用的是mysqlclient库,今天在用pip安装mysqlclient库时报错,特记录一下,避免后续继续踩坑。

环境说明:

操作系统:CentOS Linux 7.2
Python版本:Python 3.9.13
pip版本:pip 22.1.2

报错信息:

执行pip3 install mysqlclient==2.1.1 报错
报错信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Using cached http://mirrors.aliyun.com/pypi/packages/50/5f/eac919b88b9df39bbe4a855f136d58f80d191cfea34a3dcf96bf5d8ace0a/mysqlclient-2.1.1.tar.gz (88 kB)
Preparing metadata (setup.py) ... error
error: subprocess-exited-with-error

× python setup.py egg_info did not run successfully.
│ exit code: 1
╰─> [16 lines of output]
/bin/sh: mysql_config: command not found
/bin/sh: mariadb_config: command not found
/bin/sh: mysql_config: command not found
Traceback (most recent call last):
File "<string>", line 2, in <module>
File "<pip-setuptools-caller>", line 34, in <module>
File "/tmp/pip-install-i1nt_asj/mysqlclient_1b92535d58cd440b8797686ac8bc9882/setup.py", line 15, in <module>
metadata, options = get_config()
File "/tmp/pip-install-i1nt_asj/mysqlclient_1b92535d58cd440b8797686ac8bc9882/setup_posix.py", line 70, in get_config
libs = mysql_config("libs")
File "/tmp/pip-install-i1nt_asj/mysqlclient_1b92535d58cd440b8797686ac8bc9882/setup_posix.py", line 31, in mysql_config
raise OSError("{} not found".format(_mysql_config_path))
OSError: mysql_config not found
mysql_config --version
mariadb_config --version
mysql_config --libs
[end of output]

note: This error originates from a subprocess, and is likely not a problem with pip.
error: metadata-generation-failed

× Encountered error while generating package metadata.
╰─> See above for output.

note: This is an issue with the package mentioned above, not pip.
hint: See above for details.

mysqlclient报错

避坑:

从报错信息看是找不到mysql_config
通过whereis mysql_config命令查看mysql_config
发现mysql_confg没有
执行yum install mysql-devel 安装mysql-devel
执行whereis mysql_config命令查看mysql_config这时mysql_config有了

1
mysql_config: /usr/bin/mysql_config /usr/share/man/man1/mysql_config.1.gz

再次执行pip安装命令安装成功!

1
2
3
4
5
6
7
8
9
10
pip3 install mysqlclient==2.1.1

Looking in indexes: http://mirrors.aliyun.com/pypi/simple/
Collecting mysqlclient==2.1.1
Using cached http://mirrors.aliyun.com/pypi/packages/50/5f/eac919b88b9df39bbe4a855f136d58f80d191cfea34a3dcf96bf5d8ace0a/mysqlclient-2.1.1.tar.gz (88 kB)
Preparing metadata (setup.py) ... done
Using legacy 'setup.py install' for mysqlclient, since package 'wheel' is not installed.
Installing collected packages: mysqlclient
Running setup.py install for mysqlclient ... done
Successfully installed mysqlclient-2.1.1

Django加入markdown编辑器及markdown上传图片不回显避坑

发表于 2022-07-15 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 1.3k | 阅读时长 ≈ 5

一般来说一个CMS系统如博客系统都需要一个好的富文本编辑器,现在大家更多的是选择MarkDown编辑器来编辑内容。Django作为python的主流web开发框架当然少不了markdown的插件。本文介绍如何在Django框架中引入markdown编辑器及在使用markdown时的注意事项。

在Django框架中引入markdown编辑器主要是通过安装引入Django-mdeditor库来实现。
Django-mdeditor 是基于 Editor.md 的一个 django Markdown 文本编辑插件应用。
其官方下载地址见 https://pypi.org/project/django-mdeditor/
根据官方指导文档

一、安装使用

1、安装django-mdeditor

1
pip install django-mdeditor

2、在 settings 配置文件 INSTALLED_APPS 中添加 mdeditor

1
2
3
4
5
6
7
8
9
10
11
12
13
INSTALLED_APPS = [
'blog',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'django_filters',#注册条件查询
# 注册markdown的应用
'mdeditor',
]

3、针对django3.0+修改 frame 配置

1
X_FRAME_OPTIONS = 'SAMEORIGIN'  # django 3.0 + 默认为 deny

4、在 settings 中添加媒体文件的路径配置

1
2
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')

在你项目根目录下创建 uploads/editor 目录,用于存放上传的图片。

5、在项目的根 urls.py 中添加扩展url和媒体文件url:

注意是在项目的根 urls.py 中添加扩展url和媒体文件url,而不是在其他项目应用的urls.py中添加

1
2
3
4
5
6
7
8
9
10
11
12
13
from django.conf.urls import url, include
from django.conf.urls.static import static
from django.conf import settings
...

urlpatterns = [
...
url(r'mdeditor/', include('mdeditor.urls'))
]

if settings.DEBUG:
# static files (images, css, javascript, etc.)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

6、在项目model中应用markdown

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
'''博客文章'''
class BlogPost(BaseModel):
id = models.AutoField(primary_key=True)
title = models.CharField(max_length=200, verbose_name='文章标题', unique = True)
category = models.ForeignKey(BlogCategory, blank=True, null=True, verbose_name='文章分类', on_delete=models.DO_NOTHING)
isTop=models.BooleanField(default=False,verbose_name='是否置顶')
isHot=models.BooleanField(default=False,verbose_name='是否热门')
summary=models.CharField(max_length=500,verbose_name='内容摘要',default='')
content=MDTextField(verbose_name='内容')
viewsCount= models.IntegerField(default=0, verbose_name="查看数")
commentsCount=models.IntegerField(default=0, verbose_name="评论数")

def __str__(self):
return self.title

class Meta:
verbose_name = '博客文章'
verbose_name_plural = '博客文章'

见 content=MDTextField(verbose_name='内容') 表示博客文章的内容是MDTextField

7、向 admin.py 中注册model:

1
2
3
4
5
6
from django.contrib import admin
from blog.models import *
# Register your models here.
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ['title','category','isTop','isHot']

8、迁移创建数据表

运行 python manage.py makemigrations 和 python manage.py migrate 来创建你的model 数据库表,可以看到默认创建的content字段是longtext类型的
默认创建的content字段是longtext类型的

9、测试验证

启动应用,访问http://127.0.0.1:8000/admin/ 点击新增博客文章,可以看到内容字段是markdown编辑器输入了。
markdown编辑器

至此django应用中就可以使用markdown编辑器了。

二、markdown上传图片不回显避坑

按照以上步骤配置django-mdeditor,markdown编辑器可以正常使用,但是这里有个大坑,就是有些浏览器在上传图片后上传的图片不回显!
我就碰到了这样的情况。

上传图片后上传的图片不回显

在添加图片界面选择本地上传图片后发现后台接口调到了 /mdeditor/uploads/?guid=1657867564930 接口并且返回了200,但是上传的图片地址不回显,提交报“错误:图片地址不能为空。” 这就奇了怪了。
打开浏览器的调试工具,发现报了一个错,Uncaught SyntaxError: Unexpected token 下 in JSON at position 141

浏览器的调试工具,发现报了一个错

点击详情,具体应该是获取的JSON无法解析。

JSON无法解析

这个JSON为什么无法解析呢?开始进一步调试,这个JSON是上传时调用的后台上传方法返回的。所以来看看是不是后台上传接口返回的JSON串有什么问题。找到/mdeditor/uploads路由所对应的源码

/mdeditor/uploads路由所对应的源码

UploadView的源代码,就是返回一个成功的json报文

1
2
3
4
5
return JsonResponse({'success': 1,
'message': "上传成功!",
'url': os.path.join(settings.MEDIA_URL,
MDEDITOR_CONFIGS['image_folder'],
file_full_name)})

实际打断点debug也是正常返回上传成功的json报文。

打断点debug也是正常返回上传成功的json报文

这就有点奇怪了,接口返回了正常的json报文怎么就解析不了了呢?接着继续调前台js代码,看究竟是什么原因。

json串里多了几个字“下载视频”!

发现js获取的json串里多了几个字“下载视频”! 这是什么鬼?实在是没有地方有返回“下载视频”这几个字啊?看js代码是通过iframe来处理请求的,再来看看iframe的内容,发现iframe里确实有“下载视频”

iframe里确实有“下载视频”

原来是有个chrome浏览器插件,擅自给加了“下载视频”的内容。再来看浏览器装了些啥插件。原来是有个迅雷插件,应该就是这个插件搞的鬼了,罪魁祸首就是它了!
罪魁祸首迅雷插件

把这个迅雷插件删除或停用,果然一切正常!可以正常回显!!!

可以正常回显

显示插入的图片

显示插入的图片

所以,碰到markdown上传图片不回显的情况,先看下自己的浏览器是不是开启了迅雷插件应用,如果开启了迅雷插件应用先停用或删除!


作者博客:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注微信公众号,一起学习、成长!

Django的restframework接口框架自定义返回数据格式

发表于 2022-07-14 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 1.3k | 阅读时长 ≈ 5

在前后端分离是大趋势的背景下,前端获取数据都是通过调用后台的接口来获取数据微服务的应用越来越多。Django是Python进行web应用开发常用的web框架,用Django框架进行web应用框架减少了很多工作,通常用很少量的代码就可以实现数据的增、删、改、查的业务应用,同样用Django的restframework的框架对外发布接口也是非常的简单方便,几行代码就可以将数据对象通过接口的方式提供服务。因为在实际开发过程中接口的返回数据有一定的格式,本文介绍通过自定义Response返回对象来自定义接口返回数据格式。

以下示例将数据对象Friend通过restframework框架进行接口发布。
只要定义Friend数据对象

1
2
3
4
5
6
7
8
9
10
11
12
class Friend(BaseModel):
id=models.AutoField(primary_key=True)
siteName=models.CharField(max_length=20, verbose_name='友链站点名称')
path=models.CharField(max_length=100, verbose_name='地址路径')
desc=models.CharField(max_length=200, verbose_name='描述')

def __str__(self):
return self.siteName

class Meta:
verbose_name='友链'
verbose_name_plural='友链'

定义一个序列化类将返回的字段序列化

1
2
3
4
class FriendModelSerializer(serializers.ModelSerializer):
class Meta:
model = Friend
fields = "__all__"

定义一个接口视图类获取数据

1
2
3
class FriendView(viewsets.ModelViewSet):
queryset = Friend.objects.all()
serializer_class = FriendModelSerializer

定义接口路由就可以通过httprestfull的接口进行访问了

1
2
3
4
friend_list=views.FriendView.as_view({'get':'list',})
urlpatterns = [
path('friend/',friend_list),
]

接口访问效果如下:
http://localhost:8000/api/friend/
httprestfull的接口

但是在项目中经常会碰到接口格式变化的情况,restframework框架默认的返回数据格式不满足应用的需求。比如一般的接口都会有接口返回的code、msg、data,code用来标识接口返回代码比如200是正常,msg用来记录异常或其信息,data用来返回具体的数据。
通过restframework接口自定义返回数据格式也是很简单方便的。
先自定义Response返回对象,在返回对象中自定义数据返回的格式,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from rest_framework.response import Response
from rest_framework.serializers import Serializer

class CustomResponse(Response):
def __init__(self,data=None,code=None,msg=None,
status=None,
template_name=None, headers=None,
exception=False, content_type=None,**kwargs):
super().__init__(None, status=status)

if isinstance(data, Serializer):
msg = (
'You passed a Serializer instance as data, but '
'probably meant to pass serialized `.data` or '
'`.error`. representation.'
)
raise AssertionError(msg)
#自定义返回格式
self.data={'code':code,'msg':msg,'data':data}
self.data.update(kwargs)
self.template_name=template_name
self.exception=exception
self.content_type=content_type

if headers:
for name, value in headers.items():
self[name] = value

在接口接口视图类获取数据返回时,使用该自定义的Response返回对象。

1
2
3
4
5
6
7
8
class FriendView(viewsets.ModelViewSet):
queryset = Friend.objects.all()
serializer_class = FriendModelSerializer
#自定义list方法,自定义Response返回
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
serializer = self.get_serializer(queryset, many=True)
return CustomResponse(data=serializer.data, code=200, msg="OK", status=status.HTTP_200_OK)

接口访问效果如下:
可以看到返回数据格式中增加了code,msg 数据放到了data节点
自定义数据返回格式

列表数据通常接口要提供翻页功能,在接口中要有总页数、当前页、是否有下一页的信息。
可以自定义一个分页器,在分页器中自定义需要返回的分页参数
参考示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from rest_framework import status
from rest_framework.pagination import PageNumberPagination
from common.customresponse import CustomResponse

class MyPage(PageNumberPagination):
page_size = 8 #每页显示数量
max_page_size = 50 #每页最大显示数量。
page_size_query_param = 'size' #每页数量的参数名称
page_query_param = 'page' #页码的参数名称

def get_paginated_response(self, data):
#自定义分页器的返回参数
return CustomResponse(data=data,code=200,msg="OK",status=status.HTTP_200_OK, count=self.page.paginator.count,next=self.get_next_link(),previous=self.get_previous_link(),size=self.page_size,page=self.page.number)

在接口接口视图类获取数据返回时,如果有分页器则使用该分页器自定义的Response返回对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FriendView(viewsets.ModelViewSet):
queryset = Friend.objects.all()
serializer_class = FriendModelSerializer
pagination_class = MyPage
#自定义list方法,自定义Response返回
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
#如果有分页器,则进行分页后返回
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(queryset, many=True)
return CustomResponse(data=serializer.data, code=200, msg="OK", status=status.HTTP_200_OK)

接口访问效果如下:
可以看到接口中自定义增加了分页信息。
接口中自定义增加了分页信息

但是有时候可能希望分页的信息数据要放在data节点里面,这样也是可以做到的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from rest_framework import status
from rest_framework.pagination import PageNumberPagination
from common.customresponse import CustomResponse

class MyPage(PageNumberPagination):
page_size = 8 #每页显示数量
max_page_size = 50 #每页最大显示数量。
page_size_query_param = 'size' #每页数量的参数名称
page_query_param = 'page' #页码的参数名称

#自定义分页器的返回参数
def get_paginated_response(self, data):
ret_data = dict()
ret_data['items'] = data
# 加入自定义分页信息
ret_data['total'] = self.page.paginator.count
ret_data['hasNextPage'] = self.get_next_link()
ret_data['size'] = self.page_size
ret_data['page'] = self.page.number
return CustomResponse(data=ret_data,code=200,msg="OK",status=status.HTTP_200_OK)

接口访问效果如下:
可以看到接口中自定义增加了分页信息,分页的信息数据放在data节点里面了
自定义增加了分页信息,分页的信息数据放在data节点里面
至此,本文介绍了通过Django的restframework接口框架自定义Response返回对象来自定义返回数据格式。Django的restframework接口框架使用简单方便,拿来即用,能够很大程度上减少代码开发量。


博客地址:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注:微信公众号,一起学习成长!

Vue3引入vue-router路由并通过vue-wechat-title设置页面title

发表于 2022-07-03 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 1.7k | 阅读时长 ≈ 7

对于用类似Vue前后端分离技术架构的单页应用页面之间的跳转没有非前后端分离那么来得直接,甚至连设置跳转页面的Title都要费一番周折,本文介绍Vue3引入vue-router路由并设置页面Title,通过vue-router实现页面的路由,通过vue-wechat-title来设置页面的title。

一、用vue-router库实现路由管理

vue-router是Vue.js官方推荐的路由管理库。它和Vue.js的核心深度集成,让构建单页应用变得轻松容易。使用Vue.js和vue-router库创建单页应用非常的简单:使用Vue.js开发,整个应用已经被拆分成了独立的组件;使用vue-router库,可以把路由映射到各个组件,并把各个组件渲染到正确的地方。下面就来介绍如何安装引入vue-router库并实现路由管理

1、安装vue-router库

使用如下命令安装vue-router库

1
npm install -save -vue-router

也可以通过 npm install -save vue-router@4 来指定版本号@4表示版本是4
安装成功后,可以在控制台看到了安装成功的信息和版本号
控制台看到了安装成功的信息和版本号
除此之外也可以在工程中的package.json中看到依赖的库中包含有vue-router及版本号。
package.json中看到依赖的库中包含有vue-router及版本号

2、在router文件夹下创建router.js

在工程的src目录下建立router文件夹 在router文件夹下创建router.js,该文件是Vue路由管理的核心文件,所有的各组件的路由在该文件中进行配置。
参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { createRouter,createWebHistory } from "vue-router"; //引入vue-router组件
import HelloWorld from '@/components/HelloWorld'; //引入需要路由管理的页面组件HelloWorld
import siteLogin from '@/views/user/login'; //引入需要路由管理的页面组件login
import userInfo from "@/views/user/userinfo"; //引入需要路由管理的页面组件userinfo
const router = createRouter({
history:createWebHistory(),
routes:[
{
path:'/', //路由的路径
name:'Home', //路由的名称
component:HelloWorld, //路由的组件
},
{
path:'/login',
name:'Login',
component:siteLogin,
},
{
path:'/userinfo',
name:'UserInfo',
component:userInfo,
}
]
})
export default router;

代码组织结构如下:
代码组织结构如下

3、在App.vue中加入路由视图

在App.vue中加入<router-view />
App.vue示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div id="app">
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
}
</script>
<style>
</style>

4、在项目的main.js中引入路由

参考代码如下:

1
2
3
4
import { createApp } from 'vue';
import App from './App.vue';
import router from "@/router/router"; //引入路由,会去找router下的router.js的配置文件
createApp(App).use(router).mount('#app') //创建应用的时候应用路由

5、验证效果

为了显示更清楚,将默认创建的src\components\HelloWorld.vue内容稍加调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div >
第一个路由组件Home
<p>{{ name }}</p>
</div>
</template>

<script>
export default {
name: 'HelloWorld',
data() {
return {
name:"Hello World!"
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

如果上面的步骤没有遗漏,在终端输入 npm run serve 将前端服务启动起来,在浏览器访问localhost:8080可以看到如下页面:

localhost:8080

访问localhost:8080/login

访问localhost:8080/login

访问localhost:8080/userinfo

访问localhost:8080/userinfo
可以看到访问不同的URL路由到了不同的Vue页面,上述login.vue和userinfo.vue示例代码没有给出,大家可以自行随便实现。

二、用vue-wechat-title实现页面title的设置

在上面实现了不同页面的路由管理,但是访问不同的URL看到的页面title所有的页面都是一样的,如何设置不同页面不同的页面Title呢?比较方便的做法是用vue-wechat-title来实现。
同样首先要安装vue-wechat-title库

1、安装vue-wechat-title库

使用如下命令安装vue-wechat-title库

1
npm install vue-wechat-title -save

安装完成后在工程中的package.json中看到依赖的库中包含有vue-wechat-title及版本号
package.json中看到依赖的库中包含有vue-wechat-title及版本号

2、在router文件夹下的router.js中增加title的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { createRouter,createWebHistory } from "vue-router"; //引入vue-router组件
import HelloWorld from '@/components/HelloWorld'; //引入需要路由管理的页面组件HelloWorld
import siteLogin from '@/views/user/login'; //引入需要路由管理的页面组件login
import userInfo from "@/views/user/userinfo"; //引入需要路由管理的页面组件userinfo
const router = createRouter({
history:createWebHistory(),
routes:[
{
path:'/', //路由的路径
name:'Home', //路由的名称
meta:{
title: '首页' //title配置
},
component:HelloWorld, //路由的组件
},
{
path:'/login',
name:'Login',
meta:{
title:'登录'
},
component:siteLogin,
},
{
path:'/userinfo',
name:'UserInfo',
meta:{
title: '用户信息'
},
component:userInfo,
}
]
})
export default router;

主要是在路由配置时设置了meta:{title:'xxxx'}如下图:

router.js中增加title的配置

3、在App.vue页面中使用

App.vue代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div id="app" v-wechat-title="$route.meta.title">
<router-view />
</div>
</template>

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

<style>
</style>

主要是在<div id="app" v-wechat-title="$route.meta.title"> 加入了v-wechat-title="$route.meta.title"

4、在main.js中引用vue-wechat-title

在main.js中引用vue-wechat-title的时候有个坑,如果按照一般的引用会报错
mian.js代码示例如下:

1
2
3
4
5
import { createApp } from 'vue';
import App from './App.vue';
import router from "@/router/router"; //引入路由,会去找router下的router.js的配置文件
import VueWechatTitle from 'vue-wechat-title'; //引入VueWechatTitle
createApp(App).use(router,VueWechatTitle).mount('#app') //创建应用的时候应用路由

在终端输入 npm run serve 将前端服务启动起来会报错!
Uncaught TypeError: Cannot read properties of undefined (reading ‘deep’)

原因是在挂载app示例前,vue-wechat-title还没有加载好,一定要先应用再挂载app
将createApp(App).use(router,VueWechatTitle).mount(‘#app’)删除或注释掉。改用

1
2
3
4
const app=createApp(App);
app.use(VueWechatTitle);
app.use(router);
app.mount('#app')

main.js的参考示例代码如下:

1
2
3
4
5
6
7
8
9
import { createApp } from 'vue';
import App from './App.vue';
import router from "@/router/router"; //引入路由,会去找router下的router.js的配置文件
import VueWechatTitle from 'vue-wechat-title'; //引入VueWechatTitle
//createApp(App).use(router,VueWechatTitle).mount('#app') //指令定义在 mount('#app')之后,导致自定义指令未挂载到,会报错
const app=createApp(App);
app.use(VueWechatTitle);
app.use(router);
app.mount('#app')

5、验证效果

在终端输入 npm run serve 将前端服务启动起来
看到访问不同的URL会显示不同的title
http://localhost:8080/

http://localhost:8080/的title
http://localhost:8080/login

login的title登录

http://localhost:8080/userinfo

userinfo的title用户信息

本文通过以上实例实现了Vue3引入vue-router路由并设置页面Title,通过vue-router实现页面的路由,通过vue-wechat-title来设置页面的title都还比较方便。


博客地址:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注:微信公众号,一起学习成长!

Vue快速入门

发表于 2022-07-03 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 2.3k | 阅读时长 ≈ 10

一、什么是Vue

Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使用时,Vue 也完全能够为复杂的单页应用提供驱动。

二、安装

1、独立版本
直接下载并用<script>标签引入
官网下载地址:https://cn.vuejs.org/js/vue.js
2、使用CDN
和独立版本类似,与独立版本的区别就是不用下载到本地应用,直接引用CDN加速以后的地址。缺点是如果是内网封闭环境不能用,国内CDN也不稳定,国外的CDN有时无法访问。如官网的
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script> 就无法访问。
几个比较稳定的CDN
Staticfile CDN(国内) : https://cdn.staticfile.org/vue/2.2.2/vue.min.js
unpkg:https://unpkg.com/vue@2.6.14/dist/vue.min.js。
cdnjs : https://cdnjs.cloudflare.com/ajax/libs/vue/2.1.8/vue.min.js
3、命令行工具
Vue 提供了一个官方的 CLI,为单页面应用 (SPA) 快速搭建繁杂的脚手架。它为现代前端工作流提供了开箱即用的构建设置。只需要几分钟的时间就可以运行起来并带有热重载、保存时 lint 校验,以及生产环境可用的构建版本。更多详情可查阅 Vue CLI 的文档。

三、第一个Vue

程序员学一门新的语音或框架,都是从hello world!开始的。来看一下Vue的hello world!
将vue.min.js下载到本地,在vue.min.js的目录下新建一个hellovue.html的文件,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<script src="vue.min.js"></script>
</head>
<body>
<div id="app">
{{ message }}
</div>

<script type="text/javascript">
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
});
</script>
</body>
</html>

用浏览器打开,hello Vue! 成功的展现出来,第一个Vue就这么简单。
hello Vue!

在这里我们通过<script src="vue.min.js"></script>引入了本地的vue.min.js,就可以用vue框架了。
通过<div id="app">构建了一个DOM元素div标签元素,id为app,`{{message}}` 是占位符,类似于大多数的模板语法。

1
2
3
<div id="app">
{{ message }}
</div>

在javascript代码中,定义了一个Vue对象,对象中构造了el和data两个参数。el是元素选择器,通过#app选择了id="app"的div,data用来定义数据属性,这里定义了massage:'hellow Vue!',通过`{{message}}`将数据hellow Vue显示输出。
可以用chrome浏览器的开发者工具打开控制台看到app.message的值为’hellow Vue!’。
chrome浏览器的开发者工具调试

可以通过修改这个变量的值而改变显示在浏览器的值。
修改值

四、常用基本语法

模板语法
Vue.js 使用了基于 HTML 的模板语法,允许开发者声明式地将 DOM 绑定至底层 Vue 实例的数据。
Vue.js 的核心是一个允许你采用简洁的模板语法来声明式的将数据渲染进 DOM 的系统。
结合响应系统,在应用状态改变时, Vue 能够智能地计算出重新渲染组件的最小代价并应用到 DOM 操作上

插值文本

数据绑定最常见的形式就是使用 `{{xxx}}`(双大括号)的文本插值:
正如我们的第一的Vue通过`{{ message }}`将文本值插入到占位符进行数据绑定

1
2
3
<div id="app">
<p>{{ message }}</p>
</div>

绑定输出html

使用v-html 指令用于输出 html 代码:

1
2
3
4
5
6
7
8
9
10
app2:v-html指令输出html代码
<div id="app2">
<div v-html="message"></div>
</div>
var app2 = new Vue({
el: '#app2',
data: {
message: '<b>Hello Vue!</b>'
}
});

效果如下图所示:
v-html

如果不用v-html插入,将<div id="app2">标签内容改成用文本插入

1
2
3
<div id="app2">
<p>{{ message }}</p>
</div>

显示效果如下,直接将html代码给显示出来了。
直接显示HTML代码

绑定属性

HTML 属性中的值应使用 v-bind 指令。
如插入绑定 a 标签的href属性

1
2
3
4
5
6
7
8
9
10
app3:v-bind指令绑定属性值
<div id="app3">
<a target="_blank" v-bind:href="url">click me go to myblog</a>
</div>
var app3 = new Vue({
el: '#app3',
data: {
url: 'http://xiejava.ishareread.com/'
}
});

效果如下:
绑定属性

绑定样式

class 与 style 是 HTML 元素的属性,用于设置元素的样式,可以用 v-bind 来绑定设置样式属性

1
2
3
4
5
6
7
8
9
10
11
12
app4:v-band:class指令绑定样式
<div id="app4">
<div v-bind:class="{ 'active': isActive }"></div>
</div>
<br>
var app4=new Vue(
{
el: '#app4',
data: {
isActive:true
}
});

定义样式

1
2
3
4
5
6
7
<style>
.active {
width: 100px;
height: 100px;
background: red;
}
</style>

效果如下:
v-band:class

插值Javascript表达式

vue.js插值支持javascript表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app5:vue.js插值的javascript表达式支持
<div id="app5">
{{5+5}}<br>
{{ ok ? 'YES' : 'NO' }}<br>
{{ message.split('').reverse().join('') }}
<div v-bind:id="'list-' + id">xiejava</div>
</div>
<br>
var app5 = new Vue({
el: '#app5',
data: {
ok: true,
message: 'XIEJAVA',
id : 1
}
});

效果如下:

vue.js插值支持javascript表达式

常用语句

v-if v-else (条件语句)

条件判断使用 v-if 指令,可以用 v-else 指令给 v-if 添加一个 “else” 块:

1
2
3
4
5
6
7
8
9
10
11
app6:v-if条件语句
<div id="app6">
<div v-if="ok">YES</div>
<div v-else>NO</div>
</div>
var app6 = new Vue({
el:"#app6",
data:{
ok:false,
}
});

效果如下:

v-if v-else (条件语句)

for循环语句

循环使用 v-for 指令,v-for 可以绑定数据到数组来渲染一个列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app7">
<ol>
<li v-for="code in codes">
{{ code.name }}
</li>
</ol>
</div>
<br>
var app7=new Vue(
{
el: '#app7',
data: {
codes: [
{ name: 'java' },
{ name: 'python' },
{ name: 'php' }
]
}
});

效果如下:
for循环语句

v-on绑定事件

事件监听可以使用 v-on 指令进行绑定

1
2
3
4
5
6
7
8
9
10
11
<div id="app8">
<button v-on:click="counter += 1">+1</button>
<p>加了 {{ counter }} 次1。</p>
</div>
var app8=new Vue(
{
el: '#app8',
data: {
counter:0
}
});

效果如下:

v-on绑定事件

以上全部示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
</head>
<body>
app1:hello Vue!
<div id="app1">
{{ message }}
</div>
<br>

app2:v-html指令输出html代码
<div id="app2">
<div v-html="message"></div>
</div>
<br>

app3:v-bind指令绑定属性值
<div id="app3">
<a target="_blank" v-bind:href="url">click me go to myblog</a>
</div>
<br>

app4:v-band:class指令绑定样式
<div id="app4">
<div v-bind:class="{ 'active': isActive }"></div>
</div>
<br>

app5:vue.js插值的javascript表达式支持
<div id="app5">
{{5+5}}<br>
{{ ok ? 'YES' : 'NO' }}<br>
{{ message.split('').reverse().join('') }}
<div v-bind:id="'list-' + id">xiejava</div>
</div>
<br>

app6:v-if条件语句
<div id="app6">
<div v-if="ok">YES</div>
<div v-else>NO</div>
</div>
<br>

app7:for循环语句
<div id="app7">
<ol>
<li v-for="code in codes">
{{ code.name }}
</li>
</ol>
</div>
<br>

app8:v-on绑定事件
<div id="app8">
<button v-on:click="counter += 1">+1</button>
<p>加了 {{ counter }} 次1。</p>
</div>
<br>

<script type="text/javascript">
var app1 = new Vue({
el: '#app1',
data: {
message: 'Hello Vue!'
}
});

var app2 = new Vue({
el: '#app2',
data: {
message: '<b>Hello Vue!</b>'
}
});

var app3 = new Vue({
el: '#app3',
data: {
url: 'http://xiejava.ishareread.com/'
}
});

var app4=new Vue(
{
el: '#app4',
data: {
isActive:true
}
});

var app5 = new Vue({
el: '#app5',
data: {
ok: true,
message: 'XIEJAVA',
id : 1
}
});

var app6 = new Vue({
el:"#app6",
data:{
ok:false,
}
});

var app7=new Vue(
{
el: '#app7',
data: {
codes: [
{ name: 'java' },
{ name: 'python' },
{ name: 'php' }
]
}
});

var app8=new Vue(
{
el: '#app8',
data: {
counter:0
}
});
</script>
<style>
.active {
width: 100px;
height: 100px;
background: red;
}
</style>
</body>
</html>

通过上面的快速入门,基本了解什么是VUE、VUE的安装及基本的使用,常用的语法。后面还要更深入的学习VUE的组件、路由、后台接口调用等。


博客地址:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注:微信公众号,一起学习成长!

PyCharm在用Django开发时debug模式启动失败显示can't find '__main__' module的解决方法

发表于 2022-06-06 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 203 | 阅读时长 ≈ 1

初次用Django开发web应用,在试图用Pycharm进行debug的时候,出现了一个奇怪的问题。以正常模式启动或者在terminal启动都没有问题。但是以debug模式启动时,显示can't find '__main__' module”报错。在网上找了很久都没有看到解决方法,最后在某乎上看到一篇文章,在启动时加上--noreload参数,既可以debug模式启动。

报错信息:
报错信息
解决方法:
在启动时加上 --noreload 参数可以正常启动调试
加入不重新加载参数

debug启动正常也可以调试了。
debug正常启动

踩过的坑记录一下,希望能帮到碰到同样问题的人。

感谢大佬的文章 https://zhuanlan.zhihu.com/p/443763989


博客地址:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注微信公众号,一起学习、成长!

Python使用BeautifulSoup4修改网页内容实战

发表于 2022-05-18 | 更新于: 2024-06-13 | 分类于 技术 , 开发 | | 阅读次数:
字数统计: 1.2k | 阅读时长 ≈ 6

最近有个小项目,需要爬取页面上相应的资源数据后,保存到本地,然后将原始的HTML源文件保存下来,对HTML页面的内容进行修改将某些标签整个给替换掉。

对于这类需要对HTML进行操作的需求,最方便的莫过于BeautifulSoup4的库了。

样例的HTML代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<html>
<body>
<a class="videoslide" href="http://www.test.com/wp-content/uploads/1020/1381824922.JPG">
<img src="http://www.test.com/wp-content/uploads/1020/1381824922_zy_compress.JPG" data-zy-media-id="zy_location_201310151613422786"/>
</a>
<a href="http://www.test.com/wp-content/uploads/1020/第一张_1381824798.JPG">
<img data-zy-media-id="zy_image_201310151613169945" src="http://www.test.com/wp-content/uploads/1020/第一张_1381824798_zy_compress.JPG"/></a>
<a href="http://www.test.com/wp-content/uploads/1020/第二张_1381824796.jpg">
<img data-zy-media-id="zy_image_201310151613163009" src="http://www.test.com/wp-content/uploads/1020/第二张_1381824796_zy_compress.jpg"/>
</a>
<a href="http://www.test.com/wp-content/uploads/1020/第三张.jpg">
<img data-zy-media-id="zy_image_201312311838584446" src="http://www.test.com/wp-content/uploads/1020/第三张_zy_compress.jpg"/>
</a>
</body>
</html>

这里主要包括了<a >标签,<a >标签里面嵌入了<img >标签,其中有<a class="videoslide">的标识该标签实际是可以播放动画的。需要根据class="videoslide" 来判断将整个<a >标签换成播放器的<video >标签,将没有class="videoslide" 的<a >标签换成<figure>标签。

也就是将带有的<a class="videoslide" ...><img ... /></a>标签换成

1
2
3
4
5
6
<div class="video">
<video controls width="100%" poster="视频链接的图片地址.jpg">
<source src="视频文件的静态地址.mp4" type="video/mp4" />
您的浏览器不支持H5视频,请使用Chrome/Firefox/Edge浏览器。
</video>
</div>

将<a ....><img .../></a>标签换成

1
2
3
4
<figure>
< img src="图片地址_compressed.jpg" data-zy-media-id="图片地址.jpg">
<figcaption>文字说明(如果有)</figcaption>
</figure>

这里通过BeautifulSoup4 的select()方法找到标签,通过get()方法获取标签及标签属性值,通过replaceWith来替换标签,具体代码如下:
首先安装BeautifulSoup4的库,BeautifulSoup4库依赖于lxml库,所以也需要安装lxml库。

1
2
pip install bs4
pip install lxml

具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import os
from bs4 import BeautifulSoup
htmlstr='<html><body>' \
'<a class="videoslide" href="http://www.test.com/wp-content/uploads/1020/1381824922.JPG">' \
'<img src="http://www.test.com/wp-content/uploads/1020/1381824922_zy_compress.JPG" data-zy-media-id="zy_location_201310151613422786"/></a>' \
'<a href="http://www.test.com/wp-content/uploads/1020/第一张_1381824798.JPG">' \
'<img data-zy-media-id="zy_image_201310151613169945" src="http://www.test.com/wp-content/uploads/1020/第一张_1381824798_zy_compress.JPG"/></a>' \
'<a href="http://www.test.com/wp-content/uploads/1020/第二张_1381824796.jpg">' \
'<img data-zy-media-id="zy_image_201310151613163009" src="http://www.test.com/wp-content/uploads/1020/第二张_1381824796_zy_compress.jpg"/></a>' \
'<a href="http://www.test.com/wp-content/uploads/1020/第三张.jpg">' \
'<img data-zy-media-id="zy_image_201312311838584446" src="http://www.test.com/wp-content/uploads/1020/第三张_zy_compress.jpg"/></a>' \
'</body></html>'

def procHtml(htmlstr):
soup = BeautifulSoup(htmlstr, 'lxml')
a_tags=soup.select('a')
for a_tag in a_tags:
a_tag_src = a_tag.get('href')
a_tag_filename = os.path.basename(a_tag_src)
a_tag_path = os.path.join('src', a_tag_filename)
a_tag['href']=a_tag_path
next_tag=a_tag.next
#判断是视频还是图片,如果a标签带了class="videoslide" 是视频否则是图片
if a_tag.get('class') and 'videoslide'==a_tag.get('class')[0]:
# 处理视频文件
media_id = next_tag.get('data-zy-media-id')
if media_id:
media_url = 'http://www.test.com/travel/show_media/' + str(media_id)+'.mp4'
media_filename = os.path.basename(media_url)
media_path = os.path.join('src', media_filename)
# 将div.video标签替换a标签
video_html = '<div class=\"video\"><video controls width = \"100%\" poster = \"' + a_tag_path + '\" ><source src = \"' + media_path + '\" type = \"video/mp4\" /> 您的浏览器不支持H5视频,请使用Chrome / Firefox / Edge浏览器。 </video></div>'
video_soup = BeautifulSoup(video_html, 'lxml')
a_tag.replaceWith(video_soup.div)
else:
#获取图片信息
if 'img'==next_tag.name:
img_src=next_tag.get('src')
# 判断是否路径是否为本地资源 data:image和file:
if img_src.find('data:image') == -1 and img_src.find('file:') == -1:
img_filename = os.path.basename(img_src)
img_path = os.path.join('src', img_filename)
# 将<figure><img>标签替换a标签
figcaption=''
figure_html='<figure><img src=\"'+img_path+'\" data-zy-media-id=\"'+a_tag_path+'\"><figcaption>'+figcaption+'</figcaption></figure>'
figure_soup = BeautifulSoup(figure_html, 'lxml')
a_tag.replaceWith(figure_soup.figure)
html_content = soup.contents[0]
return html_content

if __name__ == '__main__':
pro_html_str=procHtml(htmlstr)
print(pro_html_str)

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<html>
<body>
<div class="video">
<video controls="" poster="src\1381824922.JPG" width="100%">
<source src="src\zy_location_201310151613422786.mp4" type="video/mp4"/> 您的浏览器不支持H5视频,请使用Chrome / Firefox / Edge浏览器。
</video>
</div>
<figure>
<img data-zy-media-id="src\第一张_1381824798.JPG" src="src\第一张_1381824798_zy_compress.JPG"/>
<figcaption></figcaption>
</figure>
<figure>
<img data-zy-media-id="src\第二张_1381824796.jpg" src="src\第二张_1381824796_zy_compress.jpg"/>
<figcaption></figcaption></figure>
<figure>
<img data-zy-media-id="src\第三张.jpg" src="src\第三张_zy_compress.jpg"/>
<figcaption></figcaption>
</figure>
</body>
</html>

博客地址:http://xiejava.ishareread.com/

网络安全设备-认识运维安全管理与审计系统(堡垒机)

发表于 2022-05-11 | 更新于: 2024-06-13 | 分类于 技术 , 网络安全 | | 阅读次数:
字数统计: 2.9k | 阅读时长 ≈ 10

一、什么是运维安全管理与审计系统

运维安全管理与审计系统(俗称 “堡垒机”):是采用新一代智能运维技术框架,基于认证、授权、访问、审计的管理流程设计理念,实现对企事业IT中心的网络设备、数据库、安全设备、主机系统、中间件等资源统一运维管理和审计;通过集中化运维管控、运维过程实时监管、运维访问合规性控制、运维过程图形化审计等功能,为企事业IT中心运维构建一套事前预防、事中监控、事后审计完善的安全管理体系。

简单的说,运维安全管理与审计系统(堡垒机)就是用来控制哪些人可以登录哪些资产(事先防范和控制),以及录像记录登录资产后做了什么事情(事中监控和事后溯源)的系统。其核心是可控及审计。可控是指权限可控、行为可控。权限可控指可以方便的设置、回收运维操作人员的权限;行为可控,比如需要集中禁用某个危险命令;可审计,指有权限操作的人员对资产的所有操作都有记录,能够被监控和审计。

二、为什么需要运维安全管理与审计系统

当企业的IT资产越来越多,当参与运维的岗位越来越多样性,运维团队达到一定的规模,不同的人员如运维人员、开发人员、第三方代维、厂商支撑人员需要控制访问不同的资产及权限,如果没有一套好的机制,就会产生混乱。无法有效的做到“哪些人允许以什么样的身份访问哪些设备”,更加没有办法知道“哪些人登录设备后做了哪些事情”,出了问题以后无法回溯。

运维混乱

运维安全管理与审计系统(堡垒机)是从跳板机(也叫前置机)的概念演变过来的。早在2000年左右,一些中大型企业为了能对运维人员的远程登录进行集中管理,会在机房部署一台跳板机。跳板机其实就是一台unix/linux/windows操作系统的服务器,所有运维人员都需要先远程登录跳板机,然后再从跳板机登录其他服务器中进行运维操作。

跳板机

跳板机解决了远程登录集中管理访问的问题,但跳板机并没有实现对运维人员操作行为的控制和审计,使用跳板机过程中还是会有误操作、违规操作导致的操作事故,一旦出现操作事故很难快速定位原因和责任人。此外,跳板机存在严重的安全风险,一旦跳板机系统被攻入,则将后端资源风险完全暴露无遗。同时,对于个别资源(如telnet)可以通过跳板机来完成一定的内控,但是对于更多更特殊的资源(ftp、rdp等)来讲就显得力不从心了。

人们逐渐认识到跳板机的不足,进而需要更新、更好的安全技术理念来实现运维操作管理。需要一种能满足角色管理与授权审批、信息资源访问控制、操作记录和审计、系统变更和维护控制要求,并生成一些统计报表配合管理规范来不断提升IT内控的合规性的产品。在这些理念的指导下,2005年前后,运维安全管理与审计系统(堡垒机)开始以一个独立的产品形态被广泛部署,有效地降低了运维操作风险,使得运维操作管理变得更简单、更安全。

堡垒机

运维安全管理与审计系统(堡垒机)承担了运维人员在运维过程中唯一的入口,通过精细化授权以明确“哪些人以什么身份访问了哪些设备”,从而让运维混乱变得有序起来,堡垒机不仅可以明确每一个运维人员的访问路径,还可以将每一次访问及操作过程变得可以“审计”,就像飞机中的黑匣子,汽车上的行车记录仪,能够做到针对运维人员的每次一操作均可以录像、全程审计,一但出了问题,可以追踪溯源。

运维安全管理与审计系统的目标可以概括为5W,主要是为了降低运维风险。具体如下:

  • 审计:你做了什么?(What)
  • 授权:你能做哪些?(Which)
  • 账号:你要去哪?(Where)
  • 认证:你是谁?(Who)
  • 来源:访问时间?(When)

运维安全管理与审计系统实现:

  • 事前预防:建立“自然人-资源-资源账号”关系,实现统一认证和授权
  • 事中控制:建立“自然人-操作-资源”关系,实现操作审计和控制
  • 事后审计:建立“自然人-资源-审计日志”关系,实现事后溯源和责任界定

三、运维安全管理与审计系统原理

原理

运维安全管理与审计系统(堡垒机),主要采用4A管理模型,对IT运维操作进行访问控制和行为审计的合规性管控系统,主要用来解决企业IT运维部门账号管理混乱,身份冒用、滥用,授权控制不明确,操作行为不规范,事件责任无法定位等问题。
4A 是指认证 Authentication、授权 Authorization、账号 Account、审计 Audit,中文名称为统一安全管理平台解决方案。即将身份认证、授权、记账和审计定义为网络安全的四大组成部分,从而确立了身份认证在整个网络安全系统中的地位与作用。
具体来说:

  • 集中认证 (authentication) 管理
    可以根据用户应用的实际需要,为用户提供不同强度的认证方式,既可以保持原有的静态口令方式,又可以提供具有双因子认证方式的高强度认证(一次性口令、数字证书、动态口令),而且还能够集成现有其它如生物特征等新型的认证方式。不仅可以实现用户认证的统一管理,并且能够为用户提供统一的认证门户,实现企业信息资源访问的单点登录。
  • 集中权限 (authorization) 管理
    可以对用户的资源访问权限进行集中控制。它既可以实现对 B/S、C/S 应用系统资源的访问权限控制,也可以实现对数据库、主机及网络设备的操作的权限控制,资源控制类型既包括 B/S 的 URL、C/S 的功能模块,也包括数据库的数据、记录及主机、网络设备的操作命令、IP 地址及端口。
  • 集中帐号(account)管理
    为用户提供统一集中的帐号管理,支持管理的资源包括主流的操作系统、网络设备和应用系统;不仅能够实现被管理资源帐号的创建、删除及同步等帐号管理生命周期所包含的基本功能,而且也可以通过平台进行帐号密码策略,密码强度、生存周期的设定。
  • 集中审计 (audit) 管理
    将用户所有的操作日志集中记录管理和分析,不仅可以对用户行为进行监控,并且可以通过集中的审计数据进行数据挖掘,以便于事后的安全事故责任的认定。

技术架构

实现的技术架构如下:
堡垒机技术架构

核心功能

主要核心功能包括:
1、访问控制
通过对访问资源的严格控制,堡垒机可以确保运维人员在其账号有效权限、期限内合法访问操作资源,降低操作风险,以实现安全监管目的,保障运维操作人员的安全、合法合规、可控制性。
2、账号管理
当运维人员在使用堡垒机时,无论是使用云主机还是局域网的主机,都可以同步导入堡垒机进行账号集中管理与密码的批量修改,并可一键批量设置SSH秘钥对等。
3、资源授权
支持云主机、局域网主机等多种形式的主机资源授权,并且堡垒机采用基于角色的访问控制模型,能够对用户、资源、功能作用进行细致化的授权管理,解决人员众多、权限交叉、资产繁琐、各类权限复制等众多运维人员遇到的运维难题。
4、指令审核
对运维人员的账号使用情况,包括登录、资源访问、资源使用等。针对敏感指令,堡垒机可以对非法操作进行阻断响应或触发审核的操作情况,审核未通过的敏感指令,堡垒机将进行拦截。
5、审计录像
除了可以提供安全层面外,还可以利用堡垒机的事前权限授权、事中敏感指令拦截外,以及堡垒机事后运维审计的特性。运维人员在堡垒机中所进行的运维操作均会以日志的形式记录,管理者即通过日志对微云人员的操作进行安全审计录像。
6、身份认证
为运维人员提供不同强度的认证方式,既可以保持原有的静态口令方式,还可以提供微信、短信等认证方式。堡垒机不仅可以实现用户认证的统一管理,还能为运维人员提供统一一致的认证门户,实现企业的信息资源访问的单点登录。
7、操作审计
将运维人员所有操作日志集中管理与分析,不仅可以对用户行为进行监控与拦截,还可以通过集中的安全审计数据进行数据挖掘,以便于运维人员对安全事故的操作审计认定。

四、运维安全管理与审计系统部署方式

1、单机部署

堡垒机主要都是旁路部署,旁挂在交换机旁边,只要能访问所有设备即可。
部署特点:
旁路部署,逻辑串联。
不影响现有网络结构。
单机部署

2、HA高可靠部署

旁路部署两台堡垒机,中间有心跳线连接,同步数据。对外提供一个虚拟IP。用户通过堡垒机虚拟IP进行访问,堡垒机自动进行会话负载分配和数据同步、冗余存储。
部署特点:
两台硬件堡垒机,一主一备/提供VIP。
当主机出现故障时,备机自动接管服务。
HA高可靠部署

五、常见运维安全管理与审计系统产品

商用

奇安信[运维安全管理与审计系统]:https://www.qianxin.com/product/detail/pid/385
亚信安全[信磐堡垒机]:https://www.asiainfo-sec.com/product/detail-27.html
绿盟[绿盟运维安全管理系统]:https://www.nsfocus.com.cn/html/2019/212_0926/20.html
启明星辰[堡垒机]:https://www.venustech.com.cn/new_type/blj/

开源

麒麟堡垒机:http://www.secvpn.com.cn/
飞致JumpServer堡垒机:https://fit2cloud.com/jumpserver/index.html


博客地址:http://xiejava.ishareread.com/


“fullbug”微信公众号

关注:微信公众号,一起学习成长!

<1…131415…22>
XieJava

XieJava

214 日志
11 分类
27 标签
RSS
GitHub
友情链接
  • 爱分享读书
  • CSDN
  • 豆瓣
© 2025 XieJava | Site words total count: 434.9k

主题 — NexT.Muse
0%