diff --git a/.claude/ADMIN_URL_SLUG_BACKEND_UPDATE.md b/.claude/ADMIN_URL_SLUG_BACKEND_UPDATE.md new file mode 100644 index 000000000..5671be1cf --- /dev/null +++ b/.claude/ADMIN_URL_SLUG_BACKEND_UPDATE.md @@ -0,0 +1,429 @@ +# Admin URL Slug 后端接口更新说明 + +## 📋 更新概述 + +为支持管理员界面的URL slug编辑和重复性验证功能,对后端接口进行了必要的修改。 + +## ✅ 已修改内容 + +### 1. 请求参数类 - `ArticlePostReq.java` + +**文件**: `paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ArticlePostReq.java` + +**新增字段**: +```java +/** + * URL slug,用于SEO友好URL + */ +private String urlSlug; +``` + +**作用**: 允许管理员在创建或更新文章时指定自定义的URL slug + +--- + +### 2. 文章查询请求类 - `SearchArticleReq.java` + +**文件**: `paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchArticleReq.java` + +**新增字段**: +```java +@ApiModelProperty("URL slug,用于SEO友好URL") +private String urlSlug; +``` + +**作用**: 支持通过URL slug进行文章查询,用于验证slug是否重复 + +--- + +### 3. 文章查询参数类 - `SearchArticleParams.java` + +**文件**: `paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchArticleParams.java` + +**新增字段**: +```java +/** + * URL slug,用于SEO友好URL + */ +private String urlSlug; +``` + +**作用**: 数据库查询参数,支持按slug查询 + +--- + +### 4. 管理端DTO - `ArticleAdminDTO.java` + +**文件**: `paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleAdminDTO.java` + +**新增字段**: +```java +/** + * URL slug,用于SEO友好URL + */ +private String urlSlug; +``` + +**作用**: 在管理端文章列表中返回URL slug信息 + +--- + +### 5. MyBatis Mapper XML - `ArticleMapper.xml` + +**文件**: `paicoding-service/src/main/resources/mapper/ArticleMapper.xml` + +#### 修改1: 查询条件支持slug过滤 + +**位置**: `` + +**新增内容**: +```xml + + and a.url_slug = #{searchParams.urlSlug} + +``` + +**作用**: 允许按URL slug精确查询文章 + +#### 修改2: 查询结果包含url_slug + +**位置**: ` - select a.article_id as articleId, a.tag_id as tagId, t.tag_name as tag - from article_tag as a - left join tag as t on a.tag_id = t.id - where a.article_id = #{articleId} - and a.deleted = 0 - - diff --git a/forum-service/src/main/resources/mapper/UserFootMapper.xml b/forum-service/src/main/resources/mapper/UserFootMapper.xml deleted file mode 100644 index e0e9b448d..000000000 --- a/forum-service/src/main/resources/mapper/UserFootMapper.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/forum-service/src/main/resources/mapper/UserRelationMapper.xml b/forum-service/src/main/resources/mapper/UserRelationMapper.xml deleted file mode 100644 index c3ea95db4..000000000 --- a/forum-service/src/main/resources/mapper/UserRelationMapper.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - limit #{pageParam.offset}, #{pageParam.limit} - - - - - - - - - diff --git a/forum-ui/README.md b/forum-ui/README.md deleted file mode 100644 index 6b9e4de5e..000000000 --- a/forum-ui/README.md +++ /dev/null @@ -1,4 +0,0 @@ -forum-ui -=== - -存储前段资源文件 \ No newline at end of file diff --git a/forum-ui/pom.xml b/forum-ui/pom.xml deleted file mode 100644 index d0461be5b..000000000 --- a/forum-ui/pom.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - quick-forum - com.github.liuyueyi.quick-forum - 0.0.1-SNAPSHOT - - 4.0.0 - - forum-ui - - - - org.springframework.boot - spring-boot-starter-thymeleaf - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/static/css/content.css b/forum-ui/src/main/resources/static/css/content.css deleted file mode 100644 index b77c4e796..000000000 --- a/forum-ui/src/main/resources/static/css/content.css +++ /dev/null @@ -1,315 +0,0 @@ -.article-content img { - max-width: 100%; - max-height: 500px; - box-sizing: content-box; - background-color: #fff; - margin: 0 auto; -} - -.article-content h1, -.article-content h2, -.article-content h3, -.article-content h4, -.article-content h5 { - color: #333; - margin-bottom: 10px; - padding-bottom: 7px; -} - -.article-content h1 { - border-bottom: 1px solid #eaecef; - font-size: 1.7em; -} - -.article-content h2 { - font-size: 1.5em; -} - -.article-content h3 { - font-size: 1.3em; -} - -.article-content h4 { - font-size: 1.1em; -} - -.article-content h5 { - font-size: 1em; -} - -.article-content p, -.article-content ol, -.article-content ul, -.article-content table, -.article-content pre, -.article-content blockquote { - /* font-weight: 400; */ - line-height: 1.8; - margin-bottom: 15px; -} - -.article-content blockquote { - padding: 0 1em; - color: #6a737d; - border-left: .25em solid #dfe2e5; -} - -.article-content ol, -.article-content ul { - padding-left: 20px; -} - -.article-content table { - display: table; - border-collapse: separate; - border-spacing: 2px; - border-color: grey; - border-spacing: 0; - border-collapse: collapse; - font-size: 14px; -} - -.article-content table th, -.article-content table tr, -.article-content table td { - padding: 6px 13px; - border: 1px solid #dfe2e5; -} - -.article-content pre { - padding: 5px; - overflow: auto; - font-size: 85%; - line-height: 1.45; - background-color: #fafafa; - border-radius: 3px; - word-wrap: normal; -} - -.article-content pre div { - background-color: #fafafa; -} - -.article-content li { - /* font-weight: 400; */ - line-height: 1.4; - font-size: 15px; - margin-bottom: 5px; -} - -.article-content .hljs-center { - text-align: center; -} - -.article-content .hljs-left { - text-align: left; -} - -.article-content .hljs-right { - text-align: right; -} - -.article-suspended-panel { - position: fixed; - margin-left: -7rem; - top: 140px; - z-index: 2; -} - -.panel-btn { - position: relative; - margin-bottom: 1.667rem; - width: 3rem; - height: 3rem; - background-color: #fff; - background-position: 50%; - background-repeat: no-repeat; - border-radius: 50%; - box-shadow: 0 2px 4px 0 rgba(0,0,0,.04); - cursor: pointer; - text-align: center; - font-size: 1.67rem -} - -.panel-btn .sprite-icon { - color: #8a919f; - height: 100% -} - -.panel-btn:hover .sprite-icon { - color: #515767 -} - -.panel-btn:not(.share-btn).active .sprite-icon { - color: #1e80ff -} - -.panel-btn:not(.share-btn).active .sprite-icon.icon-collect { - color: #ffb800 -} - -.panel-btn:not(.share-btn).active .sprite-icon { - color: #1e80ff; -} - - -.panel-btn:not(.share-btn).active.with-badge:after { - background-color: #1e80ff -} - -.panel-btn.with-badge:after { - content: attr(badge); - position: absolute; - top: 0; - left: 75%; - height: 17px; - line-height: 17px; - padding: 0 5px; - border-radius: 9px; - font-size: 11px; - text-align: center; - white-space: nowrap; - background-color: #c2c8d1; - color: #fff -} - -.panel-btn.share-btn:after { - display: block; - content: " "; - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 50% -} - -.panel-btn.share-btn:hover .share-popup { - display: flex -} - -.panel-btn.share-btn .share-popup { - display: none; - position: absolute; - top: 0; - flex-direction: column; - left: calc(100% + 14px); - z-index: 30; - background: #fff; - border-radius: 4px; - padding: 9px 0; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - box-shadow: 0 8px 24px rgba(81,87,103,.16) -} - -.panel-btn.share-btn .share-popup:after { - position: absolute; - width: 0; - height: 0; - content: " "; - right: 100%; - top: 14px; - border: 12px solid transparent; - border-right-color: #fff -} - -.panel-btn.share-btn .share-popup .share-item { - display: flex; - align-items: center; - height: 44px; - padding: 0 15px -} - -.panel-btn.share-btn .share-popup .share-item:hover { - background-color: #f2f3f5 -} - -.panel-btn.share-btn .share-popup .share-item:hover.wechat .wechat-qrcode { - display: flex -} - -.panel-btn.share-btn .share-popup .share-item:hover .share-icon { - color: #515767 -} - -.panel-btn.share-btn .share-popup .share-item .share-item-title { - margin-left: 8px; - font-size: 14px; - color: #515767 -} - -.panel-btn.share-btn .share-popup .share-item .share-icon { - color: #8a919f; - width: 20px; - height: 20px; - font-size: 1.67rem -} - -.share-title { - margin: 2.5rem 0 1rem; - font-size: 1rem; - text-align: center; - color: #c6c6c6; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none -} - -.collect-popover { - width: 200px; - box-sizing: border-box; - background-color: #fff; - padding: 12px 20px; - border-radius: 4px; - font-size: 13px; - color: #8a919f; - line-height: 22px; - text-align: left; - position: absolute; - left: 4rem; - top: -10px; - margin-left: 15px; - box-shadow: 0 8px 24px rgba(81,87,103,.26) -} - -.collect-popover:after { - content: ""; - display: block; - width: 0; - height: 0; - border-top: 12px solid #fff; - border-left: 12px solid #fff; - transform: rotate(45deg); - position: absolute; - top: 30px; - left: -6px -} - -.collect-popover-title { - color: #252933; - font-weight: 500; - font-size: 14px; - margin-bottom: 4px -} - -.collect-popover-content { - display: flex; - flex-direction: row -} - -.collect-popover-button { - color: #1e80ff; - font-weight: 500; - cursor: pointer; - margin-right: 4px -} - -.sprite-icon { - width: 1em; - height: 1em; - fill: currentColor; - vertical-align: middle; - transition: all .15s linear -} \ No newline at end of file diff --git a/forum-ui/src/main/resources/static/css/global.css b/forum-ui/src/main/resources/static/css/global.css deleted file mode 100644 index 2ea7af9ee..000000000 --- a/forum-ui/src/main/resources/static/css/global.css +++ /dev/null @@ -1,521 +0,0 @@ -body { - -webkit-font-smoothing: antialiased; -} - -.custom-empty { - text-align: center; - margin-top: 20px; - width: 100%; - font-size: 1rem; -} - -html, -.custom-bg-color { - background-color: #EEEEEE; -} - -.posts-comment-input-box, -.posts-author-box, -.posts-box, -.page-box, -.user-info-box { - background-color: #fff; -} - -.btn-outline-primary:hover, -.page-item.active .page-link, -.current-page { - color: #fff !important; -} - -.bottom-line, -.list-group, -.editor-title { - border-bottom: 1px solid rgba(0, 0, 0, .125); -} - -.faq-solution-box, -.posts-comment-input-box { - background-color: #fafbfc; -} - -.posts-comment-input-box { - margin-top: -20px; -} - -.posts-comment-box, -.posts-author-box, -.posts-box, -.editor-form-box, -.editor-title, -.card-body, -.card-header { - padding: 20px; -} - -.type-box { - padding: 10px; -} - -.tag-box, -.no-comment-box, -.posts-author-box, -.posts-box, -.card, -.user-info-box, -.page-box, -.carousel { - margin-bottom: 0px; -} - -.custom-theme-bg-color, -.btn-outline-primary:hover, -.page-item.active .page-link, -.btn-primary, -.btn-primary:active, -.btn-primary:focus, -.btn-primary:hover, -.current-page { - background-color: #007fff !important; -} - -.btn-outline-primary:hover, -.page-item.active .page-link, -.btn-primary, -.btn-primary:active, -.btn-primary:focus, -.btn-primary:hover, -.btn-outline-primary { - border-color: #007fff; -} - -.page-link, -.page-link:hover, -.btn-outline-primary, -.posts-admin-tag-official, -a:hover, -.custom-font-color { - color: #007fff; -} - -a { - color: #212529; -} - -.dropdown-menu, -.card { - border: 0; -} - -.input-group-text, -.navbar-toggler, -.modal-content, -.card { - margin-bottom: 10px; -} -.form-control, -.btn, -.dropdown-menu, -.list-group-item:first-child, -.list-group-item:last-child, -.pagination { - border-radius: 0; -} - -/* */ -html { - padding-top: 82px; -} - -.posts-list-desc, -a { - color: rgba(0, 0, 0, .87); -} - -.custom-by-both { - padding-left: 10px; - padding-right: 10px; -} - -.carousel-inner img { - width: 100%; - height: 100%; -} - -.foot { - height: 70px; -} - -.foot-link { - list-style: none; - padding: 25px 0; - width: 80%; - margin: 0 auto; - text-align: left; - font-size: 0; - border-top: 1px solid rgba(0, 0, 0, .1); -} - -.foot li { - font-size: 14px; - padding: 0 10px; - display: inline-block; - vertical-align: middle; - line-height: 1em; -} - -.foot li:last-child { - border-left: none; - float: right; - padding-right: 0; -} - -.posts-list-desc { - display: inline; - max-height: 48px; - text-overflow: -o-ellipsis-lastline; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; -} - -.posts-list-title { - font-size: 18px; - font-weight: 700; - line-height: 1.5; - margin-bottom: 5px; - color: rgba(0, 0, 0, .85); -} - -.posts-list-payload-item, -.posts-list-payload-item a, -.posts-list-payload-box-author { - color: #6c757d !important; -} - -.posts-list-payload-item { - padding: 3px 8px; -} - -.page-box { - padding: 10px; -} - -.faq-solution-box { - margin-top: 10px; - padding: 15px; -} - -.faq-solution-box, -.posts-list-desc { - font-size: 13px; - line-height: 24px; -} - -.posts-admin-tag { - margin-top: 4px; - height: 16px; - padding: 2px; - border-radius: 2px; - line-height: 1; - font-size: 12px; - margin-right: 6px; - vertical-align: middle; - -webkit-transform: translateY(1px); - -ms-transform: translateY(1px); - transform: translateY(1px); -} - -.posts-admin-tag-official { - background: rgba(101, 212, 117, 0.1); -} - -.posts-admin-tag-top { - color: #f85959; - background: rgba(248, 89, 89, 0.1); -} - -.posts-admin-tag-marrow { - color: #3c8cff; - background: rgba(60, 140, 255, 0.1); -} - -.selected-domain { - border-bottom: 0 solid #3973ff; -} - -.selected-domain a { - color: #3973ff; -} - -.user-info-box { - height: 200px; - width: 100%; - margin-left: 0; - margin-right: 0; -} - -.user-info-date-box { - height: 80px; - padding-top: 40px; - padding-left: 40px; -} - -.user-info-date-box > p { - display: inline-block; -} - -.user-info-desc-box { - margin-top: 15px; - padding-left: 40px; - padding-right: 40px; -} - -/* 覆盖框架默认样式 */ -.navbar { - padding: .5rem 4rem; -} - -.container-bg-light { - background: #ffffff; -} - -.input-icon { - position: relative; -} - -.input-icon input { - border-radius: 26px; -} - -.input-icon-addon { - position: absolute; - top: 0; - bottom: 0; - left: 0; - display: flex; - align-items: center; - justify-content: center; - min-width: 2.5rem; - color: rgb(179 174 174 / 60%); - pointer-events: none; - font-size: 1.2em; -} - -.input-icon .form-control:not(:first-child), .input-icon .form-select:not(:last-child) { - padding-left: 2.5rem; -} - - -.list-group-item { - border: none; - padding: 0.75rem 1.25rem; -} - -.btn { - padding-left: 25px; - padding-right: 25px; -} - -.btn-sm { - padding-left: 15px; - padding-right: 15px; -} - -.page-item:first-child .page-link, -.page-item:last-child .page-link { - border-radius: 0; -} - -.comment-avatar-box { - width: 40px; - float: left; -} - -.posts-comment-input-box-btn { - width: 100%; - display: none; -} - -.posts-comment-input-box-textarea { - padding: 4px 10px; - font-size: 13px; - line-height: 1.7; -} - -.posts-comment-input-box-textarea, -.comment-content-box { - width: calc(100% - 40px); - float: right; -} - -.best-answer { - margin-left: 20px; -} - -.best-answer:hover, -.reply-comment:hover { - cursor: pointer; -} - -.comment-content-box-title { - font-size: 16px; - color: #3d464d; - font-weight: 300; -} - -.comment-content-box-content { - color: #505050; - font-size: 14px; - margin: 12px 0; -} - -.comment-content-box-foot { - color: #b2b2b2; - font-size: 14px; -} - -.navbar-count-msg-box { - position: relative; -} - -.navbar-count-msg { - position: absolute; - top: 8px; - right: 0; - width: 10px; - height: 10px; - border-radius: 50%; - background-color: #f85959; -} - -.nav-item { - clear: both; - margin: 0; - padding: 5px 10px; - color: rgba(0, 0, 0, .65); - font-weight: 600; - font-size: 1.1em; - line-height: 22px; - white-space: nowrap; - cursor: pointer; - transition: all .3s; -} - -.navbar-light .navbar-nav .nav-link { - color: rgba(0, 0, 0, .85); -} - -.message-block { - margin-right: 10px; -} - -.third-oauth-login-box::before { - content: '三方账号登录'; - position: absolute; - left: 50%; - bottom: 55px; - font-size: 10px; - transform: translateX(-50%); - -webkit-transform: translate(-50%, -50%); - padding: 0 10px; - background-color: #fff; -} - -.third-oauth-login-box { - display: block; - text-align: center; -} - -/* 设备适配样式 */ -@media (max-width: 768px) { - html { - padding-top: 68px; - } - - .navbar { - padding: .5rem 1rem; - } - - .foot-link { - padding: 10px 0; - } - - .foot li { - display: block; - padding: 10px 0 0 0; - } - - .foot li:last-child { - float: none; - } - - .tag-box, - .no-comment-box, - .posts-author-box, - .posts-box, - .card { - margin-bottom: 10px; - } - .carousel - .page-box, - .user-info-box { - margin-bottom: 10px; - } - - .user-info-date-box { - display: none; - } - - .user-info-desc-box { - padding-left: 10px; - padding-right: 0; - } - - .best-answer { - margin-left: 10px; - } - - .posts-comment-box, - .posts-box, - .editor-form-box, - .card-body, - .card-header, - .list-group-item, - .faq-solution-box, - .editor-title { - padding: 10px; - } - - .type-box { - padding: 0; - } - - .posts-comment-input-box { - margin-top: -10px; - } - - .user-edit-btn { - display: none; - } - - .message-block { - margin-right: 5px; - } -} -.parent-wrapper { - display: flex; - background: #f2f3f5; - border: 1px solid #e4e6eb; - box-sizing: border-box; - border-radius: 4px; - padding: 0 12px; - line-height: 36px; - height: 36px; - font-size: 14px; - color: #8a919f; - margin-top: 8px; -} \ No newline at end of file diff --git a/forum-ui/src/main/resources/static/js/biz/forum.js b/forum-ui/src/main/resources/static/js/biz/forum.js deleted file mode 100644 index f188236ab..000000000 --- a/forum-ui/src/main/resources/static/js/biz/forum.js +++ /dev/null @@ -1,44 +0,0 @@ -const post = function (path, data, callback) { - $.ajax({ - method: 'POST', - url: path, - contentType: 'application/json', - data: JSON.stringify(data), - success: function (data) { - console.log("data", data); - if (!data || !data.status || data.status.code != 0) { - toastr.error(data.message); - } else if (callback) { - callback(data.result); - } - }, - error: function (data) { - toastr.error(data); - } - }); -}; - -const loadScript = function (url, callback) { - const secScript = document.createElement("script"); - if (secScript.readyState) { // IE - secScript.onreadystatechange = function () { - if (secScript.readyState === 'loaded' || secScript.readyState === 'complete') { - secScript.onreadystatechange = null; - callback(); - } - } - } else { // 其他浏览器 - secScript.onload = function () { - callback(); - } - } - secScript.setAttribute("type", "text/javascript"); - secScript.setAttribute("src", url); - document.body.insertBefore(secScript, document.body.lastChild); -}; - -const loadLink = function (url) { - let headHTML = document.getElementsByTagName('head')[0].innerHTML; - headHTML += ''; - document.getElementsByTagName('head')[0].innerHTML = headHTML; -}; diff --git a/forum-ui/src/main/resources/static/js/biz/login.js b/forum-ui/src/main/resources/static/js/biz/login.js deleted file mode 100644 index a77c2e278..000000000 --- a/forum-ui/src/main/resources/static/js/biz/login.js +++ /dev/null @@ -1,37 +0,0 @@ -$('#logoutBtn').click(function () { - $.ajax({ - url: "/logout", - dataType: "json", - type: "get", - success: function (data) { - toastr.success("已退出登录") - window.location.href = "/"; - } - }) -}) - -$('#loginBtn').click(function () { - const code = $('#loginCode').val(); - console.log("开始登录:" + code); - $.ajax({ - url: "/login?code=" + code, //请求的url地址 - dataType: "json", //返回格式为json - async: false,//请求是否异步,默认为异步,这也是ajax重要特性 - type: "GET", //请求方式 - success: function (data) { - //请求成功时处理 - console.log("response data:", data); - if (!data || !data.status || data.status.code !== 0) { - toastr.error(data.status.msg); - } else { - // 登录成功,刷新 - window.location.reload(); - toastr.success("登录成功"); - } - }, - error: function () { - //请求出错处理 - toastr.error("登录错误"); - } - }); -}); \ No newline at end of file diff --git a/forum-ui/src/main/resources/static/js/biz/toolaction.js b/forum-ui/src/main/resources/static/js/biz/toolaction.js deleted file mode 100644 index 2ddee21ed..000000000 --- a/forum-ui/src/main/resources/static/js/biz/toolaction.js +++ /dev/null @@ -1,41 +0,0 @@ -// 文章点赞 -const praiseArticle = function (articleId, action, callback) { - // 2 点赞, 4 取消点赞 - const type = action ? 2 : 4; - $.get('/article/api/favor?articleId=' + articleId + "&type=" + type, function (data) { - console.log("response:", data); - if (!data || !data.status || data.status.code !== 0) { - toastr.error(data.message); - } else if (callback) { - callback(data.result); - } - }); -} - -// 评论点赞 -const praiseComment = function (commentId, action, callback) { - // 2 点赞, 4 取消点赞 - const type = action ? 2 : 4; - $.get('/comment/api/favor?commentId=' + commentId + "&type=" + type, function (data) { - console.log("response:", data); - if (!data || !data.status || data.status.code !== 0) { - toastr.error(data.message); - } else if (callback) { - callback(data.result); - } - }); -} - -// 文章收藏 -const collectArticle = function (articleId, action, callback) { - // 3 收藏, 5 取消收藏 - const type = action ? 3 : 5; - $.get('/article/api/favor?articleId=' + articleId + "&type=" + type, function (data) { - console.log("response:", data); - if (!data || !data.status || data.status.code !== 0) { - toastr.error(data.message); - } else if (callback) { - callback(data.result); - } - }); -} diff --git a/forum-ui/src/main/resources/static/js/jquery.min.js b/forum-ui/src/main/resources/static/js/jquery.min.js deleted file mode 100644 index 644d35e27..000000000 --- a/forum-ui/src/main/resources/static/js/jquery.min.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! jQuery v3.2.1 | (c) JS Foundation and other contributors | jquery.org/license */ -!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.2.1",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext;function B(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()}var C=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,D=/^.[^:#\[\.,]*$/;function E(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):D.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(E(this,a||[],!1))},not:function(a){return this.pushStack(E(this,a||[],!0))},is:function(a){return!!E(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var F,G=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,H=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||F,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:G.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),C.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};H.prototype=r.fn,F=r(d);var I=/^(?:parents|prev(?:Until|All))/,J={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function K(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return K(a,"nextSibling")},prev:function(a){return K(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return B(a,"iframe")?a.contentDocument:(B(a,"template")&&(a=a.content||a),r.merge([],a.childNodes))}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(J[a]||r.uniqueSort(e),I.test(a)&&e.reverse()),this.pushStack(e)}});var L=/[^\x20\t\r\n\f]+/g;function M(a){var b={};return r.each(a.match(L)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?M(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=e||a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function N(a){return a}function O(a){throw a}function P(a,b,c,d){var e;try{a&&r.isFunction(e=a.promise)?e.call(a).done(b).fail(c):a&&r.isFunction(e=a.then)?e.call(a,b,c):b.apply(void 0,[a].slice(d))}catch(a){c.apply(void 0,[a])}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==O&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:N,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:N)),c[2][3].add(g(0,a,r.isFunction(d)?d:O))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(P(a,g.done(h(c)).resolve,g.reject,!b),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)P(e[c],h(c),g.reject);return g.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&Q.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var R=r.Deferred();r.fn.ready=function(a){return R.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||R.resolveWith(d,[r]))}}),r.ready.then=R.then;function S(){d.removeEventListener("DOMContentLoaded",S), -a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){X.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=W.get(a,b),c&&(!d||Array.isArray(c)?d=W.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return W.get(a,c)||W.access(a,c,{empty:r.Callbacks("once memory").add(function(){W.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,la=/^$|\/(?:java|ecma)script/i,ma={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/\s*$/g;function Ea(a,b){return B(a,"table")&&B(11!==b.nodeType?b:b.firstChild,"tr")?r(">tbody",a)[0]||a:a}function Fa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Ga(a){var b=Ca.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ha(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(W.hasData(a)&&(f=W.access(a),g=W.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Ba.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ja(f,b,c,d)});if(m&&(e=qa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(na(e,"script"),Fa),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=na(h),f=na(a),d=0,e=f.length;d0&&oa(g,!i&&na(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(U(c)){if(b=c[W.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[W.expando]=void 0}c[X.expando]&&(c[X.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ka(this,a,!0)},remove:function(a){return Ka(this,a)},text:function(a){return T(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.appendChild(a)}})},prepend:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(na(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return T(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!Aa.test(a)&&!ma[(ka.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}});function _a(a,b,c,d,e){return new _a.prototype.init(a,b,c,d,e)}r.Tween=_a,_a.prototype={constructor:_a,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||r.easing._default,this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(r.cssNumber[c]?"":"px")},cur:function(){var a=_a.propHooks[this.prop];return a&&a.get?a.get(this):_a.propHooks._default.get(this)},run:function(a){var b,c=_a.propHooks[this.prop];return this.options.duration?this.pos=b=r.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):_a.propHooks._default.set(this),this}},_a.prototype.init.prototype=_a.prototype,_a.propHooks={_default:{get:function(a){var b;return 1!==a.elem.nodeType||null!=a.elem[a.prop]&&null==a.elem.style[a.prop]?a.elem[a.prop]:(b=r.css(a.elem,a.prop,""),b&&"auto"!==b?b:0)},set:function(a){r.fx.step[a.prop]?r.fx.step[a.prop](a):1!==a.elem.nodeType||null==a.elem.style[r.cssProps[a.prop]]&&!r.cssHooks[a.prop]?a.elem[a.prop]=a.now:r.style(a.elem,a.prop,a.now+a.unit)}}},_a.propHooks.scrollTop=_a.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},r.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},_default:"swing"},r.fx=_a.prototype.init,r.fx.step={};var ab,bb,cb=/^(?:toggle|show|hide)$/,db=/queueHooks$/;function eb(){bb&&(d.hidden===!1&&a.requestAnimationFrame?a.requestAnimationFrame(eb):a.setTimeout(eb,r.fx.interval),r.fx.tick())}function fb(){return a.setTimeout(function(){ab=void 0}),ab=r.now()}function gb(a,b){var c,d=0,e={height:a};for(b=b?1:0;d<4;d+=2-b)c=ca[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function hb(a,b,c){for(var d,e=(kb.tweeners[b]||[]).concat(kb.tweeners["*"]),f=0,g=e.length;f1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?lb:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b), -null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),lb={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=mb[b]||r.find.attr;mb[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=mb[g],mb[g]=e,e=null!=c(a,b,d)?g:null,mb[g]=f),e}});var nb=/^(?:input|select|textarea|button)$/i,ob=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return T(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):nb.test(a.nodeName)||ob.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function pb(a){var b=a.match(L)||[];return b.join(" ")}function qb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,qb(this)))});if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,qb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,qb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(L)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=qb(this),b&&W.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":W.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+pb(qb(c))+" ").indexOf(b)>-1)return!0;return!1}});var rb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":Array.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(rb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:pb(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(Array.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var sb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!sb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,sb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(W.get(h,"events")||{})[b.type]&&W.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&U(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!U(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=W.access(d,b);e||d.addEventListener(a,c,!0),W.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=W.access(d,b)-1;e?W.access(d,b,e):(d.removeEventListener(a,c,!0),W.remove(d,b))}}});var tb=a.location,ub=r.now(),vb=/\?/;r.parseXML=function(b){var c;if(!b||"string"!=typeof b)return null;try{c=(new a.DOMParser).parseFromString(b,"text/xml")}catch(d){c=void 0}return c&&!c.getElementsByTagName("parsererror").length||r.error("Invalid XML: "+b),c};var wb=/\[\]$/,xb=/\r?\n/g,yb=/^(?:submit|button|image|reset|file)$/i,zb=/^(?:input|select|textarea|keygen)/i;function Ab(a,b,c,d){var e;if(Array.isArray(b))r.each(b,function(b,e){c||wb.test(a)?d(a,e):Ab(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d)});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)Ab(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(Array.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)Ab(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&zb.test(this.nodeName)&&!yb.test(a)&&(this.checked||!ja.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:Array.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(xb,"\r\n")}}):{name:b.name,value:c.replace(xb,"\r\n")}}).get()}});var Bb=/%20/g,Cb=/#.*$/,Db=/([?&])_=[^&]*/,Eb=/^(.*?):[ \t]*([^\r\n]*)$/gm,Fb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Gb=/^(?:GET|HEAD)$/,Hb=/^\/\//,Ib={},Jb={},Kb="*/".concat("*"),Lb=d.createElement("a");Lb.href=tb.href;function Mb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(L)||[];if(r.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function Nb(a,b,c,d){var e={},f=a===Jb;function g(h){var i;return e[h]=!0,r.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function Ob(a,b){var c,d,e=r.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&r.extend(!0,a,d),a}function Pb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}if(f)return f!==i[0]&&i.unshift(f),c[f]}function Qb(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}r.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:tb.href,type:"GET",isLocal:Fb.test(tb.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Kb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":r.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?Ob(Ob(a,r.ajaxSettings),b):Ob(r.ajaxSettings,a)},ajaxPrefilter:Mb(Ib),ajaxTransport:Mb(Jb),ajax:function(b,c){"object"==typeof b&&(c=b,b=void 0),c=c||{};var e,f,g,h,i,j,k,l,m,n,o=r.ajaxSetup({},c),p=o.context||o,q=o.context&&(p.nodeType||p.jquery)?r(p):r.event,s=r.Deferred(),t=r.Callbacks("once memory"),u=o.statusCode||{},v={},w={},x="canceled",y={readyState:0,getResponseHeader:function(a){var b;if(k){if(!h){h={};while(b=Eb.exec(g))h[b[1].toLowerCase()]=b[2]}b=h[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return k?g:null},setRequestHeader:function(a,b){return null==k&&(a=w[a.toLowerCase()]=w[a.toLowerCase()]||a,v[a]=b),this},overrideMimeType:function(a){return null==k&&(o.mimeType=a),this},statusCode:function(a){var b;if(a)if(k)y.always(a[y.status]);else for(b in a)u[b]=[u[b],a[b]];return this},abort:function(a){var b=a||x;return e&&e.abort(b),A(0,b),this}};if(s.promise(y),o.url=((b||o.url||tb.href)+"").replace(Hb,tb.protocol+"//"),o.type=c.method||c.type||o.method||o.type,o.dataTypes=(o.dataType||"*").toLowerCase().match(L)||[""],null==o.crossDomain){j=d.createElement("a");try{j.href=o.url,j.href=j.href,o.crossDomain=Lb.protocol+"//"+Lb.host!=j.protocol+"//"+j.host}catch(z){o.crossDomain=!0}}if(o.data&&o.processData&&"string"!=typeof o.data&&(o.data=r.param(o.data,o.traditional)),Nb(Ib,o,c,y),k)return y;l=r.event&&o.global,l&&0===r.active++&&r.event.trigger("ajaxStart"),o.type=o.type.toUpperCase(),o.hasContent=!Gb.test(o.type),f=o.url.replace(Cb,""),o.hasContent?o.data&&o.processData&&0===(o.contentType||"").indexOf("application/x-www-form-urlencoded")&&(o.data=o.data.replace(Bb,"+")):(n=o.url.slice(f.length),o.data&&(f+=(vb.test(f)?"&":"?")+o.data,delete o.data),o.cache===!1&&(f=f.replace(Db,"$1"),n=(vb.test(f)?"&":"?")+"_="+ub++ +n),o.url=f+n),o.ifModified&&(r.lastModified[f]&&y.setRequestHeader("If-Modified-Since",r.lastModified[f]),r.etag[f]&&y.setRequestHeader("If-None-Match",r.etag[f])),(o.data&&o.hasContent&&o.contentType!==!1||c.contentType)&&y.setRequestHeader("Content-Type",o.contentType),y.setRequestHeader("Accept",o.dataTypes[0]&&o.accepts[o.dataTypes[0]]?o.accepts[o.dataTypes[0]]+("*"!==o.dataTypes[0]?", "+Kb+"; q=0.01":""):o.accepts["*"]);for(m in o.headers)y.setRequestHeader(m,o.headers[m]);if(o.beforeSend&&(o.beforeSend.call(p,y,o)===!1||k))return y.abort();if(x="abort",t.add(o.complete),y.done(o.success),y.fail(o.error),e=Nb(Jb,o,c,y)){if(y.readyState=1,l&&q.trigger("ajaxSend",[y,o]),k)return y;o.async&&o.timeout>0&&(i=a.setTimeout(function(){y.abort("timeout")},o.timeout));try{k=!1,e.send(v,A)}catch(z){if(k)throw z;A(-1,z)}}else A(-1,"No Transport");function A(b,c,d,h){var j,m,n,v,w,x=c;k||(k=!0,i&&a.clearTimeout(i),e=void 0,g=h||"",y.readyState=b>0?4:0,j=b>=200&&b<300||304===b,d&&(v=Pb(o,y,d)),v=Qb(o,v,y,j),j?(o.ifModified&&(w=y.getResponseHeader("Last-Modified"),w&&(r.lastModified[f]=w),w=y.getResponseHeader("etag"),w&&(r.etag[f]=w)),204===b||"HEAD"===o.type?x="nocontent":304===b?x="notmodified":(x=v.state,m=v.data,n=v.error,j=!n)):(n=x,!b&&x||(x="error",b<0&&(b=0))),y.status=b,y.statusText=(c||x)+"",j?s.resolveWith(p,[m,x,y]):s.rejectWith(p,[y,x,n]),y.statusCode(u),u=void 0,l&&q.trigger(j?"ajaxSuccess":"ajaxError",[y,o,j?m:n]),t.fireWith(p,[y,x]),l&&(q.trigger("ajaxComplete",[y,o]),--r.active||r.event.trigger("ajaxStop")))}return y},getJSON:function(a,b,c){return r.get(a,b,c,"json")},getScript:function(a,b){return r.get(a,void 0,b,"script")}}),r.each(["get","post"],function(a,b){r[b]=function(a,c,d,e){return r.isFunction(c)&&(e=e||d,d=c,c=void 0),r.ajax(r.extend({url:a,type:b,dataType:e,data:c,success:d},r.isPlainObject(a)&&a))}}),r._evalUrl=function(a){return r.ajax({url:a,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},r.ajaxSettings.xhr=function(){try{return new a.XMLHttpRequest}catch(b){}};var Rb={0:200,1223:204},Sb=r.ajaxSettings.xhr();o.cors=!!Sb&&"withCredentials"in Sb,o.ajax=Sb=!!Sb,r.ajaxTransport(function(b){var c,d;if(o.cors||Sb&&!b.crossDomain)return{send:function(e,f){var g,h=b.xhr();if(h.open(b.type,b.url,b.async,b.username,b.password),b.xhrFields)for(g in b.xhrFields)h[g]=b.xhrFields[g];b.mimeType&&h.overrideMimeType&&h.overrideMimeType(b.mimeType),b.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest");for(g in e)h.setRequestHeader(g,e[g]);c=function(a){return function(){c&&(c=d=h.onload=h.onerror=h.onabort=h.onreadystatechange=null,"abort"===a?h.abort():"error"===a?"number"!=typeof h.status?f(0,"error"):f(h.status,h.statusText):f(Rb[h.status]||h.status,h.statusText,"text"!==(h.responseType||"text")||"string"!=typeof h.responseText?{binary:h.response}:{text:h.responseText},h.getAllResponseHeaders()))}},h.onload=c(),d=h.onerror=c("error"),void 0!==h.onabort?h.onabort=d:h.onreadystatechange=function(){4===h.readyState&&a.setTimeout(function(){c&&d()})},c=c("abort");try{h.send(b.hasContent&&b.data||null)}catch(i){if(c)throw i}},abort:function(){c&&c()}}}),r.ajaxPrefilter(function(a){a.crossDomain&&(a.contents.script=!1)}),r.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(a){return r.globalEval(a),a}}}),r.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),r.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(e,f){b=r(" - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/biz/article/edit.html b/forum-ui/src/main/resources/templates/biz/article/edit.html deleted file mode 100644 index a7b2f8b10..000000000 --- a/forum-ui/src/main/resources/templates/biz/article/edit.html +++ /dev/null @@ -1,183 +0,0 @@ - - -
- QuickFrom社区 - 文章发表 -
- - - -
- - - -
- - -
- - - - -
-
- -
- - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/biz/article/search.html b/forum-ui/src/main/resources/templates/biz/article/search.html deleted file mode 100644 index 200402e64..000000000 --- a/forum-ui/src/main/resources/templates/biz/article/search.html +++ /dev/null @@ -1,34 +0,0 @@ - - -
- QuickFrom社区 -
- - - -
- - -
- - -
-
-
- 正文 -
-
- - -
-
-
- - - -
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/biz/user/achievement.html b/forum-ui/src/main/resources/templates/biz/user/achievement.html deleted file mode 100644 index 6937ab957..000000000 --- a/forum-ui/src/main/resources/templates/biz/user/achievement.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - -
-
-
标题
-
    -
  • 已发布文章
  • -
  • 文章被点赞
  • -
  • 文章被阅读
  • -
  • 文章被收藏
  • -
-
-
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/biz/user/home.html b/forum-ui/src/main/resources/templates/biz/user/home.html deleted file mode 100644 index f8fba934d..000000000 --- a/forum-ui/src/main/resources/templates/biz/user/home.html +++ /dev/null @@ -1,182 +0,0 @@ - - -
- QuickFrom社区 -
- - - -
- -
- -
-
-
-
- -
-
-
一灰灰
-
-
-
    -
  • 关注数
  • -
  • 粉丝数
  • -
- - - - -
-
- -
-
-
- - - -
-
-

个人简介

-
-
- - - - -
- > -
- -
- - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/biz/user/process.html b/forum-ui/src/main/resources/templates/biz/user/process.html deleted file mode 100644 index 8df08d294..000000000 --- a/forum-ui/src/main/resources/templates/biz/user/process.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - -
-
-
标题
-

描述

-
-
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/index.html b/forum-ui/src/main/resources/templates/index.html deleted file mode 100644 index aab18e24e..000000000 --- a/forum-ui/src/main/resources/templates/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - -
- QuickFrom社区 -
- - - -
- - - -
- - -
- - -
- - - -
-
-
- 正文 -
-
-
-
-
- - -
-
-
- - -
-
-
- -
- - - -
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/layout/footer.html b/forum-ui/src/main/resources/templates/layout/footer.html deleted file mode 100644 index f503ced7b..000000000 --- a/forum-ui/src/main/resources/templates/layout/footer.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/layout/header.html b/forum-ui/src/main/resources/templates/layout/header.html deleted file mode 100644 index 0b79d5422..000000000 --- a/forum-ui/src/main/resources/templates/layout/header.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - Quick社区 - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/layout/navbar.html b/forum-ui/src/main/resources/templates/layout/navbar.html deleted file mode 100644 index 4d129985e..000000000 --- a/forum-ui/src/main/resources/templates/layout/navbar.html +++ /dev/null @@ -1,132 +0,0 @@ - - - -
- - - - - - - -
- \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/article-card.html b/forum-ui/src/main/resources/templates/plugins/article-card.html deleted file mode 100644 index acac57523..000000000 --- a/forum-ui/src/main/resources/templates/plugins/article-card.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - -
- -
-
-
- -
- 作者 -
-
- | -
10小时前
- | - -
- -
- - - - 第一个文章 -
-

- 文章简介 -

-
-

- - 阅读: 10 - - - 点赞: 10 - - - 评论: 10 - -

-
-
-
- -
- -
- -
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/article-info.html b/forum-ui/src/main/resources/templates/plugins/article-info.html deleted file mode 100644 index 6fdc6eedc..000000000 --- a/forum-ui/src/main/resources/templates/plugins/article-info.html +++ /dev/null @@ -1,93 +0,0 @@ - - - - -
-
标题
-
- -   - 作者 - - - - 更新时间 - - - · - - - - - 520 - - - · - - - - - 521 - - - - 编辑 - - - - - 删除 - - -
-
- -
- -
- -
- 最近更新于 2022年09月01日 - - - - - - -
- - -
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/carousel.html b/forum-ui/src/main/resources/templates/plugins/carousel.html deleted file mode 100644 index 0564738c3..000000000 --- a/forum-ui/src/main/resources/templates/plugins/carousel.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/categories.html b/forum-ui/src/main/resources/templates/plugins/categories.html deleted file mode 100644 index 63b7b45c1..000000000 --- a/forum-ui/src/main/resources/templates/plugins/categories.html +++ /dev/null @@ -1,16 +0,0 @@ - - - -
-
- 全部 -
-
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/comment-list.html b/forum-ui/src/main/resources/templates/plugins/comment-list.html deleted file mode 100644 index e2f009035..000000000 --- a/forum-ui/src/main/resources/templates/plugins/comment-list.html +++ /dev/null @@ -1,143 +0,0 @@ - - - - -
-
- -
- -
- -
-
-
- -
-
-
- - - -
-
-

- - - -

-

-

-       - - 回复 - -

- -
-
- - - -
-
-

- - - - -

-

-

- - - -

- - - 回复 - -

-
-
- -
-
-
- - - -
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/custom-empty.html b/forum-ui/src/main/resources/templates/plugins/custom-empty.html deleted file mode 100644 index 1301cb6d5..000000000 --- a/forum-ui/src/main/resources/templates/plugins/custom-empty.html +++ /dev/null @@ -1,8 +0,0 @@ - - - -
-

等待热评 ~ ~

-
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/follow-select-tag.html b/forum-ui/src/main/resources/templates/plugins/follow-select-tag.html deleted file mode 100644 index 298325cb3..000000000 --- a/forum-ui/src/main/resources/templates/plugins/follow-select-tag.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/home-select-tag.html b/forum-ui/src/main/resources/templates/plugins/home-select-tag.html deleted file mode 100644 index be795888b..000000000 --- a/forum-ui/src/main/resources/templates/plugins/home-select-tag.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/normal-card.html b/forum-ui/src/main/resources/templates/plugins/normal-card.html deleted file mode 100644 index 4e5c227b0..000000000 --- a/forum-ui/src/main/resources/templates/plugins/normal-card.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - -
-
-
标题
-

描述

-
-
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/user-article-card.html b/forum-ui/src/main/resources/templates/plugins/user-article-card.html deleted file mode 100644 index bf75beacd..000000000 --- a/forum-ui/src/main/resources/templates/plugins/user-article-card.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - -
- -
-
-
- -
- 作者 -
-
- | -
10小时前
- | - -
- - -

- 文章简介 -

-
-

- - 阅读: 10 - - - 点赞: 10 - - - 评论: 10 - -

-
-
-
- -
- -
- -
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/user-card.html b/forum-ui/src/main/resources/templates/plugins/user-card.html deleted file mode 100644 index 08f8d8925..000000000 --- a/forum-ui/src/main/resources/templates/plugins/user-card.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - -
-
-
-
- -
-
-
一灰灰
-

- 伪全栈*真后端(求👍,求🌟 求👋 ) - 微信公众号:一灰灰Blog -

-
-
-
-
    -
  • 点赞数
  • -
  • 阅读数
  • -
- - -
-
- - \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/plugins/user-follow-card.html b/forum-ui/src/main/resources/templates/plugins/user-follow-card.html deleted file mode 100644 index 4dc1b4f82..000000000 --- a/forum-ui/src/main/resources/templates/plugins/user-follow-card.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/forum-web/pom.xml b/forum-web/pom.xml deleted file mode 100644 index 96bf1db1e..000000000 --- a/forum-web/pom.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - quick-forum - com.github.liuyueyi.quick-forum - 0.0.1-SNAPSHOT - - 4.0.0 - - forum-web - - - - forum-ui - com.github.liuyueyi.quick-forum - - - forum-service - com.github.liuyueyi.quick-forum - - - - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot - spring-boot-starter-web - - - com.fasterxml.jackson.dataformat - jackson-dataformat-xml - - - org.projectlombok - lombok - - - - org.springframework.boot - spring-boot-starter-test - test - - - - junit - junit - test - - - \ No newline at end of file diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/QuickForumApplication.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/QuickForumApplication.java deleted file mode 100644 index 553268858..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/QuickForumApplication.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.liuyueyi.forum.web; - -import com.github.liuyueyi.forum.web.hook.interceptor.GlobalViewInterceptor; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.web.servlet.ServletComponentScan; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import javax.annotation.Resource; - -/** - * 入口 - * - * @author yihui - * @date 2022/7/6 - */ -@ServletComponentScan -@SpringBootApplication -public class QuickForumApplication implements WebMvcConfigurer { - - @Resource - private GlobalViewInterceptor globalViewInterceptor; - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(globalViewInterceptor).addPathPatterns("/**"); - } - - public static void main(String[] args) { - SpringApplication.run(QuickForumApplication.class, args); - } - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/admin/AdminController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/admin/AdminController.java deleted file mode 100644 index 72cd30d0c..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/admin/AdminController.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.liuyueyi.forum.web.admin; - -import org.springframework.stereotype.Controller; - -/** - * @author YiHui - * @date 2022/9/1 - */ -@Controller -public class AdminController { -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/admin/package-info.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/admin/package-info.java deleted file mode 100644 index bfd2d3b83..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/admin/package-info.java +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 管理员用户操作路径 - * - * @author yihui - * @date 2022/7/6 - */ -package com.github.liuyueyi.forum.web.admin; \ No newline at end of file diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/config/GlobalViewConfig.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/config/GlobalViewConfig.java deleted file mode 100644 index b4271a33b..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/config/GlobalViewConfig.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.liuyueyi.forum.web.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * @author yihui - * @date 2022/6/15 - */ -@Data -@ConfigurationProperties(prefix = "view.site") -@Component -public class GlobalViewConfig { - - private String cdnImgStyle; - - private String websiteRecord; - - private Integer pageSize; - - private String websiteName; - - private String websiteLogoUrl; - - private String websiteFaviconIconUrl; - - private String contactMeWxQrCode; - - private String contactMeTitle; - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/config/XmlWebConfig.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/config/XmlWebConfig.java deleted file mode 100644 index 73ae2e764..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/config/XmlWebConfig.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.liuyueyi.forum.web.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.http.MediaType; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; -import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -/** - * 注册xml解析器 - * - * @author yihui - * @date 2022/6/20 - */ -@Configuration -public class XmlWebConfig implements WebMvcConfigurer { - @Override - public void configureMessageConverters(List> converters) { - converters.add(new MappingJackson2HttpMessageConverter()); - converters.add(new MappingJackson2XmlHttpMessageConverter()); - } - - /** - * fixme 返回数据类型的配置, 相关知识点可以查看 - * - * @param configurer - */ - @Override - public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { - configurer.favorParameter(true) - .defaultContentType(MediaType.APPLICATION_JSON, MediaType.TEXT_XML, MediaType.APPLICATION_XML) - .parameterName("mediaType") - .mediaType("json", MediaType.APPLICATION_JSON) - .mediaType("xml", MediaType.APPLICATION_XML) - .mediaType("html", MediaType.TEXT_HTML) - .mediaType("text", MediaType.TEXT_PLAIN) - ; - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/IndexController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/IndexController.java deleted file mode 100644 index c4fcea50d..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/IndexController.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.github.liuyueyi.forum.web.front; - -import com.github.liueyueyi.forum.api.model.vo.PageParam; -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleListDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.CategoryDTO; -import com.github.liuyueyi.forum.core.util.MapUtils; -import com.github.liuyueyi.forum.service.article.service.ArticleReadService; -import com.github.liuyueyi.forum.service.article.service.CategoryService; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import javax.servlet.http.HttpServletRequest; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -/** - * @author YiHui - * @date 2022/7/6 - */ -@Controller -public class IndexController { - @Autowired - private CategoryService categoryService; - - @Autowired - private ArticleReadService articleService; - - @GetMapping(path = {"/", "", "/index"}) - public String index(Model model, HttpServletRequest request) { - String activeTab = request.getParameter("category"); - Long categoryId = categories(model, activeTab); - articleList(model, request, categoryId); - homeCarouselList(model); - sideBarItems(model); - model.addAttribute("currentDomain", "article"); - return "index"; - } - - /** - * 查询文章列表 - * - * @param model - */ - @GetMapping(path = "search") - public String searchArticleList(@RequestParam(name = "key") String key, Model model) { - if (!StringUtils.isBlank(key)) { - PageParam page = PageParam.newPageInstance(1L, 10L); - ArticleListDTO list = articleService.queryArticlesBySearchKey(key, page); - model.addAttribute("articles", list); - sideBarItems(model); - } - return "biz/article/search"; - } - - /** - * 返回分类列表 - * - * @param active - * @return - */ - private Long categories(Model model, String active) { - List list = categoryService.loadAllCategories(); - list.add(0, new CategoryDTO(0L, CategoryDTO.DEFAULT_TOTAL_CATEGORY, false)); - Long selectCategoryId = null; - for (CategoryDTO c : list) { - if (c.getCategory().equalsIgnoreCase(active)) { - selectCategoryId = c.getCategoryId(); - c.setSelected(true); - } else { - c.setSelected(false); - } - } - - if (selectCategoryId == null) { - // 未匹配时,默认选全部 - list.get(0).setSelected(true); - } - model.addAttribute("categories", list); - return selectCategoryId; - } - - /** - * 文章列表 - * - * @param model - * @param request - * @param categoryId - */ - private void articleList(Model model, HttpServletRequest request, Long categoryId) { - AtomicReference page = new AtomicReference<>(1L); - AtomicReference pageNum = new AtomicReference<>(20L); - Optional.ofNullable(request.getParameter("page")).ifPresent(p -> page.set(Long.parseLong(p))); - Optional.ofNullable(request.getParameter("size")).ifPresent(p -> pageNum.set(Long.parseLong(p))); - ArticleListDTO list = articleService.queryArticlesByCategory(categoryId, PageParam.newPageInstance(page.get(), pageNum.get())); - model.addAttribute("articles", list); - } - - /** - * 轮播图 - * - * @return - */ - private void homeCarouselList(Model model) { - List> list = new ArrayList<>(); - list.add(MapUtils.create("imgUrl", "https://spring.hhui.top/spring-blog/imgs/220425/logo.jpg", "name", "spring社区", "actionUrl", "https://spring.hhui.top/")); - list.add(MapUtils.create("imgUrl", "https://spring.hhui.top/spring-blog/imgs/220422/logo.jpg", "name", "一灰灰", "actionUrl", "https://blog.hhui.top/")); - model.addAttribute("homeCarouselList", list); - } - - - /** - * 侧边栏信息 - *

- * fixme: 后续调整为由运营推广模块返回 - * - * @return - */ - private void sideBarItems(Model model) { - List> res = new ArrayList<>(); - res.add(MapUtils.create("title", "公告", "desc", "简单的公告内容")); - res.add(MapUtils.create("title", "标签云", "desc", "java, web, html")); - model.addAttribute("sideBarItems", res); - } - - - @GetMapping(path = "/403") - public String _403() { - return "403"; - } - - @GetMapping(path = "/500") - public String _500() { - return "500"; - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/rest/ArticleRestController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/rest/ArticleRestController.java deleted file mode 100644 index 7ddb33675..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/rest/ArticleRestController.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.github.liuyueyi.forum.web.front.article.rest; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liueyueyi.forum.api.model.enums.DocumentTypeEnum; -import com.github.liueyueyi.forum.api.model.enums.OperateTypeEnum; -import com.github.liueyueyi.forum.api.model.vo.ResVo; -import com.github.liueyueyi.forum.api.model.vo.article.ArticlePostReq; -import com.github.liueyueyi.forum.api.model.vo.article.dto.CategoryDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.TagDTO; -import com.github.liueyueyi.forum.api.model.vo.constants.StatusEnum; -import com.github.liuyueyi.forum.core.permission.Permission; -import com.github.liuyueyi.forum.core.permission.UserRole; -import com.github.liuyueyi.forum.service.article.repository.entity.ArticleDO; -import com.github.liuyueyi.forum.service.article.service.ArticleReadService; -import com.github.liuyueyi.forum.service.article.service.ArticleWriteService; -import com.github.liuyueyi.forum.service.article.service.CategoryService; -import com.github.liuyueyi.forum.service.article.service.TagService; -import com.github.liuyueyi.forum.service.user.service.UserFootService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; - -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; - -/** - * 返回json格式数据 - * - * @author YiHui - * @date 2022/9/2 - */ -@RequestMapping(path = "article/api") -@RestController -public class ArticleRestController { - @Autowired - private ArticleReadService articleService; - @Autowired - private UserFootService userFootService; - @Autowired - private CategoryService categoryService; - @Autowired - private TagService tagService; - @Autowired - private ArticleWriteService articleWriteService; - - - /** - * 查询所有的标签 - * - * @return - */ - @ResponseBody - @GetMapping(path = "tag/list") - public ResVo> queryTags(Long categoryId) { - if (categoryId == null || categoryId <= 0L) { - return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS, categoryId); - } - - List list = tagService.queryTagsByCategoryId(categoryId); - return ResVo.ok(list); - } - - /** - * 获取所有的分类 - * - * @return - */ - @ResponseBody - @GetMapping(path = "category/list") - public ResVo> getCategoryList(@RequestParam(name = "categoryId", required = false) Long categoryId) { - List list = categoryService.loadAllCategories(); - list.forEach(c -> c.setSelected(c.getCategoryId().equals(categoryId))); - return ResVo.ok(list); - } - - - /** - * 收藏、点赞等相关操作 - * - * @param articleId - * @param type 取值来自于 OperateTypeEnum#code - * @return - */ - @ResponseBody - @Permission(role = UserRole.LOGIN) - @GetMapping(path = "favor") - public ResVo favor(@RequestParam(name = "articleId") Long articleId, - @RequestParam(name = "type") Integer type) { - OperateTypeEnum operate = OperateTypeEnum.fromCode(type); - if (operate == OperateTypeEnum.EMPTY) { - return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, type + "非法"); - } - - // 要求文章必须存在 - ArticleDO article = articleService.queryBasicArticle(articleId); - if (article == null) { - return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章不存在!"); - } - - userFootService.saveOrUpdateUserFoot(DocumentTypeEnum.ARTICLE, articleId, article.getUserId(), - ReqInfoContext.getReqInfo().getUserId(), - operate); - return ResVo.ok(true); - } - - - /** - * 发布文章,完成后跳转到详情页 - * - 这里有一个重定向的知识点 - * - fixme 博文:* [5.请求重定向 | 一灰灰Learning](https://hhui.top/spring-web/02.response/05.190929-springboot%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8Bweb%E7%AF%87%E4%B9%8B%E9%87%8D%E5%AE%9A%E5%90%91/) - * - * @return - */ - @Permission(role = UserRole.LOGIN) - @PostMapping(path = "post") - @ResponseBody - @Transactional(rollbackFor = Exception.class) - public ResVo post(@RequestBody ArticlePostReq req, HttpServletResponse response) throws IOException { - Long id = articleWriteService.saveArticle(req, ReqInfoContext.getReqInfo().getUserId()); -// return "redirect:/article/detail/" + id; -// response.sendRedirect("/article/detail/" + id); - // 这里采用前端重定向策略 - return ResVo.ok(id); - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/view/ArticleViewController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/view/ArticleViewController.java deleted file mode 100644 index 21a1c7140..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/view/ArticleViewController.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.github.liuyueyi.forum.web.front.article.view; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liueyueyi.forum.api.model.vo.PageParam; -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.CategoryDTO; -import com.github.liueyueyi.forum.api.model.vo.comment.dto.TopCommentDTO; -import com.github.liueyueyi.forum.api.model.vo.user.dto.UserStatisticInfoDTO; -import com.github.liuyueyi.forum.core.permission.Permission; -import com.github.liuyueyi.forum.core.permission.UserRole; -import com.github.liuyueyi.forum.service.article.service.ArticleReadService; -import com.github.liuyueyi.forum.service.article.service.CategoryService; -import com.github.liuyueyi.forum.service.article.service.TagService; -import com.github.liuyueyi.forum.service.comment.service.CommentReadService; -import com.github.liuyueyi.forum.service.user.service.UserService; -import com.github.liuyueyi.forum.web.front.article.vo.ArticleDetailVo; -import com.github.liuyueyi.forum.web.front.article.vo.ArticleEditVo; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -/** - * 文章 - * todo: 所有的入口都放在一个Controller,会导致功能划分非常混乱 - * : 文章列表 - * : 文章编辑 - * : 文章详情 - * --- - * - 返回视图 view - * - 返回json数据 - * - * @author yihui - */ -@Controller -@RequestMapping(path = "article") -public class ArticleViewController { - @Autowired - private ArticleReadService articleService; - - @Autowired - private CategoryService categoryService; - - @Autowired - private TagService tagService; - - @Autowired - private UserService userService; - - @Autowired - private CommentReadService commentService; - - /** - * 文章编辑页 - * - * @param articleId - * @return - */ - @Permission(role = UserRole.LOGIN) - @GetMapping(path = "edit") - public String edit(@RequestParam(required = false) Long articleId, Model model) { - ArticleEditVo vo = new ArticleEditVo(); - if (articleId != null) { - ArticleDTO article = articleService.queryDetailArticleInfo(articleId); - vo.setArticle(article); - if (!Objects.equals(article.getAuthor(), ReqInfoContext.getReqInfo().getUserId())) { - // 没有权限 - model.addAttribute("toast", "内容不存在"); - return "redirect:403"; - } - - List categoryList = categoryService.loadAllCategories(); - categoryList.forEach(s -> { - s.setSelected(s.getCategoryId().equals(article.getCategory().getCategoryId())); - }); - vo.setCategories(categoryList); - vo.setTags(tagService.queryTagsByCategoryId(article.getCategory().getCategoryId())); - } else { - List categoryList = categoryService.loadAllCategories(); - vo.setCategories(categoryList); - vo.setTags(Collections.emptyList()); - } - model.addAttribute("vo", vo); - return "biz/article/edit"; - } - - - /** - * 文章详情页 - * - 参数解析知识点 - * - fixme * [1.Get请求参数解析姿势汇总 | 一灰灰Learning](https://hhui.top/spring-web/01.request/01.190824-springboot%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8Bweb%E7%AF%87%E4%B9%8Bget%E8%AF%B7%E6%B1%82%E5%8F%82%E6%95%B0%E8%A7%A3%E6%9E%90%E5%A7%BF%E5%8A%BF%E6%B1%87%E6%80%BB/) - * - * @param articleId - * @return - */ - @GetMapping("detail/{articleId}") - public String detail(@PathVariable(name = "articleId") Long articleId, Model model) { - ArticleDetailVo vo = new ArticleDetailVo(); - ArticleDTO articleDTO = articleService.queryTotalArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); - vo.setArticle(articleDTO); - - // 评论信息 - List comments = commentService.getArticleComments(articleId, PageParam.newPageInstance(1L, 10L)); - vo.setComments(comments); - - // 作者信息 - UserStatisticInfoDTO user = userService.queryUserInfoWithStatistic(articleDTO.getAuthor()); - articleDTO.setAuthorName(user.getUserName()); - vo.setAuthor(user); - model.addAttribute("vo", vo); - return "biz/article/detail"; - } - - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/vo/ArticleDetailVo.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/vo/ArticleDetailVo.java deleted file mode 100644 index 01352b551..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/vo/ArticleDetailVo.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.liuyueyi.forum.web.front.article.vo; - -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.CategoryDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.TagDTO; -import com.github.liueyueyi.forum.api.model.vo.comment.dto.TopCommentDTO; -import com.github.liueyueyi.forum.api.model.vo.user.dto.UserStatisticInfoDTO; -import lombok.Data; - -import java.util.List; - -/** - * @author YiHui - * @date 2022/9/2 - */ -@Data -public class ArticleDetailVo { - - private ArticleDTO article; - - private List comments; - - private UserStatisticInfoDTO author; - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/vo/ArticleEditVo.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/vo/ArticleEditVo.java deleted file mode 100644 index db869190a..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/article/vo/ArticleEditVo.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.liuyueyi.forum.web.front.article.vo; - -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.CategoryDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.TagDTO; -import lombok.Data; - -import java.util.List; - -/** - * @author YiHui - * @date 2022/9/2 - */ -@Data -public class ArticleEditVo { - - private ArticleDTO article; - - private List categories; - - private List tags; - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/comment/rest/CommentRestController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/comment/rest/CommentRestController.java deleted file mode 100644 index 267e16143..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/comment/rest/CommentRestController.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.github.liuyueyi.forum.web.front.comment.rest; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liueyueyi.forum.api.model.vo.PageParam; -import com.github.liueyueyi.forum.api.model.vo.ResVo; -import com.github.liueyueyi.forum.api.model.vo.comment.CommentSaveReq; -import com.github.liueyueyi.forum.api.model.vo.comment.dto.TopCommentDTO; -import com.github.liueyueyi.forum.api.model.vo.constants.StatusEnum; -import com.github.liuyueyi.forum.core.permission.Permission; -import com.github.liuyueyi.forum.core.permission.UserRole; -import com.github.liuyueyi.forum.core.util.NumUtil; -import com.github.liuyueyi.forum.service.comment.service.CommentReadService; -import com.github.liuyueyi.forum.service.comment.service.CommentWriteService; -import org.apache.commons.lang3.StringEscapeUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Optional; - -/** - * 评论 - * - * @author lvmenglou - * @date : 2022/4/22 10:56 - **/ -@RestController -@RequestMapping(path = "comment/api") -public class CommentRestController { - - @Autowired - private CommentReadService commentReadService; - - @Autowired - private CommentWriteService commentWriteService; - - /** - * 评论列表页 - * - * @param articleId - * @return - */ - @ResponseBody - @RequestMapping(path = "list") - public ResVo> list(Long articleId, Long pageNum, Long pageSize) { - if (NumUtil.nullOrZero(articleId)) { - return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章id为空"); - } - pageNum = Optional.ofNullable(pageNum).orElse(PageParam.DEFAULT_PAGE_NUM); - pageSize = Optional.ofNullable(pageSize).orElse(PageParam.DEFAULT_PAGE_SIZE); - List result = commentReadService.getArticleComments(articleId, PageParam.newPageInstance(pageNum, pageSize)); - return ResVo.ok(result); - } - - /** - * 保存评论 - * - * @param req - * @return - */ - @Permission(role = UserRole.LOGIN) - @PostMapping(path = "post") - @ResponseBody - public ResVo save(@RequestBody CommentSaveReq req) { - if (req.getArticleId() == null) { - return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章id为空"); - } - req.setUserId(ReqInfoContext.getReqInfo().getUserId()); - req.setCommentContent(StringEscapeUtils.escapeHtml3(req.getCommentContent())); - Long commentId = commentWriteService.saveComment(req); - return ResVo.ok(NumUtil.upZero(commentId)); - } - - /** - * 删除评论 - * - * @param commentId - * @return - */ - @Permission(role = UserRole.LOGIN) - @RequestMapping(path = "delete") - public ResVo delete(Long commentId) { - commentWriteService.deleteComment(commentId); - return ResVo.ok(true); - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/notice/NoticeController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/notice/NoticeController.java deleted file mode 100644 index 20531c8d3..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/notice/NoticeController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.github.liuyueyi.forum.web.front.notice; - -public class NoticeController { -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/package-info.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/package-info.java deleted file mode 100644 index e0de2dda7..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/package-info.java +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 前台页用户包路径 - * - * 入口层,不做复杂的业务逻辑,主要干的事情 - * 1. 参数解析 - * 2. 视图数据封装,就是往Model中写数据 - * 3. 重定向控制 - * 4. todo: 权限判断(个人页,需要登录...) - * - * - * @author yihui - * @date 2022/7/6 - */ -package com.github.liuyueyi.forum.web.front; \ No newline at end of file diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/LoginRestController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/LoginRestController.java deleted file mode 100644 index f1fadcdf2..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/LoginRestController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.liuyueyi.forum.web.front.user.rest; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liueyueyi.forum.api.model.vo.ResVo; -import com.github.liueyueyi.forum.api.model.vo.constants.StatusEnum; -import com.github.liuyueyi.forum.core.permission.Permission; -import com.github.liuyueyi.forum.core.permission.UserRole; -import com.github.liuyueyi.forum.service.user.service.LoginService; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletResponse; -import java.util.Optional; - -/** - * 登录/登出的入口 - * - * @author YiHui - * @date 2022/8/15 - */ -@RestController -@RequestMapping -public class LoginRestController { - @Autowired - private LoginService loginService; - - @RequestMapping("/login") - public ResVo login(@RequestParam(name = "code") String code, - HttpServletResponse response) { - String session = loginService.login(code); - if (StringUtils.isNotBlank(session)) { - // cookie中写入用户登录信息 - response.addCookie(new Cookie(LoginService.SESSION_KEY, session)); - return ResVo.ok(true); - } else { - return ResVo.fail(StatusEnum.LOGIN_FAILED_MIXED, "登录码异常,请重新输入"); - } - } - - @Permission(role = UserRole.LOGIN) - @RequestMapping("logout") - public ResVo logOut() { - Optional.ofNullable(ReqInfoContext.getReqInfo()).ifPresent(s -> loginService.logout(s.getSession())); - return ResVo.ok(true); - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/UserRestController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/UserRestController.java deleted file mode 100644 index 4f19c33c1..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/UserRestController.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.github.liuyueyi.forum.web.front.user.rest; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liueyueyi.forum.api.model.exception.ExceptionUtil; -import com.github.liueyueyi.forum.api.model.vo.ResVo; -import com.github.liueyueyi.forum.api.model.vo.constants.StatusEnum; -import com.github.liueyueyi.forum.api.model.vo.user.UserInfoSaveReq; -import com.github.liueyueyi.forum.api.model.vo.user.UserRelationReq; -import com.github.liuyueyi.forum.core.permission.Permission; -import com.github.liuyueyi.forum.core.permission.UserRole; -import com.github.liuyueyi.forum.service.user.service.UserRelationService; -import com.github.liuyueyi.forum.service.user.service.relation.UserRelationServiceImpl; -import com.github.liuyueyi.forum.service.user.service.user.UserServiceImpl; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import javax.annotation.Resource; - -/** - * @author YiHui - * @date 2022/9/2 - */ -@RestController -@RequestMapping(path = "user/api") -public class UserRestController { - - @Resource - private UserServiceImpl userService; - - @Resource - private UserRelationServiceImpl userRelationService; - - /** - * 保存用户关系 - * - * @param req - * @return - * @throws Exception - */ - @Permission(role = UserRole.LOGIN) - @PostMapping(path = "saveUserRelation") - public ResVo saveUserRelation(@RequestBody UserRelationReq req) { - userRelationService.saveUserRelation(req); - return ResVo.ok(true); - } - - - /** - * 保存用户详情 - * - * @param req - * @return - * @throws Exception - */ - @Permission(role = UserRole.LOGIN) - @PostMapping(path = "saveUserInfo") - @Transactional(rollbackFor = Exception.class) - public ResVo saveUserInfo(@RequestBody UserInfoSaveReq req) { - if (!(req.getUserId() != null && req.getUserId().equals(ReqInfoContext.getReqInfo().getUserId()))) { - // 不能修改其他用户的信息 - throw ExceptionUtil.of(StatusEnum.FORBID_ERROR_MIXED, "无权修改"); - } - userService.saveUserInfo(req); - return ResVo.ok(true); - } - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/WxRestController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/WxRestController.java deleted file mode 100644 index 18cc3ad80..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/rest/WxRestController.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.liuyueyi.forum.web.front.user.rest; - -import com.github.liueyueyi.forum.api.model.vo.user.wx.WxTxtMsgReqVo; -import com.github.liueyueyi.forum.api.model.vo.user.wx.WxTxtMsgResVo; -import com.github.liuyueyi.forum.service.user.service.LoginService; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; - -import javax.servlet.http.HttpServletRequest; - -/** - * 微信公众号登录相关 - * - * @author YiHui - * @date 2022/9/2 - */ -@RequestMapping(path = "wx") -@RestController -public class WxRestController { - @Autowired - private LoginService loginService; - - /** - * 微信的公众号接入 token 验证,即返回echostr的参数值 - * - * @param request - * @return - */ - @GetMapping(path = "callback") - public String check(HttpServletRequest request) { - String echoStr = request.getParameter("echostr"); - if (StringUtils.isNoneEmpty(echoStr)) { - return echoStr; - } - return ""; - } - - /** - * fixme: 需要做防刷校验 - * 微信的响应返回 - * 本地测试访问: curl -X POST 'http://localhost:8080/wx/callback' -H 'content-type:application/xml' -d '165570057911111111' -i - * - * @param msg - * @return - */ - @PostMapping(path = "callback", - consumes = {"application/xml", "text/xml"}, - produces = "application/xml;charset=utf-8") - public WxTxtMsgResVo callBack(@RequestBody WxTxtMsgReqVo msg) { - String content = msg.getContent(); - WxTxtMsgResVo res = new WxTxtMsgResVo(); - res.setFromUserName(msg.getToUserName()); - res.setToUserName(msg.getFromUserName()); - res.setCreateTime(System.currentTimeMillis() / 1000); - res.setMsgType("text"); - if (loginSymbol(content)) { - res.setContent("登录验证码: 【" + loginService.getVerifyCode(msg.getFromUserName()) + "】 五分钟内有效"); - } else { - res.setContent("输入关键词不对!"); - } - return res; - } - - /** - * 判断是否为登录指令,后续扩展其他的响应 - * - * @param msg - * @return - */ - private boolean loginSymbol(String msg) { - if (StringUtils.isBlank(msg)) { - return false; - } - - msg = msg.trim(); - for (String key : LoginService.LOGIN_CODE_KEY) { - if (msg.equalsIgnoreCase(key)) { - return true; - } - } - return false; - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/view/UserViewController.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/view/UserViewController.java deleted file mode 100644 index be49cef76..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/view/UserViewController.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.github.liuyueyi.forum.web.front.user.view; - -import com.github.liueyueyi.forum.api.model.enums.FollowSelectEnum; -import com.github.liueyueyi.forum.api.model.enums.FollowTypeEnum; -import com.github.liueyueyi.forum.api.model.enums.HomeSelectEnum; -import com.github.liueyueyi.forum.api.model.vo.PageParam; -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleListDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.TagSelectDTO; -import com.github.liueyueyi.forum.api.model.vo.comment.dto.UserFollowListDTO; -import com.github.liueyueyi.forum.api.model.vo.user.dto.UserStatisticInfoDTO; -import com.github.liuyueyi.forum.service.article.service.impl.ArticleReadServiceImpl; -import com.github.liuyueyi.forum.service.user.service.relation.UserRelationServiceImpl; -import com.github.liuyueyi.forum.service.user.service.user.UserServiceImpl; -import com.github.liuyueyi.forum.web.front.user.vo.UserHomeVo; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import javax.annotation.Resource; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - - -/** - * 用户注册、取消,登录、登出 - * - * @author lvmenglou - * @date : 2022/8/3 10:56 - **/ -@Controller -@RequestMapping(path = "user") -@Slf4j -public class UserViewController { - - @Resource - private UserServiceImpl userService; - - @Resource - private UserRelationServiceImpl userRelationService; - - @Resource - private ArticleReadServiceImpl articleReadService; - - private static final List homeSelectTags = Arrays.asList("article", "read", "follow", "collection"); - private static final List followSelectTags = Arrays.asList("follow", "fans"); - - /** - * 获取用户主页信息 - * - * @return - */ - @GetMapping(path = "home") - public String getUserHome(@RequestParam(name = "userId") Long userId, - @RequestParam(name = "homeSelectType", required = false) String homeSelectType, - @RequestParam(name = "followSelectType", required = false) String followSelectType, - Model model) { - UserHomeVo vo = new UserHomeVo(); - vo.setHomeSelectType(StringUtils.isBlank(homeSelectType) ? HomeSelectEnum.ARTICLE.getCode() : homeSelectType); - vo.setFollowSelectType(StringUtils.isBlank(followSelectType) ? FollowTypeEnum.FOLLOW.getCode() : followSelectType); - - UserStatisticInfoDTO userInfo = userService.queryUserInfoWithStatistic(userId); - vo.setUserHome(userInfo); - - List homeSelectTags = homeSelectTags(vo.getHomeSelectType()); - vo.setHomeSelectTags(homeSelectTags); - - userHomeSelectList(vo, userId); - model.addAttribute("vo", vo); - return "biz/user/home"; - } - - /** - * 返回Home页选择列表标签 - * - * @param selectType - * @return - */ - private List homeSelectTags(String selectType) { - List tags = new ArrayList<>(); - homeSelectTags.forEach(tag -> { - TagSelectDTO tagSelectDTO = new TagSelectDTO(); - tagSelectDTO.setSelectType(tag); - tagSelectDTO.setSelectDesc(HomeSelectEnum.fromCode(tag).getDesc()); - tagSelectDTO.setSelected(selectType.equals(tag)); - tags.add(tagSelectDTO); - }); - return tags; - } - - /** - * 返回关注用户选择列表标签 - * - * @param selectType - * @return - */ - private List followSelectTags(String selectType) { - List tags = new ArrayList<>(); - followSelectTags.forEach(tag -> { - TagSelectDTO tagSelectDTO = new TagSelectDTO(); - tagSelectDTO.setSelectType(tag); - tagSelectDTO.setSelectDesc(FollowSelectEnum.fromCode(tag).getDesc()); - tagSelectDTO.setSelected(selectType.equals(tag)); - tags.add(tagSelectDTO); - }); - return tags; - } - - /** - * 返回选择列表 - * - * @param vo - * @param userId - */ - private void userHomeSelectList(UserHomeVo vo, Long userId) { - PageParam pageParam = PageParam.newPageInstance(); - HomeSelectEnum select = HomeSelectEnum.fromCode(vo.getHomeSelectType()); - if (select == null) { - return; - } - - switch (select) { - case ARTICLE: - case READ: - case COLLECTION: - ArticleListDTO dto = articleReadService.queryArticlesByUserAndType(userId, pageParam, select); - vo.setHomeSelectList(dto); - return; - case FOLLOW: - // 关注用户与被关注用户 - // 获取选择标签 - List followSelectTags = followSelectTags(vo.getFollowSelectType()); - vo.setFollowSelectTags(followSelectTags); - initFollowFansList(vo, userId, pageParam); - return; - default: - } - } - - private void initFollowFansList(UserHomeVo vo, long userId, PageParam pageParam) { - if (vo.getFollowSelectType().equals(FollowTypeEnum.FOLLOW.getCode())) { - UserFollowListDTO userFollowListDTO = userRelationService.getUserFollowList(userId, pageParam); - vo.setFollowList(userFollowListDTO); - vo.setFansList(UserFollowListDTO.emptyInstance()); - } else { - UserFollowListDTO userFollowListDTO = userRelationService.getUserFansList(userId, pageParam); - vo.setFansList(userFollowListDTO); - vo.setFollowList(UserFollowListDTO.emptyInstance()); - } - } -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/vo/UserHomeVo.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/vo/UserHomeVo.java deleted file mode 100644 index 1b16be3bc..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/front/user/vo/UserHomeVo.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.liuyueyi.forum.web.front.user.vo; - -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleListDTO; -import com.github.liueyueyi.forum.api.model.vo.article.dto.TagSelectDTO; -import com.github.liueyueyi.forum.api.model.vo.comment.dto.UserFollowListDTO; -import com.github.liueyueyi.forum.api.model.vo.user.dto.UserStatisticInfoDTO; -import lombok.Data; - -import java.util.List; - -/** - * @author YiHui - * @date 2022/9/2 - */ -@Data -public class UserHomeVo { - String homeSelectType; - List homeSelectTags; - UserFollowListDTO fansList; - UserFollowListDTO followList; - String followSelectType; - List followSelectTags; - UserStatisticInfoDTO userHome; - - ArticleListDTO homeSelectList; -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/filter/ReqRecordFilter.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/filter/ReqRecordFilter.java deleted file mode 100644 index 108c27602..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/filter/ReqRecordFilter.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.github.liuyueyi.forum.web.hook.filter; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liueyueyi.forum.api.model.vo.user.dto.BaseUserInfoDTO; -import com.github.liuyueyi.forum.core.util.CrossUtil; -import com.github.liuyueyi.forum.core.util.IpUtil; -import com.github.liuyueyi.forum.service.user.service.LoginService; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpMethod; - -import javax.servlet.*; -import javax.servlet.annotation.WebFilter; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.net.URLDecoder; - -/** - * 1. 请求参数日志输出过滤器 - * 2. 判断用户是否登录 - * - * @author YiHui - * @date 2022/7/6 - */ -@Slf4j -@WebFilter(urlPatterns = "/*", filterName = "selfProcessBeforeFilter") -public class ReqRecordFilter implements Filter { - private static Logger REQ_LOG = LoggerFactory.getLogger("req"); - - @Autowired - private LoginService loginService; - - @Override - public void init(FilterConfig filterConfig) { - } - - @Override - public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - long start = System.currentTimeMillis(); - HttpServletRequest request = null; - try { - request = this.initReqInfo((HttpServletRequest) servletRequest); - CrossUtil.buildCors(request, (HttpServletResponse) servletResponse); - filterChain.doFilter(request, servletResponse); - } finally { - buildRequestLog(ReqInfoContext.getReqInfo(), request, System.currentTimeMillis() - start); - ReqInfoContext.clear(); - } - } - - @Override - public void destroy() { - } - - private HttpServletRequest initReqInfo(HttpServletRequest request) { - try { - ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo(); - reqInfo.setHost(request.getHeader("host")); - reqInfo.setPath(request.getPathInfo()); - reqInfo.setReferer(request.getHeader("referer")); - reqInfo.setClientIp(IpUtil.getClientIp(request)); - reqInfo.setUserAgent(request.getHeader("User-Agent")); - - request = this.wrapperRequest(request, reqInfo); - ReqInfoContext.addReqInfo(reqInfo); - - for (Cookie cookie : request.getCookies()) { - if (LoginService.SESSION_KEY.equalsIgnoreCase(cookie.getName())) { - String session = cookie.getValue(); - BaseUserInfoDTO user = loginService.getUserBySessionId(session); - reqInfo.setSession(session); - reqInfo.setUserId(user.getUserId()); - reqInfo.setUser(user); - break; - } - } - } catch (Exception e) { - log.error("init reqInfo error!", e); - } - - return request; - } - - private void buildRequestLog(ReqInfoContext.ReqInfo req, HttpServletRequest request, long costTime) { - // fixme 过滤不需要记录请求日志的场景 - if (request == null - || request.getRequestURI().endsWith("css") - || request.getRequestURI().endsWith("js") - || request.getRequestURI().endsWith("png") - || request.getRequestURI().endsWith("ico")) { - return; - } - - StringBuilder msg = new StringBuilder(); - msg.append("method=").append(request.getMethod()).append("; "); - if (StringUtils.isNotBlank(req.getReferer())) { - msg.append("referer=").append(URLDecoder.decode(req.getReferer())).append("; "); - } - msg.append("remoteIp=").append(req.getClientIp()); - msg.append("; agent=").append(req.getUserAgent()); - - if (req.getUserId() != null) { - // 打印用户信息 - msg.append("; user=").append(req.getUserId()); - } - - msg.append("; uri=").append(request.getRequestURI()); - if (StringUtils.isNotBlank(request.getQueryString())) { - msg.append('?').append(URLDecoder.decode(request.getQueryString())); - } - - msg.append("; payload=").append(req.getPayload()); - msg.append("; cost=").append(costTime); - REQ_LOG.info("{}", msg); - } - - - private HttpServletRequest wrapperRequest(HttpServletRequest request, ReqInfoContext.ReqInfo reqInfo) { - if (!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())) { - return request; - } - - BodyReaderHttpServletRequestWrapper requestWrapper = new BodyReaderHttpServletRequestWrapper(request); - reqInfo.setPayload(requestWrapper.getBodyString()); - return requestWrapper; - } - -} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/interceptor/GlobalViewInterceptor.java b/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/interceptor/GlobalViewInterceptor.java deleted file mode 100644 index 36cac2a4f..000000000 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/interceptor/GlobalViewInterceptor.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.github.liuyueyi.forum.web.hook.interceptor; - -import com.github.liueyueyi.forum.api.model.context.ReqInfoContext; -import com.github.liuyueyi.forum.core.permission.Permission; -import com.github.liuyueyi.forum.core.permission.UserRole; -import com.github.liuyueyi.forum.service.user.service.UserService; -import com.github.liuyueyi.forum.web.config.GlobalViewConfig; -import lombok.Data; -import lombok.experimental.Accessors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.util.ObjectUtils; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.AsyncHandlerInterceptor; -import org.springframework.web.servlet.ModelAndView; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.util.Arrays; - -/** - * 注入全局的配置信息: - * - thymleaf 站点信息,基本信息,在这里注入 - * - * @author yihui - * @date 2022/6/15 - */ -@Slf4j -@Component -public class GlobalViewInterceptor implements AsyncHandlerInterceptor { - @Resource - private GlobalViewConfig globalViewConfig; - @Resource - private UserService userService; - @Value("${env.name}") - private String env; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - if (handler instanceof HandlerMethod) { - HandlerMethod handlerMethod = (HandlerMethod) handler; - Permission permission = handlerMethod.getMethod().getAnnotation(Permission.class); - if (permission == null) { - permission = handlerMethod.getBeanType().getAnnotation(Permission.class); - } - - if (permission == null || permission.role() == UserRole.ALL) { - return true; - } - if (ReqInfoContext.getReqInfo() == null || ReqInfoContext.getReqInfo().getUserId() == null) { - // 跳转到登录界面 - response.sendRedirect("/403"); - return false; - } - - if (permission.role() == UserRole.ADMIN && !"admin".equalsIgnoreCase(ReqInfoContext.getReqInfo().getUser().getRole())) { - response.sendRedirect("/403"); - return false; - } - } - return true; - } - - @Override - public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { - // 重定向请求不需要添加 - if (!ObjectUtils.isEmpty(modelAndView)) { - modelAndView.getModel().put("env", env); - modelAndView.getModel().put("siteInfo", globalViewConfig); - if (ReqInfoContext.getReqInfo() == null || ReqInfoContext.getReqInfo().getUserId() == null) { - modelAndView.getModel().put("isLogin", false); - - } else { - modelAndView.getModel().put("isLogin", true); - modelAndView.getModel().put("user", userService.queryUserInfoWithStatistic(ReqInfoContext.getReqInfo().getUserId())); - // 消息数 fixme 消息信息改由消息模块处理 - modelAndView.getModel().put("msgs", Arrays.asList(new UserMsg().setMsgId(100L).setMsgType(1).setMsg("模拟通知消息"))); - } - } - } - - @Data - @Accessors(chain = true) - private static class UserMsg { - private long msgId; - private int msgType; - private String msg; - } -} diff --git a/forum-web/src/main/resources-env/dev/application-dal.yml b/forum-web/src/main/resources-env/dev/application-dal.yml deleted file mode 100644 index 0b6d930c1..000000000 --- a/forum-web/src/main/resources-env/dev/application-dal.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://127.0.0.1:3306/forum?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai - username: root - password: diff --git a/forum-web/src/main/resources-env/dev/application-web.yml b/forum-web/src/main/resources-env/dev/application-web.yml deleted file mode 100644 index a3b059dc6..000000000 --- a/forum-web/src/main/resources-env/dev/application-web.yml +++ /dev/null @@ -1,12 +0,0 @@ -log: - path: logs -env: - name: dev - -spring: - thymeleaf: - mode: HTML - encoding: UTF-8 - servlet: - content-type: text/html - cache: false \ No newline at end of file diff --git a/forum-web/src/main/resources-env/pre/application-dal.yml b/forum-web/src/main/resources-env/pre/application-dal.yml deleted file mode 100644 index ab71ced79..000000000 --- a/forum-web/src/main/resources-env/pre/application-dal.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://pre.hhui.top/forum?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai - username: pre_root - password: diff --git a/forum-web/src/main/resources-env/pre/application-web.yml b/forum-web/src/main/resources-env/pre/application-web.yml deleted file mode 100644 index 01831f7c0..000000000 --- a/forum-web/src/main/resources-env/pre/application-web.yml +++ /dev/null @@ -1,12 +0,0 @@ -log: - path: ~/logs -env: - name: pre - -spring: - thymeleaf: - mode: HTML - encoding: UTF-8 - servlet: - content-type: text/html - cache: true \ No newline at end of file diff --git a/forum-web/src/main/resources-env/prod/application-dal.yml b/forum-web/src/main/resources-env/prod/application-dal.yml deleted file mode 100644 index 5a6c1e678..000000000 --- a/forum-web/src/main/resources-env/prod/application-dal.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://prod.hhui.top/forum?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai - username: prod_root - password: diff --git a/forum-web/src/main/resources-env/prod/application-web.yml b/forum-web/src/main/resources-env/prod/application-web.yml deleted file mode 100644 index b2f50800c..000000000 --- a/forum-web/src/main/resources-env/prod/application-web.yml +++ /dev/null @@ -1,13 +0,0 @@ -log: - path: /home/admin/workspace/quick-forum/logs # 请使用实际的地址进行替换 -env: - name: prod - - -spring: - thymeleaf: - mode: HTML - encoding: UTF-8 - servlet: - content-type: text/html - cache: true # 开启缓存 \ No newline at end of file diff --git a/forum-web/src/main/resources-env/test/application-dal.yml b/forum-web/src/main/resources-env/test/application-dal.yml deleted file mode 100644 index 494c7e1ba..000000000 --- a/forum-web/src/main/resources-env/test/application-dal.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://test.hhui.top/forum?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai - username: test_root - password: diff --git a/forum-web/src/main/resources-env/test/application-web.yml b/forum-web/src/main/resources-env/test/application-web.yml deleted file mode 100644 index 5cfd4e631..000000000 --- a/forum-web/src/main/resources-env/test/application-web.yml +++ /dev/null @@ -1,12 +0,0 @@ -log: - path: ~/logs -env: - name: test - -spring: - thymeleaf: - mode: HTML - encoding: UTF-8 - servlet: - content-type: text/html - cache: true \ No newline at end of file diff --git a/forum-web/src/main/resources/application-config.yml b/forum-web/src/main/resources/application-config.yml deleted file mode 100644 index fa9892c8a..000000000 --- a/forum-web/src/main/resources/application-config.yml +++ /dev/null @@ -1,10 +0,0 @@ -view: - site: - websiteName: Quick社区 - websiteLogoUrl: https://blog.hhui.top/hexblog/images/avatar.jpg - websiteFaviconIconUrl: https://blog.hhui.top/hexblog/images/avatar.jpg - contactMeTitle: 联系我 - liuyueyi25 - contactMeWxQrCode: https://blog.hhui.top/hexblog/imgs/info/wx.jpg - pageSize: 20 - cdnImgStyle: - websiteRecord: 备案记录 \ No newline at end of file diff --git a/forum-web/src/main/resources/application.yml b/forum-web/src/main/resources/application.yml deleted file mode 100644 index 3fcad9361..000000000 --- a/forum-web/src/main/resources/application.yml +++ /dev/null @@ -1,14 +0,0 @@ -server: - port: 8080 - -spring: - profiles: - active: dal,web,config - main: - allow-circular-references: true - -# mybatis 相关统一配置 -mybatis-plus: - configuration: - #开启下划线转驼峰 - map-underscore-to-camel-case: true diff --git a/forum-web/src/main/resources/logback-spring.xml b/forum-web/src/main/resources/logback-spring.xml deleted file mode 100644 index 843a9d376..000000000 --- a/forum-web/src/main/resources/logback-spring.xml +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - %d [%t] %-5level %logger{36}.%M\(%file:%line\) - %msg%n - - UTF-8 - - - - - - - - - - INFO - - ${log.path}/${log.service.name}-${log.env}.log - - - - - - ${log.path}/arch/${log.service.name}-${log.env}.%d.%i.log - - 3 - - - 100MB - - - - - - [%d{yyyy-MM-dd HH:mm:ss}] {"logger":"%logger{36}", "thread":"%thread", "msg":"%msg %replace(%ex){'\n', ' '}%nopex"}%n - - - UTF-8 - - - - - - ${log.path}/${log.req.name}-${log.env}.log - - - - ${log.path}/arch/req/req.%d{yyyy-MM-dd}.%i.log.gz - - 100MB - - 10 - - 1GB - - - - UTF-8 - [%d{yyyy-MM-dd HH:mm:ss}] - %msg%n - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/forum-web/src/main/resources/schema.sql b/forum-web/src/main/resources/schema.sql deleted file mode 100644 index 55b1d8978..000000000 --- a/forum-web/src/main/resources/schema.sql +++ /dev/null @@ -1,207 +0,0 @@ --- forum.article definition - -CREATE TABLE `article` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `article_type` tinyint NOT NULL DEFAULT '1' COMMENT '文章类型:1-博文,2-问答', - `title` varchar(120) NOT NULL COMMENT '文章标题', - `short_title` varchar(120) NOT NULL COMMENT '短标题', - `picture` varchar(128) NOT NULL DEFAULT '' COMMENT '文章头图', - `summary` varchar(300) NOT NULL DEFAULT '' COMMENT '文章摘要', - `category_id` int unsigned NOT NULL DEFAULT '0' COMMENT '类目ID', - `source` tinyint NOT NULL DEFAULT '1' COMMENT '来源:1-转载,2-原创,3-翻译', - `source_url` varchar(128) NOT NULL DEFAULT '1' COMMENT '原文链接', - `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_category_id` (`category_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表'; - - --- forum.article_detail definition - -CREATE TABLE `article_detail` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `article_id` int unsigned NOT NULL COMMENT '文章ID', - `version` int unsigned NOT NULL COMMENT '版本号', - `content` text COMMENT '文章内容', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `idx_article_version` (`article_id`,`version`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章详情表'; - - --- forum.article_tag definition - -CREATE TABLE `article_tag` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `article_id` int unsigned NOT NULL DEFAULT '0' COMMENT '文章ID', - `tag_id` int NOT NULL DEFAULT '0' COMMENT '标签', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_tag_id` (`tag_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签映射'; - - --- forum.category definition - -CREATE TABLE `category` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `category_name` varchar(64) NOT NULL COMMENT '类目名称', - `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='类目管理表'; - - --- forum.comment definition - -CREATE TABLE `comment` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `article_id` int unsigned NOT NULL COMMENT '文章ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `content` varchar(300) NOT NULL DEFAULT '' COMMENT '评论内容', - `top_comment_id` int unsigned NOT NULL DEFAULT '0' COMMENT '顶级评论ID', - `parent_comment_id` int unsigned NOT NULL DEFAULT '0' COMMENT '父评论ID', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_article_id_parent_comment_id` (`article_id`, `parent_comment_id`), - KEY `idx_user_id` (`user_id`), - KEY `idx_article_id` (`top_comment_id`), -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表'; - - --- forum.tag definition - -CREATE TABLE `tag` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tag_name` varchar(120) NOT NULL COMMENT '标签名称', - `tag_type` tinyint NOT NULL DEFAULT '1' COMMENT '标签类型:1-系统标签,2-自定义标签', - `category_id` int unsigned NOT NULL DEFAULT '0' COMMENT '类目ID', - `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_category_id` (`category_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签管理表'; - --- forum.read_count 访问计数 - -CREATE TABLE `read_count` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `document_id` int unsigned NOT NULL COMMENT '文档ID(文章/评论)', - `document_type` tinyint NOT NULL DEFAULT '1' COMMENT '文档类型:1-文章,2-评论', - `cnt` int unsigned NOT NULL COMMENT '访问计数', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `idx_document_id_type` (`document_id`,`document_type`) -) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='计数表'; - --- forum.`user` definition - -CREATE TABLE `user` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `third_account_id` varchar(128) NOT NULL DEFAULT '' COMMENT '第三方用户ID', - `login_type` tinyint NOT NULL DEFAULT '0' COMMENT '登录方式: 0-微信登录,1-账号密码登录', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `key_third_account_id` (`third_account_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户登录表'; - - --- forum.user_foot definition - -CREATE TABLE `user_foot` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `document_id` int unsigned NOT NULL COMMENT '文档ID(文章/评论)', - `document_type` tinyint NOT NULL DEFAULT '1' COMMENT '文档类型:1-文章,2-评论', - `document_user_id` int unsigned NOT NULL DEFAULT '0' COMMENT '发布该文档的用户ID', - `collection_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '收藏状态: 0-未收藏,1-已收藏,2-取消收藏', - `read_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '阅读状态: 0-未读,1-已读', - `comment_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '评论状态: 0-未评论,1-已评论,2-删除评论', - `praise_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '点赞状态: 0-未点赞,1-已点赞,2-取消点赞', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `idx_user_document` (`user_id`,`document_id`,`document_type`,`comment_id`), - KEY `idx_document_id` (`document_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户足迹表'; - - --- forum.user_info definition - -CREATE TABLE `user_info` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `user_name` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名', - `photo` varchar(128) NOT NULL DEFAULT '' COMMENT '用户图像', - `position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位', - `company` varchar(50) NOT NULL DEFAULT '' COMMENT '公司', - `profile` varchar(225) NOT NULL DEFAULT '' COMMENT '个人简介', - `extend` varchar(1024) NOT NULL DEFAULT '' COMMENT '扩展字段', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `key_user_id` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户个人信息表'; - - --- forum.user_relation definition - -CREATE TABLE `user_relation` -( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `follow_user_id` int unsigned NOT NULL COMMENT '关注用户ID', - `follow_state` tinyint(2) unsigned NOT NULL DEFAULT '0' COMMENT '阅读状态: 0-未关注,1-已关注,2-取消关注', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_user_follow` (`user_id`,`follow_user_id`), - KEY `key_follow_user_id` (`follow_user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关系表'; - --- 变更记录 -# alter table user_relation -# add `follow_state` tinyint(2) unsigned NOT NULL DEFAULT '0' COMMENT '阅读状态: 0-未关注,1-已关注,2-取消关注'; -# alter table comment change parent_comment_id `parent_comment_id` int unsigned NOT NULL DEFAULT '0' COMMENT '父评论ID'; -# alter table user_foot add `document_user_id` int unsigned NOT NULL COMMENT '发布该文档的用户ID'; -# alter table user_foot -# add `comment_id` int unsigned NOT NULL DEFAULT '0' COMMENT '当前发起评论的ID'; -# alter table user_foot change praise_stat `praise_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '点赞状态: 0-未点赞,1-已点赞'; -# alter table user_foot change collection_stat `collection_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '收藏状态: 0-未收藏,1-已收藏'; -# alter table user_foot change comment_stat `comment_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '评论状态: 0-未评论,1-已评论'; -# drop index idx_user_document on user_foot; -# alter table user_foot add unique index `idx_user_document` (`user_id`,`document_id`,`document_type`,`comment_id`); -# alter table user_foot rename column doucument_id to document_id; -# alter table user_foot rename column doucument_type to document_type; -# alter table user_foot rename column doucument_user_id to document_user_id; --- 删除用户足迹中的评论id -# alter table user_foot drop column comment_id; -# alter table `comment` add column `top_comment_id` int not null default '0' comment '顶级评论ID' after `content`; -# alter table `comment` add column `deleted` tinyint not null default '0' comment '0有效1删除' after `parent_comment_id`; \ No newline at end of file diff --git a/forum-web/src/main/resources/test-data.sql b/forum-web/src/main/resources/test-data.sql deleted file mode 100644 index b29efe7c5..000000000 --- a/forum-web/src/main/resources/test-data.sql +++ /dev/null @@ -1,339 +0,0 @@ --- MySQL dump 10.13 Distrib 8.0.29, for Linux (x86_64) --- --- Host: localhost Database: forum --- ------------------------------------------------------ --- Server version 8.0.29-0ubuntu0.20.04.3 - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!50503 SET NAMES utf8mb4 */; -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Table structure for table `article` --- - -DROP TABLE IF EXISTS `article`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `article` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `article_type` tinyint NOT NULL DEFAULT '1' COMMENT '文章类型:1-博文,2-问答', - `title` varchar(120) NOT NULL COMMENT '文章标题', - `short_title` varchar(120) NOT NULL COMMENT '短标题', - `picture` varchar(128) NOT NULL DEFAULT '' COMMENT '文章头图', - `summary` varchar(300) NOT NULL DEFAULT '' COMMENT '文章摘要', - `category_id` int unsigned NOT NULL DEFAULT '0' COMMENT '类目ID', - `source` tinyint NOT NULL DEFAULT '1' COMMENT '来源:1-转载,2-原创,3-翻译', - `source_url` varchar(128) NOT NULL DEFAULT '1' COMMENT '原文链接', - `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_category_id` (`category_id`) -) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文章表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `article` --- - -LOCK TABLES `article` WRITE; -/*!40000 ALTER TABLE `article` DISABLE KEYS */; -INSERT INTO `article` VALUES (3,1,1,'Java小技巧:巧用函数方法实现二维数组遍历','巧用函数方法实现二维数组遍历','','对于数组遍历,基本上每个开发者都写过,遍历本身没什么好说的,但是当我们在遍历的过程中,有一些复杂的业务逻辑时,将会发现代码的层级会逐渐加深',1,2,'',1,0,'2022-08-06 11:52:13','2022-08-07 02:11:42'),(4,1,1,'SpringBoot系列之xml传参与返回实战演练','xml传参与返回实战','','asd',1,2,'',1,0,'2022-08-06 11:55:04','2022-08-07 03:53:37'); -/*!40000 ALTER TABLE `article` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `article_detail` --- - -DROP TABLE IF EXISTS `article_detail`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `article_detail` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `article_id` int unsigned NOT NULL COMMENT '文章ID', - `version` int unsigned NOT NULL COMMENT '版本号', - `content` text COMMENT '文章内容', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `idx_article_version` (`article_id`,`version`) -) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文章详情表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `article_detail` --- - -LOCK TABLES `article_detail` WRITE; -/*!40000 ALTER TABLE `article_detail` DISABLE KEYS */; -INSERT INTO `article_detail` VALUES (3,3,1,'对于数组遍历,基本上每个开发者都写过,遍历本身没什么好说的,但是当我们在遍历的过程中,有一些复杂的业务逻辑时,将会发现代码的层级会逐渐加深\r\n\r\n如一个简单的case,将一个二维数组中的偶数找出来,保存到一个列表中\r\n\r\n二维数组遍历,每个元素判断下是否为偶数,很容易就可以写出来,如\r\n\r\n```java\r\npublic void getEven() {\r\n int[][] cells = new int[][]{{1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24}};\r\n List ans = new ArrayList<>();\r\n for (int i = 0; i < cells.length; i ++) {\r\n for (int j = 0; j < cells[0].length; j++) {\r\n if ((cells[i][j] & 1) == 0) {\r\n ans.add(cells[i][j]);\r\n }\r\n }\r\n }\r\n System.out.println(ans);\r\n}\r\n```\r\n\r\n上面这个实现没啥问题,但是这个代码的深度很容易就有三层了;当上面这个if中如果再有其他的判定条件,那么这个代码层级很容易增加了;二维数组还好,如果是三维数组,一个遍历就是三层;再加点逻辑,四层、五层不也是分分钟的事情么\r\n\r\n那么问题来了,代码层级变多之后会有什么问题呢?\r\n\r\n> 只要代码能跑,又能有什么问题呢?!\r\n\r\n## 1. 函数方法消减代码层级\r\n\r\n由于多维数组的遍历层级天然就很深,那么有办法进行消减么?\r\n\r\n要解决这个问题,关键是要抓住重点,遍历的重点是什么?获取每个元素的坐标!那么我们可以怎么办?\r\n\r\n> 定义一个函数方法,输入的就是函数坐标,在这个函数体中执行我们的遍历逻辑即可\r\n\r\n基于上面这个思路,相信我们可以很容易写一个二维的数组遍历通用方法\r\n\r\n```java\r\npublic static void scan(int maxX, int maxY, BiConsumer consumer) {\r\n for (int i = 0; i < maxX; i++) {\r\n for (int j = 0; j < maxY; j++) {\r\n consumer.accept(i, j);\r\n }\r\n }\r\n}\r\n```\r\n\r\n主要上面的实现,函数方法直接使用了JDK默认提供的BiConsumer,两个传参,都是int 数组下表;无返回值\r\n\r\n那么上面这个怎么用呢?\r\n\r\n同样是上面的例子,改一下之后,如\r\n\r\n```java\r\npublic void getEven() {\r\n int[][] cells = new int[][]{{1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24}};\r\n List ans = new ArrayList<>();\r\n scan(cells.length, cells[0].length, (i, j) -> {\r\n if ((cells[i][j] & 1) == 0) {\r\n ans.add(cells[i][j]);\r\n }\r\n });\r\n System.out.println(ans);\r\n}\r\n```\r\n\r\n相比于前面的,貌似也就少了一层而已,好像也没什么了不起的\r\n\r\n但是,当数组变为三维、四维、无维时,这个改动的写法层级都不会变哦\r\n\r\n## 2. 遍历中return支持\r\n\r\n前面的实现对于正常的遍历没啥问题;但是当我们在遍历过程中,遇到某个条件直接返回,能支持么?\r\n\r\n如一个遍历二维数组,我们希望判断其中是否有偶数,那么可以怎么整?\r\n\r\n仔细琢磨一下我们的scan方法,希望可以支持return,主要的问题点就是这个函数方法执行之后,我该怎么知道是继续循环还是直接return呢?\r\n\r\n很容易想到的就是执行逻辑中,添加一个额外的返回值,用于标记是否中断循环直接返回\r\n\r\n基于此思路,我们可以实现一个简单的demo版本\r\n\r\n定义一个函数方法,接受循环的下标 + 返回值\r\n\r\n```java\r\n@FunctionalInterface\r\npublic interface ScanProcess {\r\n ImmutablePair accept(int i, int j);\r\n}\r\n```\r\n\r\n循环通用方法就可以相应的改成\r\n\r\n```java\r\npublic static T scanReturn(int x, int y, ScanProcess func) {\r\n for (int i = 0; i < x; i++) {\r\n for (int j = 0; j < y; j++) {\r\n ImmutablePair ans = func.accept(i, j);\r\n if (ans != null && ans.left) {\r\n return ans.right;\r\n }\r\n }\r\n }\r\n return null;\r\n}\r\n```\r\n\r\n基于上面这种思路,我们的实际使用姿势如下\r\n\r\n```java\r\n@Test\r\npublic void getEven() {\r\n int[][] cells = new int[][]{{1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24}};\r\n List ans = new ArrayList<>();\r\n scanReturn(cells.length, cells[0].length, (i, j) -> {\r\n if ((cells[i][j] & 1) == 0) {\r\n return ImmutablePair.of(true, i + \"_\" + j);\r\n }\r\n return ImmutablePair.of(false, null);\r\n });\r\n System.out.println(ans);\r\n}\r\n```\r\n\r\n上面这个实现可满足我们的需求,唯一有个别扭的地方就是返回,总有点不太优雅;那么除了这种方式之外,还有其他的方式么?\r\n\r\n既然考虑了返回值,那么再考虑一下传参呢?通过一个定义的参数来装在是否中断以及返回结果,是否可行呢?\r\n\r\n\r\n基于这个思路,我们可以先定义一个参数包装类\r\n\r\n```java\r\npublic static class Ans {\r\n private T ans;\r\n private boolean tag = false;\r\n\r\n public Ans setAns(T ans) {\r\n tag = true;\r\n this.ans = ans;\r\n return this;\r\n }\r\n\r\n public T getAns() {\r\n return ans;\r\n }\r\n}\r\n\r\npublic interface ScanFunc {\r\n void accept(int i, int j, Ans ans)\r\n}\r\n```\r\n\r\n我们希望通过Ans这个类来记录循环结果,其中tag=true,则表示不用继续循环了,直接返回ans结果吧\r\n\r\n与之对应的方法改造及实例如下\r\n\r\n```java\r\npublic static T scanReturn(int x, int y, ScanFunc func) {\r\n Ans ans = new Ans<>();\r\n for (int i = 0; i < x; i++) {\r\n for (int j = 0; j < y; j++) {\r\n func.accept(i, j, ans);\r\n if (ans.tag) {\r\n return ans.ans;\r\n }\r\n }\r\n }\r\n return null;\r\n}\r\n \r\npublic void getEven() {\r\n int[][] cells = new int[][]{{1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24}};\r\n String ans = scanReturn(cells.length, cells[0].length, (i, j, a) -> {\r\n if ((cells[i][j] & 1) == 0) {\r\n a.setAns(i + \"_\" + j);\r\n }\r\n });\r\n System.out.println(ans);\r\n}\r\n```\r\n\r\n这样看起来就比前面的要好一点了\r\n\r\n实际跑一下,看下输出是否和我们预期的一致;\r\n\r\n![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/546a699ae4334df4b6525332da4e5770~tplv-k3u1fbpfcp-watermark.image?)\r\n\r\n## 3.小结\r\n\r\n到此一个小的技巧就分享完毕了,各位感兴趣的小伙伴可以关注我的公众号“一灰灰blog”\r\n\r\n最近正在整理的 * [分布式设计模式综述 | 一灰灰Learning](https://hhui.top/%E5%88%86%E5%B8%83%E5%BC%8F/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/01.%E5%88%86%E5%B8%83%E5%BC%8F%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E7%BB%BC%E8%BF%B0/) 欢迎各位大佬点评\r\n\r\n* [万字总结:分布式系统的38个知识点 - 掘金](https://juejin.cn/post/7125383856651239432)\r\n* [万字详解:MySql,Redis,Mq,ES的高可用方案解析 - 掘金](https://juejin.cn/post/7126864114806177822)\r\n\r\n\r\n\r\n\r\n',0,'2022-08-06 11:52:13','2022-08-07 02:13:51'),(4,4,8,'![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7224ef4796684b6b8ba597716f8cc172~tplv-k3u1fbpfcp-zoom-1.image)\n\n> SpringBoot系列之xml传参与返回实战演练\n\n最近在准备使用微信公众号来做个人站点的登录,发现微信的回调协议居然是xml格式的,之前使用json传输的较多,结果发现换成xml之后,好像并没有想象中的那么顺利,比如回传的数据始终拿不到,返回的数据对方不认等\n\n接下来我们来实际看一下,一个传参和返回都是xml的SpringBoot应用,究竟是怎样的\n\n\n\n## I. 项目搭建\n\n\n本文创建的实例工程采用`SpringBoot 2.2.1.RELEASE` + `maven 3.5.3` + `idea`进行开发\n\n### 1. pom依赖\n\n具体的SpringBoot项目工程创建就不赘述了,对于pom文件中,需要重点关注下面两个依赖类\n\n```xml\n\n \n org.springframework.boot\n spring-boot-starter-web\n \n \n com.fasterxml.jackson.dataformat\n jackson-dataformat-xml\n \n\n```\n\n### 2. 接口调研\n\n我们直接使用微信公众号的回调传参、返回来搭建项目服务,微信开发平台文档如: [基础消息能力](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html)\n\n\n其定义的推送参数如下\n\n```xml\n\n \n \n 1348831860\n \n \n 1234567890123456\n xxxx\n xxxx\n\n```\n\n要求返回的结果如下\n\n```xml\n\n \n \n 12345678\n \n \n\n```\n\n上面的结构看起来还好,但是需要注意的是外层标签为`xml`,内层标签都是大写开头的;而微信识别返回是大小写敏感的\n\n## II. 实战\n\n项目工程搭建完毕之后,首先定义一个接口,用于接收xml传参,并返回xml对象;\n\n那么核心的问题就是如何定义传参为xml,返回也是xml呢?\n\n> 没错:就是请求头 + 返回头\n\n### 1.REST接口\n\n```java\n@RestController\npublic class XmlRest {\n\n /**\n * curl -X POST \'http://localhost:8080/xml/callback\' -H \'content-type:application/xml\' -d \'165570057911111111\' -i\n *\n * @param msg\n * @param request\n * @return\n */\n @PostMapping(path = \"xml/callback\",\n consumes = {\"application/xml\", \"text/xml\"},\n produces = \"application/xml;charset=utf-8\")\n public WxTxtMsgResVo callBack(@RequestBody WxTxtMsgReqVo msg, HttpServletRequest request) {\n WxTxtMsgResVo res = new WxTxtMsgResVo();\n res.setFromUserName(msg.getToUserName());\n res.setToUserName(msg.getFromUserName());\n res.setCreateTime(System.currentTimeMillis() / 1000);\n res.setMsgType(\"text\");\n res.setContent(\"hello: \" + LocalDateTime.now());\n return res;\n }\n}\n```\n\n注意上面的接口定义,POST传参,请求头和返回头都是 `application/xml`\n\n### 2.请求参数与返回结果对象定义\n\n上面的接口中定义了`WxTxtMsgReqVo`来接收传参,定义`WxTxtMsgResVo`来返回结果,由于我们采用的是xml协议传输数据,这里需要借助`JacksonXmlRootElement`和`JacksonXmlProperty`注解;它们的实际作用与json传输时,使用`JsonProperty`来指定json key的作用相仿\n\n\n下面是具体的实体定义\n\n```java\n@Data\n@JacksonXmlRootElement(localName = \"xml\")\npublic class WxTxtMsgReqVo {\n @JacksonXmlProperty(localName = \"ToUserName\")\n private String toUserName;\n @JacksonXmlProperty(localName = \"FromUserName\")\n private String fromUserName;\n @JacksonXmlProperty(localName = \"CreateTime\")\n private Long createTime;\n @JacksonXmlProperty(localName = \"MsgType\")\n private String msgType;\n @JacksonXmlProperty(localName = \"Content\")\n private String content;\n @JacksonXmlProperty(localName = \"MsgId\")\n private String msgId;\n @JacksonXmlProperty(localName = \"MsgDataId\")\n private String msgDataId;\n @JacksonXmlProperty(localName = \"Idx\")\n private String idx;\n}\n\n@Data\n@JacksonXmlRootElement(localName = \"xml\")\npublic class WxTxtMsgResVo {\n\n @JacksonXmlProperty(localName = \"ToUserName\")\n private String toUserName;\n @JacksonXmlProperty(localName = \"FromUserName\")\n private String fromUserName;\n @JacksonXmlProperty(localName = \"CreateTime\")\n private Long createTime;\n @JacksonXmlProperty(localName = \"MsgType\")\n private String msgType;\n @JacksonXmlProperty(localName = \"Content\")\n private String content;\n}\n```\n\n重点说明:\n\n\n- JacksonXmlRootElement 注解,定义返回的xml文档中最外层的标签名\n- JacksonXmlProperty 注解,定义每个属性值对应的标签名\n- 无需额外添加``,这个会自动添加,防转义\n\n\n\n### 3.测试\n\n然后访问测试一下,直接通过curl来发送xml请求\n\n```bash\ncurl -X POST \'http://localhost:8080/xml/callback\' -H \'content-type:application/xml\' -d \'165570057911111111\' -i\n```\n\n实际响应如下\n\n```bash\nHTTP/1.1 200\nContent-Type: application/xml;charset=utf-8\nTransfer-Encoding: chunked\nDate: Tue, 05 Jul 2022 01:20:32 GMT\n\n123一灰灰blog1656984032texthello: 2022-07-05T09:20:32.155% \n```\n\n\n### 4.问题记录\n\n#### 4.1 HttpMediaTypeNotSupportedException异常\n\n通过前面的方式搭建项目之后,在实际测试时,可能会遇到下面的异常情况`Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content type \'application/xml;charset=UTF-8\' not supported]`\n\n\n当出现这个问题时,表明是没有对应的Convert来处理`application/xml`格式的请求头\n\n对应的解决方案则是主动注册上\n\n```java\n@Configuration\npublic class XmlWebConfig implements WebMvcConfigurer {\n @Override\n public void configureMessageConverters(List> converters) {\n converters.add(new MappingJackson2XmlHttpMessageConverter());\n }\n}\n```\n\n#### 4.2 其他json接口也返回xml数据\n\n另外一个场景则是配置了前面的xml之后,导致项目中其他正常的json传参、返回的接口也开始返回xml格式的数据了,此时解决方案如下\n\n```java\n@Configuration\npublic class XmlWebConfig implements WebMvcConfigurer {\n /**\n * 配置这个,默认返回的是json格式数据;若指定了xml返回头,则返回xml格式数据\n *\n * @param configurer\n */\n @Override\n public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {\n configurer.defaultContentType(MediaType.APPLICATION_JSON, MediaType.TEXT_XML, MediaType.APPLICATION_XML);\n }\n}\n```\n\n#### 4.3 微信实际回调参数一直拿不到\n\n这个问题是在实际测试回调的时候遇到的,接口定义之后始终拿不到结果,主要原因就在于最开始没有在定义的实体类上添加 `@JacksonXmlProperty`\n\n当我们没有指定这个注解时,接收的xml标签名与实体对象的fieldName完全相同,既区分大小写\n\n所以为了解决这个问题,就是老老实实如上面的写法,在每个成员上添加注解,如下\n\n```java\n@JacksonXmlProperty(localName = \"ToUserName\")\nprivate String toUserName;\n@JacksonXmlProperty(localName = \"FromUserName\")\nprivate String fromUserName;\n```\n\n### 5.小结\n\n本文主要介绍的是SpringBoot如何支持xml格式的传参与返回,大体上使用姿势与json格式并没有什么区别,但是在实际使用的时候需要注意上面提出的几个问题,避免采坑\n\n关键知识点提炼如下:\n\n- Post接口上,指定请求头和返回头:\n - `consumes = {\"application/xml\", \"text/xml\"},`\n - `produces = \"application/xml;charset=utf-8\"`\n- 实体对象,通过`JacksonXmlRootElement`和`JacksonXmlProperty`来重命名返回的标签名\n- 注册`MappingJackson2XmlHttpMessageConverter`解决HttpMediaTypeNotSupportedException异常\n- 指定`ContentNegotiationConfigurer.defaultContentType` 避免出现所有接口返回xml文档\n\n\n## III. 其他\n\n### 0. 项目与源码\n\n- 工程:[https://github.com/liuyueyi/spring-boot-demo](https://github.com/liuyueyi/spring-boot-demo)\n- 源码:[https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-boot/204-web-xml](https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-boot/204-web-xml)\n\n\n',0,'2022-08-06 11:55:04','2022-08-07 03:53:37'); -/*!40000 ALTER TABLE `article_detail` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `article_tag` --- - -DROP TABLE IF EXISTS `article_tag`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `article_tag` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `article_id` int unsigned NOT NULL DEFAULT '0' COMMENT '文章ID', - `tag_id` int NOT NULL DEFAULT '0' COMMENT '标签', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_tag_id` (`tag_id`) -) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文章标签映射'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `article_tag` --- - -LOCK TABLES `article_tag` WRITE; -/*!40000 ALTER TABLE `article_tag` DISABLE KEYS */; -INSERT INTO `article_tag` VALUES (1,3,1,0,'2022-08-06 11:52:13','2022-08-06 11:52:13'),(9,4,1,0,'2022-08-07 03:52:14','2022-08-07 03:52:14'); -/*!40000 ALTER TABLE `article_tag` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `category` --- - -DROP TABLE IF EXISTS `category`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `category` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `category_name` varchar(64) NOT NULL COMMENT '类目名称', - `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='类目管理表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `category` --- - -LOCK TABLES `category` WRITE; -/*!40000 ALTER TABLE `category` DISABLE KEYS */; -INSERT INTO `category` VALUES (1,'后端',1,0,'2022-07-20 06:58:20','2022-07-20 06:58:20'),(2,'前端',1,0,'2022-07-28 03:35:56','2022-07-28 03:35:56'),(3,'大数据',1,0,'2022-07-28 03:36:03','2022-07-28 03:36:03'),(4,'Android',1,0,'2022-07-28 03:36:19','2022-07-28 03:36:19'),(5,'IOS',1,0,'2022-07-28 03:36:24','2022-07-28 03:37:26'),(6,'人工智能',1,0,'2022-07-28 03:36:30','2022-07-28 03:37:26'),(7,'开发工具',1,0,'2022-07-28 03:36:33','2022-07-28 03:37:27'),(8,'代码人生',1,0,'2022-07-28 03:36:37','2022-07-28 03:37:27'),(9,'阅读',1,0,'2022-07-28 03:36:40','2022-07-28 03:37:27'); -/*!40000 ALTER TABLE `category` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `comment` --- - -DROP TABLE IF EXISTS `comment`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `comment` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `article_id` int unsigned NOT NULL COMMENT '文章ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `content` varchar(300) NOT NULL DEFAULT '' COMMENT '评论内容', - `parent_comment_id` int unsigned NOT NULL COMMENT '父评论ID', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_article_id` (`article_id`), - KEY `idx_user_id` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='评论表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `comment` --- - -LOCK TABLES `comment` WRITE; -/*!40000 ALTER TABLE `comment` DISABLE KEYS */; -/*!40000 ALTER TABLE `comment` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `tag` --- - -DROP TABLE IF EXISTS `tag`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `tag` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tag_name` varchar(120) NOT NULL COMMENT '标签名称', - `tag_type` tinyint NOT NULL DEFAULT '1' COMMENT '标签类型:1-系统标签,2-自定义标签', - `category_id` int unsigned NOT NULL DEFAULT '0' COMMENT '类目ID', - `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `idx_category_id` (`category_id`) -) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='标签管理表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `tag` --- - -LOCK TABLES `tag` WRITE; -/*!40000 ALTER TABLE `tag` DISABLE KEYS */; -INSERT INTO `tag` VALUES (1,'Java',1,1,0,0,'2022-07-20 07:03:56','2022-07-20 07:03:56'),(2,'Go',1,1,0,0,'2022-07-28 03:38:30','2022-07-28 03:38:30'),(3,'Python',1,1,0,0,'2022-07-28 03:38:36','2022-07-28 03:38:36'),(4,'Spring Boot',1,1,0,0,'2022-07-28 03:38:48','2022-07-28 03:38:48'),(5,'Spring',1,1,0,0,'2022-07-28 03:39:01','2022-07-28 03:39:01'),(6,'Redis',1,1,0,0,'2022-07-28 03:39:05','2022-07-28 03:39:05'),(7,'Linux',1,1,0,0,'2022-07-28 03:39:10','2022-07-28 03:39:10'),(8,'JavaScript',1,2,0,0,'2022-07-28 03:39:37','2022-07-28 03:39:37'),(9,'React.js',1,2,0,0,'2022-07-28 03:39:41','2022-07-28 03:39:41'),(10,'Vue.js',1,2,0,0,'2022-07-28 03:41:37','2022-07-28 03:41:37'),(11,'Angular.js',1,2,0,0,'2022-07-28 03:41:51','2022-07-28 03:41:51'),(12,'小程序',1,2,0,0,'2022-07-28 03:42:44','2022-07-28 03:42:44'); -/*!40000 ALTER TABLE `tag` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `user` --- - -DROP TABLE IF EXISTS `user`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `user` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `third_account_id` varchar(128) NOT NULL DEFAULT '' COMMENT '第三方用户ID', - `login_type` tinyint NOT NULL DEFAULT '0' COMMENT '登录方式: 0-微信登录,1-账号密码登录', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `key_third_account_id` (`third_account_id`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户登录表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `user` --- - -LOCK TABLES `user` WRITE; -/*!40000 ALTER TABLE `user` DISABLE KEYS */; -INSERT INTO `user` VALUES (1,'a7cb7228-0f85-4dd5-845c-7c5df3746e92',0,0,'2022-08-06 12:45:22','2022-08-06 12:45:22'); -/*!40000 ALTER TABLE `user` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `user_foot` --- - -DROP TABLE IF EXISTS `user_foot`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `user_foot` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `doucument_id` int unsigned NOT NULL COMMENT '文档ID(文章/评论)', - `doucument_type` tinyint NOT NULL DEFAULT '1' COMMENT '文档类型:1-文章,2-评论', - `doucument_user_id` int unsigned NOT NULL COMMENT '发布该文档的用户ID', - `comment_id` int unsigned NOT NULL DEFAULT '0' COMMENT '当前发起评论的ID', - `collection_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '收藏状态: 0-未收藏,1-已收藏,2-取消收藏', - `read_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '阅读状态: 0-未读,1-已读', - `comment_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '评论状态: 0-未评论,1-已评论,2-删除评论', - `praise_stat` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '点赞状态: 0-未点赞,1-已点赞,2-取消点赞', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `idx_user_doucument` (`user_id`,`doucument_id`,`doucument_type`,`comment_id`), - KEY `idx_doucument_id` (`doucument_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户足迹表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `user_foot` --- - -LOCK TABLES `user_foot` WRITE; -/*!40000 ALTER TABLE `user_foot` DISABLE KEYS */; -/*!40000 ALTER TABLE `user_foot` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `user_info` --- - -DROP TABLE IF EXISTS `user_info`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `user_info` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `user_name` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名', - `photo` varchar(128) NOT NULL DEFAULT '' COMMENT '用户图像', - `position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位', - `company` varchar(50) NOT NULL DEFAULT '' COMMENT '公司', - `profile` varchar(225) NOT NULL DEFAULT '' COMMENT '个人简介', - `extend` varchar(1024) NOT NULL DEFAULT '' COMMENT '扩展字段', - `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - KEY `key_user_id` (`user_id`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户个人信息表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `user_info` --- - -LOCK TABLES `user_info` WRITE; -/*!40000 ALTER TABLE `user_info` DISABLE KEYS */; -INSERT INTO `user_info` VALUES (1,1,'一灰灰','https://spring.hhui.top/spring-blog/css/images/avatar.jpg','java','xm','码农','',0,'2022-08-06 12:45:22','2022-08-06 12:45:22'); -/*!40000 ALTER TABLE `user_info` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `user_relation` --- - -DROP TABLE IF EXISTS `user_relation`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `user_relation` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` int unsigned NOT NULL COMMENT '用户ID', - `follow_user_id` int unsigned NOT NULL COMMENT '关注用户ID', - `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_user_follow` (`user_id`,`follow_user_id`), - KEY `key_follow_user_id` (`follow_user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户关系表'; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `user_relation` --- - -LOCK TABLES `user_relation` WRITE; -/*!40000 ALTER TABLE `user_relation` DISABLE KEYS */; -/*!40000 ALTER TABLE `user_relation` ENABLE KEYS */; -UNLOCK TABLES; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2022-08-07 11:59:37 diff --git a/forum-web/src/test/java/com/github/liueyueyi/forum/test/DemoTest.java b/forum-web/src/test/java/com/github/liueyueyi/forum/test/DemoTest.java deleted file mode 100644 index 7e3dc8650..000000000 --- a/forum-web/src/test/java/com/github/liueyueyi/forum/test/DemoTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.github.liueyueyi.forum.test; - -import org.apache.commons.lang3.tuple.ImmutablePair; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.BiConsumer; - -/** - * @author YiHui - * @date 2022/8/6 - */ -public class DemoTest { - - public static void scan(int maxX, int maxY, BiConsumer consumer) { - for (int i = 0; i < maxX; i++) { - for (int j = 0; j < maxY; j++) { - consumer.accept(i, j); - } - } - } - - public static T scanReturn(int x, int y, ScanProcess func) { - for (int i = 0; i < x; i++) { - for (int j = 0; j < y; j++) { - ImmutablePair ans = func.accept(i, j); - if (ans != null && ans.left) { - return ans.right; - } - } - } - return null; - } - - @FunctionalInterface - public interface ScanProcess { - ImmutablePair accept(int i, int j); - } - - public static T scanReturn(int x, int y, ScanFunc func) { - Ans ans = new Ans<>(); - for (int i = 0; i < x; i++) { - for (int j = 0; j < y; j++) { - func.accept(i, j, ans); - if (ans.tag) { - return ans.ans; - } - } - } - return null; - } - - public interface ScanFunc { - void accept(int i, int j, Ans ans); - } - - public static class Ans { - private T ans; - private boolean tag = false; - - public Ans setAns(T ans) { - tag = true; - this.ans = ans; - return this; - } - - public T getAns() { - return ans; - } - } - - @Test - public void testScan() { - int[][] cells = new int[][]{{1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24}}; - scan(cells.length, cells[0].length, (i, j) -> { - System.out.println(cells[i][j]); - }); - - String ans = scanReturn(cells.length, cells[0].length, (i, j) -> cells[i][j] % 2 == 0 ? - ImmutablePair.of(true, "\"index:\" " + i + " + \"_\" " + j + ";") : - null); - System.out.println(ans); - } - - @Test - public void getEven() { - int[][] cells = new int[][]{{1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24}}; - String ans = scanReturn(cells.length, cells[0].length, (i, j, a) -> { - if ((cells[i][j] & 1) == 0) { - a.setAns(i + "_" + j); - } - }); - System.out.println(ans); - } -} diff --git a/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/ArticleDaoTest.java b/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/ArticleDaoTest.java deleted file mode 100644 index 98c1e9ce7..000000000 --- a/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/ArticleDaoTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.github.liueyueyi.forum.test.dao; - -import com.github.liueyueyi.forum.api.model.vo.PageParam; -import com.github.liueyueyi.forum.api.model.vo.article.dto.ArticleListDTO; -import com.github.liueyueyi.forum.test.BasicTest; -import com.github.liuyueyi.forum.service.article.repository.dao.CategoryDao; -import com.github.liuyueyi.forum.service.article.repository.dao.TagDao; -import com.github.liuyueyi.forum.service.article.repository.entity.CategoryDO; -import com.github.liuyueyi.forum.service.article.repository.entity.TagDO; -import com.github.liuyueyi.forum.service.article.service.ArticleReadService; -import lombok.extern.slf4j.Slf4j; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; - -/** - * @author YiHui - * @date 2022/7/20 - */ -@Slf4j -public class ArticleDaoTest extends BasicTest { - - @Autowired - private TagDao tagDao; - - @Autowired - private CategoryDao categoryDao; - - @Autowired - private ArticleReadService articleService; - - @Test - public void testCategory() { - CategoryDO category = new CategoryDO(); - category.setCategoryName("后端"); - category.setStatus(1); - categoryDao.save(category); - log.info("save category:{} -> id:{}", category, category.getId()); - } - - @Test - public void testTag() { - TagDO tag = new TagDO(); - tag.setTagName("Java"); - tag.setTagType(1); - tag.setCategoryId(1L); - tagDao.save(tag); - log.info("tagId: {}", tag.getId()); - } - - @Test - public void testArticle() { - ArticleListDTO articleListDTO = articleService.queryArticlesByCategory(1L, PageParam.newPageInstance(1L, 10L)); - log.info("articleListDTO: {}", articleListDTO); - } - -} diff --git a/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/UserDaoTest.java b/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/UserDaoTest.java deleted file mode 100644 index c5cf7ac31..000000000 --- a/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/UserDaoTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.liueyueyi.forum.test.dao; - -import com.github.liueyueyi.forum.test.BasicTest; -import com.github.liuyueyi.forum.service.user.service.UserService; -import com.github.liueyueyi.forum.api.model.vo.user.dto.UserStatisticInfoDTO; -import lombok.extern.slf4j.Slf4j; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; - -/** - * @author YiHui - * @date 2022/7/20 - */ -@Slf4j -public class UserDaoTest extends BasicTest { - - @Autowired - private UserService userService; - - @Test - public void testUserHome() throws Exception { - UserStatisticInfoDTO userHomeDTO = userService.queryUserInfoWithStatistic(1L); - log.info("query userPageDTO: {}", userHomeDTO); - } -} diff --git a/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/UserRelationDaoTest.java b/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/UserRelationDaoTest.java deleted file mode 100644 index ef456bfa5..000000000 --- a/forum-web/src/test/java/com/github/liueyueyi/forum/test/dao/UserRelationDaoTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.liueyueyi.forum.test.dao; - -import com.github.liueyueyi.forum.api.model.enums.FollowStateEnum; -import com.github.liueyueyi.forum.api.model.vo.PageParam; -import com.github.liueyueyi.forum.api.model.vo.user.UserRelationReq; -import com.github.liueyueyi.forum.test.BasicTest; -import com.github.liueyueyi.forum.api.model.vo.comment.dto.UserFollowListDTO; -import com.github.liuyueyi.forum.service.user.service.UserRelationService; -import com.github.liuyueyi.forum.service.user.service.UserService; -import lombok.extern.slf4j.Slf4j; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; - -/** - * @author YiHui - * @date 2022/7/20 - */ -@Slf4j -public class UserRelationDaoTest extends BasicTest { - - @Autowired - private UserRelationService userRelationService; - - @Autowired - private UserService userService; - - @Test - public void saveUserRelation() throws Exception { - -// UserRelationReq req1 = new UserRelationReq(); -// req1.setUserId(1L); -// req1.setFollowUserId(2L); -// req1.setFollowState(FollowStateEnum.FOLLOW.getCode()); -// userRelationService.saveUserRelation(req1); -// -// UserRelationReq req2 = new UserRelationReq(); -// req2.setUserId(1L); -// req2.setFollowUserId(3L); -// req2.setFollowState(FollowStateEnum.FOLLOW.getCode()); -// userRelationService.saveUserRelation(req2); -// -// UserRelationReq req3 = new UserRelationReq(); -// req3.setUserId(2L); -// req3.setFollowUserId(1L); -// req3.setFollowState(FollowStateEnum.FOLLOW.getCode()); -// userRelationService.saveUserRelation(req3); - } - - @Test - public void testCancelUserRelation() throws Exception { -// UserRelationReq req = new UserRelationReq(); -// req.setUserRelationId(7L); -// req.setFollowState(FollowStateEnum.CANCEL_FOLLOW.getCode()); -// userRelationService.saveUserRelation(req); - } - - @Test - public void testUserRelation() { - UserFollowListDTO userFollowListDTO = userRelationService.getUserFollowList(1L, PageParam.newPageInstance(1L, 10L)); - log.info("query userFollowDTOS: {}", userFollowListDTO); - - UserFollowListDTO userFansListDTO = userRelationService.getUserFansList(1L, PageParam.newPageInstance(1L, 10L)); - log.info("query userFansList: {}", userFansListDTO); - } -} diff --git a/forum-web/src/test/java/com/github/liueyueyi/forum/test/user/UserServiceTest.java b/forum-web/src/test/java/com/github/liueyueyi/forum/test/user/UserServiceTest.java deleted file mode 100644 index 723b65b6c..000000000 --- a/forum-web/src/test/java/com/github/liueyueyi/forum/test/user/UserServiceTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.liueyueyi.forum.test.user; - -import com.github.liueyueyi.forum.api.model.vo.user.UserInfoSaveReq; -import com.github.liueyueyi.forum.api.model.vo.user.UserSaveReq; -import com.github.liueyueyi.forum.test.BasicTest; -import com.github.liuyueyi.forum.service.user.service.UserService; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.util.UUID; - -/** - * @author YiHui - * @date 2022/8/6 - */ -public class UserServiceTest extends BasicTest { - - @Autowired - private UserService userService; - - /** - * 注册一个用户 - */ - @Test - public void testRegister() { - UserSaveReq req = new UserSaveReq(); - req.setThirdAccountId(UUID.randomUUID().toString()); - req.setLoginType(0); - userService.registerOrGetUserInfo(req); - long userId = req.getUserId(); - - UserInfoSaveReq save = new UserInfoSaveReq(); - save.setUserId(userId); - save.setUserName("一灰灰"); - save.setPhoto("https://spring.hhui.top/spring-blog/css/images/avatar.jpg"); - save.setCompany("xm"); - save.setPosition("java"); - save.setProfile("码农"); - userService.saveUserInfo(save); - } - -} diff --git a/forum-web/src/test/resources/logback-spring.xml b/forum-web/src/test/resources/logback-spring.xml deleted file mode 100644 index 93399c686..000000000 --- a/forum-web/src/test/resources/logback-spring.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - %d [%t] %-5level %logger{36}.%M\(%file:%line\) - %msg%n - - UTF-8 - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/guide.html b/guide.html new file mode 100644 index 000000000..a56e74332 --- /dev/null +++ b/guide.html @@ -0,0 +1,371 @@ + + + + + + 高铁之旅 · G3198站点指南 + + + + + + + + + + + + + + +

+

G3198 次列车站点指南

+

Explore the journey • 精选沿途城市景点与美食

+
+ +
+
+ +
+ 西安北 +

西安北

+

Xian North Station

+
    +
  • + + 兵马俑 - Terracotta Warriors and Horses +
  • +
  • + + 大雁塔 - Giant Wild Goose Pagoda +
  • +
  • + + 肉夹馍 - Chinese Hamburger +
  • +
  • + + 凉皮 - Spicy Cold Noodles +
  • +
+
+ + +
+ 三门峡南 +

三门峡南

+

Sanmenxia South Station

+
    +
  • + + 黄河三峡 - Three Gorges of Yellow River +
  • +
  • + + 仰韶文化遗址 - Yangshao Culture Ruins +
  • +
  • + + 卢氏烧饼 - Lushi Sesame Cake +
  • +
  • + + 灵宝肉夹馍 - Lingbao Meat Burger +
  • +
+
+ + +
+ 洛阳龙门 +

洛阳龙门

+

Luoyang Longmen Station

+
    +
  • + + 龙门石窟 - Longmen Grottoes +
  • +
  • + + 白马寺 - White Horse Temple +
  • +
  • + + 洛阳水席 - Luoyang Water Banquet +
  • +
  • + + 不翻汤 - Bufen Tang (No-flip Soup) +
  • +
+
+ + +
+ 郑州东 +

郑州东

+

Zhengzhou East Station

+
    +
  • + + 少林寺 - Shaolin Temple +
  • +
  • + + 嵩山 - Songshan Mountain +
  • +
  • + + 烩面 - Zhengzhou Braised Noodles +
  • +
  • + + 胡辣汤 - Hula Soup with Spices +
  • +
+
+ + +
+ 商丘 +

商丘

+

Shangqiu Station

+
    +
  • + + 商丘古城 - Shangqiu Ancient City +
  • +
  • + + 火神台 - Fire God Platform +
  • +
  • + + 哨子汤 - Whistle Soup +
  • +
  • + + 葱油饼 - Scallion Oil Pancake +
  • +
+
+ + +
+ 亳州南 +

亳州南

+

Bozhou South Station

+
    +
  • + + 花戏楼 - Hua Xilou Opera Stage +
  • +
  • + + 南京巷钱庄 - Nanjing Alley Money Shop +
  • +
  • + + 曹操鸡 - Cao Cao Chicken +
  • +
  • + + 牛肉馍 - Beef-filled Flatbread +
  • +
+
+ + +
+ 阜阳西 +

阜阳西

+

Fuyang West Station

+
    +
  • + + 八里河风景区 - Balihetourist Area +
  • +
  • + + 文峰塔 - Wenfeng Pagoda +
  • +
  • + + 卷馍 - Juan Mo (Flatbread Roll) +
  • +
  • + + 格拉条 - Gelatiao (Local Noodles) +
  • +
+
+ + +
+ 合肥南 +

合肥南

+

Hefei South Station

+
    +
  • + + 包公园 - Bao Park +
  • +
  • + + 安徽名人馆 - Anhui Celebrity Museum +
  • +
  • + + 小龙虾 - Hefei Crayfish +
  • +
  • + + 淮南牛肉汤 - Huainan Beef Soup +
  • +
+
+ + +
+ 芜湖 +

芜湖

+

Wuhu Station

+
    +
  • + + 方特欢乐世界 - Fantawild Amusement World +
  • +
  • + + 镜湖公园 - Jinghu Park +
  • +
  • + + 虾籽面 - Shrimp Roe Noodles +
  • +
  • + + 小笼汤包 - Xiaolongtangbao (Soup Dumplings) +
  • +
+
+ + +
+ 宣城 +

宣城

+

Xuancheng Station

+
    +
  • + + 敬亭山 - Jingting Mountain +
  • +
  • + + 宣纸文化园 - Xuan Paper Cultural Park +
  • +
  • + + 水阳三腊 - Shuiyang Three-cured Delicacy +
  • +
  • + + 宁国粑粑 - Ningguo Rice Cake +
  • +
+
+ + +
+ 杭州西 +

杭州西

+

Hangzhou West Station

+
    +
  • + + 西湖 - West Lake +
  • +
  • + + 千岛湖 - Qiandaohu Lake +
  • +
  • + + 西湖醋鱼 - West Lake Vinegar Fish +
  • +
  • + + 龙井虾仁 - Dragon Well Prawn +
  • +
+
+
+
+ +
+

© 2025 G3198 Travel Guide | Built with ❤️ using TailwindCSS & Framer Motion

+
+ + + + \ No newline at end of file diff --git a/image.md b/image.md new file mode 100644 index 000000000..db77b56e5 --- /dev/null +++ b/image.md @@ -0,0 +1,22 @@ +![891723526443_.pic.jpg](https://cdn.tobebetterjavaer.com/paicoding/image-fdae281151294c18a14563846393465d.jpg) + + +![itwanger-zsxq-small.png](https://cdn.tobebetterjavaer.com/paicoding/image-f709477e8bbe44dcb10b13770afaff9d.png) + +![zijie-kouzi-fabu.gif](https://cdn.tobebetterjavaer.com/paicoding/image-727b20dd2440447e915f56d81d206466.gif) + +![xiaochengxu.gif](https://cdn.tobebetterjavaer.com/paicoding/image-85ab47515aa94ab99e4a7508b570140b.gif) + +![](https://cdn.tobebetterjavaer.com/paicoding/image-405945c532084351bcdfaf1dba23fb0d.png) + +![二哥大号微信](https://cdn.tobebetterjavaer.com/paicoding/image-c043ce76c05f421fb8422fb6a704740d.png) + +![二哥的狗腿子](https://cdn.tobebetterjavaer.com/paicoding/image-d81385eed032418a914370563903c7d4.png) + +![qrcode_paismart.jpg](https://cdn.tobebetterjavaer.com/paicoding/image-92f8f6ac757e4c2f88110280a7eb6247.jpg) + +![面渣逆袭 PDF](https://cdn.tobebetterjavaer.com/paicoding/image-9feebceb9eeb4076b21dc5cf5caa734b.png) + +![itwanger_cmower.jpg](https://cdn.tobebetterjavaer.com/paicoding/image-a3b05190f61e4fd0b376489336e31c14.jpg) + +![xunfei-mapijing.gif](https://cdn.tobebetterjavaer.com/paicoding/image-59a40cc5ab474c2b87ffe1a98861e39c.gif) diff --git a/launch.sh b/launch.sh old mode 100644 new mode 100755 index 4cf1f38f3..13b13284f --- a/launch.sh +++ b/launch.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -WEB_PATH="forum-web" -JAR_NAME="forum-web-0.0.1-SNAPSHOT.jar" +WEB_PATH="paicoding-web" +JAR_NAME="paicoding-web-0.0.1-SNAPSHOT.jar" # 部署 function start() { @@ -9,7 +9,7 @@ function start() { # 杀掉之前的进程 cat pid.log| xargs -I {} kill {} - mv ${JAR_NAME} ${JAR_NAME}_bk + mv ${JAR_NAME} ${JAR_NAME}.bak mvn clean install -Dmaven.test.skip=True -Pprod cd ${WEB_PATH} @@ -17,11 +17,7 @@ function start() { cd - mv ${WEB_PATH}/target/${JAR_NAME} ./ - echo "启动脚本:===========" - echo "nohup java -server -Xms512m -Xmx512m -Xmn512m -XX:NativeMemoryTracking=detail -XX:-OmitStackTraceInFastThrow -jar ${JAR_NAME} > /dev/null 2>&1 &" - echo "===========" - nohup java -server -Xms512m -Xmx512m -Xmn512m -XX:NativeMemoryTracking=detail -XX:-OmitStackTraceInFastThrow -jar ${JAR_NAME} > /dev/null 2>&1 & - echo $! 1> pid.log + run } # 重启 @@ -29,11 +25,16 @@ function restart() { # 杀掉之前的进程 cat pid.log| xargs -I {} kill {} # 重新启动 - echo "启动脚本:===========" - echo "nohup java -server -Xms512m -Xmx512m -Xmn512m -XX:NativeMemoryTracking=detail -XX:-OmitStackTraceInFastThrow -jar ${JAR_NAME} > /dev/null 2>&1 &" - echo "===========" - nohup java -server -Xmn512m -Xmn512m -Xmn512m -XX:NativeMemoryTracking=detail -XX:-OmitStackTraceInFastThrow -jar ${JAR_NAME} > /dev/null 2>&1 & - echo $! 1> pid.log + run +} + +function run() { + echo "启动脚本:===========" + echo "nohup java -server -Xms1g -Xmx1g -Xmn512m -XX:NativeMemoryTracking=detail -XX:-OmitStackTraceInFastThrow -jar ${JAR_NAME} > /dev/null 2>&1 &" + echo "===========" + # ms 堆大小 mx 最大堆大小 mn 新生代大小 + nohup java -server -Dspring.devtools.restart.enabled=false -Xms1g -Xmx1g -Xmn256m -XX:NativeMemoryTracking=detail -XX:-OmitStackTraceInFastThrow -jar ${JAR_NAME} > /dev/null 2>&1 & + echo $! 1> pid.log } if [ $# == 0 ]; then diff --git a/list.ser b/list.ser new file mode 100644 index 000000000..951a99779 Binary files /dev/null and b/list.ser differ diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/paicoding-api/pom.xml b/paicoding-api/pom.xml new file mode 100644 index 000000000..cff7c0265 --- /dev/null +++ b/paicoding-api/pom.xml @@ -0,0 +1,50 @@ + + + + paicoding-forum + com.github.paicoding.forum + 0.0.1-SNAPSHOT + + 4.0.0 + + paicoding-api + + + 8 + 8 + + + + + org.projectlombok + lombok + + + + com.baomidou + mybatis-plus-boot-starter + provided + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + provided + + + + com.github.xiaoymin + knife4j-openapi2-spring-boot-starter + provided + + + com.alibaba + transmittable-thread-local + 2.14.5 + provided + + + + \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/context/ReqInfoContext.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/context/ReqInfoContext.java new file mode 100644 index 000000000..ff720f2de --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/context/ReqInfoContext.java @@ -0,0 +1,94 @@ +package com.github.paicoding.forum.api.model.context; + +import com.alibaba.ttl.TransmittableThreadLocal; +import com.github.paicoding.forum.api.model.vo.seo.Seo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import lombok.Data; + +import java.security.Principal; + +/** + * 请求上下文,携带用户身份相关信息 + * + * @author YiHui + * @date 2022/7/6 + */ +public class ReqInfoContext { + private static TransmittableThreadLocal contexts = new TransmittableThreadLocal<>(); + + public static void addReqInfo(ReqInfo reqInfo) { + contexts.set(reqInfo); + } + + public static void clear() { + contexts.remove(); + } + + public static ReqInfo getReqInfo() { + return contexts.get(); + } + + @Data + public static class ReqInfo implements Principal { + /** + * appKey + */ + private String appKey; + /** + * 访问的域名 + */ + private String host; + /** + * 访问路径 + */ + private String path; + /** + * 客户端ip + */ + private String clientIp; + /** + * referer + */ + private String referer; + /** + * post 表单参数 + */ + private String payload; + /** + * 设备信息 + */ + private String userAgent; + + /** + * 登录的会话 + */ + private String session; + + /** + * 用户id + */ + private Long userId; + /** + * 用户信息 + */ + private BaseUserInfoDTO user; + /** + * 消息数量 + */ + private Integer msgNum; + + private Seo seo; + + private String deviceId; + + /** + * 当前聊天的会话id + */ + private String chatId; + + @Override + public String getName() { + return session; + } + } +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/entity/BaseDO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/entity/BaseDO.java similarity index 86% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/entity/BaseDO.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/entity/BaseDO.java index 7197675c3..e6b7fd768 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/entity/BaseDO.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/entity/BaseDO.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.entity; +package com.github.paicoding.forum.api.model.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/entity/BaseDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/entity/BaseDTO.java new file mode 100644 index 000000000..894c77f62 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/entity/BaseDTO.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.api.model.entity; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.Date; + +@Data +public class BaseDTO { + @ApiModelProperty(value = "业务主键") + private Long id; + + @ApiModelProperty(value = "创建时间") + private Date createTime; + + @ApiModelProperty(value = "最后编辑时间") + private Date updateTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleEventEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleEventEnum.java new file mode 100644 index 000000000..de08b75f8 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleEventEnum.java @@ -0,0 +1,48 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +/** + * 文章操作枚举 + * + * @author YiHui + * @date 2022/9/3 + */ +@Getter +public enum ArticleEventEnum { + CREATE(1, "创建"), + ONLINE(2, "发布"), + REVIEW(3, "审核"), + DELETE(4, "删除"), + OFFLINE(5, "下线"), + ; + + + private int type; + private String msg; + + private static Map mapper; + + static { + mapper = new HashMap<>(); + for (ArticleEventEnum type : values()) { + mapper.put(type.type, type); + } + } + + ArticleEventEnum(int type, String msg) { + this.type = type; + this.msg = msg; + } + + public static ArticleEventEnum typeOf(int type) { + return mapper.get(type); + } + + public static ArticleEventEnum typeOf(String type) { + return valueOf(type.toUpperCase().trim()); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleReadTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleReadTypeEnum.java new file mode 100644 index 000000000..4e8944254 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleReadTypeEnum.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +import java.util.Objects; + +/** + * 文章阅读类型枚举 + * + * @author YiHui + * @date 2024/10/29 + */ +@Getter +public enum ArticleReadTypeEnum { + NORMAL(0, "直接阅读"), + LOGIN(1, "登录阅读"), + TIME_READ(2, "限时阅读"), + STAR_READ(3, "星球阅读"), + PAY_READ(4, "付费阅读"), + ; + + private Integer type; + + private String desc; + + ArticleReadTypeEnum(Integer type, String desc) { + this.type = type; + this.desc = desc; + } + + public static ArticleReadTypeEnum typeOf(Integer type) { + for (ArticleReadTypeEnum t : values()) { + if (Objects.equals(type, t.type)) { + return t; + } + } + return null; + } +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/ArticleTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleTypeEnum.java similarity index 84% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/ArticleTypeEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleTypeEnum.java index a6dab4163..cec9fa920 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/ArticleTypeEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ArticleTypeEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; @@ -13,7 +13,9 @@ public enum ArticleTypeEnum { EMPTY(0, ""), BLOG(1, "博文"), - ANSWER(2, "问答"); + ANSWER(2, "问答"), + COLUMN(3, "专栏文章"), + ; ArticleTypeEnum(Integer code, String desc) { this.code = code; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ChatAnswerTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ChatAnswerTypeEnum.java new file mode 100644 index 000000000..042a034b6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ChatAnswerTypeEnum.java @@ -0,0 +1,42 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +@Getter +public enum ChatAnswerTypeEnum { + // 纯文本 + TEXT(0, "TEXT"), + // JSON, + JSON(1, "JSON"), + /** + * 流式返回 + */ + STREAM(2, "STREAM"), + /** + * 流式结束 + */ + STREAM_END(3, "STREAM_END") + ; + + private Integer code; + private String desc; + + + ChatAnswerTypeEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + public static ChatAnswerTypeEnum typeOf(int type) { + for (ChatAnswerTypeEnum value : ChatAnswerTypeEnum.values()) { + if (value.code.equals(type)) { + return value; + } + } + return null; + } + + public static ChatAnswerTypeEnum typeOf(String type) { + return valueOf(type.toUpperCase().trim()); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ChatSocketStateEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ChatSocketStateEnum.java new file mode 100644 index 000000000..a32b3d23c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ChatSocketStateEnum.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/7/23 + */ +@Getter +public enum ChatSocketStateEnum { + // code desc + // 连接成功 + Established(0, "Established"), + // payload 消息 + Payload(1, "Payload"), + // Closed 关闭 + Closed(2, "Closed"), + ; + + private final Integer code; + private final String desc; + + ChatSocketStateEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + public static ChatSocketStateEnum typeOf(int type) { + for (ChatSocketStateEnum value : ChatSocketStateEnum.values()) { + if (value.code.equals(type)) { + return value; + } + } + return null; + } + + public static ChatSocketStateEnum typeOf(String type) { + return valueOf(type.toUpperCase().trim()); + } + + +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/CollectionStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CollectionStatEnum.java similarity index 93% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/CollectionStatEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CollectionStatEnum.java index 55de52b92..3df2048d4 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/CollectionStatEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CollectionStatEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/CommentStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CommentStatEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/CommentStatEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CommentStatEnum.java index 78349ef4e..be95969d5 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/CommentStatEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CommentStatEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ConfigTagEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ConfigTagEnum.java new file mode 100644 index 000000000..bca660c3a --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ConfigTagEnum.java @@ -0,0 +1,36 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 配置类型枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum ConfigTagEnum { + + EMPTY(0, ""), + HOT(1, "热门"), + OFFICAL(2, "官方"), + COMMENT(3, "推荐"), + ; + + ConfigTagEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static ConfigTagEnum formCode(Integer code) { + for (ConfigTagEnum value : ConfigTagEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return ConfigTagEnum.EMPTY; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ConfigTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ConfigTypeEnum.java new file mode 100644 index 000000000..418a8056a --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ConfigTypeEnum.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 配置类型枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum ConfigTypeEnum { + + EMPTY(0, ""), + HOME_PAGE(1, "首页Banner"), + SIDE_PAGE(2, "侧边Banner"), + ADVERTISEMENT(3, "广告Banner"), + NOTICE(4, "公告"), + COLUMN(5, "教程"), + PDF(6, "电子书"); + + ConfigTypeEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static ConfigTypeEnum formCode(Integer code) { + for (ConfigTypeEnum value : ConfigTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return ConfigTypeEnum.EMPTY; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CreamStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CreamStatEnum.java new file mode 100644 index 000000000..eae36ab99 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/CreamStatEnum.java @@ -0,0 +1,33 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 加精状态枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum CreamStatEnum { + + NOT_CREAM(0, "不加精"), + CREAM(1, "加精"); + + CreamStatEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static CreamStatEnum formCode(Integer code) { + for (CreamStatEnum value : CreamStatEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return CreamStatEnum.NOT_CREAM; + } +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/DocumentTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/DocumentTypeEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/DocumentTypeEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/DocumentTypeEnum.java index bae850dd5..066091e0f 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/DocumentTypeEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/DocumentTypeEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowSelectEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowSelectEnum.java similarity index 81% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowSelectEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowSelectEnum.java index eb4ca02da..bc7b5bf3a 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowSelectEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowSelectEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; @@ -11,8 +11,8 @@ @Getter public enum FollowSelectEnum { - FOLLOW("follow", "我关注的用户"), - FANS("fans", "关注我的粉丝"); + FOLLOW("follow", "关注列表"), + FANS("fans", "粉丝列表"); FollowSelectEnum(String code, String desc) { this.code = code; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowStateEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowStateEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowStateEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowStateEnum.java index 7a08d8295..56fde96c5 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowStateEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowStateEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowTypeEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowTypeEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowTypeEnum.java index 094141518..25b5343f3 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/FollowTypeEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/FollowTypeEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/HomeSelectEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/HomeSelectEnum.java similarity index 93% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/HomeSelectEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/HomeSelectEnum.java index 722f18b03..50cb276ce 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/HomeSelectEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/HomeSelectEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/NotifyStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/NotifyStatEnum.java new file mode 100644 index 000000000..acfb1be8f --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/NotifyStatEnum.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Getter +public enum NotifyStatEnum { + UNREAD(0, "未读"), + READ(1, "已读"); + + + private int stat; + private String msg; + + NotifyStatEnum(int type, String msg) { + this.stat = type; + this.msg = msg; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/NotifyTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/NotifyTypeEnum.java new file mode 100644 index 000000000..603024370 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/NotifyTypeEnum.java @@ -0,0 +1,63 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Getter +public enum NotifyTypeEnum { + COMMENT(1, "评论"), + REPLY(2, "回复"), + PRAISE(3, "点赞"), + COLLECT(4, "收藏"), + FOLLOW(5, "关注消息"), + SYSTEM(6, "系统消息"), + DELETE_COMMENT(1, "删除评论"), + DELETE_REPLY(2, "删除回复"), + CANCEL_PRAISE(3, "取消点赞"), + CANCEL_COLLECT(4, "取消收藏"), + CANCEL_FOLLOW(5, "取消关注"), + + // 注册、登录添加系统相关提示消息 + REGISTER(6, "用户注册"), + BIND(6, "绑定星球"), + LOGIN(6, "用户登录"), + + PAYING(6, "支付中通知"), + PAY(6, "支付结果通知"), + ; + + + /** + * 表示消息类型: 1-6 对应的时评论/回复/点赞/关注消息/系统消息等 + */ + private int type; + private String msg; + + private static Map mapper; + + static { + mapper = new HashMap<>(); + for (NotifyTypeEnum type : values()) { + mapper.put(type.type, type); + } + } + + NotifyTypeEnum(int type, String msg) { + this.type = type; + this.msg = msg; + } + + public static NotifyTypeEnum typeOf(int type) { + return mapper.get(type); + } + + public static NotifyTypeEnum typeOf(String type) { + return valueOf(type.toUpperCase().trim()); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OfficalStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OfficalStatEnum.java new file mode 100644 index 000000000..11a3d6d9b --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OfficalStatEnum.java @@ -0,0 +1,33 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 官方状态枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum OfficalStatEnum { + + NOT_OFFICAL(0, "非官方"), + OFFICAL(1, "官方"); + + OfficalStatEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static OfficalStatEnum formCode(Integer code) { + for (OfficalStatEnum value : OfficalStatEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return OfficalStatEnum.NOT_OFFICAL; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OperateArticleEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OperateArticleEnum.java new file mode 100644 index 000000000..f7490ff7c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OperateArticleEnum.java @@ -0,0 +1,75 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 操作文章 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum OperateArticleEnum { + + EMPTY(0, "") { + @Override + public int getDbStatCode() { + return 0; + } + }, + OFFICAL(1, "官方") { + @Override + public int getDbStatCode() { + return OfficalStatEnum.OFFICAL.getCode(); + } + }, + CANCEL_OFFICAL(2, "非官方"){ + @Override + public int getDbStatCode() { + return OfficalStatEnum.NOT_OFFICAL.getCode(); + } + }, + TOPPING(3, "置顶"){ + @Override + public int getDbStatCode() { + return ToppingStatEnum.TOPPING.getCode(); + } + }, + CANCEL_TOPPING(4, "不置顶"){ + @Override + public int getDbStatCode() { + return ToppingStatEnum.NOT_TOPPING.getCode(); + } + }, + CREAM(5, "加精"){ + @Override + public int getDbStatCode() { + return CreamStatEnum.CREAM.getCode(); + } + }, + CANCEL_CREAM(6, "不加精"){ + @Override + public int getDbStatCode() { + return CreamStatEnum.NOT_CREAM.getCode(); + } + }; + + OperateArticleEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static OperateArticleEnum fromCode(Integer code) { + for (OperateArticleEnum value : OperateArticleEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return OperateArticleEnum.OFFICAL; + } + + public abstract int getDbStatCode(); +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OperateTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OperateTypeEnum.java new file mode 100644 index 000000000..503d2e1c8 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/OperateTypeEnum.java @@ -0,0 +1,107 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 操作类型 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum OperateTypeEnum { + + EMPTY(0, "") { + @Override + public int getDbStatCode() { + return 0; + } + }, + READ(1, "阅读") { + @Override + public int getDbStatCode() { + return ReadStatEnum.READ.getCode(); + } + }, + PRAISE(2, "点赞") { + @Override + public int getDbStatCode() { + return PraiseStatEnum.PRAISE.getCode(); + } + }, + COLLECTION(3, "收藏") { + @Override + public int getDbStatCode() { + return CollectionStatEnum.COLLECTION.getCode(); + } + }, + CANCEL_PRAISE(4, "取消点赞") { + @Override + public int getDbStatCode() { + return PraiseStatEnum.CANCEL_PRAISE.getCode(); + } + }, + CANCEL_COLLECTION(5, "取消收藏") { + @Override + public int getDbStatCode() { + return CollectionStatEnum.CANCEL_COLLECTION.getCode(); + } + }, + COMMENT(6, "评论") { + @Override + public int getDbStatCode() { + return CommentStatEnum.COMMENT.getCode(); + } + }, + DELETE_COMMENT(7, "删除评论") { + @Override + public int getDbStatCode() { + return CommentStatEnum.DELETE_COMMENT.getCode(); + } + }, + ; + + OperateTypeEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static OperateTypeEnum fromCode(Integer code) { + for (OperateTypeEnum value : OperateTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return OperateTypeEnum.EMPTY; + } + + public abstract int getDbStatCode(); + + /** + * 判断操作的是否是文章 + * + * @param type + * @return true 表示文章的相关操作 false 表示评论的相关文章 + */ + public static DocumentTypeEnum getOperateDocumentType(OperateTypeEnum type) { + return (type == COMMENT || type == DELETE_COMMENT) ? DocumentTypeEnum.COMMENT : DocumentTypeEnum.ARTICLE; + } + + public static NotifyTypeEnum getNotifyType(OperateTypeEnum type) { + switch (type) { + case PRAISE: + return NotifyTypeEnum.PRAISE; + case CANCEL_PRAISE: + return NotifyTypeEnum.CANCEL_PRAISE; + case COLLECTION: + return NotifyTypeEnum.COLLECT; + case CANCEL_COLLECTION: + return NotifyTypeEnum.CANCEL_COLLECT; + default: + return null; + } + } +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/PraiseStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/PraiseStatEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/PraiseStatEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/PraiseStatEnum.java index f32377cf3..b84e657d0 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/PraiseStatEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/PraiseStatEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/PushStatusEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/PushStatusEnum.java similarity index 85% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/PushStatusEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/PushStatusEnum.java index 417e18017..e10c15250 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/PushStatusEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/PushStatusEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; @@ -12,7 +12,8 @@ public enum PushStatusEnum { OFFLINE(0, "未发布"), - ONLINE(1,"已发布"); + ONLINE(1,"已发布"), + REVIEW(2, "审核"); PushStatusEnum(int code, String desc) { this.code = code; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/ReadStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ReadStatEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/ReadStatEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ReadStatEnum.java index d8214568e..a029920e8 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/ReadStatEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ReadStatEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/RoleEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/RoleEnum.java new file mode 100644 index 000000000..394b4c26c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/RoleEnum.java @@ -0,0 +1,33 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +import java.util.Objects; + +/** + * @author YiHui + * @date 2023/1/31 + */ +public enum RoleEnum { + NORMAL(0, "普通用户"), + ADMIN(1, "超级用户"), + ; + + @Getter + private int role; + @Getter + private String desc; + + RoleEnum(int role, String desc) { + this.role = role; + this.desc = desc; + } + + public static String role(Integer roleId) { + if (Objects.equals(roleId, 1)) { + return ADMIN.name(); + } else { + return NORMAL.name(); + } + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/SidebarStyleEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/SidebarStyleEnum.java new file mode 100644 index 000000000..ae205b9c3 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/SidebarStyleEnum.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * @author YiHui + * @date 2022/9/6 + */ +@Getter +public enum SidebarStyleEnum { + + NOTICE(1), + ARTICLES(2), + RECOMMEND(3), + ABOUT(4), + COLUMN(5), + PDF(6), + SUBSCRIBE(7), + /** + * 活跃排行榜 + */ + ACTIVITY_RANK(8); + + private int style; + + SidebarStyleEnum(int style) { + this.style = style; + } +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/SourceTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/SourceTypeEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/SourceTypeEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/SourceTypeEnum.java index 6edfd5e77..db6112482 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/SourceTypeEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/SourceTypeEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/TagTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/TagTypeEnum.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/TagTypeEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/TagTypeEnum.java index bd0e78a39..7f2d40549 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/TagTypeEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/TagTypeEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ToppingStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ToppingStatEnum.java new file mode 100644 index 000000000..343a758ef --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ToppingStatEnum.java @@ -0,0 +1,33 @@ +package com.github.paicoding.forum.api.model.enums; + +import lombok.Getter; + +/** + * 置顶状态枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum ToppingStatEnum { + + NOT_TOPPING(0, "不置顶"), + TOPPING(1, "置顶"); + + ToppingStatEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static ToppingStatEnum formCode(Integer code) { + for (ToppingStatEnum value : ToppingStatEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return ToppingStatEnum.NOT_TOPPING; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/WsConnectStateEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/WsConnectStateEnum.java new file mode 100644 index 000000000..5063e026e --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/WsConnectStateEnum.java @@ -0,0 +1,21 @@ +package com.github.paicoding.forum.api.model.enums; + +/** + * websocket 连接 状态 + * + * @author YiHui + * @date 2023/6/12 + */ +public enum WsConnectStateEnum { + // 初始化 + INIT, + // 连接中 + CONNECTING, + // 已连接 + CONNECTED, + // 连接失败 + FAILED, + // 已关闭 + CLOSED, + ; +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/YesOrNoEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/YesOrNoEnum.java similarity index 96% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/YesOrNoEnum.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/YesOrNoEnum.java index c600f2d0b..876405a6f 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/enums/YesOrNoEnum.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/YesOrNoEnum.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.enums; +package com.github.paicoding.forum.api.model.enums; import lombok.Getter; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AISourceEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AISourceEnum.java new file mode 100644 index 000000000..aa4fd9d81 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AISourceEnum.java @@ -0,0 +1,82 @@ +package com.github.paicoding.forum.api.model.enums.ai; + +import lombok.Getter; + +/** + * @author YiHui + * @date 2023/6/9 + */ +@Getter +public enum AISourceEnum { + /** + * chatgpt 3.5 + */ + CHAT_GPT_3_5(0, "chatGpt3.5"), + /** + * chatgpt 4 + */ + CHAT_GPT_4(1, "chatGpt4"), + /** + * 技术派的模拟AI + */ + PAI_AI(2, "技术派"), + /** + * 讯飞 + */ + XUN_FEI_AI(3,"讯飞") { + @Override + public boolean syncSupport() { + return false; + } + }, + /** + * 智谱 AI + */ + ZHI_PU_AI(4, "智谱") { + @Override + public boolean asyncSupport() { + return true; + } + }, + /** + * 智谱 AI + */ + ALI_AI(5, "阿里"), + + /** + * 深度求索 AI + */ + DEEP_SEEK(6, "DeepSeek"), + /** + * 豆包 AI + */ + DOU_BAO_AI(7, "豆包") + ; + + + private String name; + private Integer code; + + AISourceEnum(Integer code, String name) { + this.code = code; + this.name = name; + } + + /** + * 是否支持同步 + * + * @return + */ + public boolean syncSupport() { + return true; + } + + /** + * 是否支持异步 + * + * @return + */ + public boolean asyncSupport() { + return true; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AiBotEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AiBotEnum.java new file mode 100644 index 000000000..5d67fcb78 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AiBotEnum.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.api.model.enums.ai; + +import lombok.Getter; + +/** + * @author YiHui + * @date 2025/2/24 + */ +@Getter +public enum AiBotEnum { + HATER_BOT("sys-haterBot", "杠精派", + "/img/itwanger.jpg", + "你现在是一个名叫\"杠精派\"的专业杠精,接下来我给你一个一段文本,你来回复我,回复内容限制在800字符内"), + QA_BOT("sys-QABot", + "派聪明", + "/img/icon.png", + "请你根据用户的提问,理解问题的核心意思,然后结合下面提供的参考资料,给出最清晰、最准确的答案。回答内容控制在800个字符内\n" + + "参考资料如下:\n" + ); + + /** + * 机器人名,全局唯一,对应 user 表中的 userName + */ + private String userName; + + /** + * 机器人昵称,对应 userInfo 中的 userName + */ + private String nickName; + + private String avatar; + + /** + * 机器人的提示词 + */ + private String prompt; + + AiBotEnum(String userName, String nickName, String avatar, String prompt) { + this.userName = userName; + this.nickName = nickName; + this.avatar = avatar; + this.prompt = prompt; + } +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AiChatStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AiChatStatEnum.java new file mode 100644 index 000000000..9794040a6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/ai/AiChatStatEnum.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.api.model.enums.ai; + +/** + * @author YiHui + * @date 2023/6/15 + */ +public enum AiChatStatEnum { + IGNORE(-2) { + @Override + public boolean needResponse() { + return false; + } + }, + /** + * 会话异常 + */ + ERROR(-1), + /** + * 一次问答中,第一次返回 + */ + FIRST(0), + /** + * 一次问答中,中间的返回 + */ + MID(1), + /** + * 一次问答中,最后一次的回复 + */ + END(2), + ; + + private int state; + + AiChatStatEnum(int state) { + this.state = state; + } + + public boolean needResponse() { + return true; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnArticleReadEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnArticleReadEnum.java new file mode 100644 index 000000000..e1b9294b6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnArticleReadEnum.java @@ -0,0 +1,40 @@ +package com.github.paicoding.forum.api.model.enums.column; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +/** + * 专栏文章的阅读类型 + * + * @author YiHui + * @date 2023/8/20 + */ +@AllArgsConstructor +@Getter +public enum ColumnArticleReadEnum { + COLUMN_TYPE(0, "沿用专栏的类型"), + LOGIN(1, "登录阅读"), + TIME_FREE(2, "免费"), + STAR_READ(3, "星球阅读"), + ; + + private int read; + + private String desc; + + private static Map cache; + + static { + cache = new HashMap<>(); + for (ColumnArticleReadEnum r : values()) { + cache.put(r.read, r); + } + } + + public static ColumnArticleReadEnum valueOf(int val) { + return cache.getOrDefault(val, COLUMN_TYPE); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnStatusEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnStatusEnum.java new file mode 100644 index 000000000..4603d803a --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnStatusEnum.java @@ -0,0 +1,34 @@ +package com.github.paicoding.forum.api.model.enums.column; + +import lombok.Getter; + +/** + * 发布状态枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum ColumnStatusEnum { + + OFFLINE(0, "未发布"), + CONTINUE(1, "连载"), + OVER(2, "已完结"); + + ColumnStatusEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + private final int code; + private final String desc; + + public static ColumnStatusEnum formCode(int code) { + for (ColumnStatusEnum status : values()) { + if (status.getCode() == code) { + return status; + } + } + return ColumnStatusEnum.OFFLINE; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnTypeEnum.java new file mode 100644 index 000000000..9281a285b --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/ColumnTypeEnum.java @@ -0,0 +1,36 @@ +package com.github.paicoding.forum.api.model.enums.column; + +import lombok.Getter; + +/** + * 发布状态枚举 + * + * @author louzai + * @since 2022/7/19 + */ +@Getter +public enum ColumnTypeEnum { + + FREE(0, "免费"), + LOGIN(1, "登录阅读"), + TIME_FREE(2, "限时免费"), + STAR_READ(3, "星球阅读"), + ; + + ColumnTypeEnum(int code, String desc) { + this.type = code; + this.desc = desc; + } + + private final int type; + private final String desc; + + public static ColumnTypeEnum formCode(int code) { + for (ColumnTypeEnum status : values()) { + if (status.getType() == code) { + return status; + } + } + return ColumnTypeEnum.FREE; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/MovePositionEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/MovePositionEnum.java new file mode 100644 index 000000000..3ac4b911b --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/column/MovePositionEnum.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.api.model.enums.column; + +import lombok.Getter; + +/** + * @author YiHui + * @date 2025/7/31 + */ +@Getter +public enum MovePositionEnum { + BEFORE(-1, "前"), + AFTER(1, "后"), + + IN(0,"里"); + + private Integer code; + private String desc; + + MovePositionEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/login/LoginQrTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/login/LoginQrTypeEnum.java new file mode 100644 index 000000000..c4703a16d --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/login/LoginQrTypeEnum.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.api.model.enums.login; + +import lombok.Getter; + +/** + * 微信公众号登录二维码类型 + * + * @author YiHui + * @date 2025/9/28 + */ +@Getter +public enum LoginQrTypeEnum { + + SUBSCRIPTION_ACCOUNT("Subscription Account", "微信公众号"), + SERVICE_ACCOUNT("Service Account", "服务号"), + ; + private String code; + private String desc; + + LoginQrTypeEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/pay/PayStatusEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/pay/PayStatusEnum.java new file mode 100644 index 000000000..6638d6392 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/pay/PayStatusEnum.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.api.model.enums.pay; + +import lombok.Getter; + +import java.util.Objects; + +/** + * 支付状态 + * + * @author YiHui + * @date 2024/10/29 + */ +@Getter +public enum PayStatusEnum { + + NOT_PAY(0, "未支付"), + + PAYING(1, "支付中"), + + SUCCEED(2, "支付成功"), + + FAIL(3, "支付失败"), + ; + + private Integer status; + private String msg; + + PayStatusEnum(Integer status, String msg) { + this.status = status; + this.msg = msg; + } + + public static PayStatusEnum statusOf(Integer status) { + for (PayStatusEnum p : values()) { + if (Objects.equals(status, p.status)) { + return p; + } + } + return null; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/pay/ThirdPayWayEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/pay/ThirdPayWayEnum.java new file mode 100644 index 000000000..b971f99b0 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/pay/ThirdPayWayEnum.java @@ -0,0 +1,74 @@ +package com.github.paicoding.forum.api.model.enums.pay; + +import lombok.Getter; + +import java.util.Objects; + +/** + * 三方平台支付方式 + * + * @author YiHui + * @date 2024/12/3 + */ +public enum ThirdPayWayEnum { + // // 官方说明有效期五分钟,我们这里设置一下有效期为四分之后,避免正好卡在失效的时间点 + WX_H5("wx_h5", "H5", 250_000) { + @Override + public boolean wxPay() { + return true; + } + }, + // 官方说明有效期为两小时,我们设置为1.8小时之后失效 + WX_JSAPI("wx_jsapi", "JS", 18 * 360_000) { + @Override + public boolean wxPay() { + return true; + } + }, + // 官方说明有效期为两小时,我们设置为1.8小时之后失效 + WX_NATIVE("wx_native", "NA", 18 * 360_000) { + @Override + public boolean wxPay() { + return true; + } + }, + /** + * 个人收款码,基于邮件进行确认的模式,设置30天的有效期 + */ + EMAIL("email", "EM", 30 * 3600_000), + ; + + @Getter + private String pay; + + /** + * 外部支付编号的前缀 + */ + @Getter + private String prefix; + + /** + * prePay有效时间间隔,单位毫秒 + */ + @Getter + private Integer expireTimePeriod; + + ThirdPayWayEnum(String pay, String prefix, Integer expireTimePeriod) { + this.pay = pay; + this.prefix = prefix; + this.expireTimePeriod = expireTimePeriod; + } + + public static ThirdPayWayEnum ofPay(String pay) { + for (ThirdPayWayEnum value : values()) { + if (Objects.equals(value.pay, pay)) { + return value; + } + } + return null; + } + + public boolean wxPay() { + return false; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/rank/ActivityRankTimeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/rank/ActivityRankTimeEnum.java new file mode 100644 index 000000000..1b34621dd --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/rank/ActivityRankTimeEnum.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.api.model.enums.rank; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 活跃排行榜时间周期 + * + * @author YiHui + * @date 2023/8/19 + */ +@AllArgsConstructor +@Getter +public enum ActivityRankTimeEnum { + DAY(1, "day"), + MONTH(2, "month"), + ; + + private int type; + private String desc; + + public static ActivityRankTimeEnum nameOf(String name) { + if (DAY.desc.equalsIgnoreCase(name)) { + return DAY; + } else if (MONTH.desc.equalsIgnoreCase(name)) { + return MONTH; + } + return null; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/site/SiteVisitStatisticsEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/site/SiteVisitStatisticsEnum.java new file mode 100644 index 000000000..d01b44d10 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/site/SiteVisitStatisticsEnum.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.api.model.enums.site; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 站点统计类型枚举 + * + * @author YiHui + * @date 2023/8/22 + */ +@AllArgsConstructor +@Getter +public enum SiteVisitStatisticsEnum { + PV(1, "浏览量"), + UV(2, "独立访客"), + VV(3, "访问次数"), + ; + + private int type; + private String desc; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/LoginTypeEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/LoginTypeEnum.java new file mode 100644 index 000000000..e280fc2a5 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/LoginTypeEnum.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.api.model.enums.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author YiHui + * @date 2023/6/26 + */ +@Getter +@AllArgsConstructor +public enum LoginTypeEnum { + /** + * 微信登录 + */ + WECHAT(0), + /** + * 用户名+密码登录 + */ + USER_PWD(1), + /** + * 知识星球登录 + */ + ZSXQ(2), + ; + private int type; +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/StarSourceEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/StarSourceEnum.java new file mode 100644 index 000000000..0910c7739 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/StarSourceEnum.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.api.model.enums.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 星球来源枚举 + * + * @author YiHui + * @date 2023/6/26 + */ +@Getter +@AllArgsConstructor +public enum StarSourceEnum { + /** + * java进阶 + */ + JAVA_GUIDE(1), + /** + * 技术派 + */ + TECH_PAI(2), + ; + private int source; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/UserAIStatEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/UserAIStatEnum.java new file mode 100644 index 000000000..5cd657440 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/UserAIStatEnum.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.api.model.enums.user; + +import lombok.Getter; + +/** + * 派聪明用户状态枚举 + */ +@Getter +public enum UserAIStatEnum { + IGNORE(-1, "忽略"), + // 审核中 + AUDITING(0, "审核中"), + // 试用中 + TRYING(1, "试用中"), + // 正式用户 + FORMAL(2, "正式用户"), + // 未通过 + NOT_PASS(3, "未通过"), + EXPIRED(4, "已过期"), + ; + + UserAIStatEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + private final Integer code; + private final String desc; + + public static UserAIStatEnum fromCode(Integer code) { + for (UserAIStatEnum value : UserAIStatEnum.values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return UserAIStatEnum.AUDITING; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/UserAiStrategyEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/UserAiStrategyEnum.java new file mode 100644 index 000000000..39842dc61 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/enums/user/UserAiStrategyEnum.java @@ -0,0 +1,40 @@ +package com.github.paicoding.forum.api.model.enums.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * ai可用次数的条件策略 + * + * @author YiHui + * @date 2023/6/26 + */ +@Getter +@AllArgsConstructor +public enum UserAiStrategyEnum { + WECHAT(1), + INVITE_USER(2), + STAR_JAVA_GUIDE(4), + STAR_TECH_PAI(8), + ; + + /** + * 二进制使用姿势 + * 第0位: = 1 表示已绑定微信公众号 + * 第1位: = 1 表示绑定了邀请用户 + * 第2位: = 1 表示绑定了java星球 + * 第3位: = 1 表示绑定了技术派星球 + */ + private Integer condition; + + public Integer updateCondition(Integer input) { + if (input == null) { + input = 0; + } + return input | condition; + } + + public boolean match(Integer strategy) { + return strategy != null && (strategy & condition) == condition.intValue(); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/event/ArticleMsgEvent.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/event/ArticleMsgEvent.java new file mode 100644 index 000000000..225ce2bb2 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/event/ArticleMsgEvent.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.api.model.event; + +import com.github.paicoding.forum.api.model.enums.ArticleEventEnum; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.context.ApplicationEvent; + +/** + * @author YiHui + * @date 2023/2/22 + */ +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = true) +public class ArticleMsgEvent extends ApplicationEvent { + + private ArticleEventEnum type; + + private T content; + + + public ArticleMsgEvent(Object source, ArticleEventEnum type, T content) { + super(source); + this.type = type; + this.content = content; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/event/ConfigRefreshEvent.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/event/ConfigRefreshEvent.java new file mode 100644 index 000000000..983240355 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/event/ConfigRefreshEvent.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.api.model.event; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.context.ApplicationEvent; + +/** + * 配置变更消息事件 + * + * @author YiHui + * @date 2023/8/10 + */ +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = true) +public class ConfigRefreshEvent extends ApplicationEvent { + private String key; + private String val; + + + public ConfigRefreshEvent(Object source, String key, String value) { + super(source); + this.key = key; + this.val = value; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ExceptionUtil.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ExceptionUtil.java new file mode 100644 index 000000000..18fec1622 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ExceptionUtil.java @@ -0,0 +1,15 @@ +package com.github.paicoding.forum.api.model.exception; + +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; + +/** + * @author YiHui + * @date 2022/9/2 + */ +public class ExceptionUtil { + + public static ForumException of(StatusEnum status, Object... args) { + return new ForumException(status, args); + } + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ForumAdviceException.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ForumAdviceException.java new file mode 100644 index 000000000..c6ce8c952 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ForumAdviceException.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.api.model.exception; + +import com.github.paicoding.forum.api.model.vo.Status; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import lombok.Getter; + +/** + * 业务异常 + * + * @author YiHui + * @date 2022/9/2 + */ +public class ForumAdviceException extends RuntimeException { + @Getter + private Status status; + + public ForumAdviceException(Status status) { + this.status = status; + } + + public ForumAdviceException(int code, String msg) { + this.status = Status.newStatus(code, msg); + } + + public ForumAdviceException(StatusEnum statusEnum, Object... args) { + this.status = Status.newStatus(statusEnum, args); + } + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ForumException.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ForumException.java new file mode 100644 index 000000000..59c46e2e8 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/ForumException.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.api.model.exception; + +import com.github.paicoding.forum.api.model.vo.Status; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import lombok.Getter; + +/** + * 业务异常 + * + * @author YiHui + * @date 2022/9/2 + */ +public class ForumException extends RuntimeException { + @Getter + private Status status; + + public ForumException(Status status) { + this.status = status; + } + + public ForumException(int code, String msg) { + this.status = Status.newStatus(code, msg); + } + + public ForumException(StatusEnum statusEnum, Object... args) { + this.status = Status.newStatus(statusEnum, args); + } + +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/exception/NoVlaInGuavaException.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/NoVlaInGuavaException.java similarity index 84% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/exception/NoVlaInGuavaException.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/NoVlaInGuavaException.java index cb9444950..e4aa82b91 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/exception/NoVlaInGuavaException.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/exception/NoVlaInGuavaException.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.exception; +package com.github.paicoding.forum.api.model.exception; /** * 未命中异常 diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/openapi/user/OpenApiUserDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/openapi/user/OpenApiUserDTO.java new file mode 100644 index 000000000..4b3a9b7c9 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/openapi/user/OpenApiUserDTO.java @@ -0,0 +1,83 @@ +package com.github.paicoding.forum.api.model.openapi.user; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2025/9/15 + */ +@Data +public class OpenApiUserDTO implements Serializable { + private static final long serialVersionUID = 4663622879892017339L; + /** + * 用户id + */ + @ApiModelProperty(value = "用户id", required = true) + private Long userId; + + /** + * 用户昵称 + */ + @ApiModelProperty(value = "用户昵称", required = true) + private String userName; + + /** + * 登录用户名 + */ + @ApiModelProperty(value = "登录用户名", required = true) + private String loginName; + + /** + * 用户角色 admin, normal + */ + @ApiModelProperty(value = "角色", example = "ADMIN|NORMAL") + private String role; + + /** + * 用户图像 + */ + @ApiModelProperty(value = "用户头像") + private String photo; + + /** + * 用户的邮箱 + */ + @ApiModelProperty(value = "用户邮箱", example = "paicoding@126.com") + private String email; + + /** + * 个人简介 + */ + @ApiModelProperty(value = "用户简介") + private String profile; + /** + * 职位 + */ + @ApiModelProperty(value = "个人职位") + private String position; + + /** + * 公司 + */ + @ApiModelProperty(value = "公司") + private String company; + + + @ApiModelProperty(value = "微信id") + private String wxId; + + /** + * 星球id + */ + @ApiModelProperty(value = "星球id") + private String zsxqId; + + /** + * 星球到期时间(秒) + */ + @ApiModelProperty(value = "星球到期时间(秒)") + private Long zsxqExpireTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/package-info.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/package-info.java new file mode 100644 index 000000000..45af1f65c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/package-info.java @@ -0,0 +1,5 @@ +/** + * @author YiHui + * @date 2022/7/6 + */ +package com.github.paicoding.forum.api.model; \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/NextPageHtmlVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/NextPageHtmlVo.java new file mode 100644 index 000000000..20610ba7b --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/NextPageHtmlVo.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.api.model.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2022/7/6 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class NextPageHtmlVo implements Serializable { + private String html; + private Boolean hasMore; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageListVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageListVo.java new file mode 100644 index 000000000..b00576144 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageListVo.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.api.model.vo; + +import lombok.Data; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * @author YiHui + * @date 2022/9/4 + */ +@Data +public class PageListVo { + + /** + * 用户列表 + */ + List list; + + /** + * 是否有更多 + */ + private Boolean hasMore; + + public static PageListVo emptyVo() { + PageListVo vo = new PageListVo<>(); + vo.setList(Collections.emptyList()); + vo.setHasMore(false); + return vo; + } + + public static PageListVo newVo(List list, long pageSize) { + PageListVo vo = new PageListVo<>(); + vo.setList(Optional.ofNullable(list).orElse(Collections.emptyList())); + vo.setHasMore(vo.getList().size() == pageSize); + return vo; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageParam.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageParam.java new file mode 100644 index 000000000..52d773671 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageParam.java @@ -0,0 +1,56 @@ +package com.github.paicoding.forum.api.model.vo; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * 数据库分页参数 + * + * @author louzai + * @date 2022-07-120 + */ +@Data +public class PageParam { + + public static final Long DEFAULT_PAGE_NUM = 1L; + public static final Long DEFAULT_PAGE_SIZE = 10L; + + public static final Long TOP_PAGE_SIZE = 4L; + + + @ApiModelProperty("请求页数,从1开始计数") + private long pageNum; + + @ApiModelProperty("请求页大小,默认为 10") + private long pageSize; + private long offset; + private long limit; + + public static PageParam newPageInstance() { + return newPageInstance(DEFAULT_PAGE_NUM, DEFAULT_PAGE_SIZE); + } + + public static PageParam newPageInstance(Integer pageNum, Integer pageSize) { + return newPageInstance(pageNum.longValue(), pageSize.longValue()); + } + + public static PageParam newPageInstance(Long pageNum, Long pageSize) { + if (pageNum == null || pageSize == null) { + return null; + } + + final PageParam pageParam = new PageParam(); + pageParam.pageNum = pageNum; + pageParam.pageSize = pageSize; + + pageParam.offset = (pageNum - 1) * pageSize; + pageParam.limit = pageSize; + + return pageParam; + } + + public static String getLimitSql(PageParam pageParam) { + return String.format("limit %s,%s", pageParam.offset, pageParam.limit); + } + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageVo.java new file mode 100644 index 000000000..38839150d --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/PageVo.java @@ -0,0 +1,89 @@ +package com.github.paicoding.forum.api.model.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * @author LouZai + * @date 2022/9/17 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PageVo { + + private List list; + + private long pageSize; + + private long pageNum; + + private long pageTotal; + + private long total; + + /** + * 构造方法,int参数,需去除 + * + * @param list + * @param pageSize + * @param pageNum + * @param total + * @return + */ + @Deprecated + public PageVo(List list, int pageSize, int pageNum, int total) { + this.list = list; + this.total = total; + this.pageSize = pageSize; + this.pageNum = pageNum; + this.pageTotal = (int) Math.ceil((double) total / pageSize); + } + + /** + * 构造PageVO + * + * @param list + * @param pageSize + * @param pageNum + * @param total + * @return + */ + public PageVo(List list, long pageSize, long pageNum, long total) { + this.list = list; + this.total = total; + this.pageSize = pageSize; + this.pageNum = pageNum; + this.pageTotal = (long) Math.ceil((double) total / pageSize); + } + + /** + * 创建PageVO + * + * @param list + * @param pageSize + * @param pageNum + * @param total + * @return PageVo + */ + @Deprecated + public static PageVo build(List list, int pageSize, int pageNum, int total) { + return new PageVo<>(list, pageSize, pageNum, total); + } + + /** + * 创建PageVO + * + * @param list + * @param pageSize + * @param pageNum + * @param total + * @return PageVo + */ + public static PageVo build(List list, long pageSize, long pageNum, long total) { + return new PageVo<>(list, pageSize, pageNum, total); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/ResVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/ResVo.java new file mode 100644 index 000000000..2ac7971d9 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/ResVo.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.api.model.vo; + +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2022/7/6 + */ +@Data +public class ResVo implements Serializable { + private static final long serialVersionUID = -510306209659393854L; + @ApiModelProperty(value = "返回结果说明", required = true) + private Status status; + + @ApiModelProperty(value = "返回的实体结果", required = true) + private T result; + + + public ResVo() { + } + + public ResVo(Status status) { + this.status = status; + } + + public ResVo(T t) { + status = Status.newStatus(StatusEnum.SUCCESS); + this.result = t; + } + + public static ResVo ok(T t) { + return new ResVo<>(t); + } + + private static final String OK_DEFAULT_MESSAGE = "ok"; + + public static ResVo ok() { + return ok(OK_DEFAULT_MESSAGE); + } + + public static ResVo fail(StatusEnum status, Object... args) { + return new ResVo<>(Status.newStatus(status, args)); + } + + public static ResVo fail(Status status) { + return new ResVo<>(status); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/Status.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/Status.java new file mode 100644 index 000000000..a08212f1f --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/Status.java @@ -0,0 +1,43 @@ +package com.github.paicoding.forum.api.model.vo; + +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author YiHui + * @date 2022/7/6 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Status { + + /** + * 业务状态码 + */ + @ApiModelProperty(value = "状态码, 0表示成功返回,其他异常返回", required = true, example = "0") + private int code; + + /** + * 描述信息 + */ + @ApiModelProperty(value = "正确返回时为ok,异常时为描述文案", required = true, example = "ok") + private String msg; + + public static Status newStatus(int code, String msg) { + return new Status(code, msg); + } + + public static Status newStatus(StatusEnum status, Object... msgs) { + String msg; + if (msgs.length > 0) { + msg = String.format(status.getMsg(), msgs); + } else { + msg = status.getMsg(); + } + return newStatus(status.getCode(), msg); + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ArticlePostReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ArticlePostReq.java new file mode 100644 index 000000000..bc1048ae6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ArticlePostReq.java @@ -0,0 +1,129 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.enums.ArticleTypeEnum; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.SourceTypeEnum; +import lombok.Data; + +import java.io.Serializable; +import java.util.Set; + +/** + * 发布文章请求参数 + * + * @author YiHui + * @date 2022/7/24 + */ +@Data +public class ArticlePostReq implements Serializable { + /** + * 文章ID, 当存在时,表示更新文章 + */ + private Long articleId; + /** + * 文章标题 + */ + private String title; + + /** + * 文章短标题 + */ + private String shortTitle; + + /** + * URL slug,用于SEO友好URL + */ + private String urlSlug; + + /** + * 分类 + */ + private Long categoryId; + + /** + * 标签 + */ + private Set tagIds; + + /** + * 简介 + */ + private String summary; + + /** + * 正文内容 + */ + private String content; + + /** + * 封面 + */ + private String cover; + + /** + * 文本类型 + * + * @see ArticleTypeEnum + */ + private String articleType; + + + /** + * 来源:1-转载,2-原创,3-翻译 + * + * @see SourceTypeEnum + */ + private Integer source; + + /** + * 状态:0-未发布,1-已发布 + * + * @see com.github.paicoding.forum.api.model.enums.PushStatusEnum + */ + private Integer status; + + /** + * 原文地址 + */ + private String sourceUrl; + + /** + * POST 发表, SAVE 暂存 DELETE 删除 + */ + private String actionType; + + /** + * 专栏序号 + */ + private Long columnId; + + /** + * 文章阅读类型 + * + * @see ArticleReadTypeEnum#getType() + */ + private Integer readType; + + /** + * 当 ArticleReadTypeEnum 为 付费阅读时,这里记录具体的收款方式 + */ + private String payWay; + + /** + * 付费解锁价格 + */ + private Integer payAmount; + + public PushStatusEnum pushStatus() { + if ("post".equalsIgnoreCase(actionType)) { + return PushStatusEnum.ONLINE; + } else { + return PushStatusEnum.OFFLINE; + } + } + + public boolean deleted() { + return "delete".equalsIgnoreCase(actionType); + } +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/CategoryReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/CategoryReq.java new file mode 100644 index 000000000..8399c7046 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/CategoryReq.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 保存Category请求参数 + * + * @author LouZai + * @date 2022/9/17 + */ +@Data +public class CategoryReq implements Serializable { + + /** + * ID + */ + private Long categoryId; + + /** + * 类目名称 + */ + private String category; + + /** + * 排序 + */ + private Integer rank; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnArticleGroupReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnArticleGroupReq.java new file mode 100644 index 000000000..ee4ea1c48 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnArticleGroupReq.java @@ -0,0 +1,41 @@ + +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 保存Column分组请求参数 + * + * @author yihui + * @date 2024/12/17 + */ +@Data +public class ColumnArticleGroupReq implements Serializable { + + /** + * 主键ID + */ + private Long id; + + /** + * 专栏ID + */ + private Long columnId; + + /** + * 父分组id + */ + private Long parentGroupId; + + /** + * 文章ID + */ + private String title; + + /** + * 专栏排序 + */ + private Long section; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnArticleReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnArticleReq.java new file mode 100644 index 000000000..652135b7c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnArticleReq.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import com.github.paicoding.forum.api.model.enums.column.ColumnArticleReadEnum; +import lombok.Data; + +import java.io.Serializable; + +/** + * 保存Column文章请求参数 + * + * @author LouZai + * @date 2022/9/26 + */ +@Data +public class ColumnArticleReq implements Serializable { + + /** + * 主键ID + */ + private Long id; + + /** + * 专栏ID + */ + private Long columnId; + + + /** + * 专栏分组id + */ + private Long groupId; + + /** + * 文章ID + */ + private Long articleId; + + /** + * 文章排序 + */ + private Integer sort; + + /** + * 教程标题 + */ + private String shortTitle; + + /** + * 阅读方式 + */ + private Integer read; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnReq.java new file mode 100644 index 000000000..df2677bd0 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ColumnReq.java @@ -0,0 +1,70 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 保存Column请求参数 + * + * @author LouZai + * @date 2022/9/26 + */ +@Data +public class ColumnReq implements Serializable { + + /** + * ID + */ + private Long columnId; + + /** + * 专栏名 + */ + private String column; + + /** + * 作者 + */ + private Long author; + + /** + * 简介 + */ + private String introduction; + + /** + * 封面 + */ + private String cover; + + /** + * 状态 + */ + private Integer state; + + /** + * 排序 + */ + private Integer section; + + /** + * 专栏预计的文章数 + */ + private Integer nums; + + /** + * 专栏类型 + */ + private Integer type; + + /** + * 限时免费开始时间 + */ + private Long freeStartTime; + + /** + * 限时免费结束时间 + */ + private Long freeEndTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ContentPostReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ContentPostReq.java new file mode 100644 index 000000000..43affaf95 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/ContentPostReq.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 发布文章请求参数 + * + * @author YiHui + * @date 2022/7/24 + */ +@Data +public class ContentPostReq implements Serializable { + /** + * 正文内容 + */ + private String content; +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/MoveColumnArticleOrGroupReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/MoveColumnArticleOrGroupReq.java new file mode 100644 index 000000000..50c421118 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/MoveColumnArticleOrGroupReq.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 11/25/23 + */ +@Data +@ApiModel("拖拽移动教程顺序") +public class MoveColumnArticleOrGroupReq implements Serializable { + // 要排序的 id + @ApiModelProperty("专栏ID") + private Long columnId; + + @ApiModelProperty("移动的分组") + private Long moveGroupId; + + @ApiModelProperty("移动的教程") + private Long moveArticleId; + + /** + * 当这个不存在时,groupId必须存在,表示移动当目标分组的首个位置 + */ + @ApiModelProperty("目标教程") + private Long targetArticleId; + + /** + * 目标分组 + */ + @ApiModelProperty("教程分组") + private Long targetGroupId; + + /** + * 1 表示在目标的后面一个 + * 0 表示移动到目标里面 + * -1 表示移动到目标前面一个 + */ + @ApiModelProperty("移动位置") + private Integer movePosition; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchArticleReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchArticleReq.java new file mode 100644 index 000000000..53973449c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchArticleReq.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@Data +@ApiModel("文章查询") +public class SearchArticleReq { + + // 文章标题 + @ApiModelProperty("文章标题") + private String title; + + @ApiModelProperty("文章ID") + private Long articleId; + + @ApiModelProperty("作者ID") + private Long userId; + + @ApiModelProperty("作者名称") + private String userName; + + @ApiModelProperty("文章状态: 0-未发布,1-已发布,2-审核") + private Integer status; + + @ApiModelProperty("是否官方: 0-非官方,1-官方") + private Integer officalStat; + + @ApiModelProperty("是否置顶: 0-不置顶,1-置顶") + private Integer toppingStat; + + @ApiModelProperty("URL slug,用于SEO友好URL") + private String urlSlug; + + @ApiModelProperty("请求页数,从1开始计数") + private long pageNumber; + + @ApiModelProperty("请求页大小,默认为 10") + private long pageSize; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchCategoryReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchCategoryReq.java new file mode 100644 index 000000000..a75a6b3a3 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchCategoryReq.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/27/23 + */ +@Data +public class SearchCategoryReq { + // 类目名称 + private String category; + // 分页 + private Long pageNumber; + private Long pageSize; + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchColumnArticleReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchColumnArticleReq.java new file mode 100644 index 000000000..26002e762 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchColumnArticleReq.java @@ -0,0 +1,28 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@Data +@ApiModel("教程配套文章查询") +public class SearchColumnArticleReq { + + // 教程名称 + @ApiModelProperty("教程名称") + private String column; + + // 教程 ID + @ApiModelProperty("教程 ID") + private Long columnId; + + // 文章标题 + @ApiModelProperty("文章标题") + private String articleTitle; + + @ApiModelProperty("请求页数,从1开始计数") + private long pageNumber; + + @ApiModelProperty("请求页大小,默认为 10") + private long pageSize; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchColumnReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchColumnReq.java new file mode 100644 index 000000000..03dc8d19e --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchColumnReq.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@Data +@ApiModel("教程查询") +public class SearchColumnReq { + + // 教程名称 + @ApiModelProperty("教程名称") + private String column; + + @ApiModelProperty("请求页数,从1开始计数") + private long pageNumber; + + @ApiModelProperty("请求页大小,默认为 10") + private long pageSize; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchTagReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchTagReq.java new file mode 100644 index 000000000..6a2654ac1 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SearchTagReq.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/29/23 + */ +@Data +public class SearchTagReq { + // 标签名称 + private String tag; + // 分页 + private Long pageNumber; + private Long pageSize; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SortColumnArticleByIDReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SortColumnArticleByIDReq.java new file mode 100644 index 000000000..304a1c31d --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SortColumnArticleByIDReq.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 11/25/23 + */ +@Data +@ApiModel("教程排序,根据 ID 和新填的排序") +public class SortColumnArticleByIDReq implements Serializable { + // 要排序的 id + @ApiModelProperty("要排序的 id") + private Long id; + // 新的排序 + @ApiModelProperty("新的排序") + private Integer sort; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SortColumnArticleReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SortColumnArticleReq.java new file mode 100644 index 000000000..6313f8ea5 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/SortColumnArticleReq.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 11/23/23 + */ +@Data +@ApiModel("教程排序") +public class SortColumnArticleReq implements Serializable { + // 排序前的文章 ID + @ApiModelProperty("排序前的文章 ID") + private Long activeId; + + // 排序后的文章 ID + @ApiModelProperty("排序后的文章 ID") + private Long overId; + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/TagReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/TagReq.java new file mode 100644 index 000000000..acc70146e --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/TagReq.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.api.model.vo.article; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 保存Tag请求参数 + * + * @author LouZai + * @date 2022/9/17 + */ +@Data +public class TagReq implements Serializable { + + /** + * ID + */ + private Long tagId; + + /** + * 标签名称 + */ + private String tag; + + /** + * 类目ID + */ + private Long categoryId; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleAdminDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleAdminDTO.java new file mode 100644 index 000000000..69c7014ea --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleAdminDTO.java @@ -0,0 +1,80 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 文章信息 + *

+ * DTO 定义返回给 admin 后端的实体类 (VO) + * + * @author 沉默王二 + * @date 2023年05月23日 + */ +@Data +public class ArticleAdminDTO implements Serializable { + private static final long serialVersionUID = -793906904770296838L; + + private Long articleId; + + /** + * 作者uid + */ + private Long author; + + /** + * 作者名 + */ + private String authorName; + + /** + * 作者头像 + */ + private String authorAvatar; + + /** + * 文章标题 + */ + private String title; + + /** + * 短标题 + */ + private String shortTitle; + + /** + * URL slug,用于SEO友好URL + */ + private String urlSlug; + + /** + * 封面 + */ + private String cover; + + /** + * 0 未发布 1 已发布 + */ + private Integer status; + + /** + * 是否官方 + */ + private Integer officalStat; + + /** + * 是否置顶 + */ + private Integer toppingStat; + + /** + * 是否加精 + */ + private Integer creamStat; + + // 更新时间 + private Date updateTime; + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleDTO.java new file mode 100644 index 000000000..48eae1573 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleDTO.java @@ -0,0 +1,177 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.enums.SourceTypeEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.ArticleFootCountDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 文章信息 + *

+ * DTO 定义返回给web前端的实体类 (VO) + * + * @author YiHui + * @date 2022/7/24 + */ +@Data +public class ArticleDTO implements Serializable { + private static final long serialVersionUID = -793906904770296838L; + + private Long articleId; + + /** + * 文章类型:1-博文,2-问答 + */ + private Integer articleType; + + /** + * 作者uid + */ + private Long author; + + /** + * 作者名 + */ + private String authorName; + + /** + * 作者头像 + */ + private String authorAvatar; + + /** + * 文章标题 + */ + private String title; + + /** + * 短标题 + */ + private String shortTitle; + + /** + * URL友好的文章标识,用于SEO优化 + */ + private String urlSlug; + + /** + * 简介 + */ + private String summary; + + /** + * 封面 + */ + private String cover; + + /** + * 正文 + */ + private String content; + + /** + * 文章来源 + * + * @see SourceTypeEnum + */ + private String sourceType; + + /** + * 原文地址 + */ + private String sourceUrl; + + /** + * 0 未发布 1 已发布 + */ + private Integer status; + + /** + * 阅读类型 + * + * @see ArticleReadTypeEnum#getType() + */ + private Integer readType; + + /** + * ture 表示可以阅读 false 表示无法阅读全文 + */ + private Boolean canRead; + + /** + * 是否官方 + */ + private Integer officalStat; + + /** + * 是否置顶 + */ + private Integer toppingStat; + + /** + * 是否加精 + */ + private Integer creamStat; + + /** + * 创建时间 + */ + private Long createTime; + + /** + * 最后更新时间 + */ + private Long lastUpdateTime; + + /** + * 分类 + */ + private CategoryDTO category; + + /** + * 标签 + */ + private List tags; + + /** + * 表示当前查看的用户是否已经点赞过 + */ + private Boolean praised; + + /** + * 表示当用户是否评论过 + */ + private Boolean commented; + + /** + * 表示当前用户是否收藏过 + */ + private Boolean collected; + + /** + * 文章对应的统计计数 + */ + private ArticleFootCountDTO count; + + /** + * 点赞用户信息 + */ + private List praisedUsers; + + /** + * 支付金额,单位(元), 为了防止精度问题,返回String格式 + */ + private String payAmount; + + /** + * 付款方式 + * + * @see ThirdPayWayEnum#wxPay() + */ + private String payWay; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleOtherDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleOtherDTO.java new file mode 100644 index 000000000..5b6cde1b4 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticleOtherDTO.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 12/8/23 + */ +@Data +public class ArticleOtherDTO { + // 文章的阅读类型 + private Integer readType; + // 教程的翻页 + private ColumnArticleFlipDTO flip; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticlePayInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticlePayInfoDTO.java new file mode 100644 index 000000000..3c338fc5c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ArticlePayInfoDTO.java @@ -0,0 +1,65 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Map; + +/** + * 文章支付信息 + * + * @author YiHui + * @date 2024/10/29 + */ +@Data +public class ArticlePayInfoDTO implements Serializable { + /** + * 支付id + */ + private Long payId; + + /** + * 文章id + */ + private Long articleId; + + /** + * 支付用户 + */ + private Long payUserId; + + /** + * 支付状态 + */ + private Integer payStatus; + + /** + * 收款用户 + */ + private Long receiveUserId; + + /** + * 收款用户对应的各渠道的收款码 + */ + private Map payQrCodeMap; + + /** + * 支付方式 + */ + private String payWay; + + /** + * 支付金额 + */ + private String payAmount; + + /** + * 支付信息 + */ + private String prePayId; + + /** + * 失效时间 + */ + private Long prePayExpireTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/CategoryDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/CategoryDTO.java new file mode 100644 index 000000000..53e6fa20e --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/CategoryDTO.java @@ -0,0 +1,45 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2022/7/24 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CategoryDTO implements Serializable { + public static final String DEFAULT_TOTAL_CATEGORY = "全部"; + public static final CategoryDTO DEFAULT_CATEGORY = new CategoryDTO(0L, "全部"); + + private static final long serialVersionUID = 8272116638231812207L; + public static CategoryDTO EMPTY = new CategoryDTO(-1L, "illegal"); + + private Long categoryId; + + private String category; + + private Integer rank; + + private Integer status; + + private Boolean selected; + + public CategoryDTO(Long categoryId, String category) { + this(categoryId, category, 0); + } + + public CategoryDTO(Long categoryId, String category, Integer rank) { + this.categoryId = categoryId; + this.category = category; + this.status = PushStatusEnum.ONLINE.getCode(); + this.rank = rank; + this.selected = false; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleDTO.java new file mode 100644 index 000000000..991be3120 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleDTO.java @@ -0,0 +1,74 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.sql.Timestamp; + +/** + * 文章推荐 + * + * @author YiHui + * @date 2022/9/6 + */ +@Data +@Accessors(chain = true) +public class ColumnArticleDTO implements Serializable { + private static final long serialVersionUID = 3646376715620165839L; + + /** + * 唯一ID + */ + private Long id; + + /** + * 文章ID + */ + private Long articleId; + + /** + * 文章标题 + */ + private String title; + + /** + * 教程名称 + */ + private String shortTitle; + + /** + * 教程ID + */ + private Long columnId; + + /** + * 教程标题 + */ + private String column; + + /** + * 分组id + */ + private Long groupId; + + /** + * 分组名 + */ + private String groupName; + + /** + * 教程封面 + */ + private String columnCover; + + /** + * 文章排序 + */ + private Integer sort; + + /** + * 创建时间 + */ + private Timestamp createTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleFlipDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleFlipDTO.java new file mode 100644 index 000000000..df097c928 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleFlipDTO.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 12/8/23 + */ +@Data +public class ColumnArticleFlipDTO { + String prevHref; + Boolean prevShow; + String nextHref; + Boolean nextShow; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleGroupDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleGroupDTO.java new file mode 100644 index 000000000..169110bb4 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticleGroupDTO.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/14 + */ +@Data +public class ColumnArticleGroupDTO { + + /** + * 专栏id + */ + private Long columnId; + + /** + * 当前分组id + */ + private Long groupId; + + /** + * 父分组 + */ + private Long parentGroupId; + + /** + * 文案说明 + */ + private String title; + + /** + * 顺序 + */ + private Long section; + + /** + * 子分组 + */ + private List children; + + /** + * 分组下的文章列表 + */ + private List articles; + + public static ColumnArticleGroupDTO defaultGroup = new ColumnArticleGroupDTO(); + + public static ColumnArticleGroupDTO newDefaultGroup(Long columnId) { + ColumnArticleGroupDTO dto = new ColumnArticleGroupDTO(); + dto.setColumnId(columnId); + dto.setSection(0L); + dto.setTitle("未分组"); + return dto; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticlesDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticlesDTO.java new file mode 100644 index 000000000..80c44493d --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnArticlesDTO.java @@ -0,0 +1,65 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import com.github.paicoding.forum.api.model.enums.column.ColumnTypeEnum; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/14 + */ +@Data +public class ColumnArticlesDTO { + /** + * 专栏详情 + */ + private Long column; + + /** + * 当前查看的文章 + */ + private Integer section; + + /** + * 文章详情 + */ + private ArticleDTO article; + + /** + * 0 免费阅读 + * 1 要求登录阅读 + * 2 限时免费,若当前时间超过限时免费期,则调整为登录阅读 + * + * @see ColumnTypeEnum#getType() + */ + private Integer readType; + + /** + * 文章评论 + */ + private List comments; + + /** + * 热门评论 + */ + private TopCommentDTO hotComment; + + /** + * 划线高亮评论 + */ + private List highlightComments; + + /** + * 文章目录列表 + */ + private List articleList; + + // 翻页 + private ArticleOtherDTO other; + + // 赞赏用户列表 + private List payUsers; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnDTO.java new file mode 100644 index 000000000..c33350633 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/ColumnDTO.java @@ -0,0 +1,98 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import com.github.paicoding.forum.api.model.enums.column.ColumnStatusEnum; +import com.github.paicoding.forum.api.model.enums.column.ColumnTypeEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.ColumnFootCountDTO; +import lombok.Data; + +/** + * @author YiHui + * @date 2022/9/14 + */ +@Data +public class ColumnDTO { + + /** + * 专栏id + */ + private Long columnId; + + /** + * 专栏名 + */ + private String column; + + /** + * 说明 + */ + private String introduction; + + /** + * 封面 + */ + private String cover; + + /** + * 发布时间 + */ + private Long publishTime; + + /** + * 排序 + */ + private Integer section; + + /** + * 0 未发布 1 连载 2 完结 + * + * @see ColumnStatusEnum#getCode() + */ + private Integer state; + + /** + * 专栏预计的文章数 + */ + private Integer nums; + + /** + * 专栏类型 + * + * @see ColumnTypeEnum#getType() + */ + private Integer type; + + /** + * 限时免费开始时间 + */ + private Long freeStartTime; + + /** + * 限时免费结束时间 + */ + private Long freeEndTime; + + /** + * 作者 + */ + private Long author; + + /** + * 作者名 + */ + private String authorName; + + /** + * 作者头像 + */ + private String authorAvatar; + + /** + * 个人简介 + */ + private String authorProfile; + + /** + * 统计计数相关信息 + */ + private ColumnFootCountDTO count; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/DictCommonDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/DictCommonDTO.java new file mode 100644 index 000000000..a5193d040 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/DictCommonDTO.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2022/7/24 + */ +@Data +public class DictCommonDTO implements Serializable { + private static final long serialVersionUID = -8614833588325787479L; + + private String typeCode; + + private String dictCode; + + private String dictDesc; + + private Integer sortNo; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/PayConfirmDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/PayConfirmDTO.java new file mode 100644 index 000000000..45de4dccd --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/PayConfirmDTO.java @@ -0,0 +1,65 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2024/10/31 + */ +@Data +public class PayConfirmDTO implements Serializable { + private static final long serialVersionUID = 5470985727304836957L; + + /** + * 文章标题 + */ + private String title; + + /** + * 访问地址 + */ + private String articleUrl; + + /** + * 支付用户 + */ + private String payUser; + + /** + * 打赏时间 + */ + private String payTime; + + /** + * 支付金额 + */ + private String payAmount; + + /** + * 支付方式 + */ + private String payWay; + + /** + * 通知次数 + */ + private Integer notifyCnt; + + /** + * 备注文案 + */ + private String mark; + + /** + * 回调地址 + */ + private String callback; + + /** + * 确认用户 + */ + private Long receiveUserId; + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/SimpleArticleDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/SimpleArticleDTO.java new file mode 100644 index 000000000..7eef53f47 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/SimpleArticleDTO.java @@ -0,0 +1,51 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import com.github.paicoding.forum.api.model.enums.column.ColumnArticleReadEnum; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.sql.Timestamp; + +/** + * 文章推荐 + * + * @author YiHui + * @date 2022/9/6 + */ +@Data +@Accessors(chain = true) +public class SimpleArticleDTO implements Serializable { + private static final long serialVersionUID = 3646376715620165839L; + + @ApiModelProperty("文章ID") + private Long id; + + @ApiModelProperty("文章标题") + private String title; + + @ApiModelProperty("专栏ID") + private Long columnId; + + @ApiModelProperty("专栏标题") + private String column; + + @ApiModelProperty("文章排序") + private Integer sort; + + @ApiModelProperty("创建时间") + private Timestamp createTime; + + /** + * @see ColumnArticleReadEnum#getRead() + */ + @ApiModelProperty("阅读模式") + private Integer readType; + + @ApiModelProperty("教程分组") + private String groupName; + + @ApiModelProperty("分组层级") + private Integer groupLevel; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/SimpleColumnDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/SimpleColumnDTO.java new file mode 100644 index 000000000..d83e164a3 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/SimpleColumnDTO.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +@Data +@Accessors(chain = true) +public class SimpleColumnDTO implements Serializable { + + private static final long serialVersionUID = 3646376715620165839L; + + @ApiModelProperty("专栏id") + private Long columnId; + + @ApiModelProperty("专栏名") + private String column; + + // 封面 + @ApiModelProperty("封面") + private String cover; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/TagDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/TagDTO.java new file mode 100644 index 000000000..522c2af12 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/TagDTO.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2022/7/24 + */ +@Data +public class TagDTO implements Serializable { + private static final long serialVersionUID = -8614833588325787479L; + + private Long tagId; + + private String tag; + + private Integer status; + + private Boolean selected; +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/article/dto/TagSelectDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/TagSelectDTO.java similarity index 87% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/article/dto/TagSelectDTO.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/TagSelectDTO.java index 81998bd8c..c820bfb45 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/article/dto/TagSelectDTO.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/TagSelectDTO.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.article.dto; +package com.github.paicoding.forum.api.model.vo.article.dto; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/YearArticleDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/YearArticleDTO.java new file mode 100644 index 000000000..a5bd8e3a1 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/article/dto/YearArticleDTO.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.api.model.vo.article.dto; + +import lombok.Data; +import lombok.ToString; + +/** + * 创作历程 + * + * @author louzai + * @since 2022/7/19 + */ +@Data +@ToString(callSuper = true) +public class YearArticleDTO { + + /** + * 年份 + */ + private String year; + + /** + * 文章数量 + */ + private Integer articleCount; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/ConfigReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/ConfigReq.java new file mode 100644 index 000000000..1e71c32a5 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/ConfigReq.java @@ -0,0 +1,55 @@ +package com.github.paicoding.forum.api.model.vo.banner; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 保存Banner请求参数 + * + * @author LouZai + * @date 2022/9/17 + */ +@Data +public class ConfigReq implements Serializable { + + /** + * ID + */ + private Long configId; + + /** + * 类型 + */ + private Integer type; + + /** + * 名称 + */ + private String name; + + /** + * 图片链接 + */ + private String bannerUrl; + + /** + * 跳转链接 + */ + private String jumpUrl; + + /** + * 内容 + */ + private String content; + + /** + * 排序 + */ + private Integer rank; + + /** + * 标签 + */ + private String tags; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/SearchConfigReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/SearchConfigReq.java new file mode 100644 index 000000000..be2f3115c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/SearchConfigReq.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.api.model.vo.banner; + +import lombok.Data; + +@Data +public class SearchConfigReq { + /** + * 类型 + */ + private Integer type; + + /** + * 名称 + */ + private String name; + + /** + * 分页 + */ + private Long pageNumber; + private Long pageSize; + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/dto/ConfigDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/dto/ConfigDTO.java new file mode 100644 index 000000000..0021aeb3a --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/banner/dto/ConfigDTO.java @@ -0,0 +1,62 @@ +package com.github.paicoding.forum.api.model.vo.banner.dto; + +import com.github.paicoding.forum.api.model.entity.BaseDTO; +import com.github.paicoding.forum.api.model.enums.ConfigTagEnum; +import lombok.Data; + +/** + * Banner + * + * @author louzai + * @date 2022-09-17 + */ +@Data +public class ConfigDTO extends BaseDTO { + + /** + * 类型 + */ + private Integer type; + + /** + * 名称 + */ + private String name; + + /** + * 图片链接 + */ + private String bannerUrl; + + /** + * 跳转链接 + */ + private String jumpUrl; + + /** + * 内容 + */ + private String content; + + /** + * 排序 + */ + private Integer rank; + + /** + * 状态:0-未发布,1-已发布 + */ + private Integer status; + + /** + * json格式扩展信息 + */ + private String extra; + + /** + * 配置相关的标签:如 火,推荐,精选 等等,英文逗号分隔 + * + * @see ConfigTagEnum#getCode() + */ + private String tags; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatItemVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatItemVo.java new file mode 100644 index 000000000..a93942c6e --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatItemVo.java @@ -0,0 +1,116 @@ +package com.github.paicoding.forum.api.model.vo.chat; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * 一次qa的聊天记录 + * + * @author YiHui + * @date 2023/6/9 + */ +@Data +@Accessors(chain = true) +public class ChatItemVo implements Serializable, Cloneable { + private static final long serialVersionUID = 7230339040247758226L; + /** + * 唯一的聊天id,不要求存在,主要用于简化流式输出时,前端对返回结果的处理 + */ + private String chatUid; + + /** + * 提问的内容 + */ + private String question; + + /** + * 提问的时间点 + */ + private String questionTime; + + /** + * 回答内容 + */ + private String answer; + + /** + * 回答的时间点 + */ + private String answerTime; + + /** + * 回答的内容类型,文本、JSON 字符串 + */ + private ChatAnswerTypeEnum answerType; + + /** + * 记录问题及记录时间 + * + * @param question + * @return + */ + public ChatItemVo initQuestion(String question) { + this.question = question; + this.questionTime = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss").format(LocalDateTime.now()); + return this; + } + + /** + * 记录返回结果及回答时间 + * + * @param answer + * @return + */ + public ChatItemVo initAnswer(String answer) { + this.answer = answer; + this.answerType = ChatAnswerTypeEnum.TEXT; + setAnswerTime(); + return this; + } + + public ChatItemVo initAnswer(String answer, ChatAnswerTypeEnum answerType) { + this.answer = answer; + this.answerType = answerType; + setAnswerTime(); + return this; + } + + /** + * 流式的追加返回 + * + * @param answer + * @return + */ + public ChatItemVo appendAnswer(String answer) { + if (this.answer == null || this.answer.isEmpty()) { + this.answer = answer; + this.chatUid = UUID.randomUUID().toString().replaceAll("-", ""); + } else { + this.answer += answer; + } + this.answerType = ChatAnswerTypeEnum.STREAM; + setAnswerTime(); + return this; + } + + public ChatItemVo setAnswerTime() { + this.answerTime = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss").format(LocalDateTime.now()); + return this; + } + + @Override + public ChatItemVo clone() { + ChatItemVo item = new ChatItemVo(); + item.question = question; + item.questionTime = questionTime; + item.answer = answer; + item.answerTime = answerTime; + return item; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatRecordsVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatRecordsVo.java new file mode 100644 index 000000000..e187b2227 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatRecordsVo.java @@ -0,0 +1,61 @@ +package com.github.paicoding.forum.api.model.vo.chat; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 聊天记录 + * + * @author YiHui + * @date 2023/6/9 + */ +@Data +@Accessors(chain = true) +public class ChatRecordsVo implements Serializable, Cloneable { + private static final long serialVersionUID = -2666259615985932920L; + /** + * AI来源 + */ + private AISourceEnum source; + + /** + * 当前用户最多可问答的次数 + */ + private int maxCnt; + + /** + * 使用的次数 + */ + private int usedCnt; + + /** + * 聊天记录,最新的在前面;最多返回50条 + */ + private List records; + + @Override + public ChatRecordsVo clone() { + ChatRecordsVo vo = new ChatRecordsVo(); + vo.source = source; + vo.maxCnt = maxCnt; + vo.usedCnt = usedCnt; + if (records != null) { + vo.setRecords(records.stream().map(ChatItemVo::clone).collect(Collectors.toList())); + } + return vo; + } + + /** + * 判断是否拥有提问次数 + * + * @return true 表示拥有提问次数 + */ + public boolean hasQaCnt() { + return maxCnt > usedCnt; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatSessionItemVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatSessionItemVo.java new file mode 100644 index 000000000..f274ac270 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/chat/ChatSessionItemVo.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.api.model.vo.chat; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 对话 + * + * @author YiHui + * @date 2025/2/7 + */ +@Data +public class ChatSessionItemVo implements Serializable { + private static final long serialVersionUID = 4083274108548272765L; + /** + * 对话主题 + */ + private String title; + + /** + * 对话id,用于确认聊天历史 + */ + private String chatId; + + /** + * 首次提问时间 + */ + private Long creatTime; + + /** + * 最后一次提问应答时间 + */ + private Long updateTime; + + /** + * 问答次数 + */ + private int qasCnt; + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/CommentSaveReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/CommentSaveReq.java new file mode 100644 index 000000000..d3aae0caa --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/CommentSaveReq.java @@ -0,0 +1,49 @@ +package com.github.paicoding.forum.api.model.vo.comment; + +import com.github.paicoding.forum.api.model.vo.comment.dto.HighlightDto; +import lombok.Data; + +/** + * 评论列表入参 + * + * @author louzai + * @date 2022-07-24 + */ +@Data +public class CommentSaveReq { + + /** + * 评论ID + */ + private Long commentId; + + /** + * 文章ID + */ + private Long articleId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 评论内容 + */ + private String commentContent; + + /** + * 父评论ID + */ + private Long parentCommentId; + + /** + * 顶级评论ID + */ + private Long topCommentId; + + /** + * 引用的正文内容 + */ + private HighlightDto highlight; +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/BaseCommentDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/BaseCommentDTO.java similarity index 79% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/BaseCommentDTO.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/BaseCommentDTO.java index 34e1da81b..f5b6376be 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/BaseCommentDTO.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/BaseCommentDTO.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.comment.dto; +package com.github.paicoding.forum.api.model.vo.comment.dto; import lombok.Data; import org.jetbrains.annotations.NotNull; @@ -47,6 +47,16 @@ public class BaseCommentDTO implements Comparable { */ private Integer praiseCount; + /** + * true 表示已经点赞 + */ + private Boolean praised; + + /** + * 高亮信息 + */ + private HighlightDto highlight; + @Override public int compareTo(@NotNull BaseCommentDTO o) { return Long.compare(o.getCommentTime(), this.commentTime); diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/HighlightDto.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/HighlightDto.java new file mode 100644 index 000000000..240e27ba6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/HighlightDto.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.api.model.vo.comment.dto; + +import lombok.Data; + +/** + * 基于文章内容的划线评论 + *

+ * {"elementTag":"p","elementIndex":5,"startOffset":11,"endOffset":29,"selectedText":"层,强业务相关,其中每个划分出来的模"} + * + * @author YiHui + * @date 2025/11/3 + */ +@Data +public class HighlightDto { + /** + * 划线的文本内容 + */ + private String selectedText; + + private String elementTag; + + private Integer elementIndex; + + private Integer startOffset; + + private Integer endOffset; +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/SubCommentDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/SubCommentDTO.java similarity index 78% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/SubCommentDTO.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/SubCommentDTO.java index 49c80e7cb..c61128e03 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/SubCommentDTO.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/SubCommentDTO.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.comment.dto; +package com.github.paicoding.forum.api.model.vo.comment.dto; import lombok.Data; import lombok.ToString; @@ -19,6 +19,10 @@ public class SubCommentDTO extends BaseCommentDTO { */ private String parentContent; + /** + * 评论数量 + */ + private Integer commentCount; @Override public int compareTo(@NotNull BaseCommentDTO o) { diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/TopCommentDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/TopCommentDTO.java similarity index 92% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/TopCommentDTO.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/TopCommentDTO.java index 981ffd1c8..890ee3754 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/comment/dto/TopCommentDTO.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/comment/dto/TopCommentDTO.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.comment.dto; +package com.github.paicoding.forum.api.model.vo.comment.dto; import lombok.Data; import org.jetbrains.annotations.NotNull; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/GlobalConfigReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/GlobalConfigReq.java new file mode 100644 index 000000000..536b04927 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/GlobalConfigReq.java @@ -0,0 +1,21 @@ +package com.github.paicoding.forum.api.model.vo.config; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +@Data +public class GlobalConfigReq { + // 配置项名称 + private String keywords; + // 配置项值 + private String value; + // 备注 + private String comment; + // id + private Long id; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/SearchGlobalConfigReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/SearchGlobalConfigReq.java new file mode 100644 index 000000000..4bf6f2d71 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/SearchGlobalConfigReq.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.api.model.vo.config; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +@Data +public class SearchGlobalConfigReq { + // 配置项名称 + private String keywords; + // 配置项值 + private String value; + // 备注 + private String comment; + // 分页 + private Long pageNumber; + private Long pageSize; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/dto/GlobalConfigDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/dto/GlobalConfigDTO.java new file mode 100644 index 000000000..ef5d792ee --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/config/dto/GlobalConfigDTO.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.api.model.vo.config.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +@Data +public class GlobalConfigDTO implements Serializable { + // uid + private static final long serialVersionUID = 1L; + + // id + private Long id; + // 配置项名称 + private String keywords; + // 配置项值 + private String value; + // 备注 + private String comment; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/constants/StatusEnum.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/constants/StatusEnum.java new file mode 100644 index 000000000..7a3b7cc7c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/constants/StatusEnum.java @@ -0,0 +1,104 @@ +package com.github.paicoding.forum.api.model.vo.constants; + +import lombok.Getter; + +/** + * 异常码规范: + * xxx - xxx - xxx + * 业务 - 状态 - code + *

+ * 业务取值 + * - 100 全局 + * - 200 文章相关 + * - 300 评论相关 + * - 400 用户相关 + *

+ * 状态:基于http status的含义 + * - 4xx 调用方使用姿势问题 + * - 5xx 服务内部问题 + *

+ * code: 具体的业务code + * + * @author YiHui + * @date 2022/7/27 + */ +@Getter +public enum StatusEnum { + SUCCESS(0, "OK"), + + // -------------------------------- 通用 + + // 全局传参异常 + ILLEGAL_ARGUMENTS(100_400_001, "参数异常"), + ILLEGAL_ARGUMENTS_MIXED(100_400_002, "参数异常:%s"), + + // 全局权限相关 + FORBID_ERROR(100_403_001, "无权限"), + + FORBID_ERROR_MIXED(100_403_002, "无权限:%s"), + FORBID_NOTLOGIN(100_403_003, "未登录"), + + // 全局,数据不存在 + RECORDS_NOT_EXISTS(100_404_001, "记录不存在:%s"), + + // 系统异常 + UNEXPECT_ERROR(100_500_001, "非预期异常:%s"), + + // 图片相关异常类型 + UPLOAD_PIC_FAILED(100_500_002, "图片上传失败!"), + + // -------------------------------- + + // 文章相关异常类型,前缀为200 + ARTICLE_NOT_EXISTS(200_404_001, "文章不存在:%s"), + COLUMN_NOT_EXISTS(200_404_002, "教程不存在:%s"), + COLUMN_QUERY_ERROR(200_500_003, "教程查询异常:%s"), + // 教程文章已存在 + COLUMN_ARTICLE_EXISTS(200_500_004, "专栏教程已存在:%s"), + ARTICLE_RELATION_TUTORIAL(200_500_006, "文章已被添加为教程:%s"), + + // -------------------------------- + + // 评论相关异常类型 + COMMENT_NOT_EXISTS(300_404_001, "评论不存在:%s"), + + + // -------------------------------- + + // 用户相关异常 + LOGIN_FAILED_MIXED(400_403_001, "登录失败:%s"), + USER_NOT_EXISTS(400_404_001, "用户不存在:%s"), + USER_EXISTS(400_404_002, "用户已存在:%s"), + // 用户登录名重复 + USER_LOGIN_NAME_REPEAT(400_404_003, "用户登录名重复:%s"), + // 待审核 + USER_NOT_AUDIT(400_500_001, "用户未审核:%s"), + // 星球编号不存在 + USER_STAR_NOT_EXISTS(400_404_002, "星球编号不存在:%s"), + // 星球编号不能为空 + USER_STAR_EMPTY(400_404_002, "星球编号不能为空:%s"), + // 星球编号重复 + USER_STAR_REPEAT(400_404_002, "星球编号:%s 已经绑定了,请添加二哥微信 qing_gee 加快审核,或切换到登录窗口"), + USER_PWD_ERROR(400_500_002, "用户名or密码错误"); + + private int code; + + private String msg; + + StatusEnum(int code, String msg) { + this.code = code; + this.msg = msg; + } + + public static boolean is5xx(int code) { + return code % 1000_000 / 1000 >= 500; + } + + public static boolean is403(int code) { + return code % 1000_000 / 1000 == 403; + } + + public static boolean is4xx(int code) { + return code % 1000_000 / 1000 < 500; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/notify/NotifyMsgEvent.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/notify/NotifyMsgEvent.java new file mode 100644 index 000000000..4db98e7f2 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/notify/NotifyMsgEvent.java @@ -0,0 +1,32 @@ +package com.github.paicoding.forum.api.model.vo.notify; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.springframework.context.ApplicationEvent; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = true) +public class NotifyMsgEvent extends ApplicationEvent { + + private NotifyTypeEnum notifyType; + + private T content; + + + public NotifyMsgEvent(Object source, NotifyTypeEnum notifyType, T content) { + super(source); + this.notifyType = notifyType; + this.content = content; + } + + +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/notify/dto/NotifyMsgDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/notify/dto/NotifyMsgDTO.java new file mode 100644 index 000000000..96ec30455 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/notify/dto/NotifyMsgDTO.java @@ -0,0 +1,67 @@ +package com.github.paicoding.forum.api.model.vo.notify.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.sql.Timestamp; + +/** + * @author YiHui + * @date 2022/9/4 + */ +@Data +public class NotifyMsgDTO implements Serializable { + private static final long serialVersionUID = 3833777672628522348L; + + private Long msgId; + + /** + * 消息关联的主体,如文章、评论 + */ + private String relatedId; + + /** + * 关联的评论ID + */ + private Long commentId; + + /** + * 关联信息 + */ + private String relatedInfo; + + /** + * 发起消息的用户id + */ + private Long operateUserId; + + /** + * 发起消息的用户名 + */ + private String operateUserName; + + /** + * 发起消息的用户头像 + */ + private String operateUserPhoto; + + /** + * 消息类型 + */ + private Integer type; + + /** + * 消息正文 + */ + private String msg; + + /** + * 1 已读/ 0 未读 + */ + private Integer state; + + /** + * 消息产生时间 + */ + private Timestamp createTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/pay/dto/PayInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/pay/dto/PayInfoDTO.java new file mode 100644 index 000000000..b35fedc69 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/pay/dto/PayInfoDTO.java @@ -0,0 +1,40 @@ +package com.github.paicoding.forum.api.model.vo.pay.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Map; + +/** + * 用于支付的相关信息 + * + * @author YiHui + * @date 2024/12/9 + */ +@Data +public class PayInfoDTO implements Serializable { + /** + * 收款用户对应的各渠道的收款码 + */ + private Map payQrCodeMap; + + /** + * 支付方式 + */ + private String payWay; + + /** + * 支付金额 + */ + private String payAmount; + + /** + * 支付信息 + */ + private String prePayId; + + /** + * 失效时间 + */ + private Long prePayExpireTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/RankItemReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/RankItemReq.java new file mode 100644 index 000000000..d51986ee5 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/RankItemReq.java @@ -0,0 +1,9 @@ +package com.github.paicoding.forum.api.model.vo.rank; + +/** + * @author YiHui + * @date 2023/8/19 + */ +public class RankItemReq { + private String time; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/dto/RankInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/dto/RankInfoDTO.java new file mode 100644 index 000000000..906836176 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/dto/RankInfoDTO.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.api.model.vo.rank.dto; + +import com.github.paicoding.forum.api.model.enums.rank.ActivityRankTimeEnum; +import lombok.Data; + +import java.util.List; + +/** + * 排行榜信息 + * + * @author YiHui + * @date 2023/8/19 + */ +@Data +public class RankInfoDTO { + private ActivityRankTimeEnum time; + private List items; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/dto/RankItemDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/dto/RankItemDTO.java new file mode 100644 index 000000000..af7bb602c --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/rank/dto/RankItemDTO.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.api.model.vo.rank.dto; + +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 排行榜信息 + * + * @author YiHui + * @date 2023/8/19 + */ +@Data +@Accessors(chain = true) +public class RankItemDTO { + + /** + * 排名 + */ + private Integer rank; + + /** + * 评分 + */ + private Integer score; + + /** + * 用户 + */ + private SimpleUserInfoDTO user; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/CarouseDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/CarouseDTO.java new file mode 100644 index 000000000..2bc78ff15 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/CarouseDTO.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.api.model.vo.recommend; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * @author YiHui + * @date 2022/9/7 + */ +@Data +@Accessors(chain = true) +public class CarouseDTO implements Serializable { + + private static final long serialVersionUID = 1048555496974144842L; + /** + * 说明 + */ + private String name; + /** + * 图片地址 + */ + private String imgUrl; + /** + * 跳转地址 + */ + private String actionUrl; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/RateVisitDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/RateVisitDTO.java new file mode 100644 index 000000000..04ec9fd5d --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/RateVisitDTO.java @@ -0,0 +1,42 @@ +package com.github.paicoding.forum.api.model.vo.recommend; + +import lombok.Data; + +/** + * 资源的访问、评分信息 + * + * @author YiHui + * @date 2023/1/3 + */ +@Data +public class RateVisitDTO { + + /** + * 查看次数 + */ + private Integer visit; + + /** + * 下载次数 + */ + private Integer download; + + /** + * 评分, 浮点数,string方式返回,避免精度问题 + */ + private String rate; + + public RateVisitDTO() { + visit = 0; + download = 0; + rate = "8"; + } + + public void incrVisit() { + visit += 1; + } + + public void incrDownload() { + download += 1; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/SideBarDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/SideBarDTO.java new file mode 100644 index 000000000..ff1e485f2 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/SideBarDTO.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.api.model.vo.recommend; + +import com.github.paicoding.forum.api.model.enums.SidebarStyleEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * 侧边推广信息 + * + * @author YiHui + * @date 2022/9/6 + */ +@Data +@Accessors(chain = true) +public class SideBarDTO { + + private String title; + + private String subTitle; + + private String icon; + + private String img; + + private String url; + + private String content; + + private List items; + + /** + * 侧边栏样式 + * + * @see SidebarStyleEnum#getStyle() + */ + private Integer style; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/SideBarItemDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/SideBarItemDTO.java new file mode 100644 index 000000000..7b81fa6b6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/recommend/SideBarItemDTO.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.api.model.vo.recommend; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * 侧边推广信息 + * + * @author YiHui + * @date 2022/9/6 + */ +@Data +@Accessors(chain = true) +public class SideBarItemDTO { + + private String title; + + private String name; + + private String url; + + private String img; + + private Long time; + + /** + * tag列表 + */ + private List tags; + + /** + * 评分信息 + */ + private RateVisitDTO visit; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/seo/Seo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/seo/Seo.java new file mode 100644 index 000000000..c0ef7c3b9 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/seo/Seo.java @@ -0,0 +1,14 @@ +package com.github.paicoding.forum.api.model.vo.seo; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +public class Seo { + private List ogp; + private Map jsonLd; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/seo/SeoTagVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/seo/SeoTagVo.java new file mode 100644 index 000000000..917d5b51d --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/seo/SeoTagVo.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.api.model.vo.seo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author YiHui + * @date 2023/2/13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SeoTagVo { + + private String key; + + private String val; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/ShortLinkReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/ShortLinkReq.java new file mode 100644 index 000000000..8b0bebe97 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/ShortLinkReq.java @@ -0,0 +1,21 @@ +package com.github.paicoding.forum.api.model.vo.shortlink; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 短链接请求对象 + * + * @author betasecond + * @date 2025-02-13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ShortLinkReq { + /** + * 原始URL + */ + private String originalUrl; +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/ShortLinkVO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/ShortLinkVO.java new file mode 100644 index 000000000..c2c26146f --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/ShortLinkVO.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.api.model.vo.shortlink; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 短链接返回对象 + * + * @author betasecond + * @date 2025-02-13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ShortLinkVO { + /** + * 短链接URL + */ + private String shortUrl; + + /** + * 原始URL + */ + private String originalUrl; +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/dto/ShortLinkDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/dto/ShortLinkDTO.java new file mode 100644 index 000000000..0e6962005 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/shortlink/dto/ShortLinkDTO.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.api.model.vo.shortlink.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 短链接传输对象 + * + * @author betasecond + * @date 2025-02-13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ShortLinkDTO { + /** + * 原始URL + */ + private String originalUrl; + + /** + * 用户ID + */ + private String userId; + + /** + * 短链接代码 + */ + private String shortCode; +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/statistics/dto/StatisticsCountDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/statistics/dto/StatisticsCountDTO.java new file mode 100644 index 000000000..cfeb91340 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/statistics/dto/StatisticsCountDTO.java @@ -0,0 +1,60 @@ +package com.github.paicoding.forum.api.model.vo.statistics.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 统计计数 + * + * @author louzai + * @date 2022-10-1 + */ +@Data +@Builder +public class StatisticsCountDTO { + + /** + * PV 数量 + */ + private Long pvCount; + + /** + * 总用户数 + */ + private Long userCount; + + /** + * 总评论数 + */ + private Long commentCount; + + /** + * 总阅读数 + */ + private Long readCount; + + /** + * 总点赞数 + */ + private Long likeCount; + + /** + * 总收藏数 + */ + private Long collectCount; + + /** + * 文章数量 + */ + private Long articleCount; + + /** + * 教程数量 + */ + private Long tutorialCount; + + /** + * 星球付费人数 + */ + private Integer starPayCount; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/statistics/dto/StatisticsDayDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/statistics/dto/StatisticsDayDTO.java new file mode 100644 index 000000000..15a74b73e --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/statistics/dto/StatisticsDayDTO.java @@ -0,0 +1,28 @@ +package com.github.paicoding.forum.api.model.vo.statistics.dto; + +import lombok.Data; + +/** + * 每天的统计计数 + * + * @author louzai + * @date 2022-10-1 + */ +@Data +public class StatisticsDayDTO { + + /** + * 日期 + */ + private String date; + + /** + * 数量 + */ + private Long pvCount; + + /** + * UV数量 + */ + private Long uvCount; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/SearchZsxqUserReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/SearchZsxqUserReq.java new file mode 100644 index 000000000..914fc67b2 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/SearchZsxqUserReq.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +@Data +public class SearchZsxqUserReq { + // 用户昵称 + private String name; + // 星球编号 + private String starNumber; + // 用户登录名 + private String userCode; + + private Integer state; + // 分页 + private Long pageNumber; + private Long pageSize; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserInfoSaveReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserInfoSaveReq.java new file mode 100644 index 000000000..c5589e470 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserInfoSaveReq.java @@ -0,0 +1,57 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; + +import java.util.Map; + +/** + * 用户信息入参 + * + * @author louzai + * @date 2022-07-24 + */ +@Data +public class UserInfoSaveReq { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String userName; + + /** + * 用户图像 + */ + private String photo; + + /** + * 职位 + */ + private String position; + + /** + * 公司 + */ + private String company; + + /** + * 个人简介 + */ + private String profile; + + /** + * 用户的邮件地址 + */ + private String email; + + /** + * 收款码 + * key: qq|wx|ali --> 收款渠道 + * value: 收款二维码内容 + */ + private Map payCode; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserPwdLoginReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserPwdLoginReq.java new file mode 100644 index 000000000..ebbb9cbba --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserPwdLoginReq.java @@ -0,0 +1,64 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * 用户名密码登录方式 + * + * @author YiHui + * @date 2022/8/15 + */ +@Data +@Accessors(chain = true) +public class UserPwdLoginReq implements Serializable { + private static final long serialVersionUID = -5941617870303218990L; + + private Long userId; + /** + * 登录用户名 + */ + private String username; + + /** + * 登录密码 + */ + private String password; + + /** + * 显示名称 + */ + private String displayName; + + /** + * 用户头像 + */ + private String avatar; + + /** + * 邀请码 + */ + private String invitationCode; + + /** + * 星球编号 + */ + private String starNumber; + + /** + * 星球过期时间 + */ + private Long starExpireTime; + + /** + * 登录类型 + */ + private Integer loginType; + + /** + * 第三方账号ID + */ + private String thirdAccountId; +} \ No newline at end of file diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserRelationReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserRelationReq.java new file mode 100644 index 000000000..2a558e21b --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserRelationReq.java @@ -0,0 +1,28 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; + +/** + * 用户关系入参 + * + * @author louzai + * @date 2022-07-24 + */ +@Data +public class UserRelationReq { + + /** + * 用户ID + */ + private Long userId; + + /** + * 粉丝用户ID + */ + private Long followUserId; + + /** + * 是否关注当前用户 + */ + private Boolean followed; +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/UserSaveReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserSaveReq.java similarity index 88% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/UserSaveReq.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserSaveReq.java index 864484280..7e4e37632 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/UserSaveReq.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserSaveReq.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.user; +package com.github.paicoding.forum.api.model.vo.user; import lombok.Data; import lombok.experimental.Accessors; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserZsxqLoginReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserZsxqLoginReq.java new file mode 100644 index 000000000..3c27eab41 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/UserZsxqLoginReq.java @@ -0,0 +1,46 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * 知识星球登录 + * + * @author YiHui + * @date 2025/08/19 + */ +@Data +@Accessors(chain = true) +public class UserZsxqLoginReq implements Serializable { + private static final long serialVersionUID = 2139742660700910738L; + /** + * 知识星球用户id + */ + private Long starUserId; + /** + * 登录用户名 + */ + private String username; + + /** + * 用户昵称 + */ + private String displayName; + /** + * 星球编号 + */ + private String starNumber; + /** + * 头像 + */ + private String avatar; + + /** + * 过期时间(ms) + */ + private Long expireTime; + + private Boolean updateUserInfo; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/ZsxqUserBatchOperateReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/ZsxqUserBatchOperateReq.java new file mode 100644 index 000000000..2c5a6e727 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/ZsxqUserBatchOperateReq.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +@Data +public class ZsxqUserBatchOperateReq implements Serializable { + // ids + private List ids; + // 状态 + private Integer status; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/ZsxqUserPostReq.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/ZsxqUserPostReq.java new file mode 100644 index 000000000..d27ea7d99 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/ZsxqUserPostReq.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.api.model.vo.user; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +@Data +public class ZsxqUserPostReq implements Serializable { + // id + private Long id; + // 用户名 + private String userCode; + // 用户昵称 + private String name; + // 星球编号 + private String starNumber; + // 星球过期时间 (支持日期字符串格式) + private String expireTime; +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/dto/ArticleFootCountDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ArticleFootCountDTO.java similarity index 90% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/dto/ArticleFootCountDTO.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ArticleFootCountDTO.java index f6d655434..c0bbcd58b 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/dto/ArticleFootCountDTO.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ArticleFootCountDTO.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.user.dto; +package com.github.paicoding.forum.api.model.vo.user.dto; import lombok.Data; diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/BaseUserInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/BaseUserInfoDTO.java new file mode 100644 index 000000000..313958aba --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/BaseUserInfoDTO.java @@ -0,0 +1,104 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import com.github.paicoding.forum.api.model.entity.BaseDTO; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.Date; + +/** + * @author YiHui + * @date 2022/8/15 + */ +@Data +@ApiModel("用户基础实体对象") +@Accessors(chain = true) +public class BaseUserInfoDTO extends BaseDTO { + /** + * 用户id + */ + @ApiModelProperty(value = "用户id", required = true) + private Long userId; + + /** + * 用户名 + */ + @ApiModelProperty(value = "用户名", required = true) + private String userName; + + /** + * 用户角色 admin, normal + */ + @ApiModelProperty(value = "角色", example = "ADMIN|NORMAL") + private String role; + + /** + * 用户图像 + */ + @ApiModelProperty(value = "用户头像") + private String photo; + /** + * 个人简介 + */ + @ApiModelProperty(value = "用户简介") + private String profile; + /** + * 职位 + */ + @ApiModelProperty(value = "个人职位") + private String position; + + /** + * 公司 + */ + @ApiModelProperty(value = "公司") + private String company; + + /** + * 扩展字段 + */ + @ApiModelProperty(hidden = true) + private String extend; + + /** + * 是否删除 + */ + @ApiModelProperty(hidden = true, value = "用户是否被删除") + private Integer deleted; + + /** + * 用户最后登录区域 + */ + @ApiModelProperty(value = "用户最后登录的地理位置", example = "湖北·武汉") + private String region; + + /** + * 星球状态 + */ + private UserAIStatEnum starStatus; + + /** + * 星球编号 + */ + private String starNumber; + + /** + * 星球到期时间(秒) + */ + private Date expireTime; + + /** + * 用户的邮箱 + */ + @ApiModelProperty(value = "用户邮箱", example = "paicoding@126.com") + private String email; + + /** + * 收款码信息 + */ + @ApiModelProperty(value = "用户的收款码", example = "{\"wx\":\"wxp://f2f0YUXuGn6X2dI6FS2GrMjuG0Lw2plZqwjO4keoZaRr320\"}") + private String payCode; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ColumnFootCountDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ColumnFootCountDTO.java new file mode 100644 index 000000000..860389dfe --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ColumnFootCountDTO.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import lombok.Data; + +/** + * 专栏统计计数 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +public class ColumnFootCountDTO { + + /** + * 专栏点赞数 + */ + private Integer praiseCount; + + /** + * 专栏被阅读数 + */ + private Integer readCount; + + /** + * 专栏被收藏数 + */ + private Integer collectionCount; + + /** + * 专栏评论数 + */ + private Integer commentCount; + + /** + * 专栏已更新的文章数 + */ + private Integer articleCount; + + /** + * 专栏的文章总数 + */ + private Integer totalNums; + + public ColumnFootCountDTO() { + praiseCount = 0; + readCount = 0; + collectionCount = 0; + commentCount = 0; + articleCount = 0; + totalNums = 0; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/FollowUserInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/FollowUserInfoDTO.java new file mode 100644 index 000000000..7cc7732c2 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/FollowUserInfoDTO.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 关注者用户信息 + * + * @author YiHui + * @date 2022/11/2 + */ +@Data +public class FollowUserInfoDTO implements Serializable { + private static final long serialVersionUID = 7169636386013658631L; + /** + * 当前登录的用户与这个用户之间的关联关系id + */ + private Long relationId; + + /** + * true 表示当前登录用户关注了这个用户 + * false 标识当前登录用户没有关注这个用户 + */ + private Boolean followed; + + /** + * 用户id + */ + private Long userId; + + /** + * 用户名 + */ + private String userName; + + /** + * 用户头像 + */ + private String avatar; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/SimpleUserInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/SimpleUserInfoDTO.java new file mode 100644 index 000000000..11358d697 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/SimpleUserInfoDTO.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * 基本用户信息 + * + * @author YiHui + * @date 2022/9/26 + */ +@Data +@Accessors(chain = true) +public class SimpleUserInfoDTO implements Serializable { + private static final long serialVersionUID = 4802653694786272120L; + + @ApiModelProperty("作者ID") + private Long userId; + + @ApiModelProperty("作者名") + private String name; + + @ApiModelProperty("作者头像") + private String avatar; + + @ApiModelProperty("作者简介") + private String profile; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserFootStatisticDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserFootStatisticDTO.java new file mode 100644 index 000000000..99a2c12f2 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserFootStatisticDTO.java @@ -0,0 +1,42 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import lombok.Data; +import lombok.ToString; + +/** + * 用户主页信息 + * + * @author 沉默王二 + * @since 2023年05月25日 + */ +@Data +@ToString(callSuper = true) +public class UserFootStatisticDTO { + + /** + * 文章点赞数 + */ + private Long praiseCount; + + /** + * 文章被阅读数 + */ + private Long readCount; + + /** + * 文章被收藏数 + */ + private Long collectionCount; + + /** + * 文章被评论数 + */ + private Long commentCount; + + public UserFootStatisticDTO() { + praiseCount = 0L; + readCount = 0L; + collectionCount = 0L; + commentCount = 0L; + } +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserPayCodeDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserPayCodeDTO.java new file mode 100644 index 000000000..3ec0042e0 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserPayCodeDTO.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 用户收款码 + * + * @author YiHui + * @date 2024/10/30 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserPayCodeDTO implements Serializable { + private static final long serialVersionUID = -2601714252107169062L; + + /** + * base64格式的收款二维码图片 + */ + private String qrCode; + + /** + * 内容 + */ + private String qrMsg; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserStatisticInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserStatisticInfoDTO.java new file mode 100644 index 000000000..de24a2aea --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/UserStatisticInfoDTO.java @@ -0,0 +1,74 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import com.github.paicoding.forum.api.model.vo.article.dto.YearArticleDTO; +import lombok.Data; +import lombok.ToString; + +import java.util.List; +import java.util.Map; + +/** + * 用户主页信息 + * + * @author louzai + * @since 2022/7/19 + */ +@Data +@ToString(callSuper = true) +public class UserStatisticInfoDTO extends BaseUserInfoDTO { + + /** + * 关注数 + */ + private Integer followCount; + + /** + * 粉丝数 + */ + private Integer fansCount; + + /** + * 加入天数 + */ + private Integer joinDayCount; + + /** + * 已发布文章数 + */ + private Integer articleCount; + + /** + * 文章点赞数 + */ + private Integer praiseCount; + + /** + * 文章被阅读数 + */ + private Integer readCount; + + /** + * 文章被收藏数 + */ + private Integer collectionCount; + + /** + * 是否关注当前用户 + */ + private Boolean followed; + + /** + * 身份信息完整度百分比 + */ + private Integer infoPercent; + + /** + * 创造历程 + */ + private List yearArticleList; + + /** + * 作者的收款码信息 + */ + private Map payQrCodes; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ZsxqUserInfoDTO.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ZsxqUserInfoDTO.java new file mode 100644 index 000000000..591bf4996 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/dto/ZsxqUserInfoDTO.java @@ -0,0 +1,64 @@ +package com.github.paicoding.forum.api.model.vo.user.dto; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.Date; + +/** + * 基本用户信息 + * + * @author YiHui + * @date 2022/9/26 + */ +@Data +@Accessors(chain = true) +public class ZsxqUserInfoDTO implements Serializable { + private static final long serialVersionUID = 4802653694786272120L; + + private Long id; + + @ApiModelProperty("用户ID") + private Long userId; + + // 这个是 userinfo 表中的 username + @ApiModelProperty("用户名") + private String name; + + @ApiModelProperty("用户头像") + private String avatar; + + // 这个是 user 表中的 username + @ApiModelProperty("用户编号") + private String userCode; + + // 星球编号 + @ApiModelProperty("星球编号") + private String starNumber; + + // 邀请码 + @ApiModelProperty("邀请码") + private String inviteCode; + + // 邀请人数 + @ApiModelProperty("邀请人数") + private Integer inviteNum; + + // 状态 + @ApiModelProperty("状态") + private Integer state; + + // login_type + @ApiModelProperty("登录类型") + private Integer loginType; + + // strategy + @ApiModelProperty("AI策略") + private Integer strategy; + + // 过期时间 + @ApiModelProperty("过期时间") + private Date expireTime; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/BaseWxMsgResVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/BaseWxMsgResVo.java new file mode 100644 index 000000000..9c3b9fe2b --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/BaseWxMsgResVo.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.api.model.vo.user.wx; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Data; + +/** + * 返回的数据结构体 + *

+ * + * @author yihui + * @link + * @date 2022/6/20 + */ +@Data +@JacksonXmlRootElement(localName = "xml") +public class BaseWxMsgResVo { + + @JacksonXmlProperty(localName = "ToUserName") + private String toUserName; + @JacksonXmlProperty(localName = "FromUserName") + private String fromUserName; + @JacksonXmlProperty(localName = "CreateTime") + private Long createTime; + @JacksonXmlProperty(localName = "MsgType") + private String msgType; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxImgTxtItemVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxImgTxtItemVo.java new file mode 100644 index 000000000..ad880f9d6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxImgTxtItemVo.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.api.model.vo.user.wx; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Data; + +/** + * 返回的数据结构体 + *

+ * + * @author yihui + * @link + * @date 2022/6/20 + */ +@Data +@JacksonXmlRootElement(localName = "item") +public class WxImgTxtItemVo { + + @JacksonXmlProperty(localName = "Title") + private String title; + @JacksonXmlProperty(localName = "Description") + private String description; + @JacksonXmlProperty(localName = "PicUrl") + private String picUrl; + @JacksonXmlProperty(localName = "Url") + private String url; +} diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxImgTxtMsgResVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxImgTxtMsgResVo.java new file mode 100644 index 000000000..8580c1221 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxImgTxtMsgResVo.java @@ -0,0 +1,32 @@ +package com.github.paicoding.forum.api.model.vo.user.wx; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Data; +import lombok.ToString; + +import java.util.List; + +/** + * 返回的数据结构体 + *

+ * + * @author yihui + * @link + * @date 2022/6/20 + */ +@Data +@ToString(callSuper = true) +@JacksonXmlRootElement(localName = "xml") +public class WxImgTxtMsgResVo extends BaseWxMsgResVo { + @JacksonXmlProperty(localName = "ArticleCount") + private Integer articleCount; + @JacksonXmlElementWrapper(localName = "Articles") + @JacksonXmlProperty(localName = "item") + private List articles; + + public WxImgTxtMsgResVo() { + setMsgType("news"); + } +} diff --git a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/wx/WxTxtMsgReqVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxTxtMsgReqVo.java similarity index 79% rename from form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/wx/WxTxtMsgReqVo.java rename to paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxTxtMsgReqVo.java index 2a8f968cd..a87a78ab2 100644 --- a/form-api/src/main/java/com/github/liueyueyi/forum/api/model/vo/user/wx/WxTxtMsgReqVo.java +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxTxtMsgReqVo.java @@ -1,4 +1,4 @@ -package com.github.liueyueyi.forum.api.model.vo.user.wx; +package com.github.paicoding.forum.api.model.vo.user.wx; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; @@ -22,6 +22,12 @@ public class WxTxtMsgReqVo { private Long createTime; @JacksonXmlProperty(localName = "MsgType") private String msgType; + @JacksonXmlProperty(localName = "Event") + private String event; + @JacksonXmlProperty(localName = "EventKey") + private String eventKey; + @JacksonXmlProperty(localName = "Ticket") + private String ticket; @JacksonXmlProperty(localName = "Content") private String content; @JacksonXmlProperty(localName = "MsgId") diff --git a/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxTxtMsgResVo.java b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxTxtMsgResVo.java new file mode 100644 index 000000000..3e2e20ee6 --- /dev/null +++ b/paicoding-api/src/main/java/com/github/paicoding/forum/api/model/vo/user/wx/WxTxtMsgResVo.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.api.model.vo.user.wx; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Data; +import lombok.ToString; + +/** + * 返回的数据结构体 + *

+ * + * @author yihui + * @link + * @date 2022/6/20 + */ +@Data +@ToString(callSuper = true) +@JacksonXmlRootElement(localName = "xml") +public class WxTxtMsgResVo extends BaseWxMsgResVo { + @JacksonXmlProperty(localName = "Content") + private String content; + + public WxTxtMsgResVo() { + setMsgType("text"); + } +} diff --git a/paicoding-core/pom.xml b/paicoding-core/pom.xml new file mode 100644 index 000000000..79a7b01ae --- /dev/null +++ b/paicoding-core/pom.xml @@ -0,0 +1,211 @@ + + + + paicoding-forum + com.github.paicoding.forum + 0.0.1-SNAPSHOT + + 4.0.0 + + paicoding-core + + + 8 + 8 + + + + + com.github.paicoding.forum + paicoding-api + + + com.github.liuyueyi.media + qrcode-plugin + + + org.springframework + spring-context + + + javax.servlet + javax.servlet-api + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + org.apache.commons + commons-lang3 + + + + com.google.guava + guava + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework + spring-web + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.lionsoul + ip2region + 2.6.6 + + + + + com.github.ben-manes.caffeine + caffeine + + + org.springframework.boot + spring-boot-starter-cache + + + + + com.github.xiaoymin + knife4j-openapi2-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-mail + + + + + com.rabbitmq + amqp-client + 5.5.1 + + + + + com.vladsch.flexmark + flexmark-all + 0.62.2 + + + org.springframework + spring-jdbc + + + org.aspectj + aspectjweaver + + + org.mybatis + mybatis + 3.5.10 + compile + + + org.mybatis + mybatis-spring + 2.0.7 + compile + + + com.zaxxer + HikariCP + + + com.baomidou + mybatis-plus-core + 3.4.3.4 + compile + + + + com.alibaba + druid-spring-boot-starter + 1.2.16 + provided + + + mysql + mysql-connector-java + + + + com.github.plexpt + chatgpt + 4.4.0 + + + + com.github.houbb + sensitive-word + ${sensitive.version} + + + + com.alibaba + transmittable-thread-local + 2.14.5 + + + io.github.classgraph + classgraph + 4.8.83 + compile + + + + cn.bigmodel.openapi + oapi-java-sdk + release-V4-2.4.3 + + + + org.springframework.security + spring-security-core + 6.3.0 + + + + com.alibaba + dashscope-sdk-java + 2.16.2 + + + org.springframework + spring-messaging + provided + + + + cn.idev.excel + fastexcel + + + + + com.belerweb + pinyin4j + 2.5.1 + + + + \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/ForumCoreAutoConfig.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/ForumCoreAutoConfig.java new file mode 100644 index 000000000..d03e342c8 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/ForumCoreAutoConfig.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.core; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.config.ProxyProperties; +import com.github.paicoding.forum.core.net.ProxyCenter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; + +import javax.annotation.PostConstruct; +import java.util.concurrent.TimeUnit; + +/** + * @author YiHui + * @date 2022/9/4 + */ +@Configuration +@EnableConfigurationProperties(ProxyProperties.class) +@ComponentScan(basePackages = "com.github.paicoding.forum.core") +public class ForumCoreAutoConfig { + @Autowired + private ProxyProperties proxyProperties; + + public ForumCoreAutoConfig(RedisTemplate redisTemplate) { + RedisClient.register(redisTemplate); + } + + /** + * 定义缓存管理器,配合Spring的 @Cache 来使用 + * + * @return + */ + @Bean("caffeineCacheManager") + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCaffeine(Caffeine.newBuilder(). + // 设置过期时间,写入后五分钟国企 + expireAfterWrite(5, TimeUnit.MINUTES) + // 初始化缓存空间大小 + .initialCapacity(100) + // 最大的缓存条数 + .maximumSize(200) + ); + return cacheManager; + } + + @PostConstruct + public void init() { + // 这里借助手动解析配置信息,并实例化为Java POJO对象,来实现代理池的初始化 + ProxyCenter.initProxyPool(proxyProperties.getProxy()); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncExecute.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncExecute.java new file mode 100644 index 000000000..cafd9e910 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncExecute.java @@ -0,0 +1,48 @@ +package com.github.paicoding.forum.core.async; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 异步执行 + * + * @author YiHui + * @date 2023/11/10 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AsyncExecute { + /** + * 是否开启异步执行 + * + * @return + */ + boolean value() default true; + + /** + * 超时时间,默认3s + * + * @return + */ + int timeOut() default 3; + + /** + * 超时时间单位,默认秒,配合上面的 timeOut 使用 + * + * @return + */ + TimeUnit unit() default TimeUnit.SECONDS; + + /** + * 当出现超时返回的兜底逻辑,支持SpEL + * 如果返回的是空字符串,则表示抛出异常 + * + * @return + */ + String timeOutRsp() default ""; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncExecuteAspect.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncExecuteAspect.java new file mode 100644 index 000000000..eb12a892c --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncExecuteAspect.java @@ -0,0 +1,91 @@ +package com.github.paicoding.forum.core.async; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +/** + * 异步执行 + * + * @author YiHui + * @date 2023/11/10 + */ +@Slf4j +@Aspect +@Component +public class AsyncExecuteAspect implements ApplicationContextAware { + + /** + * 超时执行的切面 + * + * @param joinPoint + * @param asyncExecute + * @return + * @throws Throwable + */ + @Around("@annotation(asyncExecute)") + public Object handle(ProceedingJoinPoint joinPoint, AsyncExecute asyncExecute) throws Throwable { + if (!asyncExecute.value()) { + // 不支持异步执行时,直接返回 + return joinPoint.proceed(); + } + + try { + // 携带超时时间的执行调用 + return AsyncUtil.callWithTimeLimit(asyncExecute.timeOut(), asyncExecute.unit(), () -> { + try { + return joinPoint.proceed(); + } catch (Throwable e) { + throw new RuntimeException(e); + } + }); + } catch (ExecutionException | InterruptedException | TimeoutException e) { + if (StringUtils.isNotBlank(asyncExecute.timeOutRsp())) { + return defaultRespWhenTimeOut(joinPoint, asyncExecute); + } else { + throw e; + } + } catch (Exception e) { + throw e; + } + } + + private Object defaultRespWhenTimeOut(ProceedingJoinPoint joinPoint, AsyncExecute asyncExecute) { + StandardEvaluationContext context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(this.applicationContext)); + + // 超时,使用自定义的返回策略进行返回 + MethodSignature methodSignature = ((MethodSignature) joinPoint.getSignature()); + String[] parameterNames = methodSignature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + log.info("{} 执行超时,返回兜底结果!", methodSignature.getMethod().getName()); + return parser.parseExpression(asyncExecute.timeOutRsp()).getValue(context); + } + + + private ExpressionParser parser; + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.parser = new SpelExpressionParser(); + this.applicationContext = applicationContext; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncUtil.java new file mode 100644 index 000000000..1b1852d93 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/async/AsyncUtil.java @@ -0,0 +1,333 @@ +package com.github.paicoding.forum.core.async; + +import cn.hutool.core.thread.ExecutorBuilder; +import cn.hutool.core.util.ArrayUtil; +import com.alibaba.ttl.TransmittableThreadLocal; +import com.alibaba.ttl.threadpool.TtlExecutors; +import com.github.paicoding.forum.core.util.EnvUtil; +import com.google.common.util.concurrent.SimpleTimeLimiter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.CollectionUtils; + +import java.io.Closeable; +import java.text.NumberFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +/** + * 异步工具类 + * + * @author YiHui + * @date 2023/6/12 + */ +@Slf4j +public class AsyncUtil { + private static final TransmittableThreadLocal THREAD_LOCAL = new TransmittableThreadLocal<>(); + private static final ThreadFactory THREAD_FACTORY = new ThreadFactory() { + private final ThreadFactory defaultFactory = Executors.defaultThreadFactory(); + private final AtomicInteger threadNumber = new AtomicInteger(1); + + public Thread newThread(Runnable r) { + Thread thread = this.defaultFactory.newThread(r); + if (!thread.isDaemon()) { + thread.setDaemon(true); + } + + thread.setName("paicoding-" + this.threadNumber.getAndIncrement()); + return thread; + } + }; + private static ExecutorService executorService; + private static SimpleTimeLimiter simpleTimeLimiter; + + static { + initExecutorService(Runtime.getRuntime().availableProcessors() * 2, 50); + } + + public static void initExecutorService(int core, int max) { + // 异步工具类的默认线程池构建, 参数选择原则: + // 1. 技术派不存在cpu密集型任务,大部分操作都设计到 redis/mysql 等io操作 + // 2. 统一的异步封装工具,这里的线程池是一个公共的执行仓库,不希望被其他的线程执行影响,因此队列长度为0, 核心线程数满就创建线程执行,超过最大线程,就直接当前线程执行 + // 3. 同样因为属于通用工具类,再加上技术派的异步使用的情况实际上并不是非常饱和的,因此空闲线程直接回收掉即可;大部分场景下,cpu * 2的线程数即可满足要求了 + max = Math.max(core, max); + executorService = new ExecutorBuilder() + .setCorePoolSize(core) + .setMaxPoolSize(max) + .setKeepAliveTime(0) + .setKeepAliveTime(0, TimeUnit.SECONDS) + .setWorkQueue(new SynchronousQueue()) + .setHandler(new ThreadPoolExecutor.CallerRunsPolicy()) + .setThreadFactory(THREAD_FACTORY) + .buildFinalizable(); + // 包装一下线程池,避免出现上下文复用场景 + executorService = TtlExecutors.getTtlExecutorService(executorService); + simpleTimeLimiter = SimpleTimeLimiter.create(executorService); + } + + + /** + * 带超时时间的方法调用执行,当执行时间超过给定的时间,则返回一个超时异常,内部的任务还是正常执行 + * 若超时时间内执行完毕,则直接返回 + * + * @param time + * @param unit + * @param call + * @param + * @return + */ + public static T callWithTimeLimit(long time, TimeUnit unit, Callable call) throws ExecutionException, InterruptedException, TimeoutException { + return simpleTimeLimiter.callWithTimeout(call, time, unit); + } + + + public static void execute(Runnable call) { + executorService.execute(call); + } + + public static Future submit(Callable t) { + return executorService.submit(t); + } + + + public static boolean sleep(Number timeout, TimeUnit timeUnit) { + try { + timeUnit.sleep(timeout.longValue()); + return true; + } catch (InterruptedException var3) { + return false; + } + } + + public static boolean sleep(Number millis) { + return millis == null ? true : sleep(millis.longValue()); + } + + public static boolean sleep(long millis) { + if (millis > 0L) { + try { + Thread.sleep(millis); + } catch (InterruptedException var3) { + return false; + } + } + + return true; + } + + + public static class CompletableFutureBridge implements Closeable { + private List list; + private Map cost; + private String taskName; + private boolean markOver; + private ExecutorService executorService; + + public CompletableFutureBridge() { + this(AsyncUtil.executorService, "CompletableFutureExecute"); + } + + public CompletableFutureBridge(ExecutorService executorService, String task) { + this.taskName = task; + list = new CopyOnWriteArrayList<>(); + // 支持排序的耗时记录 + cost = new ConcurrentSkipListMap<>(); + cost.put(task, System.currentTimeMillis()); + this.executorService = TtlExecutors.getTtlExecutorService(executorService); + this.markOver = false; + } + + /** + * 异步执行,带返回结果 + * + * @param supplier 执行任务 + * @param name 耗时标识 + * @return + */ + public CompletableFutureBridge async(Supplier supplier, String name) { + list.add(CompletableFuture.supplyAsync(supplyWithTime(supplier, name), this.executorService)); + return this; + } + + /** + * 同步执行,待返回结果 + * + * @param supplier 执行任务 + * @param name 耗时标识 + * @param 返回类型 + * @return 任务的执行返回结果 + */ + public T sync(Supplier supplier, String name) { + return supplyWithTime(supplier, name).get(); + } + + /** + * 异步执行,无返回结果 + * + * @param run 执行任务 + * @param name 耗时标识 + * @return + */ + public CompletableFutureBridge async(Runnable run, String name) { + list.add(CompletableFuture.runAsync(runWithTime(run, name), this.executorService)); + return this; + } + + /** + * 同步执行,无返回结果 + * + * @param run 执行任务 + * @param name 耗时标识 + * @return + */ + public CompletableFutureBridge sync(Runnable run, String name) { + runWithTime(run, name).run(); + return this; + } + + private Runnable runWithTime(Runnable run, String name) { + return () -> { + startRecord(name); + try { + run.run(); + } finally { + endRecord(name); + } + }; + } + + private Supplier supplyWithTime(Supplier call, String name) { + return () -> { + startRecord(name); + try { + return call.get(); + } finally { + endRecord(name); + } + }; + } + + public CompletableFutureBridge allExecuted() { + if (!CollectionUtils.isEmpty(list)) { + CompletableFuture.allOf(ArrayUtil.toArray(list, CompletableFuture.class)).join(); + } + this.markOver = true; + endRecord(this.taskName); + return this; + } + + private void startRecord(String name) { + cost.put(name, System.currentTimeMillis()); + } + + private void endRecord(String name) { + long now = System.currentTimeMillis(); + long last = cost.getOrDefault(name, now); + if (last >= now / 1000) { + // 之前存储的是时间戳,因此我们需要更新成执行耗时 ms单位 + cost.put(name, now - last); + } + } + + public void prettyPrint() { + if (EnvUtil.isPro()) { + // 生产环境默认不打印执行耗时日志 + return; + } + + if (!this.markOver) { + // 在格式化输出时,要求所有任务执行完毕 + this.allExecuted(); + } + + StringBuilder sb = new StringBuilder(); + sb.append('\n'); + long totalCost = cost.remove(taskName); + sb.append("StopWatch '").append(taskName).append("': running time = ").append(totalCost).append(" ms"); + sb.append('\n'); + if (cost.size() <= 1) { + sb.append("No task info kept"); + } else { + sb.append("---------------------------------------------\n"); + sb.append("ms % Task name\n"); + sb.append("---------------------------------------------\n"); + NumberFormat pf = NumberFormat.getPercentInstance(); + pf.setMinimumIntegerDigits(2); + pf.setMinimumFractionDigits(2); + pf.setGroupingUsed(false); + for (Map.Entry entry : cost.entrySet()) { + sb.append(entry.getValue()).append("\t\t"); + sb.append(pf.format(entry.getValue() / (double) totalCost)).append("\t\t"); + sb.append(entry.getKey()).append("\n"); + } + } + + log.info("\n---------------------\n{}\n--------------------\n", sb); + } + + @Override + public void close() { + try { + if (!this.markOver) { + // 做一个兜底,避免业务侧没有手动结束,导致异步任务没有执行完就提前返回结果 + this.allExecuted(); + } + + AsyncUtil.release(); + prettyPrint(); + } catch (Exception e) { + log.error("释放耗时上下文异常! {}", taskName, e); + } + } + } + + public static CompletableFutureBridge concurrentExecutor(String... name) { + if (name.length > 0) { + return new CompletableFutureBridge(AsyncUtil.executorService, name[0]); + } + return new CompletableFutureBridge(); + } + + /** + * 开始桥接类 + * + * @param executorService 线程池 + * @param name 标记名 + * @return 桥接类 + */ + public static CompletableFutureBridge startBridge(ExecutorService executorService, String name) { + CompletableFutureBridge bridge = new CompletableFutureBridge(executorService, name); + THREAD_LOCAL.set(bridge); + return bridge; + } + + /** + * 获取计时桥接类 + * + * @return 桥接类 + */ + public static CompletableFutureBridge getBridge() { + return THREAD_LOCAL.get(); + } + + /** + * 释放统计 + */ + public static void release() { + THREAD_LOCAL.remove(); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/ConfigRefreshEventListener.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/ConfigRefreshEventListener.java new file mode 100644 index 000000000..ab7caef22 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/ConfigRefreshEventListener.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.core.autoconf; + +import com.github.paicoding.forum.api.model.event.ConfigRefreshEvent; +import com.github.paicoding.forum.core.autoconf.property.SpringValueRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Service; + +/** + * 配置刷新事件监听 + * + * @author YiHui + * @date 2023/09/14 + */ +@Service +public class ConfigRefreshEventListener implements ApplicationListener { + @Autowired + private DynamicConfigContainer dynamicConfigContainer; + + /** + * 监听配置变更事件 + * + * @param event + */ + @Override + public void onApplicationEvent(ConfigRefreshEvent event) { + dynamicConfigContainer.reloadConfig(); + SpringValueRegistry.updateValue(event.getKey()); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/DynamicConfigBinder.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/DynamicConfigBinder.java new file mode 100644 index 000000000..cafea2acd --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/DynamicConfigBinder.java @@ -0,0 +1,106 @@ +package com.github.paicoding.forum.core.autoconf; + +import org.springframework.beans.PropertyEditorRegistry; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver; +import org.springframework.boot.context.properties.bind.handler.IgnoreErrorsBindHandler; +import org.springframework.boot.context.properties.bind.handler.IgnoreTopLevelConverterNotFoundBindHandler; +import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler; +import org.springframework.boot.context.properties.source.ConfigurationPropertySource; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.context.properties.source.UnboundElementsSourceFilter; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.env.PropertySources; + +import java.util.function.Consumer; + +/** + * 自定义动态配置绑定 + * + * @author YiHui + * @date 2023/6/20 + */ +public class DynamicConfigBinder { + private final ApplicationContext applicationContext; + private PropertySources propertySource; + + private volatile Binder binder; + + public DynamicConfigBinder(ApplicationContext applicationContext, PropertySources propertySource) { + this.applicationContext = applicationContext; + this.propertySource = propertySource; + } + + public void bind(Bindable bindable) { + ConfigurationProperties propertiesAno = bindable.getAnnotation(ConfigurationProperties.class); + if (propertiesAno != null) { + BindHandler bindHandler = getBindHandler(propertiesAno); + getBinder().bind(propertiesAno.prefix(), bindable, bindHandler); + } + } + + public void bind(String prefix, Bindable bindable, BindHandler bindHandler) { + getBinder().bind(prefix, bindable, bindHandler); + } + + private BindHandler getBindHandler(ConfigurationProperties annotation) { + BindHandler handler = new IgnoreTopLevelConverterNotFoundBindHandler(); + if (annotation.ignoreInvalidFields()) { + handler = new IgnoreErrorsBindHandler(handler); + } + if (!annotation.ignoreUnknownFields()) { + UnboundElementsSourceFilter filter = new UnboundElementsSourceFilter(); + handler = new NoUnboundElementsBindHandler(handler, filter); + } + return handler; + } + + private Binder getBinder() { + if (this.binder == null) { + synchronized (this) { + if (this.binder == null) { + this.binder = new Binder(getConfigurationPropertySources(), + getPropertySourcesPlaceholdersResolver(), getConversionService(), + getPropertyEditorInitializer()); + } + } + } + return this.binder; + } + + private Iterable getConfigurationPropertySources() { + return ConfigurationPropertySources.from(this.propertySource); + } + + /** + * 指定占位符的前缀、后缀、默认值分隔符、未解析忽略、环境变量容器 + * + * @return + */ + private PropertySourcesPlaceholdersResolver getPropertySourcesPlaceholdersResolver() { + return new PropertySourcesPlaceholdersResolver(this.propertySource); + } + + /** + * 类型转换 + * + * @return + */ + private ConversionService getConversionService() { + return new DefaultConversionService(); + } + + private Consumer getPropertyEditorInitializer() { + if (this.applicationContext instanceof ConfigurableApplicationContext) { + return ((ConfigurableApplicationContext) this.applicationContext) + .getBeanFactory()::copyRegisteredEditorsTo; + } + return null; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/DynamicConfigContainer.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/DynamicConfigContainer.java new file mode 100644 index 000000000..99af08c1f --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/DynamicConfigContainer.java @@ -0,0 +1,186 @@ +package com.github.paicoding.forum.core.autoconf; + +import com.github.paicoding.forum.core.autoconf.property.SpringValueRegistry; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.google.common.collect.Maps; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * 自定义的配置工厂类,专门用于 ConfDot 属性配置文件的配置加载,支持从自定义的配置源获取 + * + * @author YiHui + * @date 2023/6/20 + */ +@Slf4j +@Component +public class DynamicConfigContainer implements EnvironmentAware, ApplicationContextAware, CommandLineRunner { + private ConfigurableEnvironment environment; + private ApplicationContext applicationContext; + /** + * 存储db中的全局配置,优先级最高 + */ + @Getter + public Map cache; + + private DynamicConfigBinder binder; + + /** + * 配置变更的回调任务 + */ + @Getter + private Map refreshCallback = Maps.newHashMap(); + + @Override + public void setEnvironment(Environment environment) { + this.environment = (ConfigurableEnvironment) environment; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @PostConstruct + public void init() { + cache = Maps.newHashMap(); + bindBeansFromLocalCache("dbConfig", cache); + } + + /** + * 从db中获取全量的配置信息 + * + * @return true 表示有信息变更; false 表示无信息变更 + */ + private boolean loadAllConfigFromDb() { + List> list = SpringUtil.getBean(JdbcTemplate.class).queryForList("select `key`, `value` from global_conf where deleted = 0"); + Map val = Maps.newHashMapWithExpectedSize(list.size()); + for (Map conf : list) { + val.put(conf.get("key").toString(), conf.get("value").toString()); + } + if (val.equals(cache)) { + return false; + } + cache.clear(); + cache.putAll(val); + return true; + } + + private void bindBeansFromLocalCache(String namespace, Map cache) { + // 将内存的配置信息设置为最高优先级 + MapPropertySource propertySource = new MapPropertySource(namespace, cache); + environment.getPropertySources().addFirst(propertySource); + this.binder = new DynamicConfigBinder(this.applicationContext, environment.getPropertySources()); + } + + /** + * 配置绑定 + * + * @param bindable + */ + public void bind(Bindable bindable) { + binder.bind(bindable); + } + + + /** + * 监听配置的变更 + */ + public void reloadConfig() { + String before = JsonUtil.toStr(cache); + boolean toRefresh = loadAllConfigFromDb(); + if (toRefresh) { + refreshConfig(); + log.info("配置刷新! 旧:{}, 新:{}", before, JsonUtil.toStr(cache)); + } + } + + /** + * 强制刷新缓存配置 + */ + public void forceRefresh() { + loadAllConfigFromDb(); + refreshConfig(); + log.info("db配置强制刷新! {}", JsonUtil.toStr(cache)); + } + + /** + * 支持配置的动态刷新 + */ + private void refreshConfig() { + applicationContext.getBeansWithAnnotation(ConfigurationProperties.class).values().forEach(bean -> { + Bindable target = Bindable.ofInstance(bean).withAnnotations(AnnotationUtils.findAnnotation(bean.getClass(), ConfigurationProperties.class)); + bind(target); + if (refreshCallback.containsKey(bean.getClass())) { + refreshCallback.get(bean.getClass()).run(); + } + }); + } + + /** + * 注册db的动态配置变更 + */ + private void registerConfRefreshTask() { + Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> { + try { + reloadConfig(); + } catch (Exception e) { + log.warn("自动更新db配置信息异常!", e); + } + }, 5, 5, TimeUnit.MINUTES); + } + + /** + * 注册配置变更的回调任务 + * + * @param bean + * @param run + */ + public void registerRefreshCallback(Object bean, Runnable run) { + refreshCallback.put(bean.getClass(), run); + } + + + /** + * bean先加载,此时@Value对应的成员属性直接从默认的配置中读取了;这就导致无法获取db中的真实配置信息,只有这个配置再db中发生变更,才会生效 + * 因此,我们再自定义的配置加载完毕之后,重刷一下bean中的@Value属性,保证他们都获取的是最新的配置信息 + */ + private void autoUpdateSpringValueConfig() { + Set keys = SpringValueRegistry.registry.keySet(); + keys.forEach(SpringValueRegistry::updateValue); + } + + /** + * 应用启动之后,执行的动态配置初始化 + * + * @param args + * @throws Exception + */ + @Override + public void run(String... args) throws Exception { + reloadConfig(); + registerConfRefreshTask(); + autoUpdateSpringValueConfig(); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/PlaceholderHelper.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/PlaceholderHelper.java new file mode 100644 index 000000000..5145d545d --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/PlaceholderHelper.java @@ -0,0 +1,163 @@ +package com.github.paicoding.forum.core.autoconf.property; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.beans.factory.config.Scope; +import org.springframework.util.StringUtils; + +import java.util.HashSet; +import java.util.Set; +import java.util.Stack; + +/** + * 来自apollo-client + * Placeholder helper functions. + */ +public class PlaceholderHelper { + + private static final String PLACEHOLDER_PREFIX = "${"; + private static final String PLACEHOLDER_SUFFIX = "}"; + private static final String VALUE_SEPARATOR = ":"; + private static final String SIMPLE_PLACEHOLDER_PREFIX = "{"; + private static final String EXPRESSION_PREFIX = "#{"; + private static final String EXPRESSION_SUFFIX = "}"; + + /** + * Resolve placeholder property values, e.g. + *
+ *
+ * "${somePropertyValue}" -> "the actual property value" + */ + public Object resolvePropertyValue(ConfigurableBeanFactory beanFactory, String beanName, String placeholder) { + // resolve string value + String strVal = beanFactory.resolveEmbeddedValue(placeholder); + + BeanDefinition bd = (beanFactory.containsBean(beanName) ? beanFactory + .getMergedBeanDefinition(beanName) : null); + + // resolve expressions like "#{systemProperties.myProp}" + return evaluateBeanDefinitionString(beanFactory, strVal, bd); + } + + private Object evaluateBeanDefinitionString(ConfigurableBeanFactory beanFactory, String value, + BeanDefinition beanDefinition) { + if (beanFactory.getBeanExpressionResolver() == null) { + return value; + } + Scope scope = (beanDefinition != null ? beanFactory + .getRegisteredScope(beanDefinition.getScope()) : null); + return beanFactory.getBeanExpressionResolver() + .evaluate(value, new BeanExpressionContext(beanFactory, scope)); + } + + /** + * Extract keys from placeholder, e.g. + *

+ */ + public Set extractPlaceholderKeys(String propertyString) { + Set placeholderKeys = new HashSet<>(); + + if (!isNormalizedPlaceholder(propertyString) && !isExpressionWithPlaceholder(propertyString)) { + return placeholderKeys; + } + + Stack stack = new Stack<>(); + stack.push(propertyString); + + while (!stack.isEmpty()) { + String strVal = stack.pop(); + int startIndex = strVal.indexOf(PLACEHOLDER_PREFIX); + if (startIndex == -1) { + placeholderKeys.add(strVal); + continue; + } + int endIndex = findPlaceholderEndIndex(strVal, startIndex); + if (endIndex == -1) { + // invalid placeholder? + continue; + } + + String placeholderCandidate = strVal.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + + // ${some.key:other.key} + if (placeholderCandidate.startsWith(PLACEHOLDER_PREFIX)) { + stack.push(placeholderCandidate); + } else { + // some.key:${some.other.key:100} + int separatorIndex = placeholderCandidate.indexOf(VALUE_SEPARATOR); + + if (separatorIndex == -1) { + stack.push(placeholderCandidate); + } else { + stack.push(placeholderCandidate.substring(0, separatorIndex)); + String defaultValuePart = + normalizeToPlaceholder(placeholderCandidate.substring(separatorIndex + VALUE_SEPARATOR.length())); + if (!StringUtils.isEmpty(defaultValuePart)) { + stack.push(defaultValuePart); + } + } + } + + // has remaining part, e.g. ${a}.${b} + if (endIndex + PLACEHOLDER_SUFFIX.length() < strVal.length() - 1) { + String remainingPart = normalizeToPlaceholder(strVal.substring(endIndex + PLACEHOLDER_SUFFIX.length())); + if (!StringUtils.isEmpty(remainingPart)) { + stack.push(remainingPart); + } + } + } + + return placeholderKeys; + } + + private boolean isNormalizedPlaceholder(String propertyString) { + return propertyString.startsWith(PLACEHOLDER_PREFIX) && propertyString.endsWith(PLACEHOLDER_SUFFIX); + } + + private boolean isExpressionWithPlaceholder(String propertyString) { + return propertyString.startsWith(EXPRESSION_PREFIX) && propertyString.endsWith(EXPRESSION_SUFFIX) + && propertyString.contains(PLACEHOLDER_PREFIX); + } + + private String normalizeToPlaceholder(String strVal) { + int startIndex = strVal.indexOf(PLACEHOLDER_PREFIX); + if (startIndex == -1) { + return null; + } + int endIndex = strVal.lastIndexOf(PLACEHOLDER_SUFFIX); + if (endIndex == -1) { + return null; + } + + return strVal.substring(startIndex, endIndex + PLACEHOLDER_SUFFIX.length()); + } + + private int findPlaceholderEndIndex(CharSequence buf, int startIndex) { + int index = startIndex + PLACEHOLDER_PREFIX.length(); + int withinNestedPlaceholder = 0; + while (index < buf.length()) { + if (StringUtils.substringMatch(buf, index, PLACEHOLDER_SUFFIX)) { + if (withinNestedPlaceholder > 0) { + withinNestedPlaceholder--; + index = index + PLACEHOLDER_SUFFIX.length(); + } else { + return index; + } + } else if (StringUtils.substringMatch(buf, index, SIMPLE_PLACEHOLDER_PREFIX)) { + withinNestedPlaceholder++; + index = index + SIMPLE_PLACEHOLDER_PREFIX.length(); + } else { + index++; + } + } + return -1; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/SpringValueProcessor.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/SpringValueProcessor.java new file mode 100644 index 000000000..e46c9b79b --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/SpringValueProcessor.java @@ -0,0 +1,116 @@ +package com.github.paicoding.forum.core.autoconf.property; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +import org.springframework.util.ReflectionUtils; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * 配置变更注册, 找到 @Value 注解修饰的配置,注册到 SpringValueRegistry,实现统一的配置变更自动刷新管理 + * + * @author YiHui + * @date 2023/6/26 + */ +@Slf4j +@Component +public class SpringValueProcessor implements BeanPostProcessor { + private final PlaceholderHelper placeholderHelper; + + public SpringValueProcessor() { + this.placeholderHelper = new PlaceholderHelper(); + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + Class clazz = bean.getClass(); + for (Field field : findAllField(clazz)) { + processField(bean, beanName, field); + } + for (Method method : findAllMethod(clazz)) { + processMethod(bean, beanName, method); + } + return bean; + } + + private List findAllField(Class clazz) { + final List res = new LinkedList<>(); + ReflectionUtils.doWithFields(clazz, res::add); + return res; + } + + private List findAllMethod(Class clazz) { + final List res = new LinkedList<>(); + ReflectionUtils.doWithMethods(clazz, res::add); + return res; + } + + /** + * 成员变量上添加 @Value 方式绑定的配置 + * + * @param bean + * @param beanName + * @param field + */ + protected void processField(Object bean, String beanName, Field field) { + // register @Value on field + Value value = field.getAnnotation(Value.class); + if (value == null) { + return; + } + Set keys = placeholderHelper.extractPlaceholderKeys(value.value()); + + if (keys.isEmpty()) { + return; + } + + for (String key : keys) { + SpringValueRegistry.SpringValue springValue = new SpringValueRegistry.SpringValue(key, value.value(), bean, beanName, field); + SpringValueRegistry.register(key, springValue); + log.debug("Monitoring {}", springValue); + } + } + + /** + * 通过 @Value 修饰方法的方式,通过一个传参进行实现的配置绑定 + * + * @param bean + * @param beanName + * @param method + */ + protected void processMethod(Object bean, String beanName, Method method) { + //register @Value on method + Value value = method.getAnnotation(Value.class); + if (value == null) { + return; + } + //skip Configuration bean methods + if (method.getAnnotation(Bean.class) != null) { + return; + } + if (method.getParameterTypes().length != 1) { + log.error("Ignore @Value setter {}.{}, expecting 1 parameter, actual {} parameters", bean.getClass().getName(), method.getName(), method.getParameterTypes().length); + return; + } + + Set keys = placeholderHelper.extractPlaceholderKeys(value.value()); + + if (keys.isEmpty()) { + return; + } + + for (String key : keys) { + SpringValueRegistry.SpringValue springValue = new SpringValueRegistry.SpringValue(key, value.value(), bean, beanName, method); + SpringValueRegistry.register(key, springValue); + log.debug("Monitoring {}", springValue); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/SpringValueRegistry.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/SpringValueRegistry.java new file mode 100644 index 000000000..5af993c40 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/autoconf/property/SpringValueRegistry.java @@ -0,0 +1,172 @@ +package com.github.paicoding.forum.core.autoconf.property; + +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.util.StrUtil; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; + +/** + * 配置变更注册 + * + * @author YiHui + * @date 2023/6/26 + */ +@Slf4j +public class SpringValueRegistry { + @Data + public static class SpringValue { + /** + * 适合用于:配置是通过set类方法实现注入绑定的方式,只有一个传参,为对应的配置key + */ + private MethodParameter methodParameter; + /** + * 成员变量 + */ + private Field field; + /** + * bean示例的弱引用 + */ + private WeakReference beanRef; + /** + * Spring Bean Name + */ + private String beanName; + /** + * 配置对应的key: 如 config.user + */ + private String key; + /** + * 配置引用,如 ${config.user} + */ + private String placeholder; + /** + * 配置绑定的目标类型 + */ + private Class targetType; + + public SpringValue(String key, String placeholder, Object bean, String beanName, Field field) { + this.beanRef = new WeakReference<>(bean); + this.beanName = beanName; + this.field = field; + this.placeholder = placeholder; + this.targetType = field.getType(); + this.formatKey(key); + } + + public SpringValue(String key, String placeholder, Object bean, String beanName, Method method) { + this.beanRef = new WeakReference<>(bean); + this.beanName = beanName; + this.methodParameter = new MethodParameter(method, 0); + this.placeholder = placeholder; + Class[] paramTps = method.getParameterTypes(); + this.targetType = paramTps[0]; + this.formatKey(key); + } + + private void formatKey(String key) { + this.key = StrUtil.formatSpringConfigKey(key); + if (!Objects.equals(key, this.key)) { + log.info("配置key格式化输出: {} -> {}", key, this.key); + } + } + + /** + * 配置基于反射的动态变更 + * + * @param newVal String: 配置对应的key Class: 配置绑定的成员/方法参数类型, Object 新的配置值 + * @throws Exception + */ + public void update(BiFunction newVal) throws Exception { + if (isField()) { + injectField(newVal); + } else { + injectMethod(newVal); + } + } + + private void injectField(BiFunction newVal) throws Exception { + Object bean = beanRef.get(); + if (bean == null) { + return; + } + boolean accessible = field.isAccessible(); + field.setAccessible(true); + field.set(bean, newVal.apply(key, field.getType())); + field.setAccessible(accessible); + if (log.isDebugEnabled()) { + log.debug("更新value: {}#{} = {}", beanName, field.getName(), field.get(bean)); + } + } + + private void injectMethod(BiFunction newVal) + throws Exception { + Object bean = beanRef.get(); + if (bean == null) { + return; + } + Object va = newVal.apply(key, methodParameter.getParameterType()); + methodParameter.getMethod().invoke(bean, va); + log.info("更新method: {}#{} = {}", beanName, methodParameter.getMethod().getName(), va); + } + + public boolean isField() { + return this.field != null; + } + } + + + public static Map> registry = new ConcurrentHashMap<>(); + + /** + * 像registry中注册配置key绑定的对象W + * + * @param key + * @param val + */ + public static void register(String key, SpringValue val) { + if (!registry.containsKey(key)) { + synchronized (SpringValueRegistry.class) { + if (!registry.containsKey(key)) { + registry.put(key, new HashSet<>()); + } + } + } + + Set set = registry.getOrDefault(key, new HashSet<>()); + set.add(val); + } + + /** + * key对应的配置发生了变更,找到绑定这个配置的属性,进行反射刷新 + * + * @param key + */ + public static void updateValue(String key) { + // 项目启动时,有一个配置,没有再配置文件中初始化,而是直接再应用代码中写上了默认值,此时若直接走下面的更新流程,会导致配置绑定异常,项目启动失败 + // 因此我们再执行更新时,先判断下配置上下文中是否有这个配置 + // fixme: 那么问题来了,如果是删除了一个动态配置,那应该怎么将应用中的配置刷新为默认值呢? + if (!SpringUtil.hasConfig(key)) { + return; + } + + Set set = registry.getOrDefault(key, new HashSet<>()); + set.forEach(s -> { + try { + s.update((s1, aClass) -> SpringUtil.getBinder().bindOrCreate(s1, aClass)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/cache/RedisClient.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/cache/RedisClient.java new file mode 100644 index 000000000..342940d99 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/cache/RedisClient.java @@ -0,0 +1,475 @@ +package com.github.paicoding.forum.core.cache; + +import com.github.paicoding.forum.core.util.JsonUtil; +import com.google.common.collect.Maps; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisZSetCommands; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.util.CollectionUtils; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * @author YiHui + * @date 2023/2/7 + */ +public class RedisClient { + private static final Charset CODE = StandardCharsets.UTF_8; + private static final String KEY_PREFIX = "pai_"; + private static RedisTemplate template; + + public static void register(RedisTemplate template) { + RedisClient.template = template; + } + + public static void nullCheck(Object... args) { + for (Object obj : args) { + if (obj == null) { + throw new IllegalArgumentException("redis argument can not be null!"); + } + } + } + + /** + * 技术派的缓存值序列化处理 + * + * @param val + * @param + * @return + */ + public static byte[] valBytes(T val) { + + if (val instanceof String) { + return ((String) val).getBytes(CODE); + } else { + return JsonUtil.toStr(val).getBytes(CODE); + } + } + + /** + * 生成技术派的缓存key + * + * @param key + * @return + */ + public static byte[] keyBytes(String key) { + nullCheck(key); + key = KEY_PREFIX + key; + return key.getBytes(CODE); + } + + public static byte[][] keyBytes(List keys) { + byte[][] bytes = new byte[keys.size()][]; + int index = 0; + for (String key : keys) { + bytes[index++] = keyBytes(key); + } + return bytes; + } + + /** + * 返回key的有效期 + * + * @param key + * @return + */ + public static Long ttl(String key) { + return template.execute((RedisCallback) con -> con.ttl(keyBytes(key))); + } + + /** + * 查询缓存 + * + * @param key + * @return + */ + public static String getStr(String key) { + return template.execute((RedisCallback) con -> { + byte[] val = con.get(keyBytes(key)); + return val == null ? null : new String(val); + }); + } + + /** + * 设置缓存 + * + * @param key + * @param value + */ + public static void setStr(String key, String value) { + template.execute((RedisCallback) con -> { + con.set(keyBytes(key), valBytes(value)); + return null; + }); + } + + /** + * 删除缓存 + * + * @param key + */ + public static void del(String key) { + template.execute((RedisCallback) con -> con.del(keyBytes(key))); + } + + /** + * 设置缓存有效期 + * + * @param key + * @param expire 有效期,s为单位 + */ + public static void expire(String key, Long expire) { + template.execute((RedisCallback) connection -> { + connection.expire(keyBytes(key), expire); + return null; + }); + } + + /** + * 带过期时间的缓存写入 + * + * @param key + * @param value + * @param expire s为单位 + * @return + */ + public static Boolean setStrWithExpire(String key, String value, Long expire) { + return template.execute(new RedisCallback() { + @Override + public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException { + return redisConnection.setEx(keyBytes(key), expire, valBytes(value)); + } + }); + } + + public static Map hGetAll(String key, Class clz) { + Map records = template.execute((RedisCallback>) con -> con.hGetAll(keyBytes(key))); + if (records == null) { + return Collections.emptyMap(); + } + + Map result = Maps.newHashMapWithExpectedSize(records.size()); + for (Map.Entry entry : records.entrySet()) { + if (entry.getKey() == null) { + continue; + } + + result.put(new String(entry.getKey()), toObj(entry.getValue(), clz)); + } + return result; + } + + public static T hGet(String key, String field, Class clz) { + return template.execute((RedisCallback) con -> { + byte[] records = con.hGet(keyBytes(key), valBytes(field)); + if (records == null) { + return null; + } + + return toObj(records, clz); + }); + } + + /** + * 自增 + * + * @param key + * @param filed + * @param cnt + * @return + */ + public static Long hIncr(String key, String filed, Integer cnt) { + return template.execute((RedisCallback) con -> con.hIncrBy(keyBytes(key), valBytes(filed), cnt)); + } + + public static Boolean hDel(String key, String field) { + return template.execute(new RedisCallback() { + @Override + public Boolean doInRedis(RedisConnection connection) throws DataAccessException { + return connection.hDel(keyBytes(key), valBytes(field)) > 0; + } + }); + } + + public static Boolean hSet(String key, String field, T ans) { + return template.execute(new RedisCallback() { + @Override + public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException { + return redisConnection.hSet(keyBytes(key), valBytes(field), valBytes(ans)); + } + }); + } + + public static void hMSet(String key, Map fields) { + Map val = Maps.newHashMapWithExpectedSize(fields.size()); + for (Map.Entry entry : fields.entrySet()) { + val.put(valBytes(entry.getKey()), valBytes(entry.getValue())); + } + template.execute((RedisCallback) connection -> { + connection.hMSet(keyBytes(key), val); + return null; + }); + } + + public static Map hMGet(String key, final List fields, Class clz) { + return template.execute(new RedisCallback>() { + @Override + public Map doInRedis(RedisConnection connection) throws DataAccessException { + byte[][] f = new byte[fields.size()][]; + IntStream.range(0, fields.size()).forEach(i -> f[i] = valBytes(fields.get(i))); + List ans = connection.hMGet(keyBytes(key), f); + Map result = Maps.newHashMapWithExpectedSize(fields.size()); + IntStream.range(0, fields.size()).forEach(i -> { + result.put(fields.get(i), toObj(ans.get(i), clz)); + }); + return result; + } + }); + } + + /** + * 判断value是否再set中 + * + * @param key + * @param value + * @return + */ + public static Boolean sIsMember(String key, T value) { + return template.execute(new RedisCallback() { + @Override + public Boolean doInRedis(RedisConnection connection) throws DataAccessException { + return connection.sIsMember(keyBytes(key), valBytes(value)); + } + }); + } + + /** + * 获取set中的所有内容 + * + * @param key + * @param clz + * @param + * @return + */ + public static Set sGetAll(String key, Class clz) { + return template.execute(new RedisCallback>() { + @Override + public Set doInRedis(RedisConnection connection) throws DataAccessException { + Set set = connection.sMembers(keyBytes(key)); + if (CollectionUtils.isEmpty(set)) { + return Collections.emptySet(); + } + return set.stream().map(s -> toObj(s, clz)).collect(Collectors.toSet()); + } + }); + } + + /** + * 往set中添加内容 + * + * @param key + * @param val + * @param + * @return + */ + public static boolean sPut(String key, T val) { + return template.execute(new RedisCallback() { + @Override + public Long doInRedis(RedisConnection connection) throws DataAccessException { + return connection.sAdd(keyBytes(key), valBytes(val)); + } + }) > 0; + } + + /** + * 移除set中的内容 + * + * @param key + * @param val + * @param + */ + public static void sDel(String key, T val) { + template.execute(new RedisCallback() { + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + connection.sRem(keyBytes(key), valBytes(val)); + return null; + } + }); + } + + + /** + * 分数更新 + * + * @param key + * @param value + * @param score + * @return + */ + public static Double zIncrBy(String key, String value, Integer score) { + return template.execute(new RedisCallback() { + @Override + public Double doInRedis(RedisConnection connection) throws DataAccessException { + return connection.zIncrBy(keyBytes(key), score, valBytes(value)); + } + }); + } + + public static ImmutablePair zRankInfo(String key, String value) { + double score = zScore(key, value); + int rank = zRank(key, value); + return ImmutablePair.of(rank, score); + } + + /** + * 获取分数 + * + * @param key + * @param value + * @return + */ + public static Double zScore(String key, String value) { + return template.execute(new RedisCallback() { + @Override + public Double doInRedis(RedisConnection connection) throws DataAccessException { + return connection.zScore(keyBytes(key), valBytes(value)); + } + }); + } + + public static Integer zRank(String key, String value) { + return template.execute(new RedisCallback() { + @Override + public Integer doInRedis(RedisConnection connection) throws DataAccessException { + return connection.zRank(keyBytes(key), valBytes(value)).intValue(); + } + }); + } + + /** + * 找出排名靠前的n个 + * + * @param key + * @param n + * @return + */ + public static List> zTopNScore(String key, int n) { + return template.execute(new RedisCallback>>() { + @Override + public List> doInRedis(RedisConnection connection) throws DataAccessException { + Set set = connection.zRangeWithScores(keyBytes(key), -n, -1); + if (set == null) { + return Collections.emptyList(); + } + return set.stream() + .map(tuple -> ImmutablePair.of(toObj(tuple.getValue(), String.class), tuple.getScore())) + .sorted((o1, o2) -> Double.compare(o2.getRight(), o1.getRight())).collect(Collectors.toList()); + } + }); + } + + + public static Long lPush(String key, T val) { + return template.execute(new RedisCallback() { + @Override + public Long doInRedis(RedisConnection connection) throws DataAccessException { + return connection.lPush(keyBytes(key), valBytes(val)); + } + }); + } + + public static Long rPush(String key, T val) { + return template.execute(new RedisCallback() { + @Override + public Long doInRedis(RedisConnection connection) throws DataAccessException { + return connection.rPush(keyBytes(key), valBytes(val)); + } + }); + } + + public static List lRange(String key, int start, int size, Class clz) { + return template.execute(new RedisCallback>() { + + @Override + public List doInRedis(RedisConnection connection) throws DataAccessException { + List list = connection.lRange(keyBytes(key), start, size); + if (CollectionUtils.isEmpty(list)) { + return new ArrayList<>(); + } + return list.stream().map(k -> toObj(k, clz)).collect(Collectors.toList()); + } + }); + } + + public static void lTrim(String key, int start, int size) { + template.execute(new RedisCallback() { + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + connection.lTrim(keyBytes(key), start, size); + return null; + } + }); + } + + private static T toObj(byte[] ans, Class clz) { + if (ans == null) { + return null; + } + + if (clz == String.class) { + return (T) new String(ans, CODE); + } + + return JsonUtil.toObj(new String(ans, CODE), clz); + } + + + public static PipelineAction pipelineAction() { + return new PipelineAction(); + } + + /** + * redis 管道执行的封装链路 + */ + public static class PipelineAction { + private List run = new ArrayList<>(); + + private RedisConnection connection; + + public PipelineAction add(String key, BiConsumer conn) { + run.add(() -> conn.accept(connection, RedisClient.keyBytes(key))); + return this; + } + + public PipelineAction add(String key, String field, ThreeConsumer conn) { + run.add(() -> conn.accept(connection, RedisClient.keyBytes(key), valBytes(field))); + return this; + } + + public void execute() { + template.executePipelined((RedisCallback) connection -> { + PipelineAction.this.connection = connection; + run.forEach(Runnable::run); + return null; + }); + } + } + + @FunctionalInterface + public interface ThreeConsumer { + void accept(T t, U u, P p); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/common/CommonConstants.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/common/CommonConstants.java new file mode 100644 index 000000000..9226d0b8b --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/common/CommonConstants.java @@ -0,0 +1,124 @@ +package com.github.paicoding.forum.core.common; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 通用常量 + * + * @author Louzai + * @date 2022/11/1 + */ +public class CommonConstants { + + /** + * 消息队列 + */ + public static String EXCHANGE_NAME_DIRECT = "direct.exchange"; + public static String QUERE_KEY_PRAISE = "praise"; + public static String QUERE_NAME_PRAISE = "quere.praise"; + + /** + * 分类类型 + */ + public static final String CATEGORY_ALL = "全部"; + public static final String CATEGORY_BACK_EMD = "后端"; + public static final String CATEGORY_FORNT_END = "前端"; + public static final String CATEGORY_ANDROID = "Android"; + public static final String CATEGORY_IOS = "IOS"; + public static final String CATEGORY_BIG_DATA = "大数据"; + public static final String CATEGORY_INTELLIGENCE = "人工智能"; + public static final String CATEGORY_CODE_LIFE = "代码人生"; + public static final String CATEGORY_TOOL = "开发工具"; + public static final String CATEGORY_READ = "阅读"; + + /** + * 首页图片 + */ + public static final Map> HOMEPAGE_TOP_PIC_MAP = new HashMap>() { + { + put(CATEGORY_ALL, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/8b5865e9461948aed4aacffc62adbae7.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/71505c62fb6375cbbd62af63964b2ad4.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/147915cdddea55ce37c2c5ecfc7c089e.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/dee73c8810cb699ae1ec774a54612080.jpg"); + } + }); + put(CATEGORY_BACK_EMD, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/b2af54aefab4fbf8f001065961a8118b.gif"); + add("https://cdn.tobebetterjavaer.com/paicoding/99ca3b142d901d9ca63946efca0122d8.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/cfbdbd36b2194dd4fd9da2ea18e8a56a.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/f693ed4c1969724a44004a96fcce4263.jpg"); + } + }); + put(CATEGORY_FORNT_END, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/7c591a44a9f83eec9606d16f89040632.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/4eb9f0bad37ba5903ca9e249743e3ecb.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/6a810a33480fc3b42740713a6687f3fd.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/6ed5aa120b7a06a5f4b5fe351adfda94.jpg"); + } + }); + put(CATEGORY_ANDROID, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/f266aabeb976b2b9c4bf24a107a78c5d.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/dee27ae91078714cc9f6b1774161c1ef.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/a8cfe8140b683809a68205da76e77fb1.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/b964b76b111cf36602d9b2dc30bee9ee.jpg"); + } + }); + put(CATEGORY_IOS, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/7edcb3dd19d4d517be34a30bc082338d.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/ee2a3fec62d85df3b1c27908d53698c5.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/f476fbc0cf90ed81802ef6a1d51fcf16.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/bfc7cbdeb928e03d034be1f9d73f4a9e.jpg"); + } + }); + put(CATEGORY_BIG_DATA, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/989d3c1f73ee953a05347c0b99cba46d.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/a96fe34a09d9fd9cafd64eae90410428.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/1217d3dd677d91cb65b0cc85769f7f3d.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/5de37f5c879543bd17f031f4243fae7d.jpg"); + } + }); + put(CATEGORY_INTELLIGENCE, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/077b7d8891e69701e8d3d4302392dab5.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/be98a2779d2dde96c40092bbae958864.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/d368d4bed7daf51116f4defbb4afcb6d.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/c6bfe6bea326a64a267520ba7cada539.jpg"); + } + }); + put(CATEGORY_CODE_LIFE, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/077b7d8891e69701e8d3d4302392dab5.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/be98a2779d2dde96c40092bbae958864.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/d368d4bed7daf51116f4defbb4afcb6d.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/c6bfe6bea326a64a267520ba7cada539.jpg"); + } + }); + put(CATEGORY_TOOL, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/53de05a01c7246feadffb6ba24120416.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/e3e1f7a729d5cfbde0e5373c2d61377a.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/326585eab30c33cdfa1cc058b269bf5a.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/870997d1442e4d67186bf0dbc52e2096.jpg"); + } + }); + put(CATEGORY_READ, new ArrayList() { + { + add("https://cdn.tobebetterjavaer.com/paicoding/dd3f3e90b666cfe65f4ca5e56ebfc9f8.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/7c591a44a9f83eec9606d16f89040632.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/42dbb88fed8caa2d95860dbdb359c18f.jpg"); + add("https://cdn.tobebetterjavaer.com/paicoding/b99cab7999d5bdf3f5926dc0a98d02da.jpg"); + } + }); + } + }; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/ImageProperties.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/ImageProperties.java new file mode 100644 index 000000000..d17916896 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/ImageProperties.java @@ -0,0 +1,48 @@ +package com.github.paicoding.forum.core.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 图片配置文件 + * + * @author LouZai + * @since 2022/9/7 + */ +@Setter +@Getter +@Component +@ConfigurationProperties(prefix = "image") +public class ImageProperties { + + /** + * 存储绝对路径 + */ + private String absTmpPath; + + /** + * 存储相对路径 + */ + private String webImgPath; + + /** + * 上传文件的临时存储目录 + */ + private String tmpUploadPath; + + /** + * 访问图片的host + */ + private String cdnHost; + + private OssProperties oss; + + public String buildImgUrl(String url) { + if (!url.startsWith(cdnHost)) { + return cdnHost + url; + } + return url; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/OssProperties.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/OssProperties.java new file mode 100644 index 000000000..2198b8ab6 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/OssProperties.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.core.config; + +import lombok.Data; + +/** + * @author YiHui + * @date 2023/1/12 + */ +@Data +public class OssProperties { + /** + * 上传文件前缀路径 + */ + private String prefix; + /** + * oss类型 + */ + private String type; + /** + * 下面几个是oss的配置参数 + */ + private String endpoint; + private String ak; + private String sk; + private String bucket; + private String host; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/ProxyProperties.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/ProxyProperties.java new file mode 100644 index 000000000..dc81ea117 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/ProxyProperties.java @@ -0,0 +1,35 @@ +package com.github.paicoding.forum.core.config; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.Proxy; +import java.util.List; + +/** + * @author YiHui + * @date 2023/1/12 + */ +@Data +@ConfigurationProperties(prefix = "net") +public class ProxyProperties { + private List proxy; + + @Data + @Accessors(chain = true) + public static class ProxyType { + /** + * 代理类型 + */ + private Proxy.Type type; + /** + * 代理ip + */ + private String ip; + /** + * 代理端口 + */ + private Integer port; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/RabbitmqProperties.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/RabbitmqProperties.java new file mode 100644 index 000000000..24236ea58 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/config/RabbitmqProperties.java @@ -0,0 +1,53 @@ +package com.github.paicoding.forum.core.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * RabbitMQ配置文件 + * + * @author LouZai + * @since 2023/5/10 + */ +@Setter +@Getter +@ConfigurationProperties(prefix = "rabbitmq") +public class RabbitmqProperties { + + /** + * 主机 + */ + private String host; + + /** + * 端口 + */ + private Integer port; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String passport; + + /** + * 路径 + */ + private String virtualhost; + + /** + * 连接池大小 + */ + private Integer poolSize; + + /** + * 开关 false-关闭,true-打开 + */ + private Boolean switchFlag; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DS.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DS.java new file mode 100644 index 000000000..ce03ab040 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DS.java @@ -0,0 +1,14 @@ +package com.github.paicoding.forum.core.dal; + +/** + * @author YiHui + * @date 2023/4/30 + */ +public interface DS { + /** + * 使用的数据源名 + * + * @return + */ + String name(); +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DataSourceConfig.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DataSourceConfig.java new file mode 100644 index 000000000..c786829c2 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DataSourceConfig.java @@ -0,0 +1,147 @@ +package com.github.paicoding.forum.core.dal; + +import com.alibaba.druid.pool.DruidDataSource; +import com.alibaba.druid.support.http.StatViewServlet; +import com.google.common.collect.Maps; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; +import org.springframework.util.CollectionUtils; + +import javax.sql.DataSource; +import java.util.Map; + +/** + * 当配置了多数据源时,启用 + * + * @author YiHui + * @date 2023/4/30 + */ +@Slf4j +@Configuration +@ConditionalOnProperty(prefix = "spring.dynamic", name = "primary") +@EnableConfigurationProperties(DsProperties.class) +public class DataSourceConfig { + + private Environment environment; + + public DataSourceConfig(Environment environment) { + this.environment = environment; + log.info("动态数据源初始化!"); + } + + @Bean + public DsAspect dsAspect() { + return new DsAspect(); + } + + @Bean + public SqlStateInterceptor sqlStateInterceptor() { + return new SqlStateInterceptor(); + } + + /** + * 整合主从数据源 + * + * @param dsProperties + * @return 1 + */ + @Bean + @Primary + public DataSource dataSource(DsProperties dsProperties) { + Map targetDataSources = Maps.newHashMapWithExpectedSize(dsProperties.getDatasource().size()); + dsProperties.getDatasource().forEach((k, v) -> targetDataSources.put(k.toUpperCase(), initDataSource(k, v))); + + if (CollectionUtils.isEmpty(targetDataSources)) { + throw new IllegalStateException("多数据源配置,请以 spring.dynamic 开头"); + } + + MyRoutingDataSource myRoutingDataSource = new MyRoutingDataSource(); + Object key = dsProperties.getPrimary().toUpperCase(); + if (!targetDataSources.containsKey(key)) { + if (targetDataSources.containsKey(MasterSlaveDsEnum.MASTER.name())) { + // 当们没有配置primary对应的数据源时,存在MASTER数据源,则将主库作为默认的数据源 + key = MasterSlaveDsEnum.MASTER.name(); + } else { + key = targetDataSources.keySet().iterator().next(); + } + } + + log.info("动态数据源,默认启用为: " + key); + myRoutingDataSource.setDefaultTargetDataSource(targetDataSources.get(key)); + myRoutingDataSource.setTargetDataSources(targetDataSources); + return myRoutingDataSource; + } + + + public DataSource initDataSource(String prefix, DataSourceProperties properties) { + if (!DruidCheckUtil.hasDuridPkg()) { + log.info("实例化HikarDataSource: {}", prefix); + return properties.initializeDataSourceBuilder().build(); + } + + if (properties.getType() == null || !properties.getType().isAssignableFrom(DruidDataSource.class)) { + log.info("实例化HikarDataSource: {}", prefix); + return properties.initializeDataSourceBuilder().build(); + } + + log.info("实例化DruidDataSource: {}", prefix); + // fixme 知识点:手动将配置赋值到实例中的方式 + return Binder.get(environment).bindOrCreate(DsProperties.DS_PREFIX + ".datasource." + prefix, DruidDataSource.class); + } + + /** + * 在数据源实例化之后进行创建 + * + * @return + */ + @Bean + @ConditionalOnExpression(value = "T(com.github.paicoding.forum.core.dal.DruidCheckUtil).hasDuridPkg()") + public ServletRegistrationBean druidStatViewServlet() { + //先配置管理后台的servLet,访问的入口为/druid/ + ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean<>( + new StatViewServlet(), "/druid/*"); + // IP白名单 (没有配置或者为空,则允许所有访问) + servletRegistrationBean.addInitParameter("allow", "127.0.0.1"); + // IP黑名单 (存在共同时,deny优先于allow) + servletRegistrationBean.addInitParameter("deny", ""); + servletRegistrationBean.addInitParameter("loginUsername", "admin"); + servletRegistrationBean.addInitParameter("loginPassword", "admin"); + servletRegistrationBean.addInitParameter("resetEnable", "false"); + log.info("开启druid数据源监控面板"); + return servletRegistrationBean; + } + +// @Bean +// public JdbcTemplate notifyFullJdbcTemplate(DataSource myRoutingDataSource) { +// return new JdbcTemplate(myRoutingDataSource); +// } +// +// @Bean(name = "SqlSessionFactory") +// public SqlSessionFactory test1SqlSessionFactory(DataSource dynamicDataSource, GlobalConfig globalConfig) +// throws Exception { +// MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean(); +// bean.setDataSource(dynamicDataSource); +// /**当使用多数据源时,mybatisPlus默认配置将会失效,需要单独将其注入数据源中 */ +//// bean.setPlugins(plugins); +// /** 设置全局配置 */ +// bean.setGlobalConfig(globalConfig); +// return bean.getObject(); +// } +// +// /** 全局自定义配置 */ +// @Bean(name = "globalConfig") +// @ConfigurationProperties(prefix = "mybatis-plus.global-config") +// public GlobalConfig globalConfig(){ +// return new GlobalConfig(); +// } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DruidCheckUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DruidCheckUtil.java new file mode 100644 index 000000000..7fab8275f --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DruidCheckUtil.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.core.dal; + +import com.github.hui.quick.plugin.qrcode.util.ClassUtils; + +/** + * @author YiHui + * @date 2023/5/28 + */ +public class DruidCheckUtil { + + /** + * 判断是否包含durid相关的数据包 + * + * @return + */ + public static boolean hasDuridPkg() { + return ClassUtils.isPresent("com.alibaba.druid.pool.DruidDataSource", DataSourceConfig.class.getClassLoader()); + } + +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAno.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAno.java new file mode 100644 index 000000000..773f72558 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAno.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.core.dal; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author YiHui + * @date 2023/4/30 + */ + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface DsAno { + /** + * 启用的数据源,默认主库 + * + * @return + */ + MasterSlaveDsEnum value() default MasterSlaveDsEnum.MASTER; + + /** + * 启用的数据源,如果存在,则优先使用它来替换默认的value + * + * @return + */ + String ds() default ""; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAspect.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAspect.java new file mode 100644 index 000000000..ce8bac558 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAspect.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.core.dal; + +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; + +import java.lang.reflect.Method; + +/** + * @author YiHui + * @date 2023/4/30 + */ +@Aspect +public class DsAspect { + /** + * 切入点, 拦截类上、方法上有注解的方法,用于切换数据源 + */ + @Pointcut("@annotation(com.github.paicoding.forum.core.dal.DsAno) || @within(com.github.paicoding.forum.core.dal.DsAno)") + public void pointcut() { + } + + @Around("pointcut()") + public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { + DsAno ds = getDsAno(proceedingJoinPoint); + try { + if (ds != null && (StringUtils.isNotBlank(ds.ds()) || ds.value() != null)) { + // 当上下文中没有时,则写入线程上下文,应该用哪个DB + DsContextHolder.set(StringUtils.isNoneBlank(ds.ds()) ? ds.ds() : ds.value().name()); + } + return proceedingJoinPoint.proceed(); + } finally { + // 清空上下文信息 + if (ds != null) { + DsContextHolder.reset(); + } + } + } + + private DsAno getDsAno(ProceedingJoinPoint proceedingJoinPoint) { + MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); + Method method = signature.getMethod(); + DsAno ds = method.getAnnotation(DsAno.class); + if (ds == null) { + // 获取类上的注解 + ds = (DsAno) proceedingJoinPoint.getSignature().getDeclaringType().getAnnotation(DsAno.class); + } + return ds; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsContextHolder.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsContextHolder.java new file mode 100644 index 000000000..c36ba0b65 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsContextHolder.java @@ -0,0 +1,77 @@ +package com.github.paicoding.forum.core.dal; + +/** + * 数据源选择上下持有类,用于存储当前选中的是哪个数据源 + * + * @author YiHui + * @date 2023/4/30 + */ +public class DsContextHolder { + /** + * 使用继承的线程上下文,支持异步时选择传递 + * 使用DsNode,支持链式的数据源切换,如最外层使用master数据源,内部某个方法使用slave数据源;但是请注意,对于事务的场景,不要交叉 + */ + private static final ThreadLocal CONTEXT_HOLDER = new InheritableThreadLocal<>(); + + private DsContextHolder() { + } + + + public static void set(String dbType) { + DsNode current = CONTEXT_HOLDER.get(); + CONTEXT_HOLDER.set(new DsNode(current, dbType)); + } + + public static String get() { + DsNode ds = CONTEXT_HOLDER.get(); + return ds == null ? null : ds.ds; + } + + + public static void set(DS ds) { + set(ds.name().toUpperCase()); + } + + + /** + * 移除上下文 + */ + public static void reset() { + DsNode ds = CONTEXT_HOLDER.get(); + if (ds == null) { + return; + } + + if (ds.pre != null) { + // 退出当前的数据源选择,切回去走上一次的数据源配置 + CONTEXT_HOLDER.set(ds.pre); + } else { + CONTEXT_HOLDER.remove(); + } + } + + /** + * 使用主数据源类型 + */ + public static void master() { + set(MasterSlaveDsEnum.MASTER.name()); + } + + /** + * 使用从数据源类型 + */ + public static void slave() { + set(MasterSlaveDsEnum.SLAVE.name()); + } + + public static class DsNode { + DsNode pre; + String ds; + + public DsNode(DsNode parent, String ds) { + pre = parent; + this.ds = ds; + } + } + +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsProperties.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsProperties.java new file mode 100644 index 000000000..e877d1e49 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsProperties.java @@ -0,0 +1,28 @@ +package com.github.paicoding.forum.core.dal; + +import lombok.Data; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +/** + * 多数据源的配置加载 + * + * @author YiHui + * @date 2023/4/30 + */ +@Data +@ConfigurationProperties(prefix = DsProperties.DS_PREFIX) +public class DsProperties { + public static final String DS_PREFIX = "spring.dynamic"; + /** + * 默认数据源 + */ + private String primary; + + /** + * 多数据源配置 + */ + private Map datasource; +} \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsSelectExecutor.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsSelectExecutor.java new file mode 100644 index 000000000..50b6f2b58 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsSelectExecutor.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.core.dal; + +import java.util.function.Supplier; + +/** + * 手动指定数据源的用法 + * + * @author YiHui + * @date 2023/4/30 + */ +public class DsSelectExecutor { + + /** + * 有返回结果 + * + * @param ds + * @param supplier + * @param + * @return + */ + public static T submit(DS ds, Supplier supplier) { + DsContextHolder.set(ds); + try { + return supplier.get(); + } finally { + DsContextHolder.reset(); + } + } + + /** + * 无返回结果 + * + * @param ds + * @param call + */ + public static void execute(DS ds, Runnable call) { + DsContextHolder.set(ds); + try { + call.run(); + } finally { + DsContextHolder.reset(); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/MasterSlaveDsEnum.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/MasterSlaveDsEnum.java new file mode 100644 index 000000000..a5caec65f --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/MasterSlaveDsEnum.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.core.dal; + +/** + * 主从数据源的枚举 + * + * @author YiHui + * @date 2023/4/30 + */ +public enum MasterSlaveDsEnum implements DS { + /** + * master主数据源类型 + */ + MASTER, + /** + * slave从数据源类型 + */ + SLAVE; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/MyRoutingDataSource.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/MyRoutingDataSource.java new file mode 100644 index 000000000..147272170 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/MyRoutingDataSource.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.core.dal; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.lang.Nullable; + +/** + * @author YiHui + * @date 2023/4/30 + */ +public class MyRoutingDataSource extends AbstractRoutingDataSource { + @Nullable + @Override + protected Object determineCurrentLookupKey() { + return DsContextHolder.get(); + } + +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/SqlStateInterceptor.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/SqlStateInterceptor.java new file mode 100644 index 000000000..a06353cca --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/SqlStateInterceptor.java @@ -0,0 +1,194 @@ +package com.github.paicoding.forum.core.dal; + +import com.alibaba.druid.pool.DruidPooledPreparedStatement; +import com.baomidou.mybatisplus.core.MybatisParameterHandler; +import com.github.paicoding.forum.core.util.DateUtil; +import com.mysql.cj.MysqlConnection; +import com.zaxxer.hikari.pool.HikariProxyConnection; +import com.zaxxer.hikari.pool.HikariProxyPreparedStatement; +import lombok.extern.slf4j.Slf4j; +import nonapi.io.github.classgraph.utils.ReflectionUtils; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.ParameterMapping; +import org.apache.ibatis.mapping.ParameterMode; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.reflection.MetaObject; +import org.apache.ibatis.scripting.defaults.DefaultParameterHandler; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.ResultHandler; +import org.springframework.util.CollectionUtils; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.Statement; +import java.util.List; +import java.util.Properties; +import java.util.regex.Matcher; + +/** + * mybatis拦截器。输出sql执行情况 + * + * @author YiHui + * @date 2023/5/01 + */ +@Slf4j +@Intercepts({@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}), @Signature(type = StatementHandler.class, method = "update", args = {Statement.class})}) +public class SqlStateInterceptor implements Interceptor { + @Override + public Object intercept(Invocation invocation) throws Throwable { + long time = System.currentTimeMillis(); + StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); + String sql = buildSql(statementHandler); + Object[] args = invocation.getArgs(); + String uname = ""; + if (args[0] instanceof HikariProxyPreparedStatement) { + HikariProxyConnection connection = (HikariProxyConnection) ((HikariProxyPreparedStatement) invocation.getArgs()[0]).getConnection(); + uname = connection.getMetaData().getUserName(); + } else if (DruidCheckUtil.hasDuridPkg()) { + if (args[0] instanceof DruidPooledPreparedStatement) { + Connection connection = ((DruidPooledPreparedStatement) args[0]).getStatement().getConnection(); + if (connection instanceof MysqlConnection) { + Properties properties = ((MysqlConnection) connection).getProperties(); + uname = properties.getProperty("user"); + } + } + } + + Object rs; + try { + rs = invocation.proceed(); + } catch (Throwable e) { + log.error("error sql: " + sql, e); + throw e; + } finally { + long cost = System.currentTimeMillis() - time; + sql = this.replaceContinueSpace(sql); + // 这个方法的总耗时 + log.info("\n\n ============= \nsql ----> {}\nuser ----> {}\ncost ----> {}\n ============= \n", sql, uname, cost); + } + + return rs; + } + + /** + * 拼接sql + * + * @param statementHandler + * @return + */ + private String buildSql(StatementHandler statementHandler) { + BoundSql boundSql = statementHandler.getBoundSql(); + Configuration configuration = null; + if (statementHandler.getParameterHandler() instanceof DefaultParameterHandler) { + DefaultParameterHandler handler = (DefaultParameterHandler) statementHandler.getParameterHandler(); + configuration = (Configuration) ReflectionUtils.getFieldVal(handler, "configuration", false); + } else if (statementHandler.getParameterHandler() instanceof MybatisParameterHandler) { + MybatisParameterHandler paramHandler = (MybatisParameterHandler) statementHandler.getParameterHandler(); + configuration = ((MappedStatement) ReflectionUtils.getFieldVal(paramHandler, "mappedStatement", false)).getConfiguration(); + } + + if (configuration == null) { + return boundSql.getSql(); + } + + return getSql(boundSql, configuration); + } + + + /** + * 生成要执行的SQL命令 + * + * @param boundSql + * @param configuration + * @return + */ + private String getSql(BoundSql boundSql, Configuration configuration) { + String sql = boundSql.getSql(); + Object parameterObject = boundSql.getParameterObject(); + List parameterMappings = boundSql.getParameterMappings(); + if (CollectionUtils.isEmpty(parameterMappings) || parameterObject == null) { + return sql; + } + + MetaObject mo = configuration.newMetaObject(boundSql.getParameterObject()); + for (ParameterMapping parameterMapping : parameterMappings) { + if (parameterMapping.getMode() == ParameterMode.OUT) { + continue; + } + + //参数值 + Object value; + //获取参数名称 + String propertyName = parameterMapping.getProperty(); + if (boundSql.hasAdditionalParameter(propertyName)) { + //获取参数值 + value = boundSql.getAdditionalParameter(propertyName); + } else if (configuration.getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass())) { + //如果是单个值则直接赋值 + value = parameterObject; + } else { + value = mo.getValue(propertyName); + } + String param = Matcher.quoteReplacement(getParameter(value)); + sql = sql.replaceFirst("\\?", param); + } + sql += ";"; + return sql; + } + + public String getParameter(Object parameter) { + if (parameter instanceof String) { + return "'" + parameter + "'"; + } else if (parameter instanceof Date) { + // 日期格式化 + return "'" + DateUtil.format(DateUtil.DB_FORMAT, ((Date) parameter).getTime()) + "'"; + } else if (parameter instanceof java.util.Date) { + // 日期格式化 + return "'" + DateUtil.format(DateUtil.DB_FORMAT, ((java.util.Date) parameter).getTime()) + "'"; + } + return parameter.toString(); + } + + /** + * 替换连续的空白 + * + * @param str + * @return + */ + private String replaceContinueSpace(String str) { + StringBuilder builder = new StringBuilder(str.length()); + boolean preSpace = false; + for (int i = 0, len = str.length(); i < len; i++) { + char ch = str.charAt(i); + boolean isSpace = Character.isWhitespace(ch); + if (preSpace && isSpace) { + continue; + } + + if (preSpace) { + // 前面的是空白字符,当前的不是空白字符 + preSpace = false; + builder.append(ch); + } else if (isSpace) { + // 当前字符为空白字符,前面的那个不是的 + preSpace = true; + builder.append(" "); + } else { + // 前一个和当前字符都非空白字符 + builder.append(ch); + } + } + return builder.toString(); + } + + @Override + public Object plugin(Object o) { + return Plugin.wrap(o, this); + } + + @Override + public void setProperties(Properties properties) { + } +} \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/CustomAdmonitionBlockParser.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/CustomAdmonitionBlockParser.java new file mode 100644 index 000000000..30f75c8d7 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/CustomAdmonitionBlockParser.java @@ -0,0 +1,224 @@ +package com.github.paicoding.forum.core.markdown; + +import com.vladsch.flexmark.ast.ListItem; +import com.vladsch.flexmark.ast.util.Parsing; +import com.vladsch.flexmark.ext.admonition.AdmonitionBlock; +import com.vladsch.flexmark.ext.admonition.internal.AdmonitionOptions; +import com.vladsch.flexmark.parser.block.*; +import com.vladsch.flexmark.util.ast.Block; +import com.vladsch.flexmark.util.data.DataHolder; +import com.vladsch.flexmark.util.sequence.BasedSequence; +import com.vladsch.flexmark.util.sequence.mappers.SpecialLeadInHandler; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CustomAdmonitionBlockParser extends AbstractBlockParser { + final private static String ADMONITION_START_FORMAT = "^(\\?{3}\\+|\\?{3}|!{3}|:{3})\\s*(%s)(?:\\s+(%s))?\\s*$"; + + final AdmonitionBlock block; + //private BlockContent content = new BlockContent(); + final private AdmonitionOptions options; + final private int contentIndent; + private boolean hadBlankLine; + private boolean isOver; + + CustomAdmonitionBlockParser(AdmonitionOptions options, int contentIndent) { + this.options = options; + this.contentIndent = contentIndent; + this.block = new AdmonitionBlock(); + } + + private int getContentIndent() { + return contentIndent; + } + + @Override + public Block getBlock() { + return block; + } + + @Override + public boolean isContainer() { + return true; + } + + @Override + public boolean canContain(ParserState state, BlockParser blockParser, final Block block) { + return true; + } + + @Override + public BlockContinue tryContinue(ParserState state) { + // 获取当前行内容 + BasedSequence line = state.getLine(); + final int nonSpaceIndex = state.getNextNonSpaceIndex(); + + // 判断是否是终止符 "!!!" + if (isOver) { + return BlockContinue.none(); + } + + if (line.startsWith("!!!") || line.startsWith("???") || line.startsWith(":::")) { + isOver = true;// 停止解析 + } + + // 如果当前行是空行,则继续解析,同时标记块中出现过空行 + if (state.isBlank()) { + hadBlankLine = true; + return BlockContinue.atIndex(nonSpaceIndex); + } + + // 如果允许懒惰继续(lazy continuation),且未遇到空行 + if (!hadBlankLine && options.allowLazyContinuation) { + return BlockContinue.atIndex(nonSpaceIndex); + } + + // 如果缩进足够,则继续解析当前行 + if (state.getIndent() >= options.contentIndent) { + int contentIndent = state.getColumn() + options.contentIndent; + return BlockContinue.atColumn(contentIndent); + } + + // 默认情况,继续解析当前行 + return BlockContinue.atIndex(nonSpaceIndex); + } + + @Override + public void closeBlock(ParserState state) { + block.setCharsFromContent(); + } + + public static class Factory implements CustomBlockParserFactory { + @Nullable + @Override + public Set> getAfterDependents() { + return null; + } + + @Nullable + @Override + public Set> getBeforeDependents() { + return null; + } + + @Override + public @Nullable SpecialLeadInHandler getLeadInHandler(@NotNull DataHolder options) { + return CustomAdmonitionBlockParser.AdmonitionLeadInHandler.HANDLER; + } + + @Override + public boolean affectsGlobalScope() { + return false; + } + + @NotNull + @Override + public BlockParserFactory apply(@NotNull DataHolder options) { + return new CustomAdmonitionBlockParser + .BlockFactory(options); + } + } + + static class AdmonitionLeadInHandler implements SpecialLeadInHandler { + final static SpecialLeadInHandler HANDLER = new CustomAdmonitionBlockParser + .AdmonitionLeadInHandler(); + + @Override + public boolean escape(@NotNull BasedSequence sequence, @Nullable DataHolder options, @NotNull Consumer consumer) { + if ((sequence.length() == 3 || sequence.length() == 4 && sequence.charAt(3) == '+') && (sequence.startsWith("???") || sequence.startsWith("!!!") || sequence.startsWith(":::"))) { + consumer.accept("\\"); + consumer.accept(sequence); + return true; + } + return false; + } + + @Override + public boolean unEscape(@NotNull BasedSequence sequence, @Nullable DataHolder options, @NotNull Consumer consumer) { + if ((sequence.length() == 4 || sequence.length() == 5 && sequence.charAt(4) == '+') && (sequence.startsWith("\\???") || sequence.startsWith("\\!!!") || sequence.startsWith("\\:::"))) { + consumer.accept(sequence.subSequence(1)); + return true; + } + return false; + } + } + + static boolean isMarker( + final ParserState state, + final int index, + final boolean inParagraph, + final boolean inParagraphListItem, + final AdmonitionOptions options + ) { + final boolean allowLeadingSpace = options.allowLeadingSpace; + final boolean interruptsParagraph = options.interruptsParagraph; + final boolean interruptsItemParagraph = options.interruptsItemParagraph; + final boolean withLeadSpacesInterruptsItemParagraph = options.withSpacesInterruptsItemParagraph; + CharSequence line = state.getLine(); + if (!inParagraph || interruptsParagraph) { + if ((allowLeadingSpace || state.getIndent() == 0) && (!inParagraphListItem || interruptsItemParagraph)) { + if (inParagraphListItem && !withLeadSpacesInterruptsItemParagraph) { + return state.getIndent() == 0; + } else { + return state.getIndent() < state.getParsing().CODE_BLOCK_INDENT; + } + } + } + return false; + } + + private static class BlockFactory extends AbstractBlockParserFactory { + final private AdmonitionOptions options; + + BlockFactory(DataHolder options) { + super(options); + this.options = new AdmonitionOptions(options); + } + + @Override + public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { + if (state.getIndent() >= 4) { + return BlockStart.none(); + } + + int nextNonSpace = state.getNextNonSpaceIndex(); + BlockParser matched = matchedBlockParser.getBlockParser(); + boolean inParagraph = matched.isParagraphParser(); + boolean inParagraphListItem = inParagraph && matched.getBlock().getParent() instanceof ListItem && matched.getBlock() == matched.getBlock().getParent().getFirstChild(); + + if (isMarker(state, nextNonSpace, inParagraph, inParagraphListItem, options)) { + BasedSequence line = state.getLine(); + BasedSequence trySequence = line.subSequence(nextNonSpace, line.length()); + Parsing parsing = state.getParsing(); + Pattern startPattern = Pattern.compile(String.format(ADMONITION_START_FORMAT, parsing.ATTRIBUTENAME, parsing.LINK_TITLE_STRING)); + Matcher matcher = startPattern.matcher(trySequence); + + if (matcher.find()) { + // admonition block + BasedSequence openingMarker = line.subSequence(nextNonSpace + matcher.start(1), nextNonSpace + matcher.end(1)); + BasedSequence info = line.subSequence(nextNonSpace + matcher.start(2), nextNonSpace + matcher.end(2)); + BasedSequence titleChars = matcher.group(3) == null ? BasedSequence.NULL : line.subSequence(nextNonSpace + matcher.start(3), nextNonSpace + matcher.end(3)); + + int contentOffset = options.contentIndent; + + CustomAdmonitionBlockParser admonitionBlockParser = new CustomAdmonitionBlockParser (options, contentOffset); + admonitionBlockParser.block.setOpeningMarker(openingMarker); + admonitionBlockParser.block.setInfo(info); + admonitionBlockParser.block.setTitleChars(titleChars); + + return BlockStart.of(admonitionBlockParser) + .atIndex(line.length()); + } else { + return BlockStart.none(); + } + } else { + return BlockStart.none(); + } + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/CustomAdmonitionExtension.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/CustomAdmonitionExtension.java new file mode 100644 index 000000000..d34528109 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/CustomAdmonitionExtension.java @@ -0,0 +1,215 @@ +package com.github.paicoding.forum.core.markdown; + +import com.vladsch.flexmark.ext.admonition.AdmonitionBlock; + +import com.vladsch.flexmark.ext.admonition.internal.AdmonitionNodeFormatter; +import com.vladsch.flexmark.ext.admonition.internal.AdmonitionNodeRenderer; +import com.vladsch.flexmark.formatter.Formatter; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.data.DataKey; +import com.vladsch.flexmark.util.data.MutableDataHolder; +import org.jetbrains.annotations.NotNull; + +import java.io.*; +import java.util.HashMap; +import java.util.Map; + +/** + * Extension for admonitions + *

+ * Create it with {@link #create()} and then configure it on the builders + *

+ * The parsed admonition text is turned into {@link AdmonitionBlock} nodes. + */ +public class CustomAdmonitionExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension, Formatter.FormatterExtension + // , Parser.ReferenceHoldingExtension +{ + final public static DataKey CONTENT_INDENT = new DataKey<>("ADMONITION.CONTENT_INDENT", 4); + final public static DataKey ALLOW_LEADING_SPACE = new DataKey<>("ADMONITION.ALLOW_LEADING_SPACE", true); + final public static DataKey INTERRUPTS_PARAGRAPH = new DataKey<>("ADMONITION.INTERRUPTS_PARAGRAPH", true); + final public static DataKey INTERRUPTS_ITEM_PARAGRAPH = new DataKey<>("ADMONITION.INTERRUPTS_ITEM_PARAGRAPH", true); + final public static DataKey WITH_SPACES_INTERRUPTS_ITEM_PARAGRAPH = new DataKey<>("ADMONITION.WITH_SPACES_INTERRUPTS_ITEM_PARAGRAPH", true); + final public static DataKey ALLOW_LAZY_CONTINUATION = new DataKey<>("ADMONITION.ALLOW_LAZY_CONTINUATION", true); + final public static DataKey UNRESOLVED_QUALIFIER = new DataKey<>("ADMONITION.UNRESOLVED_QUALIFIER", "note"); + final public static DataKey> QUALIFIER_TYPE_MAP = new DataKey<>("ADMONITION.QUALIFIER_TYPE_MAP", CustomAdmonitionExtension::getQualifierTypeMap); + final public static DataKey> QUALIFIER_TITLE_MAP = new DataKey<>("ADMONITION.QUALIFIER_TITLE_MAP", CustomAdmonitionExtension::getQualifierTitleMap); + final public static DataKey> TYPE_SVG_MAP = new DataKey<>("ADMONITION.TYPE_SVG_MAP", CustomAdmonitionExtension::getQualifierSvgValueMap); + + public static Map getQualifierTypeMap() { + HashMap infoSvgMap = new HashMap<>(); + // qualifier type map + infoSvgMap.put("abstract", "abstract"); + infoSvgMap.put("summary", "abstract"); + infoSvgMap.put("tldr", "abstract"); + + infoSvgMap.put("bug", "bug"); + + infoSvgMap.put("danger", "danger"); + infoSvgMap.put("error", "danger"); + + infoSvgMap.put("example", "example"); + infoSvgMap.put("snippet", "example"); + + infoSvgMap.put("fail", "fail"); + infoSvgMap.put("failure", "fail"); + infoSvgMap.put("missing", "fail"); + + infoSvgMap.put("faq", "faq"); + infoSvgMap.put("question", "faq"); + infoSvgMap.put("help", "faq"); + + infoSvgMap.put("info", "info"); + infoSvgMap.put("todo", "info"); + + infoSvgMap.put("note", "note"); + infoSvgMap.put("seealso", "note"); + + infoSvgMap.put("quote", "quote"); + infoSvgMap.put("cite", "quote"); + + infoSvgMap.put("success", "success"); + infoSvgMap.put("check", "success"); + infoSvgMap.put("done", "success"); + + infoSvgMap.put("tip", "tip"); + infoSvgMap.put("hint", "tip"); + infoSvgMap.put("important", "tip"); + + infoSvgMap.put("warning", "warning"); + infoSvgMap.put("caution", "warning"); + infoSvgMap.put("attention", "warning"); + + return infoSvgMap; + } + + public static Map getQualifierTitleMap() { + HashMap infoTitleMap = new HashMap<>(); + infoTitleMap.put("abstract", "Abstract"); + infoTitleMap.put("summary", "Summary"); + infoTitleMap.put("tldr", "TLDR"); + + infoTitleMap.put("bug", "Bug"); + + infoTitleMap.put("danger", "Danger"); + infoTitleMap.put("error", "Error"); + + infoTitleMap.put("example", "Example"); + infoTitleMap.put("snippet", "Snippet"); + + infoTitleMap.put("fail", "Fail"); + infoTitleMap.put("failure", "Failure"); + infoTitleMap.put("missing", "Missing"); + + infoTitleMap.put("faq", "Faq"); + infoTitleMap.put("question", "Question"); + infoTitleMap.put("help", "Help"); + + infoTitleMap.put("info", "Info"); + infoTitleMap.put("todo", "To Do"); + + infoTitleMap.put("note", "Note"); + infoTitleMap.put("seealso", "See Also"); + + infoTitleMap.put("quote", "Quote"); + infoTitleMap.put("cite", "Cite"); + + infoTitleMap.put("success", "Success"); + infoTitleMap.put("check", "Check"); + infoTitleMap.put("done", "Done"); + + infoTitleMap.put("tip", "Tip"); + infoTitleMap.put("hint", "Hint"); + infoTitleMap.put("important", "Important"); + + infoTitleMap.put("warning", "Warning"); + infoTitleMap.put("caution", "Caution"); + infoTitleMap.put("attention", "Attention"); + + return infoTitleMap; + } + + public static Map getQualifierSvgValueMap() { + HashMap typeSvgMap = new HashMap<>(); + typeSvgMap.put("abstract", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-abstract.svg"))); + typeSvgMap.put("bug", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-bug.svg"))); + typeSvgMap.put("danger", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-danger.svg"))); + typeSvgMap.put("example", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-example.svg"))); + typeSvgMap.put("fail", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-fail.svg"))); + typeSvgMap.put("faq", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-faq.svg"))); + typeSvgMap.put("info", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-info.svg"))); + typeSvgMap.put("note", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-note.svg"))); + typeSvgMap.put("quote", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-quote.svg"))); + typeSvgMap.put("success", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-success.svg"))); + typeSvgMap.put("tip", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-tip.svg"))); + typeSvgMap.put("warning", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-warning.svg"))); + return typeSvgMap; + } + + public static String getInputStreamContent(InputStream inputStream) { + try { + InputStreamReader streamReader = new InputStreamReader(inputStream); + StringWriter stringWriter = new StringWriter(); + copy(streamReader, stringWriter); + stringWriter.close(); + return stringWriter.toString(); + } catch (Exception e) { + e.printStackTrace(); + return ""; + } + } + + public static String getDefaultCSS() { + return getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/admonition.css")); + } + + public static String getDefaultScript() { + return getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/admonition.js")); + } + + public static void copy(Reader reader, Writer writer) throws IOException { + char[] buffer = new char[4096]; + int n; + while (-1 != (n = reader.read(buffer))) { + writer.write(buffer, 0, n); + } + writer.flush(); + reader.close(); + } + + private CustomAdmonitionExtension() { + } + + public static CustomAdmonitionExtension create() { + return new CustomAdmonitionExtension(); + } + + @Override + public void extend(Formatter.Builder formatterBuilder) { + formatterBuilder.nodeFormatterFactory(new AdmonitionNodeFormatter.Factory()); + } + + @Override + public void rendererOptions(@NotNull MutableDataHolder options) { + + } + + @Override + public void parserOptions(MutableDataHolder options) { + + } + + @Override + public void extend(Parser.Builder parserBuilder) { + parserBuilder.customBlockParserFactory(new CustomAdmonitionBlockParser.Factory()); + } + + @Override + public void extend(@NotNull HtmlRenderer.Builder htmlRendererBuilder, @NotNull String rendererType) { + if (htmlRendererBuilder.isRendererType("HTML")) { + htmlRendererBuilder.nodeRendererFactory(new AdmonitionNodeRenderer.Factory()); + } else if (htmlRendererBuilder.isRendererType("JIRA")) { + + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/ImageCaptionExtension.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/ImageCaptionExtension.java new file mode 100644 index 000000000..ff426d6fb --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/ImageCaptionExtension.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.core.markdown; + +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.util.data.MutableDataHolder; +import org.jetbrains.annotations.NotNull; + +/** + * Extension for rendering images with captions from alt text + *

+ * Create it with {@link #create()} and then configure it on the builders + *

+ * This extension automatically wraps images with alt text in figure tags + * and displays the alt text as a caption below the image. + * + * @author 沉默王二 + * @date 2025-10-20 + */ +public class ImageCaptionExtension implements HtmlRenderer.HtmlRendererExtension { + + private ImageCaptionExtension() { + } + + public static ImageCaptionExtension create() { + return new ImageCaptionExtension(); + } + + @Override + public void rendererOptions(@NotNull MutableDataHolder options) { + // No options needed for now + } + + @Override + public void extend(@NotNull HtmlRenderer.Builder htmlRendererBuilder, @NotNull String rendererType) { + if (htmlRendererBuilder.isRendererType("HTML")) { + htmlRendererBuilder.nodeRendererFactory(new ImageCaptionNodeRenderer.Factory()); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/ImageCaptionNodeRenderer.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/ImageCaptionNodeRenderer.java new file mode 100644 index 000000000..5f672d030 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/markdown/ImageCaptionNodeRenderer.java @@ -0,0 +1,85 @@ +package com.github.paicoding.forum.core.markdown; + +import com.vladsch.flexmark.ast.Image; +import com.vladsch.flexmark.html.HtmlWriter; +import com.vladsch.flexmark.html.renderer.*; +import com.vladsch.flexmark.util.ast.TextCollectingVisitor; +import com.vladsch.flexmark.util.data.DataHolder; +import org.jetbrains.annotations.NotNull; + +import java.util.HashSet; +import java.util.Set; + +/** + * Node renderer for images with captions + *

+ * Renders images with alt text wrapped in figure tags with figcaption + * + * @author 沉默王二 + * @date 2025-10-20 + */ +public class ImageCaptionNodeRenderer implements NodeRenderer { + + public ImageCaptionNodeRenderer(DataHolder options) { + // No options needed for now + } + + @Override + public Set> getNodeRenderingHandlers() { + HashSet> set = new HashSet<>(); + set.add(new NodeRenderingHandler<>(Image.class, this::render)); + return set; + } + + private void render(Image node, NodeRendererContext context, HtmlWriter html) { + if (!context.isDoNotRenderLinks()) { + // Collect the alt text from the image node + String altText = new TextCollectingVisitor().collectAndGetText(node); + + // Resolve the image URL + ResolvedLink resolvedLink = context.resolveLink(LinkType.IMAGE, node.getUrl().unescape(), null); + String url = resolvedLink.getUrl(); + + // Check if alt text exists and is not empty + if (altText != null && !altText.trim().isEmpty()) { + // Render as figure with caption + html.line(); + html.withAttr().tag("figure"); + html.line(); + + // Render the image + html.attr("src", url); + html.attr("alt", altText); + if (node.getTitle().isNotNull()) { + html.attr("title", node.getTitle().unescape()); + } + html.srcPos(node.getChars()).withAttr(resolvedLink).tagVoid("img"); + html.line(); + + // Render the caption + html.withAttr().tag("figcaption"); + html.text(altText); + html.tag("/figcaption"); + html.line(); + + html.tag("/figure"); + html.line(); + } else { + // No alt text, render as normal image + html.attr("src", url); + if (node.getTitle().isNotNull()) { + html.attr("title", node.getTitle().unescape()); + } + html.srcPos(node.getChars()).withAttr(resolvedLink).tagVoidLine("img"); + } + } + } + + public static class Factory implements NodeRendererFactory { + @NotNull + @Override + public NodeRenderer apply(@NotNull DataHolder options) { + return new ImageCaptionNodeRenderer(options); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcAspect.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcAspect.java new file mode 100644 index 000000000..433efd40d --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcAspect.java @@ -0,0 +1,93 @@ +package com.github.paicoding.forum.core.mdc; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +/** + * @author YiHui + * @date 2023/5/26 + */ +@Slf4j +@Aspect +@Component +public class MdcAspect implements ApplicationContextAware { + private ExpressionParser parser = new SpelExpressionParser(); + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + @Pointcut("@annotation(MdcDot) || @within(MdcDot)") + public void getLogAnnotation() { + } + + @Around("getLogAnnotation()") + public Object handle(ProceedingJoinPoint joinPoint) throws Throwable { + long start = System.currentTimeMillis(); + boolean hasTag = addMdcCode(joinPoint); + try { + Object ans = joinPoint.proceed(); + return ans; + } finally { + log.info("执行耗时: {}#{} = {}ms", + joinPoint.getSignature().getDeclaringType().getSimpleName(), + joinPoint.getSignature().getName(), + System.currentTimeMillis() - start); + if (hasTag) { + MdcUtil.reset(); + } + } + } + + private boolean addMdcCode(ProceedingJoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + MdcDot dot = method.getAnnotation(MdcDot.class); + if (dot == null) { + dot = (MdcDot) joinPoint.getSignature().getDeclaringType().getAnnotation(MdcDot.class); + } + + if (dot != null) { + MdcUtil.add("bizCode", loadBizCode(dot.bizCode(), joinPoint)); + return true; + } + return false; + } + + private String loadBizCode(String key, ProceedingJoinPoint joinPoint) { + if (StringUtils.isBlank(key)) { + return ""; + } + + StandardEvaluationContext context = new StandardEvaluationContext(); + + context.setBeanResolver(new BeanFactoryResolver(applicationContext)); + String[] params = parameterNameDiscoverer.getParameterNames(((MethodSignature) joinPoint.getSignature()).getMethod()); + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < args.length; i++) { + context.setVariable(params[i], args[i]); + } + return parser.parseExpression(key).getValue(context, String.class); + } + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcDot.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcDot.java new file mode 100644 index 000000000..b1525635e --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcDot.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.core.mdc; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author YiHui + * @date 2023/5/26 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MdcDot { + String bizCode() default ""; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcUtil.java new file mode 100644 index 000000000..e56734ac3 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/MdcUtil.java @@ -0,0 +1,34 @@ +package com.github.paicoding.forum.core.mdc; + +import org.slf4j.MDC; + +/** + * @author YiHui + * @date 2023/5/29 + */ +public class MdcUtil { + public static final String TRACE_ID_KEY = "traceId"; + + public static void add(String key, String val) { + MDC.put(key, val); + } + + public static void addTraceId() { + // traceId的生成规则,技术派提供了两种生成策略,可以使用自定义的也可以使用SkyWalking; 实际项目中选择一种即可 + MDC.put(TRACE_ID_KEY, SelfTraceIdGenerator.generate()); + } + + public static String getTraceId() { + return MDC.get(TRACE_ID_KEY); + } + + public static void reset() { + String traceId = MDC.get(TRACE_ID_KEY); + MDC.clear(); + MDC.put(TRACE_ID_KEY, traceId); + } + + public static void clear() { + MDC.clear(); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/SelfTraceIdGenerator.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/SelfTraceIdGenerator.java new file mode 100644 index 000000000..bdfafdc04 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/SelfTraceIdGenerator.java @@ -0,0 +1,102 @@ +package com.github.paicoding.forum.core.mdc; + +import com.github.paicoding.forum.core.util.IpUtil; +import com.google.common.base.Splitter; +import lombok.extern.slf4j.Slf4j; + +import java.lang.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; +import java.net.InetAddress; +import java.time.Instant; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 自定义的traceId生成器 + *

+ * 生成规则参考 + * + * @author YiHui + * @date 2023/5/29 + */ +@Slf4j +public class SelfTraceIdGenerator { + private final static Integer MIN_AUTO_NUMBER = 1000; + private final static Integer MAX_AUTO_NUMBER = 10000; + private static volatile Integer autoIncreaseNumber = MIN_AUTO_NUMBER; + + /** + *

+ * 生成32位traceId,规则是 服务器 IP + 产生ID时的时间 + 自增序列 + 当前进程号 + * IP 8位:39.105.208.175 -> 2769d0af + * 产生ID时的时间 13位: 毫秒时间戳 -> 1403169275002 + * 当前进程号 5位: PID + * 自增序列 4位: 1000-9999循环 + *

+ * w + * + * @return ac13e001.1685348263825.095001000 + */ + public static String generate() { + StringBuilder traceId = new StringBuilder(); + try { + // 1. IP - 8 + traceId.append(convertIp(IpUtil.getLocalIp4Address())).append("."); + // 2. 时间戳 - 13 + traceId.append(Instant.now().toEpochMilli()).append("."); + // 3. 当前进程号 - 5 + traceId.append(getProcessId()); + // 4. 自增序列 - 4 + traceId.append(getAutoIncreaseNumber()); + } catch (Exception e) { + log.error("generate trace id error!", e); + return UUID.randomUUID().toString().replaceAll("-", ""); + } + return traceId.toString(); + } + + /** + * IP转换为十六进制 - 8位 + * + * @param ip 39.105.208.175 + * @return 2769d0af + */ + private static String convertIp(String ip) { + return Splitter.on(".").splitToStream(ip) + .map(s -> String.format("%02x", Integer.valueOf(s))) + .collect(Collectors.joining()); + } + + /** + * 使得自增序列在1000-9999之间循环 - 4位 + * + * @return 自增序列号 + */ + private static int getAutoIncreaseNumber() { + if (autoIncreaseNumber >= MAX_AUTO_NUMBER) { + autoIncreaseNumber = MIN_AUTO_NUMBER; + return autoIncreaseNumber; + } else { + return autoIncreaseNumber++; + } + } + + /** + * @return 5位当前进程号 + */ + private static String getProcessId() { + RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean(); + String processId = runtime.getName().split("@")[0]; + return String.format("%05d", Integer.parseInt(processId)); + } + + public static void main(String[] args) { + String t = generate(); + System.out.println(t); + String t2 = generate(); + System.out.println(t2); + + String trace = SkyWalkingTraceIdGenerator.generate(); + System.out.println(trace); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/SkyWalkingTraceIdGenerator.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/SkyWalkingTraceIdGenerator.java new file mode 100644 index 000000000..31ce33fcd --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/mdc/SkyWalkingTraceIdGenerator.java @@ -0,0 +1,84 @@ +package com.github.paicoding.forum.core.mdc; + +import com.google.common.base.Joiner; + +import java.util.UUID; + +/** + * SkyWalking的traceId生成策略 + *

+ * 源码: + * + * @author YiHui + * @date 2023/5/29 + */ +public class SkyWalkingTraceIdGenerator { + private static final String PROCESS_ID = UUID.randomUUID().toString().replaceAll("-", ""); + private static final ThreadLocal THREAD_ID_SEQUENCE = ThreadLocal.withInitial( + () -> new IDContext(System.currentTimeMillis(), (short) 0)); + + private SkyWalkingTraceIdGenerator() { + } + + /** + * Generate a new id, combined by three parts. + *

+ * The first one represents application instance id. + *

+ * The second one represents thread id. + *

+ * The third one also has two parts, 1) a timestamp, measured in milliseconds 2) a seq, in current thread, between + * 0(included) and 9999(included) + * + * @return unique id to represent a trace or segment + */ + public static String generate() { + return Joiner.on(".").join( + PROCESS_ID, + String.valueOf(Thread.currentThread().getId()), + String.valueOf(THREAD_ID_SEQUENCE.get().nextSeq()) + ); + } + + private static class IDContext { + private static final int MAX_SEQ = 10_000; + private long lastTimestamp; + private short threadSeq; + + // Just for considering time-shift-back only. + private long lastShiftTimestamp; + private int lastShiftValue; + + private IDContext(long lastTimestamp, short threadSeq) { + this.lastTimestamp = lastTimestamp; + this.threadSeq = threadSeq; + } + + private long nextSeq() { + return timestamp() * 10000 + nextThreadSeq(); + } + + private long timestamp() { + long currentTimeMillis = System.currentTimeMillis(); + + if (currentTimeMillis < lastTimestamp) { + // Just for considering time-shift-back by Ops or OS. @hanahmily 's suggestion. + if (lastShiftTimestamp != currentTimeMillis) { + lastShiftValue++; + lastShiftTimestamp = currentTimeMillis; + } + return lastShiftValue; + } else { + lastTimestamp = currentTimeMillis; + return lastTimestamp; + } + } + + private short nextThreadSeq() { + if (threadSeq == MAX_SEQ) { + threadSeq = 0; + } + return threadSeq++; + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/HttpRequestHelper.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/HttpRequestHelper.java new file mode 100644 index 000000000..870cfa1f0 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/HttpRequestHelper.java @@ -0,0 +1,317 @@ +package com.github.paicoding.forum.core.net; + +import com.alibaba.fastjson.JSONObject; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +import javax.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * 请求工具类 + * + * @author YiHui + * @date 2023/04/23 + */ +@Slf4j +public class HttpRequestHelper { + public static final String CHROME_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"; + + /** + * rest template + */ + private static LoadingCache restTemplateMap; + + static { + restTemplateMap = CacheBuilder.newBuilder().expireAfterAccess(10, TimeUnit.MINUTES).build(new CacheLoader() { + @Override + public RestTemplate load(String key) throws Exception { + return buildRestTemplate(); + } + }); + } + + /** + * build rest template + * + * @return + */ + private static RestTemplate buildRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(15000); + factory.setReadTimeout(15000); + return new RestTemplate(factory); + } + + @Scheduled(cron = "0 0 0/1 * * ?") + public static void refreshRestTemplate() { + restTemplateMap.cleanUp(); + } + + + /** + * 文件上传 + * + * @param url 上传url + * @param paramName 参数名 + * @param fileName 上传的文件名 + * @param bytes 上传文件流 + * @return + */ + public static String upload(String url, String paramName, String fileName, byte[] bytes) { + //设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + //设置请求体,注意是LinkedMultiValueMap + ByteArrayResource fileSystemResource = new ByteArrayResource(bytes) { + @Override + public String getFilename() { + return fileName; + } + }; + MultiValueMap form = new LinkedMultiValueMap<>(); + // post的文件 + form.add(paramName, fileSystemResource); + + //用HttpEntity封装整个请求报文 + HttpEntity> files = new HttpEntity<>(form, headers); + String threadName = Thread.currentThread().getName(); + RestTemplate restTemplate = restTemplateMap.getUnchecked(threadName); + HttpEntity res = restTemplate.postForEntity(url, files, String.class); + return res.getBody(); + } + + /** + * @param url + * @param method + * @param params + * @param headers + * @param responseClass + * @param + * @return + */ + public static R fetchContentWithProxy(String url, HttpMethod method, Map params, HttpHeaders headers, Class responseClass) { + R result = fetchContent(url, method, params, headers, responseClass, true); + if (result == null) { + return fetchContent(url, method, params, headers, responseClass, false); + } + + return result; + } + + /** + * @param url + * @param method + * @param params + * @param headers + * @param responseClass + * @param + * @return + */ + public static R fetchContentWithoutProxy(String url, HttpMethod method, Map params, HttpHeaders headers, Class responseClass) { + return fetchContent(url, method, params, headers, responseClass, false); + } + + /** + * fetch content + * + * @param url + * @param method + * @param params + * @param headers + * @param responseClass + * @param useProxy + * @param + * @return + */ + private static R fetchContent(String url, HttpMethod method, Map params, HttpHeaders headers, Class responseClass, boolean useProxy) { + String threadName = Thread.currentThread().getName(); + RestTemplate restTemplate = restTemplateMap.getUnchecked(threadName); + + String host = ""; + try { + host = new URL(url).getHost(); + } catch (MalformedURLException e) { + log.error("Failed to parse url:{}", url); + } + + if (useProxy) { + ensureProxy(restTemplate, host); + } else { + ensureProxy(restTemplate, ""); + } + + return fetchContentInternal(restTemplate, url, method, params, headers, responseClass); + } + + /** + * ensure proxy + * + * @param restTemplate + * @param host + */ + private static void ensureProxy(RestTemplate restTemplate, String host) { + SimpleClientHttpRequestFactory factory = (SimpleClientHttpRequestFactory) restTemplate.getRequestFactory(); + if (StringUtils.isBlank(host)) { + factory.setProxy(null); + return; + } + + Optional.ofNullable(ProxyCenter.loadProxy(host)).ifPresent(factory::setProxy); + } + + /** + * fetch content + * + * @param restTemplate + * @param url + * @param method + * @param params + * @param headers + * @param responseClass + * @param + * @return + */ + @SuppressWarnings("unchecked") + private static R fetchContentInternal(RestTemplate restTemplate, String url, HttpMethod method, Map params, HttpHeaders headers, Class responseClass) { + ResponseEntity responseEntity; + try { + SslUtils.ignoreSSL(); + if (method.equals(HttpMethod.GET)) { + HttpEntity entity = new HttpEntity<>(headers); + responseEntity = restTemplate.exchange(url, method, entity, responseClass, params); + } else { + MultiValueMap args = new LinkedMultiValueMap<>(); + args.setAll(params); + HttpEntity> entity = new HttpEntity<>(args, headers); + responseEntity = restTemplate.exchange(url, method, entity, responseClass); + } + } catch (RestClientResponseException e) { + String res = e.getResponseBodyAsString(); + if (String.class.isAssignableFrom(responseClass)) { + return (R) res; + } else if (JSONObject.class.isAssignableFrom(responseClass)) { + return (R) JSONObject.parseObject(res); + } + return null; + } catch (Exception e) { + log.warn("Failed to fetch content, url:{}, params:{}, exception:{}", url, params, e.getMessage()); + return null; + } + + return responseEntity.getBody(); + } + + public static R fetchByRequestBody(String url, Map params, HttpHeaders headers, Class responseClass) { + ResponseEntity responseEntity; + try { + String threadName = Thread.currentThread().getName(); + RestTemplate restTemplate = restTemplateMap.getUnchecked(threadName); + HttpEntity> entity = new HttpEntity<>(params, headers); + responseEntity = restTemplate.exchange(url, HttpMethod.POST, entity, responseClass); + } catch (Exception e) { + log.warn("Failed to fetch content, url:{}, params:{}, exception:{}", url, params, e.getMessage()); + return null; + } + + if (responseEntity != null) { + return responseEntity.getBody(); + } + + return null; + } + + /** + * get 请求,参数通过params传递,要求url中有参数占位符,这样发起请求时,会自动将 params 中的参数拼接到url中 + * + * @param url + * @param params + * @param res + * @param + * @return + */ + public static R get(String url, Map params, Class res) { + return fetchContentWithoutProxy(url, HttpMethod.GET, params, new HttpHeaders(), res); + } + + public static R get(String url, Class res) { + return fetchContentWithoutProxy(url, HttpMethod.GET, new HashMap<>(), new HttpHeaders(), res); + } + + + public static R postJsonData(String url, Object data, Class res) { + ResponseEntity responseEntity; + try { + String threadName = Thread.currentThread().getName(); + RestTemplate restTemplate = restTemplateMap.getUnchecked(threadName); + + // 设置请求头为 application/json + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(data, headers); + responseEntity = restTemplate.exchange(url, HttpMethod.POST, entity, res); + } catch (Exception e) { + log.warn("Failed to fetch content, url:{}, params:{}, exception:{}", url, data, e.getMessage()); + return null; + } + + return responseEntity.getBody(); + } + + + /** + * readData + * + * @param request request + * @return result + */ + // CHECKSTYLE:OFF:InnerAssignment + public static String readReqData(HttpServletRequest request) { + BufferedReader reader = null; + try { + reader = new BufferedReader(new InputStreamReader(request.getInputStream(), StandardCharsets.UTF_8)); + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + return stringBuilder.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + log.error("请求参数解析异常! {}", request.getRequestURI(), e); + } + } + } + } +} \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/ProxyCenter.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/ProxyCenter.java new file mode 100644 index 000000000..548a5104e --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/ProxyCenter.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.core.net; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.paicoding.forum.core.config.ProxyProperties; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.ArrayList; +import java.util.List; + +/** + * @author YiHui + * @date 2023/6/2 + */ +public class ProxyCenter { + + /** + * 记录每个source使用的proxy索引 + */ + private static final Cache HOST_PROXY_INDEX = Caffeine.newBuilder().maximumSize(16).build(); + /** + * proxy + */ + private static List PROXIES = new ArrayList<>(); + + + public static void initProxyPool(List proxyTypes) { + PROXIES = proxyTypes; + } + + /** + * get proxy + * + * @return + */ + static ProxyProperties.ProxyType getProxy(String host) { + Integer index = HOST_PROXY_INDEX.getIfPresent(host); + if (index == null) { + index = -1; + } + + ++index; + if (index >= PROXIES.size()) { + index = 0; + } + HOST_PROXY_INDEX.put(host, index); + return PROXIES.get(index); + } + + public static Proxy loadProxy(String host) { + ProxyProperties.ProxyType proxyType = getProxy(host); + if (proxyType == null) { + return null; + } + return new Proxy(proxyType.getType(), new InetSocketAddress(proxyType.getIp(), proxyType.getPort())); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/SslUtils.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/SslUtils.java new file mode 100644 index 000000000..26d92f7a0 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/net/SslUtils.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.core.net; + +import javax.net.ssl.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * @author YiHui + * @date 2023/4/20 + */ +public class SslUtils { + private static void trustAllHttpsCertificates() throws Exception { + TrustManager[] trustAllCerts = new TrustManager[1]; + TrustManager tm = new miTM(); + trustAllCerts[0] = tm; + SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(null, trustAllCerts, null); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + } + + static class miTM implements TrustManager, X509TrustManager { + @Override + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public boolean isServerTrusted(X509Certificate[] certs) { + return true; + } + + public boolean isClientTrusted(X509Certificate[] certs) { + return true; + } + + @Override + public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException { + } + + @Override + public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException { + } + } + + /** + * 忽略HTTPS请求的SSL证书,必须在openConnection之前调用 + * + * @throws Exception + */ + public static void ignoreSSL() throws Exception { + HostnameVerifier hv = (urlHostName, session) -> { + System.out.println("Warning: URL Host: " + urlHostName + " vs. " + session.getPeerHost()); + return true; + }; + trustAllHttpsCertificates(); + HttpsURLConnection.setDefaultHostnameVerifier(hv); + } +} + diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/package-info.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/package-info.java new file mode 100644 index 000000000..cdc879783 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/package-info.java @@ -0,0 +1,7 @@ +/** + * 公共依赖的核心包模块 + * + * @author YiHui + * @date 2022/7/6 + */ +package com.github.paicoding.forum.core; \ No newline at end of file diff --git a/forum-core/src/main/java/com/github/liuyueyi/forum/core/permission/Permission.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/permission/Permission.java similarity index 85% rename from forum-core/src/main/java/com/github/liuyueyi/forum/core/permission/Permission.java rename to paicoding-core/src/main/java/com/github/paicoding/forum/core/permission/Permission.java index da57a382d..af7eac513 100644 --- a/forum-core/src/main/java/com/github/liuyueyi/forum/core/permission/Permission.java +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/permission/Permission.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.core.permission; +package com.github.paicoding.forum.core.permission; import java.lang.annotation.*; diff --git a/forum-core/src/main/java/com/github/liuyueyi/forum/core/permission/UserRole.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/permission/UserRole.java similarity index 79% rename from forum-core/src/main/java/com/github/liuyueyi/forum/core/permission/UserRole.java rename to paicoding-core/src/main/java/com/github/paicoding/forum/core/permission/UserRole.java index 53649d52d..da667dc67 100644 --- a/forum-core/src/main/java/com/github/liuyueyi/forum/core/permission/UserRole.java +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/permission/UserRole.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.core.permission; +package com.github.paicoding.forum.core.permission; /** * @author YiHui diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqConnection.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqConnection.java new file mode 100644 index 000000000..b9de2ff3a --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqConnection.java @@ -0,0 +1,51 @@ +package com.github.paicoding.forum.core.rabbitmq; + +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; + +import java.io.IOException; +import java.util.concurrent.TimeoutException; + +/** + * @author Louzai + * @date 2023/5/10 + */ +public class RabbitmqConnection { + + private Connection connection; + + public RabbitmqConnection(String host, int port, String userName, String password, String virtualhost) { + ConnectionFactory connectionFactory = new ConnectionFactory(); + connectionFactory.setHost(host); + connectionFactory.setPort(port); + connectionFactory.setUsername(userName); + connectionFactory.setPassword(password); + connectionFactory.setVirtualHost(virtualhost); + try { + connection = connectionFactory.newConnection(); + } catch (IOException | TimeoutException e) { + e.printStackTrace(); + } + } + + /** + * 获取链接 + * + * @return + */ + public Connection getConnection() { + return connection; + } + + /** + * 关闭链接 + * + */ + public void close() { + try { + connection.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqConnectionPool.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqConnectionPool.java new file mode 100644 index 000000000..7cccf0292 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqConnectionPool.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.core.rabbitmq; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +public class RabbitmqConnectionPool { + + private static BlockingQueue pool; + + public static void initRabbitmqConnectionPool(String host, int port, String userName, String password, + String virtualhost, + Integer poolSize) { + pool = new LinkedBlockingQueue<>(poolSize); + for (int i = 0; i < poolSize; i++) { + pool.add(new RabbitmqConnection(host, port, userName, password, virtualhost)); + } + } + + public static RabbitmqConnection getConnection() throws InterruptedException { + return pool.take(); + } + + public static void returnConnection(RabbitmqConnection connection) { + pool.add(connection); + } + + public static void close() { + pool.forEach(RabbitmqConnection::close); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqUtil.java new file mode 100644 index 000000000..6e4efe986 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/rabbitmq/RabbitmqUtil.java @@ -0,0 +1,82 @@ +package com.github.paicoding.forum.core.rabbitmq; + +import com.rabbitmq.client.ConnectionFactory; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 说明:添加rabbitmq连接池后,这个就可以废弃掉 + * @author Louzai + * @date 2023/5/10 + */ +public class RabbitmqUtil { + + /** + * 每个 host 都有自己的工厂,便于后面改造成多机的方式 + */ + private static Map executors = new ConcurrentHashMap<>(); + + /** + * 初始化一个工厂 + * + * @param host + * @param port + * @param username + * @param passport + * @param virtualhost + * @return + */ + private static ConnectionFactory init(String host, + Integer port, + String username, + String passport, + String virtualhost) { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(passport); + factory.setVirtualHost(virtualhost); + return factory; + } + + /** + * 工厂单例,每个host都有属于自己的工厂 + * + * @param host + * @param port + * @param username + * @param passport + * @param virtualhost + * @return + */ + public static ConnectionFactory getOrInitConnectionFactory(String host, + Integer port, + String username, + String passport, + String virtualhost) { + String key = getConnectionFactoryKey(host, port); + ConnectionFactory connectionFactory = executors.get(key); + if (null == connectionFactory) { + synchronized (RabbitmqUtil.class) { + connectionFactory = executors.get(key); + if (null == connectionFactory) { + connectionFactory = init(host, port, username, passport, virtualhost); + executors.put(key, connectionFactory); + } + } + } + return connectionFactory; + } + + /** + * 获取key + * @param host + * @param port + * @return + */ + private static String getConnectionFactoryKey(String host, Integer port) { + return host + ":" + port; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/region/IpRegionInfo.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/region/IpRegionInfo.java new file mode 100644 index 000000000..39541df6c --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/region/IpRegionInfo.java @@ -0,0 +1,72 @@ +package com.github.paicoding.forum.core.region; + +import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +import java.util.Objects; + +/** + * ip区域信息 + * + * @author YiHui + * @date 2023/01/03 + */ +@Data +public class IpRegionInfo { + /** + * 国家or地区 + */ + private String country; + /** + * 区域 + */ + private String region; + /** + * 省份 + */ + private String province; + /** + * 城市 + */ + private String city; + /** + * 网络运营商 + */ + private String isp; + + public IpRegionInfo(String info) { + String[] cells = StringUtils.split(info, "|"); + if (cells.length < 5) { + country = ""; + region = ""; + province = ""; + city = ""; + isp = ""; + return; + } + country = "0".equals(cells[0]) ? "" : cells[0]; + region = "0".equals(cells[1]) ? "" : cells[1]; + province = "0".equals(cells[2]) ? "" : cells[2]; + city = "0".equals(cells[3]) ? "" : cells[3]; + isp = "0".equals(cells[4]) ? "" : cells[4]; + } + + public String toRegionStr() { + if (Objects.equals(country, "中国")) { + // 大陆,返回省 + 城市 + if (StringUtils.isNotBlank(province) && StringUtils.isNotBlank(city)) { + return province + "·" + city; + } else if (StringUtils.isNotBlank(province)) { + return province; + } else { + return country; + } + } else { + if (StringUtils.isNotBlank(province)) { + // 非大陆,返回国家+省份 + return country + "·" + province; + } + return country; + } + } +} \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/SensitiveProperty.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/SensitiveProperty.java new file mode 100644 index 000000000..58a15b412 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/SensitiveProperty.java @@ -0,0 +1,34 @@ +package com.github.paicoding.forum.core.senstive; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 敏感词相关配置,db配置表中的配置优先级更高,支持动态刷新 + * + * @author YiHui + * @date 2023/8/9 + */ +@Data +@Component +@ConfigurationProperties(prefix = SensitiveProperty.SENSITIVE_KEY_PREFIX) +public class SensitiveProperty { + public static final String SENSITIVE_KEY_PREFIX = "paicoding.sensitive"; + /** + * true 表示开启敏感词校验 + */ + private Boolean enable; + + /** + * 自定义的敏感词 + */ + private List deny; + + /** + * 自定义的非敏感词 + */ + private List allow; +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/SensitiveService.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/SensitiveService.java new file mode 100644 index 000000000..efc460764 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/SensitiveService.java @@ -0,0 +1,125 @@ +package com.github.paicoding.forum.core.senstive; + +import com.github.houbb.sensitive.word.api.IWordAllow; +import com.github.houbb.sensitive.word.api.IWordDeny; +import com.github.houbb.sensitive.word.bs.SensitiveWordBs; +import com.github.houbb.sensitive.word.support.allow.WordAllowSystem; +import com.github.houbb.sensitive.word.support.deny.WordDenySystem; +import com.github.paicoding.forum.core.autoconf.DynamicConfigContainer; +import com.github.paicoding.forum.core.cache.RedisClient; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import javax.annotation.PostConstruct; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 敏感词服务类 + * + * @author YiHui + * @date 2023/8/9 + */ +@Slf4j +@Service +public class SensitiveService { + /** + * 敏感词命中计数统计 + */ + private static final String SENSITIVE_WORD_CNT_PREFIX = "sensitive_word"; + private volatile SensitiveWordBs sensitiveWordBs; + @Autowired + private SensitiveProperty sensitiveConfig; + @Autowired + private DynamicConfigContainer dynamicConfigContainer; + + @PostConstruct + public void refresh() { + dynamicConfigContainer.registerRefreshCallback(sensitiveConfig, this::refresh); + IWordDeny deny = () -> { + List sub = WordDenySystem.getInstance().deny(); + sub.addAll(sensitiveConfig.getDeny()); + return sub; + }; + + IWordAllow allow = () -> { + List sub = WordAllowSystem.getInstance().allow(); + sub.addAll(sensitiveConfig.getAllow()); + return sub; + }; + sensitiveWordBs = SensitiveWordBs.newInstance() + .wordDeny(deny) + .wordAllow(allow) + .init(); + log.info("敏感词初始化完成!"); + } + + /** + * 判断是否包含敏感词 + * + * @param txt 需要校验的文本 + * @return 返回命中的敏感词 + */ + public List contains(String txt) { + if (!BooleanUtils.isTrue(sensitiveConfig.getEnable())) { + return Collections.emptyList(); + } + + List ans = sensitiveWordBs.findAll(txt); + if (CollectionUtils.isEmpty(ans)) { + return ans; + } + + // 敏感词命中次数+1 + RedisClient.PipelineAction action = RedisClient.pipelineAction(); + ans.forEach(key -> action.add(SENSITIVE_WORD_CNT_PREFIX, key, (connection, k, v) -> connection.hIncrBy(k, v, 1))); + action.execute(); + return ans; + } + + + /** + * 返回已命中的敏感词 + * + * @return key: 敏感词, value:计数 + */ + public Map getHitSensitiveWords() { + return RedisClient.hGetAll(SENSITIVE_WORD_CNT_PREFIX, Integer.class); + } + + /** + * 移除敏感词 + * + * @param word + */ + public void removeSensitiveWord(String word) { + RedisClient.hDel(SENSITIVE_WORD_CNT_PREFIX, word); + } + + /** + * 敏感词替换 + * + * @param txt + * @return + */ + public String replace(String txt) { + if (BooleanUtils.isTrue(sensitiveConfig.getEnable())) { + return sensitiveWordBs.replace(txt); + } + return txt; + } + + /** + * 查询文本中所有命中的敏感词 + * + * @param txt 校验文本 + * @return 命中的敏感词 + */ + public List findAll(String txt) { + return sensitiveWordBs.findAll(txt); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ano/SensitiveField.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ano/SensitiveField.java new file mode 100644 index 000000000..579865d5e --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ano/SensitiveField.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.core.senstive.ano; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author YiHui + * @date 2023/8/9 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface SensitiveField { + /** + * 绑定的db中的哪个字段 + * + * @return + */ + String bind() default ""; + +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveMetaCache.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveMetaCache.java new file mode 100644 index 000000000..a7380f430 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveMetaCache.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.core.senstive.ibatis; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * 敏感词缓存 + * + * @author YiHui + * @date 2023/8/9 + */ +public class SensitiveMetaCache { + private static ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + public static SensitiveObjectMeta get(String key) { + return CACHE.get(key); + } + + public static void put(String key, SensitiveObjectMeta meta) { + CACHE.put(key, meta); + } + + public static void remove(String key) { + CACHE.remove(key); + } + + public static boolean contains(String key) { + return CACHE.containsKey(key); + } + + public static SensitiveObjectMeta putIfAbsent(String key, SensitiveObjectMeta meta) { + return CACHE.putIfAbsent(key, meta); + } + + public static SensitiveObjectMeta computeIfAbsent(String key, Function function) { + return CACHE.computeIfAbsent(key, function); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveObjectMeta.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveObjectMeta.java new file mode 100644 index 000000000..2c7c0b365 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveObjectMeta.java @@ -0,0 +1,87 @@ +package com.github.paicoding.forum.core.senstive.ibatis; + +import com.github.paicoding.forum.core.senstive.ano.SensitiveField; +import lombok.Data; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static com.google.common.collect.Lists.newArrayList; +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + +/** + * 敏感词相关配置,db配置表中的配置优先级更高,支持动态刷新 + * + * @author YiHui + * @date 2023/8/9 + */ +@Data +public class SensitiveObjectMeta { + private static final String JAVA_LANG_OBJECT = "java.lang.object"; + /** + * 是否启用脱敏 + */ + private Boolean enabledSensitiveReplace; + + /** + * 类名 + */ + private String className; + + /** + * 标注 SensitiveField 的成员 + */ + private List sensitiveFieldMetaList; + + public static Optional buildSensitiveObjectMeta(Object param) { + if (isNull(param)) { + return Optional.empty(); + } + + Class clazz = param.getClass(); + SensitiveObjectMeta sensitiveObjectMeta = new SensitiveObjectMeta(); + sensitiveObjectMeta.setClassName(clazz.getName()); + + List sensitiveFieldMetaList = newArrayList(); + sensitiveObjectMeta.setSensitiveFieldMetaList(sensitiveFieldMetaList); + boolean sensitiveField = parseAllSensitiveFields(clazz, sensitiveFieldMetaList); + sensitiveObjectMeta.setEnabledSensitiveReplace(sensitiveField); + return Optional.of(sensitiveObjectMeta); + } + + + private static boolean parseAllSensitiveFields(Class clazz, List sensitiveFieldMetaList) { + Class tempClazz = clazz; + boolean hasSensitiveField = false; + while (nonNull(tempClazz) && !JAVA_LANG_OBJECT.equalsIgnoreCase(tempClazz.getName())) { + for (Field field : tempClazz.getDeclaredFields()) { + SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class); + if (nonNull(sensitiveField)) { + SensitiveFieldMeta sensitiveFieldMeta = new SensitiveFieldMeta(); + sensitiveFieldMeta.setName(field.getName()); + sensitiveFieldMeta.setBindField(sensitiveField.bind()); + sensitiveFieldMetaList.add(sensitiveFieldMeta); + hasSensitiveField = true; + } + } + tempClazz = tempClazz.getSuperclass(); + } + return hasSensitiveField; + } + + + @Data + public static class SensitiveFieldMeta { + /** + * 默认根据字段名,找db中同名的字段 + */ + private String name; + + /** + * 绑定的数据库字段别名 + */ + private String bindField; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveReadInterceptor.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveReadInterceptor.java new file mode 100644 index 000000000..f1ddd5de4 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/senstive/ibatis/SensitiveReadInterceptor.java @@ -0,0 +1,150 @@ +package com.github.paicoding.forum.core.senstive.ibatis; + + +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.github.paicoding.forum.core.senstive.SensitiveService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ClassUtils; +import org.apache.ibatis.executor.resultset.ResultSetHandler; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Plugin; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.reflection.MetaObject; +import org.apache.ibatis.reflection.SystemMetaObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; + +import static com.google.common.collect.Lists.newArrayList; + + +/** + * 敏感词替换拦截器,这里主要是针对从db中读取的数据进行敏感词处理 (如果需要在写入db时,进行脱敏如加密,也可以使用类似的方式来实现) + * + * @author YiHui + * @date 2023/8/9 + */ +@Intercepts({ + @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {java.sql.Statement.class}) +}) +@Component +@Slf4j +public class SensitiveReadInterceptor implements Interceptor { + + private static final String MAPPED_STATEMENT = "mappedStatement"; + + @Autowired + private SensitiveService sensitiveService; + + @SuppressWarnings("unchecked") + @Override + public Object intercept(Invocation invocation) throws Throwable { + final List results = (List) invocation.proceed(); + + if (results.isEmpty()) { + return results; + } + + final ResultSetHandler statementHandler = realTarget(invocation.getTarget()); + final MetaObject metaObject = SystemMetaObject.forObject(statementHandler); + final MappedStatement mappedStatement = (MappedStatement) metaObject.getValue(MAPPED_STATEMENT); + + Optional firstOpt = results.stream().filter(Objects::nonNull).findFirst(); + if (!firstOpt.isPresent()) { + return results; + } + Object firstObject = firstOpt.get(); + // 找到需要进行敏感词替换的数据库实体类的成员信息 + SensitiveObjectMeta sensitiveObjectMeta = findSensitiveObjectMeta(firstObject); + + // 执行替换的敏感词替换 + replaceSensitiveResults(results, mappedStatement, sensitiveObjectMeta); + return results; + } + + /** + * 执行具体的敏感词替换 + * + * @param results + * @param mappedStatement + * @param sensitiveObjectMeta + */ + private void replaceSensitiveResults(Collection results, MappedStatement mappedStatement, SensitiveObjectMeta sensitiveObjectMeta) { + for (Object obj : results) { + if (sensitiveObjectMeta.getSensitiveFieldMetaList() == null) { + continue; + } + + final MetaObject objMetaObject = mappedStatement.getConfiguration().newMetaObject(obj); + sensitiveObjectMeta.getSensitiveFieldMetaList().forEach(i -> { + Object value = objMetaObject.getValue(StringUtils.isBlank(i.getBindField()) ? i.getName() : i.getBindField()); + if (value == null) { + return; + } else if (value instanceof String) { + String strValue = (String) value; + String processVal = sensitiveService.replace(strValue); + objMetaObject.setValue(i.getName(), processVal); + } else if (value instanceof Collection) { + Collection listValue = (Collection) value; + if (CollectionUtils.isNotEmpty(listValue)) { + Optional firstValOpt = listValue.stream().filter(Objects::nonNull).findFirst(); + if (firstValOpt.isPresent()) { + SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(firstValOpt.get()); + if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) { + replaceSensitiveResults(listValue, mappedStatement, valSensitiveObjectMeta); + } + } + } + } else if (!ClassUtils.isPrimitiveOrWrapper(value.getClass())) { + // 对于非基本类型的,需要对其内部进行敏感词替换 + SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(value); + if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) { + replaceSensitiveResults(newArrayList(value), mappedStatement, valSensitiveObjectMeta); + } + } + }); + } + } + + /** + * 查询对象中,携带有 @SensitiveField 的成员,进行敏感词替换 + * + * @param firstObject 待查询的对象 + * @return 返回对象的敏感词元数据 + */ + private SensitiveObjectMeta findSensitiveObjectMeta(Object firstObject) { + SensitiveMetaCache.computeIfAbsent(firstObject.getClass().getName(), s -> { + Optional sensitiveObjectMetaOpt = SensitiveObjectMeta.buildSensitiveObjectMeta(firstObject); + return sensitiveObjectMetaOpt.orElse(null); + }); + + return SensitiveMetaCache.get(firstObject.getClass().getName()); + } + + @Override + public Object plugin(Object o) { + return Plugin.wrap(o, this); + } + + @Override + public void setProperties(Properties properties) { + } + + public static T realTarget(Object target) { + if (Proxy.isProxyClass(target.getClass())) { + MetaObject metaObject = SystemMetaObject.forObject(target); + return realTarget(metaObject.getValue("h.target")); + } + return (T) target; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/AlarmUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/AlarmUtil.java new file mode 100644 index 000000000..e252f6f96 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/AlarmUtil.java @@ -0,0 +1,34 @@ +package com.github.paicoding.forum.core.util; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import com.github.paicoding.forum.core.async.AsyncUtil; + +/** + * @author YiHui + * @date 2023/3/19 + */ +public class AlarmUtil extends AppenderBase { + private static final long INTERVAL = 10 * 1000 * 60; + private long lastAlarmTime = 0; + + @Override + protected void append(ILoggingEvent iLoggingEvent) { + if (canAlarm()) { + EmailUtil.sendMail(iLoggingEvent.getLoggerName(), + SpringUtil.getConfig("alarm.user", "xhhuiblog@163.com"), + iLoggingEvent.getFormattedMessage()); + } + } + + private boolean canAlarm() { + // 做一个简单的频率过滤,一分钟内只允许发送一条报警 + long now = System.currentTimeMillis(); + if (now - lastAlarmTime >= INTERVAL) { + lastAlarmTime = now; + return true; + } else { + return false; + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/ArticleUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/ArticleUtil.java new file mode 100644 index 000000000..664af513e --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/ArticleUtil.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.core.util; + + +import org.apache.commons.lang3.StringUtils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author YiHui + * @date 2022/12/23 + */ +public class ArticleUtil { + private static final Integer MAX_SUMMARY_CHECK_TXT_LEN = 2000; + private static final Integer SUMMARY_LEN = 256; + private static Pattern LINK_IMG_PATTERN = Pattern.compile("!?\\[(.*?)\\]\\((.*?)\\)"); + private static Pattern CONTENT_PATTERN = Pattern.compile("[0-9a-zA-Z\u4e00-\u9fa5:;\"'<>,.?/·~!:;“”‘’《》,。?、()]"); + + private static Pattern HTML_TAG_PATTERN = Pattern.compile("<[^>]+>"); + + public static String pickSummary(String summary) { + if (StringUtils.isBlank(summary)) { + return StringUtils.EMPTY; + } + + // 首先移除所有的图片,链接 + summary = summary.substring(0, Math.min(summary.length(), MAX_SUMMARY_CHECK_TXT_LEN)).trim(); + // 移除md的图片、超链 + summary = summary.replaceAll(LINK_IMG_PATTERN.pattern(), ""); + // 移除html标签 + summary = HTML_TAG_PATTERN.matcher(summary).replaceAll(""); + + // 匹配对应字符 + StringBuilder result = new StringBuilder(); + Matcher matcher = CONTENT_PATTERN.matcher(summary); + while (matcher.find()) { + result.append(summary, matcher.start(), matcher.end()); + if (result.length() >= SUMMARY_LEN) { + return result.substring(0, SUMMARY_LEN).trim(); + } + } + return result.toString().trim(); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CodeGenerateUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CodeGenerateUtil.java new file mode 100644 index 000000000..189cee965 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CodeGenerateUtil.java @@ -0,0 +1,92 @@ +package com.github.paicoding.forum.core.util; + +import org.apache.commons.lang3.math.NumberUtils; +import com.github.paicoding.forum.api.model.enums.login.LoginQrTypeEnum; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +/** + * @author YiHui + * @date 2022/8/15 + */ +public class CodeGenerateUtil { + public static final Integer CODE_LEN = 3; + + private static final Random random = new Random(); + + // 订阅号使用的特殊验证码列表(用户手动输入) + private static final List specialCodes = Arrays.asList( + "666", "888", "000", "999", "555", "222", "333", "777", + "520", "911", + "234", "345", "456", "567", "678", "789" + ); + + /** + * 根据登录类型生成验证码 + * + * @param cnt 计数器(订阅号时使用) + * @param loginType 登录类型 + * @return 验证码 + */ + public static String genCode(int cnt, LoginQrTypeEnum loginType) { + if (loginType == LoginQrTypeEnum.SERVICE_ACCOUNT) { + // 服务号:生成随机验证码,不使用specialCodes + return genRandomCode(); + } else { + // 订阅号:使用specialCodes列表 + return genSpecialCode(cnt); + } + } + + /** + * 兼容性方法,默认使用订阅号方式 + */ + public static String genCode(int cnt) { + return genSpecialCode(cnt); + } + + /** + * 生成订阅号专用的特殊验证码 + */ + private static String genSpecialCode(int cnt) { + if (cnt >= specialCodes.size()) { + int num = random.nextInt(1000); + if (num >= 100 && num <= 200) { + // 100-200之间的数字作为关键词回复,不用于验证码 + return genSpecialCode(cnt); + } + return String.format("%0" + CODE_LEN + "d", num); + } else { + return specialCodes.get(cnt); + } + } + + /** + * 生成服务号专用的随机验证码 + */ + private static String genRandomCode() { + // 使用UUID的哈希码作为随机源,确保唯一性 + int hashCode = Math.abs(UUID.randomUUID().hashCode()); + // 生成3位数字,避免100-200(保留用于关键词回复) + int num = hashCode % 900 + 100; // 100-999 + + // 如果落在101-200范围内,调整到其他范围 + if (num > 100 && num <= 200) { + num = ((num - 101) % 700) + 201; // 201-999 + } + + return String.format("%0" + CODE_LEN + "d", num); + } + + public static boolean isVerifyCode(String content) { + if (!NumberUtils.isDigits(content) || content.length() != CodeGenerateUtil.CODE_LEN) { + return false; + } + + int num = Integer.parseInt(content); + return num < 100 || num > 200; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CompressUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CompressUtil.java new file mode 100644 index 000000000..edb61eedb --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CompressUtil.java @@ -0,0 +1,149 @@ +package com.github.paicoding.forum.core.util; + +import java.nio.ByteBuffer; + +/** + * 压缩工具类 + * + * @author YiHui + * @date 2023/10/17 + */ +public class CompressUtil { + /** + * 进制转换数组 + */ + private static char[] BINARY_ARRAY = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + + public static String int2str(long num) { + return int2str(num, BINARY_ARRAY.length); + } + + /** + * 整数的进制转换 + * + * @param num 数字 + * @param size 进制长度 + * @return 返回String格式的数据 + */ + public static String int2str(long num, int size) { + if (size > BINARY_ARRAY.length) { + size = BINARY_ARRAY.length; + } + + StringBuilder builder = new StringBuilder(); + while (num > 0) { + builder.insert(0, BINARY_ARRAY[(int) (num % size)]); + num /= size; + } + return builder.toString(); + } + + private static long zigzag(long n) { + return (n << 1) ^ (n >> 57); + } + + private static long unZigzag(long n) { + return (n >>> 1) ^ (n & 1); + } + + /** + * Returns the encoding size in bytes of its input value. + * + * @param v the long to be measured + * @return the encoding size in bytes of a given long value. + */ + public static int varLongSize(long v) { + int result = 0; + do { + result++; + v >>>= 7; + } while (v != 0); + return result; + } + + /** + * Reads an up to 64 bit long varint from the current position of the + * given ByteBuffer and returns the decoded value as long. + * + *

The position of the buffer is advanced to the first byte after the + * decoded varint. + * + * @param src the ByteBuffer to get the var int from + * @return The integer value of the decoded long varint + */ + public static long getVarLong(ByteBuffer src) { + long tmp; + if ((tmp = src.get()) >= 0) { + return tmp; + } + long result = tmp & 0x7f; + if ((tmp = src.get()) >= 0) { + result |= tmp << 7; + } else { + result |= (tmp & 0x7f) << 7; + if ((tmp = src.get()) >= 0) { + result |= tmp << 14; + } else { + result |= (tmp & 0x7f) << 14; + if ((tmp = src.get()) >= 0) { + result |= tmp << 21; + } else { + result |= (tmp & 0x7f) << 21; + if ((tmp = src.get()) >= 0) { + result |= tmp << 28; + } else { + result |= (tmp & 0x7f) << 28; + if ((tmp = src.get()) >= 0) { + result |= tmp << 35; + } else { + result |= (tmp & 0x7f) << 35; + if ((tmp = src.get()) >= 0) { + result |= tmp << 42; + } else { + result |= (tmp & 0x7f) << 42; + if ((tmp = src.get()) >= 0) { + result |= tmp << 49; + } else { + result |= (tmp & 0x7f) << 49; + if ((tmp = src.get()) >= 0) { + result |= tmp << 56; + } else { + result |= (tmp & 0x7f) << 56; + result |= ((long) src.get()) << 63; + } + } + } + } + } + } + } + } + return result; + } + + /** + * Encodes a long integer in a variable-length encoding, 7 bits per byte, to a + * ByteBuffer sink. + * + * @param v the value to encode + * @param sink the ByteBuffer to add the encoded value + */ + public static void putVarLong(long v, ByteBuffer sink) { + while (true) { + int bits = ((int) v) & 0x7f; + v >>>= 7; + if (v == 0) { + sink.put((byte) bits); + return; + } + sink.put((byte) (bits | 0x80)); + } + } + + public static String putVarLong(long v) { + byte[] bytes = new byte[varLongSize(v)]; + ByteBuffer sink = ByteBuffer.wrap(bytes); + putVarLong(v, sink); + return new String(bytes); + } +} diff --git a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/CrossUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CrossUtil.java similarity index 83% rename from forum-core/src/main/java/com/github/liuyueyi/forum/core/util/CrossUtil.java rename to paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CrossUtil.java index 315c82340..c1e1835fc 100644 --- a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/CrossUtil.java +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/CrossUtil.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.core.util; +package com.github.paicoding.forum.core.util; import org.apache.commons.lang3.StringUtils; @@ -21,11 +21,11 @@ public static void buildCors(HttpServletRequest request, HttpServletResponse res String origin = request.getHeader("Origin"); if (StringUtils.isBlank(origin)) { response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Credentials", "false"); } else { response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Credentials", "true"); } - response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); - response.setHeader("Access-Control-Allow-Credentials", "true"); response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, HEAD"); response.setHeader("Access-Control-Max-Age", "3600"); response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, X-Real-IP, X-Forwarded-For, d-uuid, User-Agent, x-zd-cs, Proxy-Client-IP, HTTP_CLIENT_IP, HTTP_X_FORWARDED_FOR"); diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/DateUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/DateUtil.java new file mode 100644 index 000000000..231eb2811 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/DateUtil.java @@ -0,0 +1,89 @@ +package com.github.paicoding.forum.core.util; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * @author YiHui + * @date 2022/8/25 + */ +public class DateUtil { + public static final DateTimeFormatter UTC_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + + public static final DateTimeFormatter DB_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + public static final DateTimeFormatter BLOG_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm"); + + public static final DateTimeFormatter BLOG_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy年MM月dd日"); + + + // 微信支付日期格式 + public static final DateTimeFormatter WX_PAY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'+08:00'"); + + + /** + * 一天对应的毫秒数 + */ + public static final Long ONE_DAY_MILL = 86400_000L; + public static final Long ONE_DAY_SECONDS = 86400L; + public static final Long ONE_MONTH_SECONDS = 31 * 86400L; + + + public static final Long THREE_DAY_MILL = 3 * ONE_DAY_MILL; + + /** + * 毫秒转日期 + * + * @param timestamp + * @return + */ + public static String time2day(long timestamp) { + return format(BLOG_TIME_FORMAT, timestamp); + } + + public static String time2day(Timestamp timestamp) { + return time2day(timestamp.getTime()); + } + + public static LocalDateTime time2LocalTime(long timestamp) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()); + } + + public static String time2utc(long timestamp) { + return format(UTC_FORMAT, timestamp); + } + + public static String time2date(long timestamp) { + return format(BLOG_DATE_FORMAT, timestamp); + } + + public static String time2date(Timestamp timestamp) { + return time2date(timestamp.getTime()); + } + + + public static String format(DateTimeFormatter format, long timestamp) { + LocalDateTime time = time2LocalTime(timestamp); + return format.format(time); + } + + /** + * 微信的支付时间,转时间戳 "2018-06-08T10:34:56+08:00" + * + * @param day + * @return + */ + public static Long wxDayToTimestamp(String day) { + LocalDateTime parse = LocalDateTime.parse(day, WX_PAY_FORMATTER); + return LocalDateTime.from(parse).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + + + public static boolean skipDay(long last, long now) { + last = last / ONE_DAY_MILL; + now = now / ONE_DAY_MILL; + return last != now; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/EmailUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/EmailUtil.java new file mode 100644 index 000000000..3731f3bd7 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/EmailUtil.java @@ -0,0 +1,57 @@ +package com.github.paicoding.forum.core.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; + +import javax.mail.internet.MimeMessage; + +/** + * @author YiHui + * @date 2023/3/19 + */ +@Slf4j +public class EmailUtil { + private static volatile String from; + + public static String getFrom() { + if (from == null) { + synchronized (EmailUtil.class) { + if (from == null) { + from = SpringUtil.getConfig("spring.mail.from", "xhhuiblog@163.com"); + } + } + } + return from; + } + + /** + * springboot-email封装的发送邮件 + * + * @param title + * @param to + * @param content + * @return + */ + public static boolean sendMail(String title, String to, String content) { + try { + JavaMailSender javaMailSender = SpringUtil.getBean(JavaMailSender.class); + MimeMessage mimeMailMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMailMessage, true); + mimeMessageHelper.setFrom(getFrom()); + mimeMessageHelper.setTo(to); + mimeMessageHelper.setSubject(title); + //邮件内容,第二个参数设置为true,支持html模板 + mimeMessageHelper.setText(content, true); + // 解决 JavaMailSender no object DCH for MIME type multipart/mixed 问题 + // 详情参考:[Email发送失败问题记录 - 一灰灰Blog](https://blog.hhui.top/hexblog/2021/10/28/211028-Email%E5%8F%91%E9%80%81%E5%A4%B1%E8%B4%A5%E9%97%AE%E9%A2%98%E8%AE%B0%E5%BD%95/) + Thread.currentThread().setContextClassLoader(EmailUtil.class.getClassLoader()); + javaMailSender.send(mimeMailMessage); + return true; + } catch (Exception e) { + log.warn("sendEmail error {}@{}", title, to, e); + return false; + } + } + +} diff --git a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/EnvUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/EnvUtil.java similarity index 90% rename from forum-core/src/main/java/com/github/liuyueyi/forum/core/util/EnvUtil.java rename to paicoding-core/src/main/java/com/github/paicoding/forum/core/util/EnvUtil.java index 1e49c7862..3505f897f 100644 --- a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/EnvUtil.java +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/EnvUtil.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.core.util; +package com.github.paicoding.forum.core.util; import org.springframework.util.Assert; @@ -44,7 +44,7 @@ public static EnvEnum getEnv() { } } } - Assert.isTrue(env != null, "env.name环境配置必然存在!"); + Assert.isTrue(env != null, "env.name环境配置必须存在!"); return env; } } diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/IpUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/IpUtil.java new file mode 100644 index 000000000..6cbb9206d --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/IpUtil.java @@ -0,0 +1,282 @@ +package com.github.paicoding.forum.core.util; + +import com.github.hui.quick.plugin.base.file.FileWriteUtil; +import com.github.paicoding.forum.core.region.IpRegionInfo; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.lionsoul.ip2region.xdb.Searcher; + +import javax.servlet.http.HttpServletRequest; +import java.io.File; +import java.io.IOException; +import java.net.DatagramSocket; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Optional; + +/** + * @author YiHui + * @date 2022/7/6 + */ +@Slf4j +public class IpUtil { + private static final String UNKNOWN = "unKnown"; + + public static final String DEFAULT_IP = "127.0.0.1"; + + /** + * 获取本机所有网卡信息 得到所有IP信息 + * + * @return Inet4Address> + */ + private static List getLocalIp4AddressFromNetworkInterface() throws SocketException { + List addresses = new ArrayList<>(1); + + // 所有网络接口信息 + Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + if (ObjectUtils.isEmpty(networkInterfaces)) { + return addresses; + } + while (networkInterfaces.hasMoreElements()) { + NetworkInterface networkInterface = networkInterfaces.nextElement(); + //滤回环网卡、点对点网卡、非活动网卡、虚拟网卡并要求网卡名字是eth或ens开头 + if (!isValidInterface(networkInterface)) { + continue; + } + + // 所有网络接口的IP地址信息 + Enumeration inetAddresses = networkInterface.getInetAddresses(); + while (inetAddresses.hasMoreElements()) { + InetAddress inetAddress = inetAddresses.nextElement(); + // 判断是否是IPv4,并且内网地址并过滤回环地址. + if (isValidAddress(inetAddress)) { + addresses.add((Inet4Address) inetAddress); + } + } + } + return addresses; + } + + /** + * 过滤回环网卡、点对点网卡、非活动网卡、虚拟网卡并要求网卡名字是eth或ens开头 + * + * @param ni 网卡 + * @return 如果满足要求则true,否则false + */ + private static boolean isValidInterface(NetworkInterface ni) throws SocketException { + return !ni.isLoopback() && !ni.isPointToPoint() && ni.isUp() && !ni.isVirtual() + && (ni.getName().startsWith("eth") || ni.getName().startsWith("ens")); + } + + /** + * 判断是否是IPv4,并且内网地址并过滤回环地址. + */ + private static boolean isValidAddress(InetAddress address) { + return address instanceof Inet4Address && address.isSiteLocalAddress() && !address.isLoopbackAddress(); + } + + /** + * 通过Socket 唯一确定一个IP + * 当有多个网卡的时候,使用这种方式一般都可以得到想要的IP。甚至不要求外网地址8.8.8.8是可连通的 + * + * @return Inet4Address> + */ + private static Optional getIpBySocket() throws SocketException { + try (final DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 10002); + if (socket.getLocalAddress() instanceof Inet4Address) { + return Optional.of((Inet4Address) socket.getLocalAddress()); + } + } catch (UnknownHostException networkInterfaces) { + throw new RuntimeException(networkInterfaces); + } + return Optional.empty(); + } + + private static String LOCAL_IP = null; + + /** + * 获取本地IPv4地址 + * + * @return Inet4Address> + */ + public static String getLocalIp4Address() throws SocketException { + if (LOCAL_IP != null) { + return LOCAL_IP; + } + + final List inet4Addresses = getLocalIp4AddressFromNetworkInterface(); + if (inet4Addresses.size() != 1) { + final Optional ipBySocketOpt = getIpBySocket(); + LOCAL_IP = ipBySocketOpt.map(Inet4Address::getHostAddress).orElseGet(() -> inet4Addresses.isEmpty() ? DEFAULT_IP : inet4Addresses.get(0).getHostAddress()); + return LOCAL_IP; + } + LOCAL_IP = inet4Addresses.get(0).getHostAddress(); + return LOCAL_IP; + } + + + /** + * 获取请求来源的ip地址 + * + * @param request + * @return + */ + public static String getClientIp(HttpServletRequest request) { + try { + String xIp = request.getHeader("X-Real-IP"); + String xFor = request.getHeader("X-Forwarded-For"); + if (StringUtils.isNotEmpty(xFor) && !UNKNOWN.equalsIgnoreCase(xFor)) { + //多次反向代理后会有多个ip值,第一个ip才是真实ip + int index = xFor.indexOf(","); + if (index != -1) { + return xFor.substring(0, index); + } else { + return xFor; + } + } + xFor = xIp; + if (StringUtils.isNotEmpty(xFor) && !UNKNOWN.equalsIgnoreCase(xFor)) { + return xFor; + } + if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) { + xFor = request.getHeader("Proxy-Client-IP"); + } + if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) { + xFor = request.getHeader("WL-Proxy-Client-IP"); + } + if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) { + xFor = request.getHeader("HTTP_CLIENT_IP"); + } + if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) { + xFor = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) { + xFor = request.getRemoteAddr(); + } + + if ("localhost".equalsIgnoreCase(xFor) || "127.0.0.1".equalsIgnoreCase(xFor) || "0:0:0:0:0:0:0:1".equalsIgnoreCase(xFor)) { + return getLocalIp4Address(); + } + return xFor; + } catch (Exception e) { + log.error("get remote ip error!", e); + return "x.0.0.1"; + } + } + + /** + * 判断IP是否在指定的CIDR范围内 + * + * @param ip IP地址,如 "192.168.1.100" + * @param cidr CIDR格式的网段,如 "192.168.1.0/24",表示允许 192.168.1.0到192.168.1.255的IP地址;特殊的,对于 0.0.0.0/0,表示允许所有IP地址 + * @return 如果IP在CIDR范围内返回true,否则返回false + */ + public static boolean isIpInRange(String ip, String cidr) { + if ("0.0.0.0/0".equals(cidr)) { + return true; + } + + try { + String[] parts = cidr.split("/"); + String network = parts[0]; + int prefixLength = Integer.parseInt(parts[1]); + + // 将IP地址转换为整数 + long ipAddr = ipToLong(ip); + long networkAddr = ipToLong(network); + + // 计算子网掩码 + long mask = -(1L << (32 - prefixLength)); + + // 比较网络地址是否匹配 + return (ipAddr & mask) == (networkAddr & mask); + } catch (Exception e) { + return false; + } + } + + /** + * 将IP地址转换为long类型 + * + * @param ip IP地址字符串 + * @return long类型的IP地址 + */ + private static long ipToLong(String ip) { + String[] parts = ip.split("\\."); + long result = 0; + for (int i = 0; i < 4; i++) { + result |= (Long.parseLong(parts[i]) << (24 - i * 8)); + } + return result & 0xFFFFFFFFL; + } + + /** + * ip库路径 + * + */ + private static final String dbPath = "data/ip2region.xdb"; + private static String tmpPath = null; + private static volatile byte[] vIndex = null; + + private static void initVIndex() { + if (vIndex == null) { + synchronized (IpUtil.class) { + if (vIndex == null) { + try { + String file = IpUtil.class.getClassLoader().getResource(dbPath).getFile(); + if (file.contains(".jar!")) { + // RandomAccessFile 无法加载jar包内的文件,因此我们将资源拷贝到临时目录下 + FileWriteUtil.FileInfo tmpFile = new FileWriteUtil.FileInfo("/tmp/data", "ip2region", "xdb"); + tmpPath = tmpFile.getAbsFile(); + if (!new File(tmpPath).exists()) { + // fixme 如果已经存在,则无需继续拷贝,因此当ip库变更之后,需要手动去删除 临时目录下生成的文件,避免出现更新不生效;更好的方式则是比较两个文件的差异性;当不同时,也需要拷贝过去 + FileWriteUtil.saveFileByStream(IpUtil.class.getClassLoader().getResourceAsStream(dbPath), tmpFile); + } + } else { + tmpPath = file; + } + vIndex = Searcher.loadVectorIndexFromFile(tmpPath); + } catch (Exception e) { + log.error("failed to load vector index from {}\n", dbPath, e); + } + } + } + } + } + + /** + * 根据ip查询对应的地址: 国家|区域|省份|城市|ISP + * 若对应的位置不存在值,则为0 + * + * @param ip + * @return + */ + public static IpRegionInfo getLocationByIp(String ip) { + // 2、使用全局的 vIndex 创建带 VectorIndex 缓存的查询对象。 + initVIndex(); + Searcher searcher = null; + try { + searcher = Searcher.newWithVectorIndex(tmpPath, vIndex); + return new IpRegionInfo(searcher.search(ip)); + } catch (Exception e) { + log.error("failed to create vectorIndex cached searcher with {}: {}\n", dbPath, e); + return new IpRegionInfo(""); + } finally { + if (searcher != null) { + try { + searcher.close(); + } catch (IOException e) { + log.error("failed to close file:{}\n", dbPath, e); + } + } + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/JsonUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/JsonUtil.java new file mode 100644 index 000000000..7ab34d545 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/JsonUtil.java @@ -0,0 +1,91 @@ +package com.github.paicoding.forum.core.util; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * @author YiHui + * @date 2022/9/5 + */ +public class JsonUtil { + + private static final ObjectMapper jsonMapper = new ObjectMapper(); + + public static JsonNode toNode(String str) { + try { + return jsonMapper.readTree(str); + } catch (Exception e) { + throw new UnsupportedOperationException(e); + } + } + + public static T toObj(String str, Class clz) { + try { + return jsonMapper.readValue(str, clz); + } catch (Exception e) { + throw new UnsupportedOperationException(e); + } + } + + public static String toStr(T t) { + try { + return jsonMapper.writeValueAsString(t); + } catch (Exception e) { + throw new UnsupportedOperationException(e); + } + } + + /** + * 序列换成json时,将所有的long变成string + * 因为js中得数字类型不能包含所有的java long值 + */ + public static SimpleModule bigIntToStrsimpleModule() { + SimpleModule simpleModule = new SimpleModule(); + simpleModule.addSerializer(Long.class, newSerializer(s -> String.valueOf(s))); + simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance); + simpleModule.addSerializer(long[].class, newSerializer((Function) String::valueOf)); + simpleModule.addSerializer(Long[].class, newSerializer((Function) String::valueOf)); + simpleModule.addSerializer(BigDecimal.class, newSerializer(BigDecimal::toString)); + simpleModule.addSerializer(BigDecimal[].class, newSerializer(BigDecimal::toString)); + simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance); + simpleModule.addSerializer(BigInteger[].class, newSerializer((Function) BigInteger::toString)); + return simpleModule; + } + + public static JsonSerializer newSerializer(Function func) { + return new JsonSerializer() { + @Override + public void serialize(T t, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + if (t == null) { + jsonGenerator.writeNull(); + return; + } + + if (t.getClass().isArray()) { + jsonGenerator.writeStartArray(); + Stream.of(t).forEach(s -> { + try { + jsonGenerator.writeString(func.apply((K) s)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + jsonGenerator.writeEndArray(); + } else { + jsonGenerator.writeString(func.apply((K) t)); + } + } + }; + } +} diff --git a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/MapUtils.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MapUtils.java similarity index 94% rename from forum-core/src/main/java/com/github/liuyueyi/forum/core/util/MapUtils.java rename to paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MapUtils.java index 00ec95a01..21437e943 100644 --- a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/MapUtils.java +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MapUtils.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.core.util; +package com.github.paicoding.forum.core.util; import com.google.common.collect.Maps; import org.springframework.util.CollectionUtils; diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MarkdownConverter.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MarkdownConverter.java new file mode 100644 index 000000000..12974f286 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MarkdownConverter.java @@ -0,0 +1,51 @@ +package com.github.paicoding.forum.core.util; + +import com.github.paicoding.forum.core.markdown.CustomAdmonitionBlockParser; +import com.github.paicoding.forum.core.markdown.CustomAdmonitionExtension; +import com.github.paicoding.forum.core.markdown.ImageCaptionExtension; +import com.vladsch.flexmark.ext.admonition.AdmonitionExtension; +import com.vladsch.flexmark.ext.autolink.AutolinkExtension; +import com.vladsch.flexmark.ext.emoji.EmojiExtension; +import com.vladsch.flexmark.ext.footnotes.FootnoteExtension; +import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension; +import com.vladsch.flexmark.ext.gitlab.GitLabExtension; +import com.vladsch.flexmark.ext.tables.TablesExtension; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.data.MutableDataSet; + +import java.util.Arrays; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 4/15/23 + */ +public class MarkdownConverter { + // 定义一个静态方法,将 Markdown 文本转换为 HTML + public static String markdownToHtml(String markdown) { + // 创建一个 MutableDataSet 对象来配置 Markdown 解析器的选项 + MutableDataSet options = new MutableDataSet(); + + // 添加各种 Markdown 解析器的扩展 + options.set(Parser.EXTENSIONS, Arrays.asList( + AutolinkExtension.create(), // 自动链接扩展,将URL文本转换为链接 + EmojiExtension.create(), // 表情符号扩展,用于解析表情符号 + GitLabExtension.create(), // GitLab特有的Markdown扩展 + FootnoteExtension.create(), // 脚注扩展,用于添加和解析脚注 + TaskListExtension.create(), // 任务列表扩展,用于创建任务列表 + CustomAdmonitionExtension.create(), // 提示框扩展,用于创建提示框 + ImageCaptionExtension.create(), // 图片说明扩展,将alt文本显示为图片底部说明 + TablesExtension.create())); // 表格扩展,用于解析和渲染表格 + + + // 使用配置的选项构建一个 Markdown 解析器 + Parser parser = Parser.builder(options).build(); + // 使用相同的选项构建一个 HTML 渲染器 + HtmlRenderer renderer = HtmlRenderer.builder(options).build(); + + // 解析传入的 Markdown 文本并将其渲染为 HTML + return renderer.render(parser.parse(markdown)); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/Md5Util.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/Md5Util.java new file mode 100644 index 000000000..79f01409e --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/Md5Util.java @@ -0,0 +1,56 @@ +package com.github.paicoding.forum.core.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * @author YiHui + * @date 2022/10/13 + */ +public class Md5Util { + private Md5Util() { + } + + public static String encode(String data) { + byte[] bytes = data.getBytes(StandardCharsets.UTF_8); + return encode(bytes); + } + + public static String encode(byte[] bytes) { + return encode(bytes, 0, bytes.length); + } + + public static String encode(byte[] data, int offset, int len) { + MessageDigest md; + try { + md = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException var5) { + throw new RuntimeException(var5); + } + + md.update(data, offset, len); + byte[] secretBytes = md.digest(); + return getFormattedText(secretBytes); + } + + private static String getFormattedText(byte[] src) { + if (src != null && src.length != 0) { + StringBuilder stringBuilder = new StringBuilder(32); + + for (int i = 0; i < src.length; ++i) { + int v = src[i] & 255; + String hv = Integer.toHexString(v); + if (hv.length() < 2) { + stringBuilder.append(0); + } + + stringBuilder.append(hv); + } + + return stringBuilder.toString(); + } else { + return ""; + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MdImgLoader.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MdImgLoader.java new file mode 100644 index 000000000..57fcb58bb --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/MdImgLoader.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.core.util; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * markdown文本中的图片识别 + * + * @author YiHui + * @date 2022/11/24 + */ +public class MdImgLoader { + private static Pattern IMG_PATTERN = Pattern.compile("!\\[(.*?)\\]\\((.*?)\\)"); + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class MdImg { + /** + * 原始文本 + */ + private String origin; + /** + * 图片描述 + */ + private String desc; + /** + * 图片地址 + */ + private String url; + } + + public static List loadImgs(String content) { + Matcher matcher = IMG_PATTERN.matcher(content); + List list = new ArrayList<>(); + while (matcher.find()) { + list.add(new MdImg(matcher.group(0), matcher.group(1), matcher.group(2))); + } + return list; + } +} diff --git a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/NumUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/NumUtil.java similarity index 90% rename from forum-core/src/main/java/com/github/liuyueyi/forum/core/util/NumUtil.java rename to paicoding-core/src/main/java/com/github/paicoding/forum/core/util/NumUtil.java index 8ac0f37c5..87ba21e41 100644 --- a/forum-core/src/main/java/com/github/liuyueyi/forum/core/util/NumUtil.java +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/NumUtil.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.core.util; +package com.github.paicoding.forum.core.util; /** * @author YiHui diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/PriceUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/PriceUtil.java new file mode 100644 index 000000000..c5979d6e0 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/PriceUtil.java @@ -0,0 +1,65 @@ +package com.github.paicoding.forum.core.util; + +import org.apache.commons.lang3.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; + +/** + * @author YiHui + * @date 2024/12/04 + */ +public class PriceUtil { + + /** + * 价格转分 + * + * @param price + * @return + */ + public static Integer toCentPrice(String price) { + if (StringUtils.isBlank(price)) { + return null; + } + + BigDecimal ans = new BigDecimal(price).multiply(new BigDecimal("100.0")).setScale(0, RoundingMode.HALF_DOWN); + return ans.intValue(); + } + + /** + * 分的价格转元 + * + * @param price + * @return + */ + public static String toYuanPrice(Integer price) { + if (price == null) { + return null; + } + DecimalFormat df1 = new DecimalFormat("0.00"); + String ans = df1.format(price / 100f); + if (price % 100 == 0) { + // 整元时,移除后面的小数 + return ans.substring(0, ans.length() - 3); + } else if (price % 10 == 0) { + return ans.substring(0, ans.length() - 1); + } + return ans; + } + + /** + * 判断是否为合法的价格 + * + * @param price + * @return ture 合法价格 + */ + public static boolean legalPrice(String price) { + if (StringUtils.isBlank(price)) { + return false; + } + + Integer pp = toCentPrice(price); + return pp != null && pp > 0; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/RandUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/RandUtil.java new file mode 100644 index 000000000..b779a7936 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/RandUtil.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.core.util; + + +import java.util.Random; + +/** + * 随机工具类 + * + * @author YiHui + * @date 2024/9/7 + */ +public class RandUtil { + private static Random random = new Random(); + private static final String txt = "0123456789qwertyuiopasdfghjklzxcvbnm"; + + public static String random(int len) { + StringBuilder builder = new StringBuilder(); + int size = txt.length(); + for (int i = 0; i < len; i++) { + builder.append(txt.charAt(random.nextInt(size))); + } + return builder.toString(); + } + +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SessionUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SessionUtil.java new file mode 100644 index 000000000..93cf60ec2 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SessionUtil.java @@ -0,0 +1,190 @@ +package com.github.paicoding.forum.core.util; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2023/6/6 + */ +@Slf4j +public class SessionUtil { + private static final int COOKIE_AGE = 5 * 86400; + + public static String buildSetCookieString(Cookie cookie) { + StringBuilder sb = new StringBuilder(); + sb.append(cookie.getName()).append("=").append(cookie.getValue()); + + if (cookie.getMaxAge() >= 0) { + sb.append("; Max-Age=").append(cookie.getMaxAge()); + } + + if (cookie.getDomain() != null) { + sb.append("; Domain=").append(cookie.getDomain()); + } + + if (cookie.getPath() != null) { + sb.append("; Path=").append(cookie.getPath()); + } + + if (cookie.getSecure()) { + sb.append("; Secure"); + } + + if (cookie.isHttpOnly()) { + sb.append("; HttpOnly"); + } + + return sb.toString(); + } + + public static Cookie newCookie(String key, String session) { + return newCookie(key, session, "/", COOKIE_AGE); + } + + public static Cookie newCookie(String key, String session, String path, int maxAge) { + String host = ReqInfoContext.getReqInfo() == null ? "" : ReqInfoContext.getReqInfo().getHost(); + return newCookie(key, session, host, path, maxAge); + } + + public static Cookie newCookie(String key, String session, String domain, String path, int maxAge) { + // 移除端口号 + domain = removePortFromHost(domain); + Cookie cookie = new Cookie(key, session); + if (StringUtils.isNotBlank(domain)) { + if (EnvUtil.isPro() && "127.0.0.1".equals(domain)) { + // 说明:对于使用nginx进行转发的场景,需要设置: proxy_set_header X-Forwarded-Host $host; 否则会导致这里拿到的host为 127.0.0.1 + log.info("登录的来源:{}", ReqInfoContext.getReqInfo()); + domain = "paicoding.com"; + } + cookie.setDomain(domain); + } + cookie.setPath(path); + cookie.setMaxAge(maxAge); + return cookie; + } + + /** + * 从host中移除端口号 + * + * @param host 包含端口号的host,如 "localhost:8080" + * @return 移除端口号后的host,如 "localhost" + */ + private static String removePortFromHost(String host) { + if (StringUtils.isBlank(host)) { + return host; + } + int portIndex = host.indexOf(':'); + if (portIndex > 0) { + // 移除端口号 + host = host.substring(0, portIndex); + } + + // 将 www 开头的域名,移除掉开头的www + if (host.startsWith("www.")) { + host = host.substring(4); + } + return host; + } + + public static Cookie delCookie(String key) { + String host = ReqInfoContext.getReqInfo() == null ? "" : ReqInfoContext.getReqInfo().getHost(); + return delCookie(key, host); + } + + /** + * 移除所有相关的Cookie + * + * @param key + */ + public static void delCookies(String key) { + HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse(); + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + Arrays.stream(request.getCookies()).filter(ck -> Objects.equals(ck.getName(), key)).forEach(ck -> { + ck.setMaxAge(0); + if (response != null) { + response.addCookie(ck); + } + }); + } + + public static Cookie delCookie(String key, String host) { + return delCookie(key, host, "/"); + } + + public static Cookie delCookie(String key, String host, String path) { + Cookie cookie = new Cookie(key, null); + cookie.setPath(path); + if (StringUtils.isNotBlank(host)) { + cookie.setDomain(removePortFromHost(host)); + } + cookie.setMaxAge(0); + return cookie; + } + + public static void delCookie(Cookie ck) { + HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse(); + ck.setMaxAge(0); + if (response != null) { + response.addCookie(ck); + } + } + + /** + * 根据key查询cookie + * + * @param request + * @param name + * @return + */ + public static Cookie findCookieByName(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return null; + } + + return Arrays.stream(cookies).filter(cookie -> StringUtils.equalsAnyIgnoreCase(cookie.getName(), name)) + .findFirst().orElse(null); + } + + public static List findCookiesByName(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return null; + } + + return Arrays.stream(cookies).filter(cookie -> StringUtils.equalsAnyIgnoreCase(cookie.getName(), name)).collect(Collectors.toList()); + } + + + public static String findCookieByName(ServerHttpRequest request, String name) { + List list = request.getHeaders().get("cookie"); + if (CollectionUtils.isEmpty(list)) { + return null; + } + + for (String sub : list) { + String[] elements = StringUtils.split(sub, ";"); + for (String element : elements) { + String[] subs = StringUtils.split(element, "="); + if (subs.length == 2 && StringUtils.equalsAnyIgnoreCase(subs[0].trim(), name)) { + return subs[1].trim(); + } + } + } + return null; + } +} \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SocketUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SocketUtil.java new file mode 100644 index 000000000..54a37dc43 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SocketUtil.java @@ -0,0 +1,66 @@ +package com.github.paicoding.forum.core.util; + +import javax.net.ServerSocketFactory; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.Random; + +/** + * @author YiHui + * @date 2022/11/26 + */ +public class SocketUtil { + + /** + * 判断端口是否可用 + * + * @param port + * @return + */ + public static boolean isPortAvailable(int port) { + try { + ServerSocket serverSocket = ServerSocketFactory.getDefault().createServerSocket(port, 1); + serverSocket.close(); + return true; + } catch (Exception var3) { + return false; + } + } + + private static Random random = new Random(); + + private static int findRandomPort(int minPort, int maxPort) { + int portRange = maxPort - minPort; + return minPort + random.nextInt(portRange + 1); + } + + /** + * 找一个可用的端口号 + * + * @param minPort + * @param maxPort + * @param defaultPort + * @return + */ + public static int findAvailableTcpPort(int minPort, int maxPort, int defaultPort) { + if (isPortAvailable(defaultPort)) { + return defaultPort; + } + + if (maxPort <= minPort) { + throw new IllegalArgumentException("maxPort should bigger than miPort!"); + } + int portRange = maxPort - minPort; + int searchCounter = 0; + + while (searchCounter <= portRange) { + int candidatePort = findRandomPort(minPort, maxPort); + ++searchCounter; + if (isPortAvailable(candidatePort)) { + return candidatePort; + } + } + + throw new IllegalStateException(String.format("Could not find an available %s port in the range [%d, %d] after %d attempts", SocketUtil.class.getName(), minPort, maxPort, searchCounter)); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SpringUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SpringUtil.java new file mode 100644 index 000000000..f799e45ce --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/SpringUtil.java @@ -0,0 +1,130 @@ +package com.github.paicoding.forum.core.util; + +import org.springframework.beans.BeansException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * @author YiHui + * @date 2022/8/29 + */ +@Component +public class SpringUtil implements ApplicationContextAware, EnvironmentAware { + private volatile static ApplicationContext context; + private volatile static Environment environment; + + private static Binder binder; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + // 容器启动时自动注入,方便后续获取bean + SpringUtil.context = applicationContext; + } + + @Override + public void setEnvironment(Environment environment) { + SpringUtil.environment = environment; + binder = Binder.get(environment); + } + + /** + * 获取ApplicationContext + * + * @return + */ + public static ApplicationContext getContext() { + return context; + } + + /** + * 获取bean + * + * @param bean + * @param + * @return + */ + public static T getBean(Class bean) { + if (context != null) { + return context.getBean(bean); + } else { + throw new IllegalStateException("Spring ApplicationContext is not active or has been closed."); + } + } + + public static T getBeanOrNull(Class bean) { + try { + return context.getBean(bean); + } catch (Exception e) { + return null; + } + } + + public static Object getBean(String beanName) { + return context.getBean(beanName); + } + + public static Object getBeanOrNull(String beanName) { + try { + return context.getBean(beanName); + } catch (Exception e) { + return null; + } + } + + public static boolean hasConfig(String key) { + return environment.containsProperty(key); + } + + /** + * 获取配置 + * + * @param key + * @return + */ + public static String getConfig(String key) { + return environment.getProperty(key); + } + + public static String getConfigOrElse(String mainKey, String slaveKey) { + String ans = environment.getProperty(mainKey); + if (ans == null) { + return environment.getProperty(slaveKey); + } + return ans; + } + + /** + * 获取配置 + * + * @param key + * @param val 配置不存在时的默认值 + * @return + */ + public static String getConfig(String key, String val) { + return environment.getProperty(key, val); + } + + /** + * 发布事件消息 + * + * @param event + */ + public static void publishEvent(ApplicationEvent event) { + context.publishEvent(event); + } + + + /** + * 配置绑定类 + * + * @return + */ + public static Binder getBinder() { + return binder; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StarNumberUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StarNumberUtil.java new file mode 100644 index 000000000..fbbf1441a --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StarNumberUtil.java @@ -0,0 +1,35 @@ +package com.github.paicoding.forum.core.util; + +import org.apache.commons.lang3.StringUtils; + +/** + * 星球编号工具类 + * + * @author YiHui + * @date 2025/8/24 + */ +public class StarNumberUtil { + + /** + * 将星球编号格式化为5位数字符串,不足位数的前面补0 + * + * @param starNumber 星球编号 + * @return 格式化后的5位数字符串 + */ + public static String formatStarNumber(String starNumber) { + if (StringUtils.isBlank(starNumber)) { + return ""; + } + + // 移除可能存在的前导零,然后重新格式化 + try { + // 解析为整数以去除前导零 + int number = Integer.parseInt(starNumber); + // 格式化为5位数字符串 + return String.format("%05d", number); + } catch (NumberFormatException e) { + // 如果不是有效数字,直接返回原值 + return starNumber; + } + } +} \ No newline at end of file diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StopWatchUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StopWatchUtil.java new file mode 100644 index 000000000..67fa71d5c --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StopWatchUtil.java @@ -0,0 +1,73 @@ +package com.github.paicoding.forum.core.util; + +import org.springframework.util.StopWatch; + +import java.util.concurrent.Callable; + +/** + * 统计耗时工具类, 这个只支持同步的耗时打印,不支持异步的场景 + * + * @author YiHui + * @date 2023/11/10 + */ +public class StopWatchUtil { + private StopWatch stopWatch; + + private StopWatchUtil(String task) { + stopWatch = task == null ? new StopWatch() : new StopWatch(task); + } + + /** + * 初始化 + * + * @param task + * @return + */ + public static StopWatchUtil init(String... task) { + return new StopWatchUtil(task.length > 0 ? task[0] : null); + } + + /** + * 同步耗时计时 + * + * @param task 任务名 + * @param call 执行业务逻辑 + * @param 返回类型 + * @return 返回结果 + */ + public T record(String task, Callable call) { + stopWatch.start(task); + try { + return call.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + stopWatch.stop(); + } + } + + /** + * 同步耗时计时 + * + * @param task 任务名 + * @param run 执行业务逻辑 + */ + public void record(String task, Runnable run) { + stopWatch.start(task); + try { + run.run(); + } finally { + stopWatch.stop(); + } + } + + + /** + * 计时信息输出 + * + * @return + */ + public String prettyPrint() { + return stopWatch.prettyPrint(); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StrUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StrUtil.java new file mode 100644 index 000000000..452f2170a --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StrUtil.java @@ -0,0 +1,160 @@ +package com.github.paicoding.forum.core.util; + +import org.apache.commons.lang3.CharUtils; +import org.apache.commons.lang3.StringUtils; + +/** + * @author YiHui + * @date 2024/12/5 + */ +public class StrUtil { + + /** + * 微信支付的提示信息,不支持表情包,因此我们只保留中文 + 数字 + 英文字母 + 符号 '《》【】-_.' + * + * @return + */ + public static String pickWxSupportTxt(String text) { + if (StringUtils.isBlank(text)) { + return text; + } + + StringBuilder str = new StringBuilder(); + for (char c : text.toCharArray()) { + if (c >= '\u4E00' && c <= '\u9FA5') { + str.append(c); + } else if (CharUtils.isAsciiAlphanumeric(c)) { + str.append(c); + } else if (c == '【' || c == '】' || c == '《' || c == '》' || c == '-' || c == '_' || c == '.') { + str.append(c); + } + } + return str.toString(); + } + + private static final char MID_LINE = '-'; + private static final char DOT = '.'; + + /** + * Spring的配置命名规则有要求, 若不满足时,可能出现启动异常 + *

+ * Reason: Canonical names should be kebab-case (’-’ separated), lowercase alpha-numeric characters, and must start with a letter。 + * + * @return + */ + public static String formatSpringConfigKey(String key) { + if (null == key || key.isEmpty()) { + return null; + } + + int len = key.length(); + StringBuilder res = new StringBuilder(len + 2); + char pre = 0; + for (int i = 0; i < len; i++) { + char ch = key.charAt(i); + if (Character.isUpperCase(ch)) { + // 当前为大写字母时,若前面一个是中划线/点号,则直接转为小写;否则插入一个中划线 + if (pre != MID_LINE && pre != DOT) { + res.append(MID_LINE); + } + res.append(Character.toLowerCase(ch)); + } else { + res.append(ch); + } + pre = ch; + } + return res.toString(); + } + + + /** + * 安全地截取HTML内容,确保标签完整性 + * + * @param html 原始HTML内容 + * @param maxLength 截取长度 + * @return 截取后的HTML内容 + */ + public static String safeSubstringHtml(String html, int maxLength) { + if (html == null || html.length() <= maxLength) { + return html; + } + + try { + // 使用Jsoup解析HTML + org.jsoup.nodes.Document doc = org.jsoup.Jsoup.parseBodyFragment(html); + org.jsoup.nodes.Element body = doc.body(); + + // 递归截取内容直到达到指定长度 + StringBuilder result = new StringBuilder(); + truncateElement(body, result, maxLength); + + return result.toString(); + } catch (Exception e) { + // 降级处理 + String subContent = html.substring(0, maxLength); + int lastTagEnd = subContent.lastIndexOf('>'); + if (lastTagEnd > 0 && subContent.lastIndexOf('<') > lastTagEnd) { + // 存在未闭合标签,截断到最近的完整标签 + return subContent.substring(0, lastTagEnd + 1) + "..."; + } + return subContent + "..."; + } + } + + private static void truncateElement(org.jsoup.nodes.Element element, StringBuilder result, int maxLength) { + if (result.length() >= maxLength) { + return; + } + + for (org.jsoup.nodes.Node node : element.childNodes()) { + if (result.length() >= maxLength) { + break; + } + + if (node instanceof org.jsoup.nodes.TextNode) { + org.jsoup.nodes.TextNode textNode = (org.jsoup.nodes.TextNode) node; + String text = textNode.getWholeText(); + int availableLength = maxLength - result.length(); + if (text.length() > availableLength) { + result.append(text, 0, availableLength).append("..."); + break; + } else { + result.append(text); + } + } else if (node instanceof org.jsoup.nodes.Element) { + org.jsoup.nodes.Element child = (org.jsoup.nodes.Element) node; + String tagName = child.tagName(); + result.append("<").append(tagName); + + // 添加属性 + for (org.jsoup.nodes.Attribute attr : child.attributes()) { + result.append(" ").append(attr.getKey()).append("=\"").append(attr.getValue()).append("\""); + } + result.append(">"); + + // 递归处理子元素 + truncateElement(child, result, maxLength); + + // 添加闭合标签 + if (!child.tag().isSelfClosing()) { + result.append(""); + } + } + } + } + + + public static void main(String[] args) { + String text = "这是一个有趣的表😄过滤- 123 143 d 哒哒"; + System.out.println(pickWxSupportTxt(text)); + + text = "view.site.Host"; + System.out.println(formatSpringConfigKey(text)); + + text = "view.site.webHost"; + System.out.println(formatSpringConfigKey(text)); + + text = "view.site.web-Host"; + System.out.println(formatSpringConfigKey(text)); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/TransactionUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/TransactionUtil.java new file mode 100644 index 000000000..eeab1de3d --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/TransactionUtil.java @@ -0,0 +1,85 @@ +package com.github.paicoding.forum.core.util; + +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * 事务辅助工具类 + * + * @author YiHui + * @date 2023/6/26 + */ +public class TransactionUtil { + /** + * 注册事务回调-事务提交前执行,如果没在事务中就立即执行 + * + * @param runnable + */ + public static void registryBeforeCommitOrImmediatelyRun(Runnable runnable) { + if (runnable == null) { + return; + } + // 处于事务中 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + // 等事务提交前执行,发生错误会回滚事务 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void beforeCommit(boolean readOnly) { + runnable.run(); + } + }); + } else { + // 马上执行 + runnable.run(); + } + } + + /** + * 事务执行完/回滚完之后执行 + * + * @param runnable + */ + public static void registryAfterCompletionOrImmediatelyRun(Runnable runnable) { + if (runnable == null) { + return; + } + // 处于事务中 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + // 等事务提交或者回滚之后执行 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + runnable.run(); + } + }); + } else { + // 马上执行 + runnable.run(); + } + } + + + /** + * 事务正常提交之后执行 + * + * @param runnable + */ + public static void registryAfterCommitOrImmediatelyRun(Runnable runnable) { + if (runnable == null) { + return; + } + // 处于事务中 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + // 等事务提交之后执行 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + runnable.run(); + } + }); + } else { + // 马上执行 + runnable.run(); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/UrlSlugUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/UrlSlugUtil.java new file mode 100644 index 000000000..f035527da --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/UrlSlugUtil.java @@ -0,0 +1,151 @@ +package com.github.paicoding.forum.core.util; + +import net.sourceforge.pinyin4j.PinyinHelper; +import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType; +import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat; +import net.sourceforge.pinyin4j.format.HanyuPinyinToneType; +import net.sourceforge.pinyin4j.format.HanyuPinyinVCharType; +import org.apache.commons.lang3.StringUtils; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * URL Slug生成工具类 + * 用于将文章标题转换为SEO友好的URL标识 + * + * @author Claude + * @date 2025-11-10 + */ +public class UrlSlugUtil { + + private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]"); + private static final Pattern WHITESPACE = Pattern.compile("[\\s]"); + private static final Pattern DUPLICATE_DASH = Pattern.compile("-+"); + private static final int MAX_SLUG_LENGTH = 100; + + /** + * 生成URL友好的slug + * 支持中文(转拼音)、英文、数字 + * + * @param text 原始文本(通常是文章标题) + * @return URL友好的slug字符串 + */ + public static String generateSlug(String text) { + if (StringUtils.isBlank(text)) { + return ""; + } + + // 1. 转小写 + String slug = text.toLowerCase(Locale.ENGLISH); + + // 2. 移除特殊字符,保留中文、英文、数字、空格、连字符 + slug = slug.replaceAll("[^a-z0-9\\s\\-\\u4e00-\\u9fa5]", ""); + + // 3. 将中文转换为拼音 + slug = chineseToPinyin(slug); + + // 4. Unicode标准化 + slug = Normalizer.normalize(slug, Normalizer.Form.NFD); + + // 5. 移除非ASCII字符 + slug = NON_LATIN.matcher(slug).replaceAll("-"); + + // 6. 将空白字符替换为连字符 + slug = WHITESPACE.matcher(slug).replaceAll("-"); + + // 7. 移除重复的连字符 + slug = DUPLICATE_DASH.matcher(slug).replaceAll("-"); + + // 8. 移除首尾的连字符 + slug = slug.replaceAll("^-+|-+$", ""); + + // 9. 限制长度 + if (slug.length() > MAX_SLUG_LENGTH) { + slug = slug.substring(0, MAX_SLUG_LENGTH); + // 确保不在单词中间截断 + int lastDash = slug.lastIndexOf('-'); + if (lastDash > 0) { + slug = slug.substring(0, lastDash); + } + } + + // 10. 如果最终结果为空,使用时间戳 + if (StringUtils.isBlank(slug)) { + slug = "article-" + System.currentTimeMillis(); + } + + return slug; + } + + /** + * 将中文字符串转换为拼音 + * + * @param chinese 包含中文的字符串 + * @return 转换后的拼音字符串 + */ + private static String chineseToPinyin(String chinese) { + if (StringUtils.isBlank(chinese)) { + return ""; + } + + StringBuilder pinyin = new StringBuilder(); + HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat(); + format.setCaseType(HanyuPinyinCaseType.LOWERCASE); + format.setToneType(HanyuPinyinToneType.WITHOUT_TONE); + format.setVCharType(HanyuPinyinVCharType.WITH_V); + + char[] chars = chinese.toCharArray(); + for (char c : chars) { + if (Character.toString(c).matches("[\\u4e00-\\u9fa5]")) { + // 是中文字符 + try { + String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(c, format); + if (pinyinArray != null && pinyinArray.length > 0) { + pinyin.append(pinyinArray[0]); + } else { + pinyin.append(c); + } + } catch (Exception e) { + // 转换失败,保留原字符 + pinyin.append(c); + } + } else { + // 非中文字符直接保留 + pinyin.append(c); + } + } + + return pinyin.toString(); + } + + /** + * 生成唯一的slug(带文章ID后缀) + * + * @param title 文章标题 + * @param articleId 文章ID + * @return 唯一的slug + */ + public static String generateUniqueSlug(String title, Long articleId) { + String baseSlug = generateSlug(title); + if (articleId != null && articleId > 0) { + return baseSlug; + } + return baseSlug; + } + + /** + * 验证slug格式是否有效 + * + * @param slug 要验证的slug + * @return 是否有效 + */ + public static boolean isValidSlug(String slug) { + if (StringUtils.isBlank(slug)) { + return false; + } + // slug只能包含小写字母、数字和连字符 + return slug.matches("^[a-z0-9-]+$") && slug.length() <= MAX_SLUG_LENGTH; + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/IdUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/IdUtil.java new file mode 100644 index 000000000..0f16a9426 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/IdUtil.java @@ -0,0 +1,91 @@ +package com.github.paicoding.forum.core.util.id; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.util.CompressUtil; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.core.util.id.snowflake.PaiSnowflakeIdGenerator; +import com.github.paicoding.forum.core.util.id.snowflake.SnowflakeProducer; +import org.apache.commons.lang3.StringUtils; + +import java.util.concurrent.atomic.AtomicLong; + +import static com.github.paicoding.forum.core.util.CompressUtil.int2str; + +/** + * @author YiHui + * @date 2023/8/30 + */ +public class IdUtil { + /** + * 默认的id生成器 + */ + public static SnowflakeProducer DEFAULT_ID_PRODUCER = new SnowflakeProducer(new PaiSnowflakeIdGenerator()); + + private static AtomicLong INCR = new AtomicLong((int) (Math.random() * 500)); + private static long lastTime = 0; + + + /** + * 生成全局id + * + * @return + */ + public static Long genId() { + return DEFAULT_ID_PRODUCER.genId(); + } + + /** + * 生成字符串格式全局id + * + * @return + */ + public static String genStrId() { + return CompressUtil.int2str(genId()); + } + + + /** + * 生成支付的唯一code + * 简化的规则:payWay前缀 + 年月日+时分秒 + * + * @return + */ + public static String genPayCode(ThirdPayWayEnum payWay, Long id) { + long now = System.currentTimeMillis(); + if (DateUtil.skipDay(lastTime, now)) { + lastTime = now; + INCR.set((int) (Math.random() * 500)); + } + return payWay.getPrefix() + String.format("%06d", INCR.addAndGet(1)) + "-" + id; + } + + /** + * 根据payCode 解析获取 payId + * + * @param code + * @return + */ + public static Long getPayIdFromPayCode(String code) { + String[] str = StringUtils.split(code, "-"); + return Long.valueOf(str[str.length - 1]); + } + + public static void main(String[] args) { + System.out.println(IdUtil.genStrId()); + Long id = IdUtil.genId(); + System.out.println(id + " = " + int2str(id)); + System.out.println(IdUtil.genId() + "->" + IdUtil.genStrId()); + AsyncUtil.sleep(2000); + System.out.println(IdUtil.genId() + "->" + IdUtil.genStrId()); + + System.out.println("-----"); + + SnowflakeProducer producer = new SnowflakeProducer(new PaiSnowflakeIdGenerator()); + id = producer.genId(); + System.out.println("id: " + id + " -> " + int2str(id)); + AsyncUtil.sleep(3000L); + id = producer.genId(); + System.out.println("id: " + id + " -> " + int2str(id)); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/HuToolSnowflakeIdGenerator.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/HuToolSnowflakeIdGenerator.java new file mode 100644 index 000000000..0388eb7d8 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/HuToolSnowflakeIdGenerator.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.core.util.id.snowflake; + +import cn.hutool.core.lang.Snowflake; + +import java.util.Date; + + +/** + * @author YiHui + * @date 2023/10/17 + */ +public class HuToolSnowflakeIdGenerator implements IdGenerator { + private static final Date EPOC = new Date(2023, 1, 1); + private Snowflake snowflake; + + public HuToolSnowflakeIdGenerator(int workId, int datacenter) { + snowflake = new Snowflake(EPOC, workId, datacenter, false); + } + + @Override + public Long nextId() { + return snowflake.nextId(); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/IdGenerator.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/IdGenerator.java new file mode 100644 index 000000000..b19d5d00c --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/IdGenerator.java @@ -0,0 +1,14 @@ +package com.github.paicoding.forum.core.util.id.snowflake; + +/** + * @author YiHui + * @date 2023/10/17 + */ +public interface IdGenerator { + /** + * 生成分布式id + * + * @return + */ + Long nextId(); +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/PaiSnowflakeIdGenerator.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/PaiSnowflakeIdGenerator.java new file mode 100644 index 000000000..3a07bfc6d --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/PaiSnowflakeIdGenerator.java @@ -0,0 +1,153 @@ +package com.github.paicoding.forum.core.util.id.snowflake; + +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.core.util.IpUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.time.LocalDateTime; + +/** + * 自定义实现的雪花算法生成器 + *

+ * 时间 + 数据中心(3位) + 机器id(7位) + 序列号(12位) + * + * @author YiHui + * @date 2023/10/16 + */ +@Slf4j +public class PaiSnowflakeIdGenerator implements IdGenerator { + /** + * 自增序号位数 + */ + private static final long SEQUENCE_BITS = 10L; + + /** + * 机器位数 + */ + private static final long WORKER_ID_BITS = 7L; + private static final long DATA_CENTER_BITS = 3L; + + private static final long SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1; + + private static final long WORKER_ID_LEFT_SHIFT_BITS = SEQUENCE_BITS; + private static final long DATACENTER_LEFT_SHIFT_BITS = SEQUENCE_BITS + WORKER_ID_BITS; + private static final long TIMESTAMP_LEFT_SHIFT_BITS = WORKER_ID_LEFT_SHIFT_BITS + WORKER_ID_BITS + DATA_CENTER_BITS; + /** + * 机器id (7位) + */ + private long workId = 1; + /** + * 数据中心 (3位) + */ + private long dataCenter = 1; + + /** + * 上次的访问时间 + */ + private long lastTime; + /** + * 自增序号 + */ + private long sequence; + + private byte sequenceOffset; + + public PaiSnowflakeIdGenerator() { + try { + String ip = IpUtil.getLocalIp4Address(); + String[] cells = StringUtils.split(ip, "."); + this.dataCenter = Integer.parseInt(cells[0]) & ((1 << DATA_CENTER_BITS) - 1); + this.workId = Integer.parseInt(cells[3]) >> 16 & ((1 << WORKER_ID_BITS) - 1); + } catch (Exception e) { + this.dataCenter = 1; + this.workId = 1; + } + } + + public PaiSnowflakeIdGenerator(int workId, int dateCenter) { + this.workId = workId; + this.dataCenter = dateCenter; + } + + /** + * 生成趋势自增的id + * + * @return + */ + @Override + public synchronized Long nextId() { + long nowTime = waitToIncrDiffIfNeed(getNowTime()); + if (lastTime == nowTime) { + if (0L == (sequence = (sequence + 1) & SEQUENCE_MASK)) { + // 表示当前这一时刻的自增数被用完了;等待下一时间点 + nowTime = waitUntilNextTime(nowTime); + } + } else { + // 上一毫秒若以0作为序列号开始值,则这一秒以1为序列号开始值 + vibrateSequenceOffset(); + sequence = sequenceOffset; + } + lastTime = nowTime; + long ans = ((nowTime % DateUtil.ONE_DAY_SECONDS) << TIMESTAMP_LEFT_SHIFT_BITS) | (dataCenter << DATACENTER_LEFT_SHIFT_BITS) | (workId << WORKER_ID_LEFT_SHIFT_BITS) | sequence; + if (log.isDebugEnabled()) { + log.debug("seconds:{}, datacenter:{}, work:{}, seq:{}, ans={}", nowTime % DateUtil.ONE_DAY_SECONDS, dataCenter, workId, sequence, ans); + } + return Long.parseLong(String.format("%s%011d", getDaySegment(nowTime), ans)); + } + + /** + * 若当前时间比上次执行时间要小,则等待时间追上来,避免出现时钟回拨导致的数据重复 + * + * @param nowTime 当前时间戳 + * @return 返回新的时间戳 + */ + private long waitToIncrDiffIfNeed(final long nowTime) { + if (lastTime <= nowTime) { + return nowTime; + } + long diff = lastTime - nowTime; + AsyncUtil.sleep(diff); + return getNowTime(); + } + + /** + * 等待下一秒 + * + * @param lastTime + * @return + */ + private long waitUntilNextTime(final long lastTime) { + long result = getNowTime(); + while (result <= lastTime) { + result = getNowTime(); + } + return result; + } + + private void vibrateSequenceOffset() { + sequenceOffset = (byte) (~sequenceOffset & 1); + } + + + /** + * 获取当前时间 + * + * @return 秒为单位 + */ + private long getNowTime() { + return System.currentTimeMillis() / 1000; + } + + /** + * 基于年月日构建分区 + * + * @param time 时间戳 + * @return 时间分区 + */ + private static String getDaySegment(long time) { + LocalDateTime localDate = DateUtil.time2LocalTime(time * 1000L); + return String.format("%02d%03d", localDate.getYear() % 100, localDate.getDayOfYear()); + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/SnowflakeProducer.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/SnowflakeProducer.java new file mode 100644 index 000000000..74afa5a09 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/snowflake/SnowflakeProducer.java @@ -0,0 +1,71 @@ +package com.github.paicoding.forum.core.util.id.snowflake; + +import com.github.paicoding.forum.core.util.DateUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * 基于雪花算法计算的id生成器 + * + * @author YiHui + * @date 2023/8/30 + */ +@Slf4j +public class SnowflakeProducer { + private BlockingQueue queue; + + /** + * id失效的间隔时间 + */ + public static final Long ID_EXPIRE_TIME_INTER = DateUtil.ONE_DAY_MILL; + private static final int QUEUE_SIZE = 10; + private ExecutorService es = Executors.newSingleThreadExecutor((Runnable r) -> { + Thread t = new Thread(r); + t.setName("SnowflakeProducer-generate-thread"); + t.setDaemon(true); + return t; + }); + + public SnowflakeProducer(final IdGenerator generator) { + queue = new LinkedBlockingQueue<>(QUEUE_SIZE); + es.submit(() -> { + long lastTime = System.currentTimeMillis(); + while (true) { + try { + queue.offer(generator.nextId(), 1, TimeUnit.MINUTES); + } catch (InterruptedException e1) { + } catch (Exception e) { + log.info("gen id error! {}", e.getMessage()); + } + + // 当出现跨天时,自动重置业务id + try { + long now = System.currentTimeMillis(); + if (now / ID_EXPIRE_TIME_INTER - lastTime / ID_EXPIRE_TIME_INTER > 0) { + // 跨天,清空队列 + queue.clear(); + log.info("清空id队列,重新设置"); + } + lastTime = now; + + } catch (Exception e) { + log.info("auto remove illegal ids error! {}", e.getMessage()); + } + } + }); + } + + public Long genId() { + try { + return queue.take(); + } catch (InterruptedException e) { + log.error("雪花算法生成逻辑异常", e); + throw new RuntimeException("雪花算法生成id异常!", e); + } + } +} diff --git a/paicoding-core/src/main/java/com/github/paicoding/forum/core/ws/WebSocketResponseUtil.java b/paicoding-core/src/main/java/com/github/paicoding/forum/core/ws/WebSocketResponseUtil.java new file mode 100644 index 000000000..e4fc4d933 --- /dev/null +++ b/paicoding-core/src/main/java/com/github/paicoding/forum/core/ws/WebSocketResponseUtil.java @@ -0,0 +1,76 @@ +package com.github.paicoding.forum.core.ws; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.core.mdc.MdcUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +/** + * websocket消息响应封装工具类 + * + * @author YiHui + * @date 2024/11/27 + */ +public class WebSocketResponseUtil { + private static volatile SimpMessagingTemplate simpMessagingTemplate; + + /** + * 初始化 + */ + private static void initSimpMessageTemplate() { + if (simpMessagingTemplate == null) { + synchronized (WebSocketResponseUtil.class) { + if (simpMessagingTemplate == null) { + simpMessagingTemplate = SpringUtil.getBean(SimpMessagingTemplate.class); + } + } + } + } + + /** + * 给用户发送消息 + * + * @param user 用户 + * @param destination 用户订阅地址 + * @param data 消息实体 + */ + public static void sendMsgToUser(String user, String destination, Object data) { + initSimpMessageTemplate(); + simpMessagingTemplate.convertAndSendToUser(user, destination, data); + } + + /** + * 消息广播 + * + * @param destination 订阅地址 + * @param data 消息实体 + */ + public static void broadcastMsg(String destination, Object data) { + initSimpMessageTemplate(); + simpMessagingTemplate.convertAndSend(destination, data); + } + + /** + * 封装websocket的消息处理,主要是设置上下文,全链路traceId + * + * @param accessor 请求 + * @param func 执行体 + */ + public static void execute(SimpMessageHeaderAccessor accessor, Runnable func) { + try { + ReqInfoContext.ReqInfo reqInfo = (ReqInfoContext.ReqInfo) accessor.getUser(); + ReqInfoContext.addReqInfo(reqInfo); + String traceId = (String) accessor.getSessionAttributes().get(MdcUtil.TRACE_ID_KEY); + MdcUtil.add(MdcUtil.TRACE_ID_KEY, traceId); + + + // 执行具体的业务逻辑 + func.run(); + + } finally { + ReqInfoContext.clear(); + MdcUtil.clear(); + } + } +} diff --git a/paicoding-core/src/main/resources/META-INF/spring.factories b/paicoding-core/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..8ce866a20 --- /dev/null +++ b/paicoding-core/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.github.paicoding.forum.core.ForumCoreAutoConfig \ No newline at end of file diff --git a/paicoding-core/src/test/java/com/github/paicoding/forum/test/markdown/ImageCaptionTest.java b/paicoding-core/src/test/java/com/github/paicoding/forum/test/markdown/ImageCaptionTest.java new file mode 100644 index 000000000..43dc41622 --- /dev/null +++ b/paicoding-core/src/test/java/com/github/paicoding/forum/test/markdown/ImageCaptionTest.java @@ -0,0 +1,59 @@ +package com.github.paicoding.forum.test.markdown; + +import com.github.paicoding.forum.core.util.MarkdownConverter; +import org.junit.Test; + +/** + * 图片说明扩展测试 + * + * @author 沉默王二 + * @date 2025-10-20 + */ +public class ImageCaptionTest { + + @Test + public void testImageWithAlt() { + String markdown = "![这是一张美丽的风景图](https://example.com/image.jpg)"; + String html = MarkdownConverter.markdownToHtml(markdown); + System.out.println("========== 带 alt 属性的图片 =========="); + System.out.println("Markdown: " + markdown); + System.out.println("HTML: " + html); + System.out.println(); + } + + @Test + public void testImageWithoutAlt() { + String markdown = "![](https://example.com/image.jpg)"; + String html = MarkdownConverter.markdownToHtml(markdown); + System.out.println("========== 不带 alt 属性的图片 =========="); + System.out.println("Markdown: " + markdown); + System.out.println("HTML: " + html); + System.out.println(); + } + + @Test + public void testImageWithTitle() { + String markdown = "![图片说明](https://example.com/image.jpg \"这是标题\")"; + String html = MarkdownConverter.markdownToHtml(markdown); + System.out.println("========== 带 alt 和 title 的图片 =========="); + System.out.println("Markdown: " + markdown); + System.out.println("HTML: " + html); + System.out.println(); + } + + @Test + public void testMultipleImages() { + String markdown = "# 图片测试\n\n" + + "这是第一张图片:\n\n" + + "![美丽的风景](https://example.com/landscape.jpg)\n\n" + + "这是第二张图片(没有说明):\n\n" + + "![](https://example.com/no-caption.jpg)\n\n" + + "这是第三张图片:\n\n" + + "![可爱的小猫](https://example.com/cat.jpg \"小猫的标题\")"; + + String html = MarkdownConverter.markdownToHtml(markdown); + System.out.println("========== 多张图片测试 =========="); + System.out.println("Markdown:\n" + markdown); + System.out.println("\nHTML:\n" + html); + } +} diff --git a/paicoding-service/pom.xml b/paicoding-service/pom.xml new file mode 100644 index 000000000..c93b23db4 --- /dev/null +++ b/paicoding-service/pom.xml @@ -0,0 +1,122 @@ + + + + paicoding-forum + com.github.paicoding.forum + 0.0.1-SNAPSHOT + + 4.0.0 + + paicoding-service + + + + com.github.paicoding.forum + paicoding-core + + + org.springframework + spring-context + + + + com.fasterxml.jackson.core + jackson-databind + + + + com.baomidou + mybatis-plus-boot-starter + + + mysql + mysql-connector-java + + + + com.aliyun.oss + aliyun-sdk-oss + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + + + org.mapstruct + mapstruct + + + org.mapstruct + mapstruct-processor + compile + + + + + org.elasticsearch + elasticsearch + 6.8.2 + + + + org.elasticsearch.client + elasticsearch-rest-client + 6.8.2 + + + org.elasticsearch.client + elasticsearch-rest-high-level-client + 6.8.2 + + + + cn.hutool + hutool-all + + + + com.auth0 + java-jwt + ${jwt.version} + + + org.springframework.security + spring-security-crypto + + + org.thymeleaf + thymeleaf-spring5 + provided + + + + org.springframework + spring-messaging + + + + + com.github.wechatpay-apiv3 + wechatpay-java + 0.2.14 + + + + cn.idev.excel + fastexcel + + + + + com.volcengine + volcengine-java-sdk-ark-runtime + 0.1.150 + + + + + \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/ServiceAutoConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/ServiceAutoConfig.java new file mode 100644 index 000000000..6953b48f9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/ServiceAutoConfig.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.service; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author YiHui + * @date 2022/7/6 + */ +@Configuration +@ComponentScan("com.github.paicoding.forum.service") +@MapperScan(basePackages = { + "com.github.paicoding.forum.service.article.repository.mapper", + "com.github.paicoding.forum.service.user.repository.mapper", + "com.github.paicoding.forum.service.comment.repository.mapper", + "com.github.paicoding.forum.service.config.repository.mapper", + "com.github.paicoding.forum.service.statistics.repository.mapper", + "com.github.paicoding.forum.service.notify.repository.mapper", + "com.github.paicoding.forum.service.shortlink.repository.mapper", +}) +public class ServiceAutoConfig { + + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ArticleConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ArticleConverter.java new file mode 100644 index 000000000..6123e045b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ArticleConverter.java @@ -0,0 +1,162 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.enums.ArticleTypeEnum; +import com.github.paicoding.forum.api.model.enums.SourceTypeEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; +import com.github.paicoding.forum.api.model.vo.article.CategoryReq; +import com.github.paicoding.forum.api.model.vo.article.SearchArticleReq; +import com.github.paicoding.forum.api.model.vo.article.TagReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.core.util.PriceUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.CategoryDO; +import com.github.paicoding.forum.service.article.repository.entity.TagDO; +import com.github.paicoding.forum.service.article.repository.params.SearchArticleParams; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 文章转换 + *

+ * + * @author louzai + * @date 2022-07-31 + */ +public class ArticleConverter { + + public static ArticleDO toArticleDo(ArticlePostReq req, Long author) { + ArticleDO article = new ArticleDO(); + article.setUserId(author); + article.setId(req.getArticleId()); + article.setTitle(req.getTitle()); + article.setShortTitle(req.getShortTitle()); + + article.setArticleType(ArticleTypeEnum.valueOf(req.getArticleType().toUpperCase()).getCode()); + article.setPicture(req.getCover() == null ? "" : req.getCover()); + article.setCategoryId(req.getCategoryId()); + article.setSource(req.getSource()); + article.setSourceUrl(req.getSourceUrl()); + article.setSummary(req.getSummary()); + article.setStatus(req.pushStatus().getCode()); + article.setDeleted(req.deleted() ? YesOrNoEnum.YES.getCode() : YesOrNoEnum.NO.getCode()); + article.setReadType(req.getReadType() == null ? ArticleReadTypeEnum.NORMAL.getType() : req.getReadType()); + if (article.getReadType().equals(ArticleReadTypeEnum.PAY_READ.getType())) { + article.setPayAmount(req.getPayAmount() == null ? 99 : req.getPayAmount()); + article.setPayWay(StringUtils.isBlank(req.getPayWay()) ? ThirdPayWayEnum.WX_NATIVE.getPay() : req.getPayWay()); + } + return article; + } + + public static ArticleDTO toDto(ArticleDO articleDO) { + if (articleDO == null) { + return null; + } + ArticleDTO articleDTO = new ArticleDTO(); + articleDTO.setAuthor(articleDO.getUserId()); + articleDTO.setArticleId(articleDO.getId()); + articleDTO.setArticleType(articleDO.getArticleType()); + articleDTO.setTitle(articleDO.getTitle()); + articleDTO.setShortTitle(articleDO.getShortTitle()); + articleDTO.setUrlSlug(articleDO.getUrlSlug()); + articleDTO.setSummary(articleDO.getSummary()); + articleDTO.setCover(articleDO.getPicture()); + articleDTO.setSourceType(SourceTypeEnum.formCode(articleDO.getSource()).getDesc()); + articleDTO.setSourceUrl(articleDO.getSourceUrl()); + articleDTO.setStatus(articleDO.getStatus()); + articleDTO.setCreateTime(articleDO.getCreateTime().getTime()); + articleDTO.setLastUpdateTime(articleDO.getUpdateTime().getTime()); + articleDTO.setOfficalStat(articleDO.getOfficalStat()); + articleDTO.setToppingStat(articleDO.getToppingStat()); + articleDTO.setCreamStat(articleDO.getCreamStat()); + articleDTO.setReadType(articleDO.getReadType()); + articleDTO.setPayAmount(PriceUtil.toYuanPrice(articleDO.getPayAmount())); + articleDTO.setPayWay(articleDO.getPayWay()); + + // 设置类目id + articleDTO.setCategory(new CategoryDTO(articleDO.getCategoryId(), null)); + return articleDTO; + } + + public static List toArticleDtoList(List articleDOS) { + return articleDOS.stream().map(ArticleConverter::toDto).collect(Collectors.toList()); + } + + /** + * do转换 + * + * @param tag + * @return + */ + public static TagDTO toDto(TagDO tag) { + if (tag == null) { + return null; + } + TagDTO dto = new TagDTO(); + dto.setTag(tag.getTagName()); + dto.setTagId(tag.getId()); + dto.setStatus(tag.getStatus()); + return dto; + } + + public static List toDtoList(List tags) { + return tags.stream().map(ArticleConverter::toDto).collect(Collectors.toList()); + } + + + public static CategoryDTO toDto(CategoryDO category) { + CategoryDTO dto = new CategoryDTO(); + dto.setCategory(category.getCategoryName()); + dto.setCategoryId(category.getId()); + dto.setRank(category.getRank()); + dto.setStatus(category.getStatus()); + dto.setSelected(false); + return dto; + } + + public static List toCategoryDtoList(List categorys) { + return categorys.stream().map(ArticleConverter::toDto).collect(Collectors.toList()); + } + + public static TagDO toDO(TagReq tagReq) { + if (tagReq == null) { + return null; + } + TagDO tagDO = new TagDO(); + tagDO.setTagName(tagReq.getTag()); + return tagDO; + } + + public static CategoryDO toDO(CategoryReq categoryReq) { + if (categoryReq == null) { + return null; + } + CategoryDO categoryDO = new CategoryDO(); + categoryDO.setCategoryName(categoryReq.getCategory()); + categoryDO.setRank(categoryReq.getRank()); + return categoryDO; + } + + public static SearchArticleParams toSearchParams(SearchArticleReq req) { + if (req == null) { + return null; + } + SearchArticleParams searchArticleParams = new SearchArticleParams(); + searchArticleParams.setTitle(req.getTitle()); + searchArticleParams.setArticleId(req.getArticleId()); + searchArticleParams.setUserId(req.getUserId()); + searchArticleParams.setStatus(req.getStatus()); + searchArticleParams.setOfficalStat(req.getOfficalStat()); + searchArticleParams.setToppingStat(req.getToppingStat()); + searchArticleParams.setPageNum(req.getPageNumber()); + searchArticleParams.setPageSize(req.getPageSize()); + return searchArticleParams; + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ArticleStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ArticleStructMapper.java new file mode 100644 index 000000000..1baa72832 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ArticleStructMapper.java @@ -0,0 +1,15 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.vo.article.SearchArticleReq; +import com.github.paicoding.forum.service.article.repository.params.SearchArticleParams; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper +public interface ArticleStructMapper { + ArticleStructMapper INSTANCE = Mappers.getMapper( ArticleStructMapper.class ); + + @Mapping(source = "pageNumber", target = "pageNum") + SearchArticleParams toSearchParams(SearchArticleReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/CategoryStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/CategoryStructMapper.java new file mode 100644 index 000000000..31b6ec2db --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/CategoryStructMapper.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.vo.article.CategoryReq; +import com.github.paicoding.forum.api.model.vo.article.SearchCategoryReq; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.service.article.repository.entity.CategoryDO; +import com.github.paicoding.forum.service.article.repository.params.SearchCategoryParams; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/27/23 + */ +@Mapper +public interface CategoryStructMapper { + // instance + CategoryStructMapper INSTANCE = Mappers.getMapper( CategoryStructMapper.class ); + + // req to params + @Mapping(source = "pageNumber", target = "pageNum") + SearchCategoryParams toSearchParams(SearchCategoryReq req); + + // do to dto + @Mapping(source = "id", target = "categoryId") + @Mapping(source = "categoryName", target = "category") + CategoryDTO toDTO(CategoryDO categoryDO); + + List toDTOs(List list); + + // req to do + @Mapping(source = "category", target = "categoryName") + CategoryDO toDO(CategoryReq categoryReq); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnArticleStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnArticleStructMapper.java new file mode 100644 index 000000000..22b42d3c3 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnArticleStructMapper.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.SearchColumnArticleReq; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.params.ColumnArticleParams; +import com.github.paicoding.forum.service.article.repository.params.SearchColumnArticleParams; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +@Mapper +public interface ColumnArticleStructMapper { + ColumnArticleStructMapper INSTANCE = Mappers.getMapper( ColumnArticleStructMapper.class ); + + SearchColumnArticleParams toSearchParams(SearchColumnArticleReq req); + + ColumnArticleParams toParams(ColumnArticleReq req); + + ColumnArticleDO reqToDO(ColumnArticleReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnConvert.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnConvert.java new file mode 100644 index 000000000..e28f890c4 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnConvert.java @@ -0,0 +1,71 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.ColumnReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnInfoDO; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/15 + */ +public class ColumnConvert { + + public static ColumnDTO toDto(ColumnInfoDO info) { + ColumnDTO dto = new ColumnDTO(); + dto.setColumnId(info.getId()); + dto.setColumn(info.getColumnName()); + dto.setCover(info.getCover()); + dto.setIntroduction(info.getIntroduction()); + dto.setState(info.getState()); + dto.setNums(info.getNums()); + dto.setAuthor(info.getUserId()); + dto.setSection(info.getSection()); + dto.setPublishTime(info.getPublishTime().getTime()); + dto.setType(info.getType()); + dto.setFreeStartTime(info.getFreeStartTime().getTime()); + dto.setFreeEndTime(info.getFreeEndTime().getTime()); + return dto; + } + + public static List toDtos(List columnInfoDOS) { + List columnDTOS = new ArrayList<>(); + columnInfoDOS.forEach(info -> columnDTOS.add(ColumnConvert.toDto(info))); + return columnDTOS; + } + + public static ColumnInfoDO toDo(ColumnReq columnReq) { + if (columnReq == null) { + return null; + } + ColumnInfoDO columnInfoDO = new ColumnInfoDO(); + columnInfoDO.setColumnName(columnReq.getColumn()); + columnInfoDO.setUserId(columnReq.getAuthor()); + columnInfoDO.setIntroduction(columnReq.getIntroduction()); + columnInfoDO.setCover(columnReq.getCover()); + columnInfoDO.setState(columnReq.getState()); + columnInfoDO.setSection(columnReq.getSection()); + columnInfoDO.setNums(columnReq.getNums()); + columnInfoDO.setType(columnReq.getType()); + columnInfoDO.setFreeStartTime(new Date(columnReq.getFreeStartTime())); + columnInfoDO.setFreeEndTime(new Date(columnReq.getFreeEndTime())); + return columnInfoDO; + } + + public static ColumnArticleDO toDo(ColumnArticleReq columnArticleReq) { + if (columnArticleReq == null) { + return null; + } + ColumnArticleDO columnArticleDO = new ColumnArticleDO(); + columnArticleDO.setColumnId(columnArticleReq.getColumnId()); + columnArticleDO.setArticleId(columnArticleReq.getArticleId()); + columnArticleDO.setSection(columnArticleReq.getSort()); + return columnArticleDO; + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnStructMapper.java new file mode 100644 index 000000000..4ca72fc9a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/ColumnStructMapper.java @@ -0,0 +1,74 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleGroupReq; +import com.github.paicoding.forum.api.model.vo.article.ColumnReq; +import com.github.paicoding.forum.api.model.vo.article.SearchColumnReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleGroupDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleColumnDTO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleGroupDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnInfoDO; +import com.github.paicoding.forum.service.article.repository.params.SearchColumnParams; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ColumnStructMapper { + ColumnStructMapper INSTANCE = Mappers.getMapper( ColumnStructMapper.class); + + /** + * SearchColumnReq to SearchColumnParams + * @param req + * @return + */ + SearchColumnParams reqToSearchParams(SearchColumnReq req); + + /** + * ColumnInfoDO to ColumnDTO + * @param columnInfoDO + * @return + */ + // sources 是参数,target 是目标 + @Mapping(source = "id", target = "columnId") + @Mapping(source = "columnName", target = "column") + @Mapping(source = "userId", target = "author") + // Date 转 Long + @Mapping(target = "publishTime", expression = "java(columnInfoDO.getPublishTime().getTime())") + @Mapping(target = "freeStartTime", expression = "java(columnInfoDO.getFreeStartTime().getTime())") + @Mapping(target = "freeEndTime", expression = "java(columnInfoDO.getFreeEndTime().getTime())") + ColumnDTO infotoDto(ColumnInfoDO columnInfoDO); + + List infoToDtos(List columnInfoDOs); + + + /** + * ColumnInfoDO to SimpleColumnDTO + * @param columnInfoDO + * @return + */ + // 专栏 ID 、专栏名、封面 + @Mapping(source = "id", target = "columnId") + @Mapping(source = "columnName", target = "column") + SimpleColumnDTO infoToSimpleDto(ColumnInfoDO columnInfoDO); + + List infoToSimpleDtos(List columnInfoDOs); + + @Mapping(source = "column", target = "columnName") + @Mapping(source = "author", target = "userId") + // Long 转 Date + @Mapping(target = "freeStartTime", expression = "java(new java.util.Date(req.getFreeStartTime()))") + @Mapping(target = "freeEndTime", expression = "java(new java.util.Date(req.getFreeEndTime()))") + ColumnInfoDO toDo(ColumnReq req); + + + ColumnArticleGroupDO toGroupDO(ColumnArticleGroupReq req); + + @Mapping(source = "id", target = "groupId") + ColumnArticleGroupDTO toGroupDTO(ColumnArticleGroupDO entity); + + @Mapping(source = "id", target = "groupId") + List toGroupDTOList(List entity); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/PayConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/PayConverter.java new file mode 100644 index 000000000..6c52041a2 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/PayConverter.java @@ -0,0 +1,87 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.hui.quick.plugin.base.Base64Util; +import com.github.hui.quick.plugin.qrcode.wrapper.QrCodeGenV3; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticlePayInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserPayCodeDTO; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.PriceUtil; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import org.apache.commons.lang3.StringUtils; + +import java.awt.image.BufferedImage; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * @author YiHui + * @date 2024/10/29 + */ +public class PayConverter { + + public static ArticlePayInfoDTO toPay(ArticlePayRecordDO record) { + ArticlePayInfoDTO info = new ArticlePayInfoDTO(); + info.setPayId(record.getId()); + info.setPayUserId(record.getPayUserId()); + info.setPayStatus(record.getPayStatus()); + info.setReceiveUserId(record.getReceiveUserId()); + info.setArticleId(record.getArticleId()); + info.setPayAmount(PriceUtil.toYuanPrice(record.getPayAmount())); + ThirdPayWayEnum payWay = ThirdPayWayEnum.ofPay(record.getPayWay()); + if (payWay != null) { + info.setPayWay(payWay.getPay()); + info.setPrePayExpireTime(record.getPrePayExpireTime() == null ? null : record.getPrePayExpireTime().getTime()); + info.setPrePayId(genQrCode(record.getPrePayId())); + } + return info; + } + + + /** + * 格式化收款码 + * + * @return key: 渠道 value: 收款二维码base64格式 + */ + public static Map formatPayCode(String dbCode) { + if (StringUtils.isBlank(dbCode)) { + return Collections.emptyMap(); + } + + JsonNode node = JsonUtil.toNode(dbCode); + Map result = new HashMap<>(); + node.fields().forEachRemaining(kv -> { + String key = kv.getKey(); + String value = kv.getValue().asText(); + result.put(key, genQrCode(value)); + }); + return result; + } + + public static Map formatPayCodeInfo(String dbCode) { + if (StringUtils.isBlank(dbCode)) { + return Collections.emptyMap(); + } + + JsonNode node = JsonUtil.toNode(dbCode); + Map result = new HashMap<>(); + node.fields().forEachRemaining(kv -> { + String key = kv.getKey(); + String value = kv.getValue().asText(); + result.put(key, new UserPayCodeDTO(genQrCode(value), value)); + }); + return result; + } + + + public static String genQrCode(String txt) { + try { + BufferedImage img = QrCodeGenV3.of(txt).setSize(500).asImg(); + return Base64Util.encode(img, "png"); + } catch (Exception e) { + return txt; + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/TagStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/TagStructMapper.java new file mode 100644 index 000000000..10478c832 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/conveter/TagStructMapper.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.service.article.conveter; + +import com.github.paicoding.forum.api.model.vo.article.SearchTagReq; +import com.github.paicoding.forum.api.model.vo.article.TagReq; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.service.article.repository.entity.TagDO; +import com.github.paicoding.forum.service.article.repository.params.SearchTagParams; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/29/23 + */ +@Mapper +public interface TagStructMapper { + // instance + TagStructMapper INSTANCE = Mappers.getMapper( TagStructMapper.class ); + + // req to params + @Mapping(source = "pageNumber", target = "pageNum") + SearchTagParams toSearchParams(SearchTagReq req); + + // do to dto + @Mapping(source = "id", target = "tagId") + @Mapping(source = "tagName", target = "tag") + TagDTO toDTO(TagDO tagDO); + + List toDTOs(List list); + + @Mapping(source = "tag", target = "tagName") + TagDO toDO(TagReq tagReq); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/helper/TreeBuilder.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/helper/TreeBuilder.java new file mode 100644 index 000000000..779bc9dff --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/helper/TreeBuilder.java @@ -0,0 +1,148 @@ +package com.github.paicoding.forum.service.article.helper; + +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleGroupDTO; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * @author YiHui + * @date 2025/7/30 + */ +public class TreeBuilder { + + public static List buildTree(List list + , Function idKey + , Function pidKey + , Function sortFunc + , Function> childGetFunc + , BiConsumer> updateChildFunc + ) { + if (list == null || list.isEmpty()) { + return new ArrayList<>(); + } + + // 创建Map存储所有节点,key为groupId,value为节点对象 + Map nodeMap = new HashMap<>(); + for (T node : list) { + nodeMap.put(idKey.apply(node), node); + } + + // 存储根节点列表 + List rootNodes = new ArrayList<>(); + + // 构建树结构 + for (T node : list) { + Long parentGroupId = pidKey.apply(node); + + // 如果parentGroupId为null或不存在于nodeMap中,则为根节点 + if (parentGroupId == null || parentGroupId == 0 || !nodeMap.containsKey(parentGroupId)) { + rootNodes.add(node); + } else { + // 找到父节点,并将当前节点添加到父节点的children中 + T parentNode = nodeMap.get(parentGroupId); + if (childGetFunc.apply(parentNode) == null) { + updateChildFunc.accept(parentNode, new ArrayList<>()); + } + + childGetFunc.apply(parentNode).add(node); + } + } + + // 对所有节点的子节点按section升序排序 + sortChildrenBySection(nodeMap.values(), sortFunc, childGetFunc); + + // 对根节点按section升序排序 + rootNodes.sort(Comparator.comparing(sortFunc::apply)); + + return rootNodes; + } + + /** + * 递归对所有节点的子节点按section排序 + * + * @param nodes 节点集合 + */ + private static void sortChildrenBySection(Collection nodes + , Function sortFunc + , Function> childGetFunc) { + for (T node : nodes) { + List child = childGetFunc.apply(node); + if (CollectionUtils.isNotEmpty(child)) { + // 按section升序排序 + child.sort(Comparator.comparing(sortFunc::apply)); + // 递归处理子节点 + sortChildrenBySection(child, sortFunc, childGetFunc); + } + } + } + + /** + * 将列表转换为树结构 + * + * @param list 原始列表数据 + * @return 树结构的根节点列表 + */ + public static List buildTree(List list) { + if (list == null || list.isEmpty()) { + return new ArrayList<>(); + } + + // 创建Map存储所有节点,key为groupId,value为节点对象 + Map nodeMap = new HashMap<>(); + for (ColumnArticleGroupDTO node : list) { + nodeMap.put(node.getGroupId(), node); + } + + // 存储根节点列表 + List rootNodes = new ArrayList<>(); + + // 构建树结构 + for (ColumnArticleGroupDTO node : list) { + Long parentGroupId = node.getParentGroupId(); + + // 如果parentGroupId为null或不存在于nodeMap中,则为根节点 + if (parentGroupId == null || parentGroupId == 0 || !nodeMap.containsKey(parentGroupId)) { + rootNodes.add(node); + } else { + // 找到父节点,并将当前节点添加到父节点的children中 + ColumnArticleGroupDTO parentNode = nodeMap.get(parentGroupId); + if (parentNode.getChildren() == null) { + parentNode.setChildren(new ArrayList<>()); + } + parentNode.getChildren().add(node); + } + } + + // 对所有节点的子节点按section升序排序 + sortChildrenBySection(nodeMap.values()); + + // 对根节点按section升序排序 + rootNodes.sort(Comparator.comparing(ColumnArticleGroupDTO::getSection)); + + return rootNodes; + } + + /** + * 递归对所有节点的子节点按section排序 + * + * @param nodes 节点集合 + */ + private static void sortChildrenBySection(Collection nodes) { + for (ColumnArticleGroupDTO node : nodes) { + if (node.getChildren() != null && !node.getChildren().isEmpty()) { + // 按section升序排序 + node.getChildren().sort(Comparator.comparing(ColumnArticleGroupDTO::getSection)); + // 递归处理子节点 + sortChildrenBySection(node.getChildren()); + } + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleDao.java new file mode 100644 index 000000000..626f93686 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleDao.java @@ -0,0 +1,392 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.OfficalStatEnum; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleAdminDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.YearArticleDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDetailDO; +import com.github.paicoding.forum.service.article.repository.entity.ReadCountDO; +import com.github.paicoding.forum.service.article.repository.mapper.ArticleDetailMapper; +import com.github.paicoding.forum.service.article.repository.mapper.ArticleMapper; +import com.github.paicoding.forum.service.article.repository.mapper.ReadCountMapper; +import com.github.paicoding.forum.service.article.repository.params.SearchArticleParams; +import com.google.common.collect.Maps; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 文章相关DB操作 + *

+ * 多表结构的操作封装,只与DB操作相关 + * + * @author louzai + * @date 2022-07-18 + */ +@Repository +public class ArticleDao extends ServiceImpl { + @Resource + private ArticleDetailMapper articleDetailMapper; + @Resource + private ReadCountMapper readCountMapper; + @Resource + private ArticleMapper articleMapper; + + + /** + * 查询文章详情 + * + * @param articleId + * @return + */ + public ArticleDTO queryArticleDetail(Long articleId) { + // 查询文章记录 + ArticleDO article = baseMapper.selectById(articleId); + if (article == null || Objects.equals(article.getDeleted(), YesOrNoEnum.YES.getCode())) { + return null; + } + + // 查询文章正文 + ArticleDTO dto = ArticleConverter.toDto(article); + if (showReviewContent(article)) { + ArticleDetailDO detail = findLatestDetail(articleId); + dto.setContent(detail.getContent()); + } else { + // 对于审核中的文章,只有作者本人才能看到原文 + dto.setContent("### 文章审核中,请稍后再看"); + } + return dto; + } + + /** + * 判断展示审核中的字样,还是展示原文 + * + * @param article 文章实体 + * @return false 表示需要展示审核中的字样 | true 表示展示原文 + */ + private boolean showReviewContent(ArticleDO article) { + if (article.getStatus() != PushStatusEnum.REVIEW.getCode()) { + return true; + } + + BaseUserInfoDTO user = ReqInfoContext.getReqInfo().getUser(); + if (user == null) { + return false; + } + + // 作者本人和admin超管可以看到审核内容 + return user.getUserId().equals(article.getUserId()) || (user.getRole() != null && user.getRole().equalsIgnoreCase(UserRole.ADMIN.name())); + } + + + // ------------ article content ---------------- + + public ArticleDetailDO findLatestDetail(long articleId) { + // 查询文章内容 + LambdaQueryWrapper contentQuery = Wrappers.lambdaQuery(); + contentQuery.eq(ArticleDetailDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(ArticleDetailDO::getArticleId, articleId) + .orderByDesc(ArticleDetailDO::getVersion); + return articleDetailMapper.selectList(contentQuery).get(0); + } + + /** + * 保存文章正文 + * + * @param articleId + * @param content + * @return + */ + public Long saveArticleContent(Long articleId, String content) { + ArticleDetailDO detail = new ArticleDetailDO(); + detail.setArticleId(articleId); + detail.setContent(content); + detail.setVersion(1L); + articleDetailMapper.insert(detail); + return detail.getId(); + } + + /** + * 更正文章正文 + * + * @param articleId + * @param content + * @param update true 表示更新最后一条记录; false 表示新插入一个新的记录 + */ + public void updateArticleContent(Long articleId, String content, boolean update) { + if (update) { + articleDetailMapper.updateContent(articleId, content); + } else { + ArticleDetailDO latest = findLatestDetail(articleId); + latest.setVersion(latest.getVersion() + 1); + latest.setId(null); + latest.setContent(content); + articleDetailMapper.insert(latest); + } + } + + // ------------- 文章列表查询 -------------- + + public List listArticlesByUserId(Long userId, PageParam pageParam) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(ArticleDO::getUserId, userId) + .last(PageParam.getLimitSql(pageParam)) + .orderByDesc(ArticleDO::getId); + if (!Objects.equals(ReqInfoContext.getReqInfo().getUserId(), userId)) { + // 作者本人,可以查看草稿、审核、上线文章;其他用户,只能查看上线的文章 + query.eq(ArticleDO::getStatus, PushStatusEnum.ONLINE.getCode()); + } + return baseMapper.selectList(query); + } + + + public List listArticlesByCategoryId(Long categoryId, PageParam pageParam) { + if (categoryId != null && categoryId <= 0) { + // 分类不存在时,表示查所有 + categoryId = null; + } + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(ArticleDO::getStatus, PushStatusEnum.ONLINE.getCode()); + + // 如果分页中置顶的四条数据,需要加上官方的查询条件 + // 说明是查询官方的文章,非置顶的文章,只限制全部分类 + if (categoryId == null && pageParam.getPageSize() == PageParam.TOP_PAGE_SIZE) { + query.eq(ArticleDO::getOfficalStat, OfficalStatEnum.OFFICAL.getCode()); + } + + Optional.ofNullable(categoryId).ifPresent(cid -> query.eq(ArticleDO::getCategoryId, cid)); + query.last(PageParam.getLimitSql(pageParam)) + .orderByDesc(ArticleDO::getToppingStat, ArticleDO::getCreateTime); + return baseMapper.selectList(query); + } + + public Long countArticleByCategoryId(Long categoryId) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(ArticleDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .eq(ArticleDO::getCategoryId, categoryId); + return baseMapper.selectCount(query); + } + + /** + * 按照分类统计文章的数量 + * + * @return key: categoryId, value: count + */ + public Map countArticleByCategoryId() { + QueryWrapper query = Wrappers.query(); + query.select("category_id, count(*) as cnt") + .eq("deleted", YesOrNoEnum.NO.getCode()) + .eq("status", PushStatusEnum.ONLINE.getCode()).groupBy("category_id"); + List> mapList = baseMapper.selectMaps(query); + Map result = Maps.newHashMapWithExpectedSize(mapList.size()); + for (Map mp : mapList) { + Long cnt = (Long) mp.get("cnt"); + if (cnt != null && cnt > 0) { + result.put((Long) mp.get("category_id"), cnt); + } + } + return result; + } + + public List listArticlesByBySearchKey(String key, PageParam pageParam) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(ArticleDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .and(!StringUtils.isEmpty(key), + v -> v.like(ArticleDO::getTitle, key) + .or() + .like(ArticleDO::getShortTitle, key) + .or() + .like(ArticleDO::getSummary, key)); + query.last(PageParam.getLimitSql(pageParam)) + .orderByDesc(ArticleDO::getId); + return baseMapper.selectList(query); + } + + /** + * 通过关键词,从标题中找出相似的进行推荐,只返回主键 + 标题 + * + * @param key + * @return + */ + public List listSimpleArticlesByBySearchKey(String key) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(ArticleDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .and(!StringUtils.isEmpty(key), + v -> v.like(ArticleDO::getTitle, key) + .or() + .like(ArticleDO::getShortTitle, key) + ); + query.select(ArticleDO::getId, ArticleDO::getTitle, ArticleDO::getShortTitle) + .last("limit 10") + .orderByDesc(ArticleDO::getId); + return baseMapper.selectList(query); + } + + + /** + * 阅读计数 + * + * @param articleId + * @return + */ + public int incrReadCount(Long articleId) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ReadCountDO::getDocumentId, articleId).eq(ReadCountDO::getDocumentType, DocumentTypeEnum.ARTICLE.getCode()); + ReadCountDO record = readCountMapper.selectOne(query); + if (record == null) { + record = new ReadCountDO().setDocumentId(articleId).setDocumentType(DocumentTypeEnum.ARTICLE.getCode()).setCnt(1); + readCountMapper.insert(record); + } else { + // fixme: 这里存在并发覆盖问题,推荐使用 update read_count set cnt = cnt + 1 where id = xxx + record.setCnt(record.getCnt() + 1); + readCountMapper.updateById(record); + } + return record.getCnt(); + } + + /** + * 统计用户的文章计数 + * + * @param userId + * @return + */ + public int countArticleByUser(Long userId) { + return lambdaQuery().eq(ArticleDO::getUserId, userId) + .eq(ArticleDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .count().intValue(); + } + + + /** + * 热门文章推荐,适用于首页的侧边栏 + * + * @param pageParam + * @return + */ + public List listHotArticles(PageParam pageParam) { + return baseMapper.listArticlesByReadCounts(pageParam); + } + + /** + * 作者的热门文章推荐,适用于作者的详情页侧边栏 + * + * @param userId + * @param pageParam + * @return + */ + public List listAuthorHotArticles(long userId, PageParam pageParam) { + return baseMapper.listArticlesByUserIdOrderByReadCounts(userId, pageParam); + } + + /** + * 根据相同的类目 + 标签进行推荐 + * + * @param categoryId + * @param tagIds + * @return + */ + public List listRelatedArticlesOrderByReadCount(Long categoryId, List tagIds, PageParam pageParam) { + List list = baseMapper.listArticleByCategoryAndTags(categoryId, tagIds, pageParam); + if (CollectionUtils.isEmpty(list)) { + return new ArrayList<>(); + } + + List ids = list.stream().map(ReadCountDO::getDocumentId).collect(Collectors.toList()); + List result = baseMapper.selectBatchIds(ids); + result.sort((o1, o2) -> { + int i1 = ids.indexOf(o1.getId()); + int i2 = ids.indexOf(o2.getId()); + return Integer.compare(i1, i2); + }); + return result; + } + + + /** + * 根据用户ID获取创作历程 + * + * @param userId + * @return + */ + public List listYearArticleByUserId(Long userId) { + return baseMapper.listYearArticleByUserId(userId); + } + + /** + * 抽取样板代码 + */ + private LambdaQueryChainWrapper buildQuery(SearchArticleParams searchArticleParams) { + return lambdaQuery() + .like(StringUtils.isNotBlank(searchArticleParams.getTitle()), ArticleDO::getTitle, searchArticleParams.getTitle()) + // ID 不为空 + .eq(Objects.nonNull(searchArticleParams.getArticleId()), ArticleDO::getId, searchArticleParams.getArticleId()) + .eq(Objects.nonNull(searchArticleParams.getUserId()), ArticleDO::getUserId, searchArticleParams.getUserId()) + .eq(Objects.nonNull(searchArticleParams.getStatus()) && searchArticleParams.getStatus() != -1, ArticleDO::getStatus, searchArticleParams.getStatus()) + .eq(Objects.nonNull(searchArticleParams.getOfficalStat()) && searchArticleParams.getOfficalStat() != -1, ArticleDO::getOfficalStat, searchArticleParams.getOfficalStat()) + .eq(Objects.nonNull(searchArticleParams.getToppingStat()) && searchArticleParams.getToppingStat() != -1, ArticleDO::getToppingStat, searchArticleParams.getToppingStat()) + .eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()); + } + + + /** + * 文章列表(用于后台) + */ + public List listArticlesByParams(SearchArticleParams params) { + return articleMapper.listArticlesByParams(params, + PageParam.newPageInstance(params.getPageNum(), params.getPageSize())); + } + + /** + * 文章总数(用于后台) + */ + public Long countArticleByParams(SearchArticleParams searchArticleParams) { + return articleMapper.countArticlesByParams(searchArticleParams); + } + + /** + * 文章总数(用于后台) + * + * @return + */ + public Long countArticle() { + return lambdaQuery() + .eq(ArticleDO::getDeleted, YesOrNoEnum.NO.getCode()) + .count(); + } + + public List selectByIds(List ids) { + + List articleDOS = baseMapper.selectBatchIds(ids); + return articleDOS; + + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticlePayDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticlePayDao.java new file mode 100644 index 000000000..50a06ac30 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticlePayDao.java @@ -0,0 +1,74 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import com.github.paicoding.forum.service.article.repository.mapper.ArticleMapper; +import com.github.paicoding.forum.service.article.repository.mapper.ArticlePayRecordMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.CollectionUtils; + +import javax.annotation.Resource; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 文章支付记录 + *

+ * + * @author YiHui + * @date 2024-10-29 + */ +@Repository +public class ArticlePayDao extends ServiceImpl { + + @Resource + private ArticleMapper articleMapper; + + /** + * 用户的文章支付记录 + * + * @param articleId 文章id + * @param payUserId 支付用户id + * @return 支付记录 + */ + public ArticlePayRecordDO queryRecordByArticleId(Long articleId, Long payUserId) { + List list = lambdaQuery() + .eq(ArticlePayRecordDO::getArticleId, articleId) + .eq(ArticlePayRecordDO::getPayUserId, payUserId).list(); + if (CollectionUtils.isEmpty(list)) { + return null; + } + return list.get(0); + } + + + /** + * 查询文章成功支付的用户id + * + * @param articleId 文章id + * @return + */ + public List querySucceedPayUsersByArticleId(Long articleId) { + List records = lambdaQuery().select(ArticlePayRecordDO::getPayUserId) + .eq(ArticlePayRecordDO::getArticleId, articleId) + .eq(ArticlePayRecordDO::getPayStatus, PayStatusEnum.SUCCEED.getStatus()) + .list(); + return records.stream().map(ArticlePayRecordDO::getPayUserId).collect(Collectors.toList()); + } + + + /** + * 加写锁 + * + * @param id + * @return + */ + public ArticlePayRecordDO selectForUpdate(Long id) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("id", id); + queryWrapper.last("for update"); + return baseMapper.selectOne(queryWrapper); + } +} \ No newline at end of file diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/dao/ArticleTagDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleTagDao.java similarity index 86% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/dao/ArticleTagDao.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleTagDao.java index 43321d373..f5e280af2 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/dao/ArticleTagDao.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleTagDao.java @@ -1,10 +1,10 @@ -package com.github.liuyueyi.forum.service.article.repository.dao; +package com.github.paicoding.forum.service.article.repository.dao; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.github.liueyueyi.forum.api.model.enums.YesOrNoEnum; -import com.github.liueyueyi.forum.api.model.vo.article.dto.TagDTO; -import com.github.liuyueyi.forum.service.article.repository.entity.ArticleTagDO; -import com.github.liuyueyi.forum.service.article.repository.mapper.ArticleTagMapper; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleTagDO; +import com.github.paicoding.forum.service.article.repository.mapper.ArticleTagMapper; import org.apache.ibatis.annotations.Param; import org.springframework.stereotype.Repository; diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/CategoryDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/CategoryDao.java new file mode 100644 index 000000000..69ee1f54f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/CategoryDao.java @@ -0,0 +1,68 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.service.article.conveter.CategoryStructMapper; +import com.github.paicoding.forum.service.article.repository.entity.CategoryDO; +import com.github.paicoding.forum.service.article.repository.mapper.CategoryMapper; +import com.github.paicoding.forum.service.article.repository.params.SearchCategoryParams; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 类目Service + * + * @author louzai + * @date 2022-07-20 + */ +@Repository +public class CategoryDao extends ServiceImpl { + /** + * @return + */ + public List listAllCategoriesFromDb() { + return lambdaQuery() + .eq(CategoryDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(CategoryDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .list(); + } + + // 抽一个私有方法,构造查询条件 + private LambdaQueryChainWrapper createCategoryQuery(SearchCategoryParams params) { + return lambdaQuery() + .eq(CategoryDO::getDeleted, YesOrNoEnum.NO.getCode()) + .like(StringUtils.isNotBlank(params.getCategory()), CategoryDO::getCategoryName, params.getCategory()); + } + + /** + * 获取所有 Categorys 列表(分页) + * + * @return + */ + public List listCategory(SearchCategoryParams params) { + List list = createCategoryQuery(params) + .orderByDesc(CategoryDO::getUpdateTime) + .orderByAsc(CategoryDO::getRank) + .last(PageParam.getLimitSql( + PageParam.newPageInstance(params.getPageNum(), params.getPageSize()) + )) + .list(); + return CategoryStructMapper.INSTANCE.toDTOs(list); + } + + /** + * 获取所有 Categorys 总数(分页) + * + * @return + */ + public Long countCategory(SearchCategoryParams params) { + return createCategoryQuery(params) + .count(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnArticleDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnArticleDao.java new file mode 100644 index 000000000..c38d1a798 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnArticleDao.java @@ -0,0 +1,101 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.mapper.ColumnArticleMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.CollectionUtils; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/30/23 + */ +@Repository +public class ColumnArticleDao extends ServiceImpl { + @Resource + private ColumnArticleMapper columnArticleMapper; + + /** + * 返回专栏最大更新章节数 + * + * @param columnId + * @return 专栏内无文章时,返回0;否则返回当前最大的章节数 + */ + public int selectMaxSection(Long columnId) { + return columnArticleMapper.selectMaxSection(columnId); + } + + /** + * 根据文章id,查询再所属的专栏信息 + * fixme: 如果一篇文章,在多个专栏内,就会有问题 + * + * @param articleId + * @return + */ + public ColumnArticleDO selectColumnArticleByArticleId(Long articleId) { + List list = lambdaQuery() + .eq(ColumnArticleDO::getArticleId, articleId) + .list(); + if (CollectionUtils.isEmpty(list)) { + return null; + } + return list.get(0); + } + + public ColumnArticleDO selectBySection(Long columnId, Integer sort) { + return lambdaQuery() + .eq(ColumnArticleDO::getColumnId, columnId) + .eq(ColumnArticleDO::getSection, sort) + .one(); + } + + + public ColumnArticleDO selectOneByGroupId(Long groupId) { + return lambdaQuery() + .eq(ColumnArticleDO::getGroupId, groupId) + .one(); + } + + public void updateColumnGroupId(Long groupId, Long columnId) { + lambdaUpdate() + .eq(ColumnArticleDO::getColumnId, columnId) + .set(ColumnArticleDO::getGroupId, groupId) + .update(); + } + + public ColumnArticleDO selectColumnArticle(Long columnId, Long articleId) { + return lambdaQuery() + .eq(ColumnArticleDO::getColumnId, columnId) + .eq(ColumnArticleDO::getArticleId, articleId) + .one(); + } + + public void updateColumnArticleSection(Long id, Integer section) { + lambdaUpdate().eq(ColumnArticleDO::getId, id) + .set(ColumnArticleDO::getSection, section) + .update(); + } + + public void updateColumnArticleSection(Long columnId, Long articleId, Long groupId, Integer section) { + lambdaUpdate() + .eq(ColumnArticleDO::getColumnId, columnId) + .eq(ColumnArticleDO::getArticleId, articleId) + .set(ColumnArticleDO::getSection, section) + .set(ColumnArticleDO::getGroupId, groupId) + .update(); + } + + public void updateColumnArticleGESectionToAdd(Long columnId, Long groupId, Integer section, Integer add) { + lambdaUpdate() + .eq(ColumnArticleDO::getColumnId, columnId) + .eq(ColumnArticleDO::getGroupId, groupId) + .ge(ColumnArticleDO::getSection, section) + .setSql("section = section + " + add) + .update(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnArticleGroupDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnArticleGroupDao.java new file mode 100644 index 000000000..7a9356b27 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnArticleGroupDao.java @@ -0,0 +1,68 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleGroupDO; +import com.github.paicoding.forum.service.article.repository.mapper.ColumnArticleGroupMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 专栏文章分组 + * + * @author yihui + * @date 24/12/17 + */ +@Repository +public class ColumnArticleGroupDao extends ServiceImpl { + + /** + * 不同层级的排序间隔 + */ + public static final int SECTION_STEP = 1000; + + + /** + * 获取专栏对应的分组列表 + * + * @param columnId 专栏 + * @return + */ + public List selectByColumnId(Long columnId) { + return lambdaQuery() + .eq(ColumnArticleGroupDO::getColumnId, columnId) + .eq(ColumnArticleGroupDO::getDeleted, YesOrNoEnum.NO.getCode()) + .orderByAsc(ColumnArticleGroupDO::getSection) + .list(); + } + + /** + * 根据父分组进行查询 + * + * @param parentGroupId + * @return + */ + public ColumnArticleGroupDO selectByParentGroupId(Long parentGroupId) { + return lambdaQuery() + .eq(ColumnArticleGroupDO::getParentGroupId, parentGroupId) + .eq(ColumnArticleGroupDO::getDeleted, YesOrNoEnum.NO.getCode()) + .one(); + } + + /** + * 获取同一父分组下的所有数据 + * + * @param columnId + * @param parentGroupId + * @return + */ + public List selectColumnGroupsBySameParent(Long columnId, Long parentGroupId) { + return lambdaQuery() + .eq(ColumnArticleGroupDO::getColumnId, columnId) + .eq(ColumnArticleGroupDO::getParentGroupId, parentGroupId) + .eq(ColumnArticleGroupDO::getDeleted, YesOrNoEnum.NO.getCode()) + .orderByAsc(ColumnArticleGroupDO::getSection) + .list(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnDao.java new file mode 100644 index 000000000..ba57d1c63 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ColumnDao.java @@ -0,0 +1,145 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.column.ColumnStatusEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnInfoDO; +import com.github.paicoding.forum.service.article.repository.mapper.ColumnArticleMapper; +import com.github.paicoding.forum.service.article.repository.mapper.ColumnInfoMapper; +import com.github.paicoding.forum.service.article.repository.params.SearchColumnArticleParams; +import com.github.paicoding.forum.service.article.repository.params.SearchColumnParams; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/14 + */ +@Repository +public class ColumnDao extends ServiceImpl { + + @Autowired + private ColumnArticleMapper columnArticleMapper; + + /** + * 分页查询专辑列表 + * + * @param pageParam + * @return + */ + public List listOnlineColumns(PageParam pageParam) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.gt(ColumnInfoDO::getState, ColumnStatusEnum.OFFLINE.getCode()) + .last(PageParam.getLimitSql(pageParam)) + .orderByAsc(ColumnInfoDO::getSection); + return baseMapper.selectList(query); + } + + /** + * 统计专栏的文章数 + * + * @return + */ + public int countColumnArticles(Long columnId) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(ColumnArticleDO::getColumnId, columnId); + return columnArticleMapper.selectCount(query).intValue(); + } + + public Long countColumnArticles() { + return columnArticleMapper.selectCount(Wrappers.emptyWrapper()); + } + + /** + * 统计专栏的阅读人数 + * @return + */ + public int countColumnReadPeoples(Long columnId) { + return columnArticleMapper.countColumnReadUserNums(columnId).intValue(); + } + + /** + * 根据教程ID查询文章信息列表 + * @return + */ + public List listColumnArticlesDetail(SearchColumnArticleParams params, + PageParam pageParam) { + return columnArticleMapper.listColumnArticlesByColumnIdArticleName(params.getColumnId(), + params.getArticleTitle(), + pageParam); + } + + public Integer countColumnArticles(SearchColumnArticleParams params) { + return columnArticleMapper.countColumnArticlesByColumnIdArticleName(params.getColumnId(), + params.getArticleTitle()).intValue(); + } + + /** + * 根据教程ID查询文章ID列表 + * + * @param columnId + * @return + */ + public List listColumnArticles(Long columnId) { + return columnArticleMapper.listColumnArticles(columnId); + } + + public ColumnArticleDO getColumnArticleId(long columnId, Integer section) { + return columnArticleMapper.getColumnArticle(columnId, section); + } + + /** + * 删除专栏 + * + * fixme 改为逻辑删除 + * + * @param columnId + */ + public void deleteColumn(Long columnId) { + ColumnInfoDO columnInfoDO = baseMapper.selectById(columnId); + if (columnInfoDO != null) { + // 如果专栏对应的文章不为空,则不允许删除 + // 统计专栏的文章数 + int count = countColumnArticles(columnId); + if (count > 0) { + throw ExceptionUtil.of(StatusEnum.COLUMN_ARTICLE_EXISTS,"请先删除教程"); + } + + // 删除专栏 + baseMapper.deleteById(columnId); + } + } + + /** + * 查询教程 + */ + public List listColumnsByParams(SearchColumnParams params, PageParam pageParam) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + // 加上判空条件 + query.like(StringUtils.isNotBlank(params.getColumn()), ColumnInfoDO::getColumnName, params.getColumn()); + query.last(PageParam.getLimitSql(pageParam)) + .orderByAsc(ColumnInfoDO::getSection) + .orderByDesc(ColumnInfoDO::getUpdateTime); + return baseMapper.selectList(query); + + } + + /** + * 查询教程总数 + */ + public Integer countColumnsByParams(SearchColumnParams params) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + lambdaQuery().like(StringUtils.isNotBlank(params.getColumn()), ColumnInfoDO::getColumnName, params.getColumn()); + return baseMapper.selectCount(query).intValue(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/TagDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/TagDao.java new file mode 100644 index 000000000..ef055889f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/TagDao.java @@ -0,0 +1,118 @@ +package com.github.paicoding.forum.service.article.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.entity.TagDO; +import com.github.paicoding.forum.service.article.repository.mapper.TagMapper; +import com.github.paicoding.forum.service.article.repository.params.SearchTagParams; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Repository +public class TagDao extends ServiceImpl { + + /** + * 获取已上线 Tags 列表(分页) + * + * @return + */ + public List listOnlineTag(String key, PageParam pageParam) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(TagDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .eq(TagDO::getDeleted, YesOrNoEnum.NO.getCode()) + .and(StringUtils.isNotBlank(key), v -> v.like(TagDO::getTagName, key)) + .orderByDesc(TagDO::getId); + if (pageParam != null) { + query.last(PageParam.getLimitSql(pageParam)); + } + List list = baseMapper.selectList(query); + return ArticleConverter.toDtoList(list); + } + + /** + * 获取已上线 Tags 总数(分页) + * + * @return + */ + public Integer countOnlineTag(String key) { + return lambdaQuery() + .eq(TagDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .eq(TagDO::getDeleted, YesOrNoEnum.NO.getCode()) + .and(!StringUtils.isEmpty(key), v -> v.like(TagDO::getTagName, key)) + .count() + .intValue(); + } + + private LambdaQueryChainWrapper createTagQuery(SearchTagParams params) { + return lambdaQuery() + .eq(TagDO::getDeleted, YesOrNoEnum.NO.getCode()) + .apply(StringUtils.isNotBlank(params.getTag()), + "LOWER(tag_name) LIKE {0}", + "%" + params.getTag().toLowerCase() + "%"); + } + + /** + * 获取所有 Tags 列表(分页) + * + * @return + */ + public List listTag(SearchTagParams params) { + List list = createTagQuery(params) + .orderByDesc(TagDO::getUpdateTime) + .last(PageParam.getLimitSql( + PageParam.newPageInstance(params.getPageNum(), params.getPageSize()) + )) + .list(); + return list; + } + + + + /** + * 获取所有 Tags 总数(分页) + * + * @return + */ + public Long countTag(SearchTagParams params) { + return createTagQuery(params) + .count(); + } + + /** + * 查询tagId + * + * @param tag + * @return + */ + public Long selectTagIdByTag(String tag) { + TagDO record = lambdaQuery().select(TagDO::getId) + .eq(TagDO::getDeleted, YesOrNoEnum.NO.getCode()) + .eq(TagDO::getTagName, tag) + .last("limit 1") + .one(); + return record != null ? record.getId() : null; + } + + /** + * 查询tag + * @param tagId + * @return + */ + public TagDTO selectById(Long tagId) { + TagDO tagDO = lambdaQuery().eq(TagDO::getId, tagId).one(); + return ArticleConverter.toDto(tagDO); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleDO.java new file mode 100644 index 000000000..622a66b62 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleDO.java @@ -0,0 +1,114 @@ +package com.github.paicoding.forum.service.article.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.SourceTypeEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 文章表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("article") +public class ArticleDO extends BaseDO { + private static final long serialVersionUID = 1L; + + /** + * 作者 + */ + private Long userId; + + /** + * 文章类型:1-博文,2-问答, 3-专栏文章 + */ + private Integer articleType; + + /** + * 文章标题 + */ + private String title; + + /** + * 短标题 + */ + private String shortTitle; + + /** + * URL友好的文章标识,用于SEO优化 + */ + private String urlSlug; + + /** + * 文章头图 + */ + private String picture; + + /** + * 文章摘要 + */ + private String summary; + + /** + * 类目ID + */ + private Long categoryId; + + /** + * 来源:1-转载,2-原创,3-翻译 + * + * @see SourceTypeEnum + */ + private Integer source; + + /** + * 原文地址 + */ + private String sourceUrl; + + /** + * 状态:0-未发布,1-已发布 + * + * @see PushStatusEnum + */ + private Integer status; + + /** + * 是否官方 + */ + private Integer officalStat; + + /** + * 是否置顶 + */ + private Integer toppingStat; + + /** + * 是否加精 + */ + private Integer creamStat; + + private Integer deleted; + + /** + * 阅读类型 + * @see ArticleReadTypeEnum#getType() + */ + private Integer readType; + + /** + * 支付解锁金额 + */ + private Integer payAmount; + + /** + * 支付方式 + */ + private String payWay; +} diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ArticleDetailDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleDetailDO.java similarity index 82% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ArticleDetailDO.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleDetailDO.java index 78541d82e..83c4c5fff 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ArticleDetailDO.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleDetailDO.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.article.repository.entity; +package com.github.paicoding.forum.service.article.repository.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.github.liueyueyi.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.entity.BaseDO; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticlePayRecordDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticlePayRecordDO.java new file mode 100644 index 000000000..293a37826 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticlePayRecordDO.java @@ -0,0 +1,101 @@ + +package com.github.paicoding.forum.service.article.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 文章支付记录 + * + * @author YiHui + * @date 2024-10-29 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("article_pay_record") +public class ArticlePayRecordDO extends BaseDO { + private static final long serialVersionUID = 1L; + + /** + * 支付用户 + */ + private Long payUserId; + + /** + * 收款用户 + */ + private Long receiveUserId; + + /** + * 文章 + */ + private Long articleId; + + /** + * 支付状态 + */ + private Integer payStatus; + + /** + * 邮件通知用户的时间 + */ + private Date notifyTime; + + /** + * 通知确认次数 + */ + private Integer notifyCnt; + + /** + * 备注信息 + */ + private String notes; + + /** + * - 个人收款码场景: 用于验证合法性的code + * - 微信支付场景: 这里是传递给第三方系统的唯一外部订单号 + */ + private String verifyCode; + + /** + * 支付金额 + * 说明:对人个人收款码场景,无法知道具体的收款金额 + */ + private Integer payAmount; + + /** + * 微信支付回传的关键参数 + * h5支付: 返回的是支付中间页地址 + * jspai支付:返回的是唤起支付的prePayId + * native支付:返回的是用于生成微信支付二维码的字符串 + */ + private String prePayId; + + /** + * prePayId的有效截止时间 + */ + private Date prePayExpireTime; + + /** + * 支付方式 + * + * @see ThirdPayWayEnum#getPay() + */ + private String payWay; + + /** + * 记录的是三方交易单号 + */ + private String thirdTransCode; + + /** + * 回调的支付成功/失败时间 + */ + private Date payCallbackTime; + +} diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ArticleTagDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleTagDO.java similarity index 79% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ArticleTagDO.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleTagDO.java index 28470376f..c949cebc2 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ArticleTagDO.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ArticleTagDO.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.article.repository.entity; +package com.github.paicoding.forum.service.article.repository.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.github.liueyueyi.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.entity.BaseDO; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/CategoryDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/CategoryDO.java new file mode 100644 index 000000000..2b7638b09 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/CategoryDO.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.service.article.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 类目管理表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("category") +public class CategoryDO extends BaseDO { + + private static final long serialVersionUID = 1L; + + /** + * 类目名称 + */ + private String categoryName; + + /** + * 状态:0-未发布,1-已发布 + */ + private Integer status; + + /** + * 排序 + */ + @TableField("`rank`") + private Integer rank; + + private Integer deleted; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnArticleDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnArticleDO.java new file mode 100644 index 000000000..03dc64d9e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnArticleDO.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.service.article.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.column.ColumnArticleReadEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 专栏文章 + * + * @author YiHui + * @date 2022/9/14 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("column_article") +public class ColumnArticleDO extends BaseDO { + private static final long serialVersionUID = -2372103913090667453L; + + /** + * 专栏 + */ + private Long columnId; + + /** + * 专栏文章 + */ + private Long articleId; + + /** + * 专栏文章分组 + */ + private Long groupId; + + /** + * 顺序,越小越靠前 + */ + private Integer section; + + /** + * 专栏类型:免费、登录阅读、收费阅读等 + * + * @see ColumnArticleReadEnum#getRead() + */ + private Integer readType; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnArticleGroupDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnArticleGroupDO.java new file mode 100644 index 000000000..8819ae352 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnArticleGroupDO.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.service.article.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 专栏文章 + * + * @author YiHui + * @date 2022/9/14 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("column_article_group") +public class ColumnArticleGroupDO extends BaseDO { + private static final long serialVersionUID = -2372103913090667453L; + + /** + * 专栏id + */ + private Long columnId; + + /** + * 父分组id,如果为0或者null,表示当前分组为顶层 + */ + private Long parentGroupId; + + /** + * 分组名 + */ + private String title; + + /** + * 顺序,越小越靠前 + */ + private Long section; + + /** + * 是否删除 + */ + private Integer deleted; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnInfoDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnInfoDO.java new file mode 100644 index 000000000..2ae383bf9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ColumnInfoDO.java @@ -0,0 +1,79 @@ +package com.github.paicoding.forum.service.article.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.column.ColumnStatusEnum; +import com.github.paicoding.forum.api.model.enums.column.ColumnTypeEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * @author YiHui + * @date 2022/9/14 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("column_info") +public class ColumnInfoDO extends BaseDO { + + private static final long serialVersionUID = 1920830534262012026L; + /** + * 专栏名 + */ + private String columnName; + + /** + * 作者 + */ + private Long userId; + + /** + * 简介 + */ + private String introduction; + + /** + * 封面 + */ + private String cover; + + /** + * 状态 + * + * @see ColumnStatusEnum#getCode() + */ + private Integer state; + + /** + * 排序 + */ + private Integer section; + + /** + * 上线时间 + */ + private Date publishTime; + + /** + * 专栏预计的文章数 + */ + private Integer nums; + + /** + * 专栏类型:免费、登录阅读、收费阅读等 + * @see ColumnTypeEnum#getType() + */ + private Integer type; + + /** + * 免费开始时间 + */ + private Date freeStartTime; + + /** + * 免费结束时间 + */ + private Date freeEndTime; +} diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ReadCountDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ReadCountDO.java similarity index 82% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ReadCountDO.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ReadCountDO.java index 6b47b1f79..97b1984e5 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/ReadCountDO.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/ReadCountDO.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.article.repository.entity; +package com.github.paicoding.forum.service.article.repository.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.github.liueyueyi.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.entity.BaseDO; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/TagDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/TagDO.java similarity index 78% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/TagDO.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/TagDO.java index 46739a949..384bc62d7 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/entity/TagDO.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/entity/TagDO.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.article.repository.entity; +package com.github.paicoding.forum.service.article.repository.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.github.liueyueyi.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.entity.BaseDO; import lombok.Data; import lombok.EqualsAndHashCode; @@ -27,15 +27,13 @@ public class TagDO extends BaseDO { */ private Integer tagType; - /** - * 类目ID - */ - private Long categoryId; - /** * 状态:0-未发布,1-已发布 */ private Integer status; + /** + * 是否删除 + */ private Integer deleted; } diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/mapper/ArticleDetailMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleDetailMapper.java similarity index 84% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/mapper/ArticleDetailMapper.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleDetailMapper.java index a0447f841..d23ea533e 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/article/repository/mapper/ArticleDetailMapper.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleDetailMapper.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.article.repository.mapper; +package com.github.paicoding.forum.service.article.repository.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.github.liuyueyi.forum.service.article.repository.entity.ArticleDetailDO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDetailDO; import org.apache.ibatis.annotations.Update; /** @@ -22,5 +22,4 @@ public interface ArticleDetailMapper extends BaseMapper { */ @Update("update article_detail set `content` = #{content}, `version` = `version` + 1 where article_id = #{articleId} and `deleted`=0 order by `version` desc limit 1") int updateContent(long articleId, String content); - } diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleMapper.java new file mode 100644 index 000000000..35dbf0cb8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleMapper.java @@ -0,0 +1,73 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleAdminDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.YearArticleDTO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ReadCountDO; +import com.github.paicoding.forum.service.article.repository.params.SearchArticleParams; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 文章mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface ArticleMapper extends BaseMapper { + + /** + * 通过id遍历文章, 用于生成sitemap.xml + * + * @param lastId + * @param size + * @return + */ + List listArticlesOrderById(@Param("lastId") Long lastId, @Param("size") int size); + + /** + * 根据阅读次数获取热门文章 + * + * @param pageParam + * @return + */ + List listArticlesByReadCounts(@Param("pageParam") PageParam pageParam); + + /** + * 查询作者的热门文章 + * + * @param userId + * @param pageParam + * @return + */ + List listArticlesByUserIdOrderByReadCounts(@Param("userId") Long userId, @Param("pageParam") PageParam pageParam); + + /** + * 根据类目 + 标签查询文章 + * + * @param category + * @param tagIds + * @param pageParam + * @return + */ + List listArticleByCategoryAndTags(@Param("categoryId") Long category, + @Param("tags") List tagIds, + @Param("pageParam") PageParam pageParam); + + /** + * 根据用户ID获取创作历程 + * + * @param userId + * @return + */ + List listYearArticleByUserId(@Param("userId") Long userId); + + List listArticlesByParams(@Param("searchParams") SearchArticleParams searchArticleParams, + @Param("pageParam") PageParam pageParam); + + Long countArticlesByParams(@Param("searchParams") SearchArticleParams searchArticleParams); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticlePayRecordMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticlePayRecordMapper.java new file mode 100644 index 000000000..5dfea9453 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticlePayRecordMapper.java @@ -0,0 +1,16 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; + +/** + * 文章详情mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface ArticlePayRecordMapper extends BaseMapper { + + + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleTagMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleTagMapper.java new file mode 100644 index 000000000..78bf82212 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ArticleTagMapper.java @@ -0,0 +1,28 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleTagDO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 文章标签映mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface ArticleTagMapper extends BaseMapper { + + /** + * 查询文章标签 + * + * @param articleId + * @return + */ + List listArticleTagDetails(@Param("articleId") Long articleId); + + + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/CategoryMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/CategoryMapper.java new file mode 100644 index 000000000..81c17d603 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/CategoryMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.article.repository.entity.CategoryDO; + +/** + * 类目管理mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface CategoryMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnArticleGroupMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnArticleGroupMapper.java new file mode 100644 index 000000000..02aa51459 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnArticleGroupMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleGroupDO; + +/** + * 专栏文章分组 + * + * @author YiHui + * @date 2024/12/17 + */ +public interface ColumnArticleGroupMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnArticleMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnArticleMapper.java new file mode 100644 index 000000000..98a46322c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnArticleMapper.java @@ -0,0 +1,65 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/14 + */ +public interface ColumnArticleMapper extends BaseMapper { + /** + * 查询文章列表 + * + * @param columnId + * @return + */ + List listColumnArticles(@Param("columnId") Long columnId); + + /** + * 查询文章 + * + * @param columnId + * @param section + * @return + */ + ColumnArticleDO getColumnArticle(@Param("columnId") Long columnId, @Param("section") Integer section); + + + /** + * 统计专栏的阅读人数 + * + * @param columnId + * @return + */ + Long countColumnReadUserNums(@Param("columnId") Long columnId); + + /** + * 根据教程 ID 文章名称查询文章列表 + * + * @param columnId + * @param articleTitle + * @return + */ + List listColumnArticlesByColumnIdArticleName(@Param("columnId") Long columnId, + @Param("articleTitle") String articleTitle, + @Param("pageParam") PageParam pageParam); + + Long countColumnArticlesByColumnIdArticleName(@Param("columnId") Long columnId, @Param("articleTitle") String articleTitle); + + /** + * 根据教程 ID 查询当前教程中最大的 section + * + * @param columnId + * @return 教程内无文章时,返回0 + */ + @Select("select ifnull(max(section), 0) from column_article where column_id = #{columnId}") + int selectMaxSection(@Param("columnId") Long columnId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnInfoMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnInfoMapper.java new file mode 100644 index 000000000..f94d6fd36 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ColumnInfoMapper.java @@ -0,0 +1,11 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.article.repository.entity.ColumnInfoDO; + +/** + * @author YiHui + * @date 2022/9/14 + */ +public interface ColumnInfoMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ReadCountMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ReadCountMapper.java new file mode 100644 index 000000000..835a1dbfa --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/ReadCountMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.article.repository.entity.ReadCountDO; + +/** + * 标签mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface ReadCountMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/TagMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/TagMapper.java new file mode 100644 index 000000000..4c2254f79 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/mapper/TagMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.article.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.article.repository.entity.TagDO; + +/** + * 标签mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface TagMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/ColumnArticleParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/ColumnArticleParams.java new file mode 100644 index 000000000..a652ebe77 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/ColumnArticleParams.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.service.article.repository.params; + +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/30/23 + */ +@Data +public class ColumnArticleParams { + // 教程 ID + private Long columnId; + // 文章 ID + private Long articleId; + // section + private Integer section; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchArticleParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchArticleParams.java new file mode 100644 index 000000000..c77a806bc --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchArticleParams.java @@ -0,0 +1,53 @@ +package com.github.paicoding.forum.service.article.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 文章查询 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchArticleParams extends PageParam { + + /** + * 文章标题 + */ + private String title; + + /** + * 文章ID + */ + private Long articleId; + + /** + * 作者ID + */ + private Long userId; + + /** + * 作者名称 + */ + private String userName; + + /** + * 文章状态: 0-未发布,1-已发布,2-审核 + */ + private Integer status; + + /** + * 是否官方: 0-非官方,1-官方 + */ + private Integer officalStat; + + /** + * 是否置顶: 0-不置顶,1-置顶 + */ + private Integer toppingStat; + + /** + * URL slug,用于SEO友好URL + */ + private String urlSlug; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchCategoryParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchCategoryParams.java new file mode 100644 index 000000000..c3a17ed09 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchCategoryParams.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.article.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/27/23 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchCategoryParams extends PageParam { + // 类目名称 + private String category; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchColumnArticleParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchColumnArticleParams.java new file mode 100644 index 000000000..88c6cb4db --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchColumnArticleParams.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.service.article.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/** + * 专栏查询 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchColumnArticleParams extends PageParam { + + /** + * 专栏名称 + */ + private String column; + + /** + * 专栏id + */ + private Long columnId; + + /** + * 文章标题 + */ + private String articleTitle; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchColumnParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchColumnParams.java new file mode 100644 index 000000000..236cac470 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchColumnParams.java @@ -0,0 +1,16 @@ +package com.github.paicoding.forum.service.article.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; + +/** + * 专栏查询 + */ +@Data +public class SearchColumnParams extends PageParam { + + /** + * 专栏名称 + */ + private String column; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchTagParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchTagParams.java new file mode 100644 index 000000000..586b7da8f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/params/SearchTagParams.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.article.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/29/23 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchTagParams extends PageParam { + // 标签名称 + private String tag; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticlePayService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticlePayService.java new file mode 100644 index 000000000..b328b1345 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticlePayService.java @@ -0,0 +1,74 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticlePayInfoDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.PayConfirmDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; + +import java.util.List; + +/** + * @author YiHui + * @date 2024/10/29 + */ +public interface ArticlePayService { + + /** + * 用户是否已经支付过了 + * + * @param article + * @param currentUerId + * @return + */ + boolean hasPayed(Long article, Long currentUerId); + + /** + * 唤起支付 + * + * @param articleId 文章 + * @param currentUserId 当前用户 + * @param notes 备注 + */ + ArticlePayInfoDTO toPay(Long articleId, Long currentUserId, String notes); + + /** + * 更新为支付中,由用户告诉后端,表明自己已经支付成功了 + * + * @param payId 支付id + * @param currentUserId 当前登录用户 + * @param notes 备注 + * @return true 表示更新成功 + */ + boolean updatePaying(Long payId, Long currentUserId, String notes); + + /** + * 支付状态更新 + * + * @param payId 支付id + * @param verifyCode 验证码 + * @param payStatus 支付状态 + * @param payTime 支付成功时间 + * @param transactionId 三方交易流水号 + * @return + */ + boolean updatePayStatus(Long payId, String verifyCode, PayStatusEnum payStatus, Long payTime, String transactionId); + + /** + * 构建支付结果回调的基础信息 + * + * @param payId 支付id + * @param record 支付记录 + * @return + */ + PayConfirmDTO buildPayConfirmInfo(Long payId, ArticlePayRecordDO record); + + + /** + * 查询文章的打赏用户 + * + * @param articleId 文章id + * @return + */ + List queryPayUsers(Long articleId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleReadService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleReadService.java new file mode 100644 index 000000000..69ed594f8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleReadService.java @@ -0,0 +1,167 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.enums.HomeSelectEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; + +import java.util.List; +import java.util.Map; + +public interface ArticleReadService { + + /** + * 查询基础的文章信息 + * + * @param articleId + * @return + */ + ArticleDO queryBasicArticle(Long articleId); + + /** + * 提前文章摘要 + * + * @param content + * @return + */ + String generateSummary(String content); + + /** + * 查询文章标签列表 + * + * @param articleId + * @return + */ + PageVo queryTagsByArticleId(Long articleId); + + /** + * 获取文章内容,用于AI + * + * @param articleId + * @return + */ + String queryArticleContentForAI(Long articleId); + + /** + * 查询文章详情,包括正文内容,分类、标签等信息 + * + * @param articleId + * @return + */ + ArticleDTO queryDetailArticleInfo(Long articleId); + + /** + * 查询文章所有的关联信息,正文,分类,标签,阅读计数+1,当前登录用户是否点赞、评论过 + * + * @param articleId 文章id + * @param currentUser 当前查看的用户ID + * @return + */ + ArticleDTO queryFullArticleInfo(Long articleId, Long currentUser); + + /** + * 查询某个分类下的文章,支持翻页 + * + * @param categoryId + * @param page + * @return + */ + PageListVo queryArticlesByCategory(Long categoryId, PageParam page); + + + /** + * 获取 Top 文章 + * + * @param categoryId + * @return + */ + List queryTopArticlesByCategory(Long categoryId); + + + /** + * 获取分类文章数 + * + * @param categoryId + * @return + */ + Long queryArticleCountByCategory(Long categoryId); + + /** + * 根据分类统计文章计数 + * + * @return + */ + Map queryArticleCountsByCategory(); + + /** + * 查询某个标签下的文章,支持翻页 + * + * @param tagId + * @param page + * @return + */ + PageListVo queryArticlesByTag(Long tagId, PageParam page); + + /** + * 根据关键词匹配标题,查询用于推荐的文章列表,只返回 articleId + title + * + * @param key + * @return + */ + List querySimpleArticleBySearchKey(String key); + + /** + * 根据查询条件查询文章列表,支持翻页 + * + * @param key + * @param page + * @return + */ + PageListVo queryArticlesBySearchKey(String key, PageParam page); + + /** + * 查询用户的文章列表 + * + * @param userId + * @param pageParam + * @param select + * @return + */ + PageListVo queryArticlesByUserAndType(Long userId, PageParam pageParam, HomeSelectEnum select); + + /** + * 文章实体补齐统计、作者、分类标签等信息 + * + * @param records + * @param pageSize + * @return + */ + PageListVo buildArticleListVo(List records, long pageSize); + + /** + * 查询热门文章 + * + * @param pageParam + * @return + */ + PageListVo queryHotArticlesForRecommend(PageParam pageParam); + + /** + * 查询作者的文章数 + * + * @param authorId + * @return + */ + int queryArticleCount(long authorId); + + /** + * 返回总的文章计数 + * + * @return + */ + Long getArticleCount(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleRecommendService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleRecommendService.java new file mode 100644 index 000000000..abdca3656 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleRecommendService.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; + +/** + * @author YiHui + * @date 2022/9/26 + */ +public interface ArticleRecommendService { + /** + * 文章关联推荐 + * + * @param article + * @param pageParam + * @return + */ + PageListVo relatedRecommend(Long article, PageParam pageParam); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleSettingService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleSettingService.java new file mode 100644 index 000000000..7296fd209 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleSettingService.java @@ -0,0 +1,46 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.enums.OperateArticleEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; +import com.github.paicoding.forum.api.model.vo.article.SearchArticleReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleAdminDTO; + +/** + * 文章后台接口 + * + * @author louzai + * @date 2022-09-19 + */ +public interface ArticleSettingService { + + /** + * 更新文章 + * + * @param req + */ + void updateArticle(ArticlePostReq req); + + /** + * 获取文章列表 + * + * @param req + * @return + */ + PageVo getArticleList(SearchArticleReq req); + + /** + * 删除文章 + * + * @param articleId + */ + void deleteArticle(Long articleId); + + /** + * 操作文章 + * + * @param articleId + * @param operate + */ + void operateArticle(Long articleId, OperateArticleEnum operate); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleSlugMigrationService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleSlugMigrationService.java new file mode 100644 index 000000000..d673dbaa4 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleSlugMigrationService.java @@ -0,0 +1,151 @@ +package com.github.paicoding.forum.service.article.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.paicoding.forum.core.util.UrlSlugUtil; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 文章URL Slug数据迁移服务 + * 为现有文章生成SEO友好的URL标识 + * + * @author Claude + * @date 2025-11-10 + */ +@Slf4j +@Service +public class ArticleSlugMigrationService { + + @Autowired + private ArticleDao articleDao; + + /** + * 为所有文章迁移生成slug + * 该方法是幂等的,可以重复执行 + * + * @return 处理的文章数量 + */ + public int migrateAllArticleSlugs() { + log.info("========================================"); + log.info("开始迁移文章URL slugs..."); + log.info("========================================"); + + // 查询所有没有slug或slug为空的文章 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.and(wrapper -> wrapper + .isNull(ArticleDO::getUrlSlug) + .or() + .eq(ArticleDO::getUrlSlug, "") + ); + + List articles = articleDao.list(queryWrapper); + + if (articles.isEmpty()) { + log.info("没有需要迁移的文章!"); + return 0; + } + + log.info("发现 {} 篇需要生成slug的文章", articles.size()); + + int successCount = 0; + int failCount = 0; + + for (ArticleDO article : articles) { + try { + // 优先使用shortTitle,其次使用title + String titleForSlug = StringUtils.isNotBlank(article.getShortTitle()) + ? article.getShortTitle() + : article.getTitle(); + + if (StringUtils.isBlank(titleForSlug)) { + log.warn("文章 ID={} 标题为空,跳过", article.getId()); + failCount++; + continue; + } + + String slug = UrlSlugUtil.generateSlug(titleForSlug); + article.setUrlSlug(slug); + + articleDao.updateById(article); + successCount++; + + if (successCount % 100 == 0) { + log.info("进度: 已处理 {}/{} 篇文章", successCount, articles.size()); + } + + // 打印前几篇的示例 + if (successCount <= 5) { + log.info("示例 #{}: 标题=\"{}\" -> slug=\"{}\"", successCount, titleForSlug, slug); + } + + } catch (Exception e) { + log.error("处理文章 ID={} 时发生错误", article.getId(), e); + failCount++; + } + } + + log.info("========================================"); + log.info("URL slug迁移完成!"); + log.info("总计: {} 篇", articles.size()); + log.info("成功: {} 篇", successCount); + log.info("失败: {} 篇", failCount); + log.info("========================================"); + + return successCount; + } + + /** + * 为指定文章ID重新生成slug + * 用于修正特定文章的slug + * + * @param articleId 文章ID + * @return 是否成功 + */ + public boolean regenerateSlug(Long articleId) { + ArticleDO article = articleDao.getById(articleId); + if (article == null) { + log.warn("文章 ID={} 不存在", articleId); + return false; + } + + String titleForSlug = StringUtils.isNotBlank(article.getShortTitle()) + ? article.getShortTitle() + : article.getTitle(); + + if (StringUtils.isBlank(titleForSlug)) { + log.warn("文章 ID={} 标题为空", articleId); + return false; + } + + String oldSlug = article.getUrlSlug(); + String newSlug = UrlSlugUtil.generateSlug(titleForSlug); + + article.setUrlSlug(newSlug); + articleDao.updateById(article); + + log.info("文章 ID={} slug更新: \"{}\" -> \"{}\"", articleId, oldSlug, newSlug); + return true; + } + + /** + * 统计需要迁移的文章数量 + * + * @return 需要迁移的文章数量 + */ + public long countArticlesNeedMigration() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.and(wrapper -> wrapper + .isNull(ArticleDO::getUrlSlug) + .or() + .eq(ArticleDO::getUrlSlug, "") + ); + + return articleDao.count(queryWrapper); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleWriteService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleWriteService.java new file mode 100644 index 000000000..7252cb2a6 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ArticleWriteService.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; + +public interface ArticleWriteService { + + /** + * 保存or更新文章 + * + * @param req 上传的文章体 + * @param author 作者 + * @return 返回文章主键 + */ + Long saveArticle(ArticlePostReq req, Long author); + + /** + * 删除文章 + * + * @param articleId 文章id + * @param loginUserId 执行操作的用户 + */ + void deleteArticle(Long articleId, Long loginUserId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/CategoryService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/CategoryService.java new file mode 100644 index 000000000..8ad9a46a7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/CategoryService.java @@ -0,0 +1,43 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; + +import java.util.List; + +/** + * 标签Service + * + * @author louzai + * @date 2022-07-20 + */ +public interface CategoryService { + /** + * 查询类目名 + * + * @param categoryId + * @return + */ + String queryCategoryName(Long categoryId); + + + /** + * 查询所有的分离 + * + * @return + */ + List loadAllCategories(); + + /** + * 查询类目id + * + * @param category + * @return + */ + Long queryCategoryId(String category); + + + /** + * 刷新缓存 + */ + public void refreshCache(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/CategorySettingService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/CategorySettingService.java new file mode 100644 index 000000000..828ae6ea8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/CategorySettingService.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.CategoryReq; +import com.github.paicoding.forum.api.model.vo.article.SearchCategoryReq; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; + +/** + * 分类后台接口 + * + * @author louzai + * @date 2022-09-17 + */ +public interface CategorySettingService { + + void saveCategory(CategoryReq categoryReq); + + void deleteCategory(Integer categoryId); + + void operateCategory(Integer categoryId, Integer pushStatus); + + /** + * 获取category列表 + * + * @param pageParam + * @return + */ + PageVo getCategoryList(SearchCategoryReq params); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ColumnService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ColumnService.java new file mode 100644 index 000000000..a380509b2 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ColumnService.java @@ -0,0 +1,71 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/14 + */ +public interface ColumnService { + /** + * 根据文章id,构建对应的专栏详情地址 + * + * @param articleId 文章主键 + * @return 专栏详情页 + */ + ColumnArticleDO getColumnArticleRelation(Long articleId); + + /** + * 专栏列表 + * + * @param pageParam + * @return + */ + PageListVo listColumn(PageParam pageParam); + + /** + * 获取专栏中的第N篇文章 + * + * @param columnId + * @param order + * @return + */ + ColumnArticleDO queryColumnArticle(long columnId, Integer order); + + /** + * 只查询基本的专栏信息,不需要统计、作者等信息 + * + * @param columnId + * @return + */ + ColumnDTO queryBasicColumnInfo(Long columnId); + + /** + * 专栏详情 + * + * @param columnId + * @return + */ + ColumnDTO queryColumnInfo(Long columnId); + + /** + * 专栏 + 文章列表详情 + * + * @param columnId + * @return + */ + List queryColumnArticles(long columnId); + + /** + * 返回教程数量 + * + * @return + */ + Long getTutorialCount(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ColumnSettingService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ColumnSettingService.java new file mode 100644 index 000000000..a3639af1b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/ColumnSettingService.java @@ -0,0 +1,110 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleGroupReq; +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.ColumnReq; +import com.github.paicoding.forum.api.model.vo.article.MoveColumnArticleOrGroupReq; +import com.github.paicoding.forum.api.model.vo.article.SearchColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.SearchColumnReq; +import com.github.paicoding.forum.api.model.vo.article.SortColumnArticleByIDReq; +import com.github.paicoding.forum.api.model.vo.article.SortColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleGroupDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleColumnDTO; + +import java.util.List; + +/** + * 专栏后台接口 + * + * @author louzai + * @date 2022-09-19 + */ +public interface ColumnSettingService { + + /** + * 将文章保存到对应的专栏中 + * + * @param articleId + * @param columnId + */ + void saveColumnArticle(Long articleId, Long columnId); + + /** + * 保存专栏 + * + * @param columnReq + */ + void saveColumn(ColumnReq columnReq); + + /** + * 保存专栏文章分组 + * + * @param req + * @return + */ + void saveColumnArticleGroup(ColumnArticleGroupReq req); + + /** + * 保存专栏文章 + * + * @param req + */ + void saveColumnArticle(ColumnArticleReq req); + + /** + * 删除专栏 + * + * @param columnId + */ + void deleteColumn(Long columnId); + + /** + * 删除专栏文章 + * + * @param id + */ + void deleteColumnArticle(Long id); + + /** + * 通过关键词,从标题中找出相似的进行推荐,只返回主键 + 标题 + * + * @param key + * @return + */ + List listSimpleColumnBySearchKey(String key); + + PageVo getColumnList(SearchColumnReq req); + + PageVo getColumnArticleList(SearchColumnArticleReq req); + + void sortColumnArticleApi(SortColumnArticleReq req); + + void sortColumnArticleByIDApi(SortColumnArticleByIDReq req); + + + + /** + * 获取专栏的分组情况 + * + * @param columnId 专栏id + * @return + */ + List getColumnGroups(Long columnId); + + + boolean deleteColumnGroup(Long groupId); + + /** + * 查询专栏下的文章信息 + * + * @param columnId 专栏 + * @return + */ + List getColumnGroupAndArticles(Long columnId); + + + boolean moveColumnArticleOrGroup(MoveColumnArticleOrGroupReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/SlugGeneratorService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/SlugGeneratorService.java new file mode 100644 index 000000000..43deaee52 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/SlugGeneratorService.java @@ -0,0 +1,211 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.service.chatai.service.impl.zhipu.ZhipuIntegration; +import com.github.paicoding.forum.service.chatai.service.impl.chatgpt.ChatGptIntegration; +import com.github.paicoding.forum.service.chatai.service.impl.xunfei.XunFeiIntegration; +import com.github.paicoding.forum.service.chatai.service.impl.deepseek.DeepSeekIntegration; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.regex.Pattern; + +/** + * URL Slug AI生成服务 + * 使用AI从文章标题中提取关键词并翻译为英文 + * + * @author YiHui + * @date 2025/12/03 + */ +@Slf4j +@Service +public class SlugGeneratorService { + + private static final Pattern WHITESPACE = Pattern.compile("[\\s]"); + private static final Pattern DUPLICATE_DASH = Pattern.compile("-+"); + private static final int MAX_SLUG_LENGTH = 50; + + @Autowired(required = false) + private ZhipuIntegration zhipuIntegration; + + @Autowired(required = false) + private ChatGptIntegration chatGptIntegration; + + @Autowired(required = false) + private XunFeiIntegration xunFeiIntegration; + + @Autowired(required = false) + private DeepSeekIntegration deepSeekIntegration; + + /** + * 使用AI生成URL slug + * 直接调用AI接口,不经过用户额度检查 + * + * @param title 文章标题 + * @return URL友好的slug + */ + public String generateSlugWithAI(String title) { + if (StringUtils.isBlank(title)) { + return ""; + } + + String prompt = buildPrompt(title); + ChatItemVo chatItem = new ChatItemVo(); + chatItem.setQuestion(prompt); + + if (zhipuIntegration != null) { + try { + zhipuIntegration.directReturn(0L, chatItem); + if (StringUtils.isNotBlank(chatItem.getAnswer())) { + String slug = cleanAIResponse(chatItem.getAnswer()); + if (StringUtils.isNotBlank(slug) && isValidSlug(slug)) { + log.info("Generated slug with ZhipuAI for title '{}': {}", title, slug); + return slug; + } + } + } catch (Exception e) { + log.warn("ZhipuAI failed, trying next AI service: {}", e.getMessage()); + } + } + + if (chatGptIntegration != null) { + try { + chatItem = new ChatItemVo(); + chatItem.setQuestion(prompt); + chatGptIntegration.directReturn(0L, chatItem); + if (StringUtils.isNotBlank(chatItem.getAnswer())) { + String slug = cleanAIResponse(chatItem.getAnswer()); + if (StringUtils.isNotBlank(slug) && isValidSlug(slug)) { + log.info("Generated slug with ChatGPT for title '{}': {}", title, slug); + return slug; + } + } + } catch (Exception e) { + log.warn("ChatGPT failed, trying next AI service: {}", e.getMessage()); + } + } + + if (deepSeekIntegration != null) { + try { + chatItem = new ChatItemVo(); + chatItem.setQuestion(prompt); + deepSeekIntegration.directReturn(chatItem); + if (StringUtils.isNotBlank(chatItem.getAnswer())) { + String slug = cleanAIResponse(chatItem.getAnswer()); + if (StringUtils.isNotBlank(slug) && isValidSlug(slug)) { + log.info("Generated slug with DeepSeek for title '{}': {}", title, slug); + return slug; + } + } + } catch (Exception e) { + log.warn("DeepSeek failed: {}", e.getMessage()); + } + } + + log.warn("All AI services failed, using fallback slug for title: {}", title); + return generateFallbackSlug(title); + } + + /** + * 构建AI提示词 + */ + private String buildPrompt(String title) { + return "任务:提取文章标题的核心关键词(1-2个),转为简短英文slug\n" + + "要求:\n" + + "- 只保留最核心的技术词汇或动作词\n" + + "- 人名、修饰词全部忽略\n" + + "- 翻译成英文,小写,用-连接\n" + + "- 总长度控制在2-3个单词内\n" + + "- 只返回slug,无任何解释\n\n" + + "特殊词汇映射:\n" + + "- 技术派 => paicoding\n" + + "- 派聪明 => paismart\n\n" + + "示例:\n" + + "沉默王二的Java教程 => java-tutorial\n" + + "我要学习Spring Boot => spring-boot\n" + + "深入理解JVM虚拟机原理 => jvm-principle\n" + + "Redis性能优化技巧分享 => redis-optimization\n" + + "沉默王二很牛逼,我要引流了 => traffic-guide\n" + + "技术派社区使用指南 => paicoding-guide\n" + + "派聪明AI助手介绍 => paismart-intro\n\n" + + "标题:" + title + "\n" + + "Slug:"; + } + + /** + * 清理AI返回的响应 + */ + private String cleanAIResponse(String aiResponse) { + if (StringUtils.isBlank(aiResponse)) { + return ""; + } + + // 移除可能的前后缀说明文字 + String cleaned = aiResponse.trim(); + + // 提取可能的slug(查找符合格式的部分) + String[] lines = cleaned.split("\n"); + for (String line : lines) { + line = line.trim(); + // 跳过空行和明显的说明文字 + if (StringUtils.isBlank(line) || line.contains(":") || line.contains(":") + || line.contains("标题") || line.length() < 3) { + continue; + } + // 清理可能的引号 + line = line.replaceAll("[\"'`]", ""); + // 转小写 + line = line.toLowerCase(); + // 只保留字母、数字和连字符 + line = line.replaceAll("[^a-z0-9-]", ""); + + if (StringUtils.isNotBlank(line) && line.matches("^[a-z0-9-]+$")) { + return limitLength(line); + } + } + + // 如果没找到合适的,对整个响应做清理 + cleaned = cleaned.toLowerCase(); + cleaned = cleaned.replaceAll("[^a-z0-9-\\s]", ""); + cleaned = WHITESPACE.matcher(cleaned).replaceAll("-"); + cleaned = DUPLICATE_DASH.matcher(cleaned).replaceAll("-"); + cleaned = cleaned.replaceAll("^-+|-+$", ""); + + return limitLength(cleaned); + } + + /** + * 限制slug长度 + */ + private String limitLength(String slug) { + if (slug.length() > MAX_SLUG_LENGTH) { + slug = slug.substring(0, MAX_SLUG_LENGTH); + int lastDash = slug.lastIndexOf('-'); + if (lastDash > 0) { + slug = slug.substring(0, lastDash); + } + } + return slug; + } + + /** + * 验证slug格式 + */ + private boolean isValidSlug(String slug) { + if (StringUtils.isBlank(slug)) { + return false; + } + return slug.matches("^[a-z0-9-]+$") && slug.length() <= MAX_SLUG_LENGTH; + } + + /** + * 降级方案:简单的slug生成 + */ + private String generateFallbackSlug(String title) { + return "article-" + System.currentTimeMillis(); + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/TagService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/TagService.java new file mode 100644 index 000000000..452112087 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/TagService.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; + +/** + * 标签Service + * + * @author louzai + * @date 2022-07-20 + */ +public interface TagService { + + PageVo queryTags(String key, PageParam pageParam); + + Long queryTagId(String tag); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/TagSettingService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/TagSettingService.java new file mode 100644 index 000000000..6d8c3f074 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/TagSettingService.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.service.article.service; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.SearchTagReq; +import com.github.paicoding.forum.api.model.vo.article.TagReq; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; + +/** + * 标签后台接口 + * + * @author louzai + * @date 2022-09-17 + */ +public interface TagSettingService { + + void saveTag(TagReq tagReq); + + void deleteTag(Integer tagId); + + void operateTag(Integer tagId, Integer pushStatus); + + /** + * 获取tag列表 + * + * @param pageParam + * @return + */ + PageVo getTagList(SearchTagReq req); + + TagDTO getTagById(Long tagId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticlePayServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticlePayServiceImpl.java new file mode 100644 index 000000000..aa0f83609 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticlePayServiceImpl.java @@ -0,0 +1,321 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticlePayInfoDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.PayConfirmDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.pay.dto.PayInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.core.util.PriceUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.util.id.IdUtil; +import com.github.paicoding.forum.service.article.conveter.PayConverter; +import com.github.paicoding.forum.service.article.repository.dao.ArticlePayDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.notify.help.MsgNotifyHelper; +import com.github.paicoding.forum.service.pay.PayServiceFactory; +import com.github.paicoding.forum.service.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * @author YiHui + * @date 2024/10/29 + */ +@Slf4j +@Service +public class ArticlePayServiceImpl implements ArticlePayService { + @Autowired + private ArticleReadService articleReadService; + @Autowired + private UserService userService; + + @Autowired + private ArticlePayDao articlePayDao; + + @Value("${view.site.host:https://paicoding.com}") + private String host; + + @Autowired + private PayServiceFactory payServiceFactory; + + + @Override + public boolean hasPayed(Long article, Long currentUerId) { + ArticlePayRecordDO dbRecord = articlePayDao.queryRecordByArticleId(article, currentUerId); + if (dbRecord == null) { + return false; + } + + return PayStatusEnum.SUCCEED.getStatus().equals(dbRecord.getPayStatus()); + } + + /** + * 唤起支付 + * + * @param articleId 文章 + * @param currentUserId 当前用户 + */ + @Transactional(rollbackFor = Exception.class) + public ArticlePayInfoDTO toPay(Long articleId, Long currentUserId, String notes) { + ArticlePayRecordDO dbRecord = articlePayDao.queryRecordByArticleId(articleId, currentUserId); + boolean recordChanged = false; + if (dbRecord == null) { + // 不存在时,创建一个 + dbRecord = createPayRecord(articleId, currentUserId, notes); + recordChanged = true; + } + + // 加事务写锁,防止并发修改支付记录,出现的支付状态不一致的问题 + dbRecord = articlePayDao.selectForUpdate(dbRecord.getId()); + ThirdPayWayEnum payWay = ThirdPayWayEnum.ofPay(dbRecord.getPayWay()); + + // 已经支付成功 或者 已经是支付中,则直接返回 + if (Objects.equals(dbRecord.getPayStatus(), PayStatusEnum.SUCCEED.getStatus()) + || Objects.equals(dbRecord.getPayStatus(), PayStatusEnum.PAYING.getStatus())) { + recordChanged = false; + } else if (Objects.equals(dbRecord.getPayStatus(), PayStatusEnum.FAIL.getStatus())) { + // 支付失败,需要重置支付相关信息 + dbRecord.setVerifyCode(IdUtil.genPayCode(payWay, dbRecord.getId())); + dbRecord.setNotifyTime(null); + dbRecord.setPayStatus(PayStatusEnum.NOT_PAY.getStatus()); + recordChanged = true; + } else if (dbRecord.getPrePayExpireTime() == null + || System.currentTimeMillis() >= dbRecord.getPrePayExpireTime().getTime()) { + // 未支付、但是唤起支付的verifyCode已经过期的场景 + dbRecord.setVerifyCode(IdUtil.genPayCode(payWay, dbRecord.getId())); + recordChanged = true; + } else { + // 可以直接使用数据库中缓存的用于唤起支付的信息 + recordChanged = false; + } + + // 收款用户信息 + ArticlePayInfoDTO dto = PayConverter.toPay(dbRecord); + // 存在数据变更时,需要调用支付服务,重新获取支付相关信息 + PayInfoDTO payInfo = payServiceFactory.getPayService(payWay).toPay(dbRecord, recordChanged); + if (recordChanged) { + // 如果数据有变更,执行落库操作 + articlePayDao.updateById(dbRecord); + } + + // 补齐支付信息 + dto.setPrePayId(payInfo.getPrePayId()); + dto.setPrePayExpireTime(payInfo.getPrePayExpireTime()); + dto.setPayQrCodeMap(payInfo.getPayQrCodeMap()); + return dto; + } + + private ArticlePayRecordDO createPayRecord(Long articleId, Long currentUserId, String notes) { + ArticleDO articleDO = articleReadService.queryBasicArticle(articleId); + if (articleDO == null) { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, articleId); + } + + ThirdPayWayEnum payWay = ThirdPayWayEnum.ofPay(articleDO.getPayWay()); + if (payWay == null) { + // 文章不需要付费阅读 + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "文章不需要付费阅读!"); + } + + if (payWay.wxPay() && Objects.equals(SpringUtil.getConfig("view.site.wxPayEnable"), "false")) { + // 微信支付未开启时,只能走个人收款码方式 + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "微信支付未开启,请联系作者换用个人收款码支付方式吧!"); + } + + ArticlePayRecordDO record = new ArticlePayRecordDO(); + record.setArticleId(articleId); + record.setReceiveUserId(articleDO.getUserId()); + record.setPayUserId(currentUserId); + record.setPayStatus(PayStatusEnum.NOT_PAY.getStatus()); + record.setNotifyTime(null); + record.setNotifyCnt(0); + String mark = String.format("支付解锁阅读《%s》-- %s", articleDO.getTitle(), notes == null ? "" : notes); + record.setNotes(mark); + record.setId(IdUtil.genId()); + record.setVerifyCode(IdUtil.genPayCode(payWay, record.getId())); + record.setPayWay(payWay.getPay()); + record.setPayAmount(articleDO.getPayAmount()); + articlePayDao.save(record); + return record; + } + + + /** + * 前端回调,告知后端已经支付成功了 + * - 首先做权限管控,不能修改别人的支付记录 + * - 已经知道是支付成功/支付失败(即回调的结果已经过来了),直接返回不做任何处理 + * - 未支付 -> 将支付状态设置为支付中 ; 有数据变更 -> 执行业务变更 ==> 调用支付服务,执行支付中的逻辑 + * - 发送消息变更事件 + * + * @param payId 支付id + * @param currentUserId 当前登录用户 + * @param notes 备注 + * @return + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updatePaying(Long payId, Long currentUserId, String notes) { + ArticlePayRecordDO record = articlePayDao.selectForUpdate(payId); + if (!record.getPayUserId().equals(currentUserId)) { + // 用户不一致,不支持更新 + throw ExceptionUtil.of(StatusEnum.FORBID_ERROR); + } + + if (Objects.equals(record.getPayStatus(), PayStatusEnum.SUCCEED.getStatus()) || + Objects.equals(record.getPayStatus(), PayStatusEnum.FAIL.getStatus())) { + // 支付状态幂等 + return true; + } + + // 更新为支付中 + ThirdPayWayEnum payWay = ThirdPayWayEnum.ofPay(record.getPayWay()); + if (PayStatusEnum.NOT_PAY.getStatus().equals(record.getPayStatus())) { + record.setPayStatus(PayStatusEnum.PAYING.getStatus()); + record.setUpdateTime(new Date()); + if (StringUtils.isNotBlank(notes)) { + // 更新备注信息 + record.setNotes(notes); + } + } else if (StringUtils.isNotBlank(notes) && Objects.equals(notes, record.getNotes())) { + // 备注信息不同时,更新并发送邮件通知 + record.setUpdateTime(new Date()); + record.setNotes(notes); + } + + // 调用具体的支付服务,执行支付中的逻辑;然后回写变更逻辑到db + boolean dbChanged = payServiceFactory.getPayService(payWay).paying(record); + if (!dbChanged) { + return true; + } + // 保存数据变更 + boolean ans = articlePayDao.updateById(record); + if (!ans) { + return false; + } + + // 发布支付状态变更消息 + this.publishPayStatusChangeNotify(record); + return true; + } + + + /** + * 根据支付状态发布对应的通知消息 + * + * @param record + */ + private void publishPayStatusChangeNotify(ArticlePayRecordDO record) { + // 支付状态变更的消息回调 + if (Objects.equals(record.getPayStatus(), PayStatusEnum.PAYING.getStatus())) { + // 更新支付状态为支付中 + MsgNotifyHelper.publish(NotifyTypeEnum.PAYING, record); + } else if (Objects.equals(record.getPayStatus(), PayStatusEnum.SUCCEED.getStatus()) + || Objects.equals(record.getPayStatus(), PayStatusEnum.FAIL.getStatus())) { + // 支付成功or失败 + MsgNotifyHelper.publish(NotifyTypeEnum.PAY, record); + } + } + + /** + * 更新支付状态 + * + * @param payId + * @param payStatus + * @return + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updatePayStatus(Long payId, String verifyCode, PayStatusEnum payStatus, + Long payTime, String transactionId) { + ArticlePayRecordDO dbRecord = articlePayDao.selectForUpdate(payId); + if (dbRecord == null || !Objects.equals(dbRecord.getVerifyCode(), verifyCode)) { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, "支付记录:" + payId); + } + + if (Objects.equals(payStatus.getStatus(), dbRecord.getPayStatus()) + || PayStatusEnum.SUCCEED.getStatus().equals(dbRecord.getPayStatus())) { + // 幂等 or 已支付成功到了终态,不再进行后续的更新 + return true; + } + + // 更新原来的支付状态为最新的结果 + dbRecord.setPayStatus(payStatus.getStatus()); + dbRecord.setPayCallbackTime(new Date(payTime)); + dbRecord.setUpdateTime(new Date()); + dbRecord.setThirdTransCode(transactionId); + boolean ans = articlePayDao.updateById(dbRecord); + if (ans) { + publishPayStatusChangeNotify(dbRecord); + } + return ans; + } + + /** + * 给作者提供的一个支付确认中间页 + * + * @param payId 支付id + * @param record 支付记录 + * @return + */ + @Override + public PayConfirmDTO buildPayConfirmInfo(Long payId, ArticlePayRecordDO record) { + if (record == null) { + record = articlePayDao.getById(payId); + } + + // 文章 + ArticleDO article = articleReadService.queryBasicArticle(record.getArticleId()); + // 支付用户 + BaseUserInfoDTO pay = userService.queryBasicUserInfo(record.getPayUserId()); + + PayConfirmDTO confirm = new PayConfirmDTO(); + confirm.setTitle(article.getTitle()); + confirm.setArticleUrl(String.format("%s/article/detail/%s", host, article.getId())); + confirm.setNotifyCnt(record.getNotifyCnt()); + confirm.setPayTime(record.getNotifyTime() == null ? "-" : DateUtil.format(DateUtil.DB_FORMAT, record.getNotifyTime().getTime())); + confirm.setPayUser(pay.getUserName()); + confirm.setMark(record.getNotes()); + confirm.setReceiveUserId(record.getReceiveUserId()); + confirm.setPayWay(record.getPayWay()); + confirm.setPayAmount(Objects.equals(record.getPayWay(), ThirdPayWayEnum.EMAIL.getPay()) ? "" : PriceUtil.toYuanPrice(record.getPayAmount())); + confirm.setCallback(host + "/article/api/pay/callback?payId=" + record.getId() + "&verifyCode=" + record.getVerifyCode()); + return confirm; + } + + + /** + * 查询文章的打上用户列表 + * + * @param articleId + * @return + */ + public List queryPayUsers(Long articleId) { + List users = articlePayDao.querySucceedPayUsersByArticleId(articleId); + if (CollectionUtils.isEmpty(users)) { + return Collections.emptyList(); + } + + return userService.batchQuerySimpleUserInfo(users); + } + + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleReadServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleReadServiceImpl.java new file mode 100644 index 000000000..de8fa3c74 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleReadServiceImpl.java @@ -0,0 +1,348 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.enums.CollectionStatEnum; +import com.github.paicoding.forum.api.model.enums.CommentStatEnum; +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.HomeSelectEnum; +import com.github.paicoding.forum.api.model.enums.OperateTypeEnum; +import com.github.paicoding.forum.api.model.enums.PraiseStatEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.util.ArticleUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ArticleTagDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.constant.EsFieldConstant; +import com.github.paicoding.forum.service.constant.EsIndexConstant; +import com.github.paicoding.forum.service.statistics.service.CountService; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.service.UserFootService; +import com.github.paicoding.forum.service.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.RestHighLevelClient; +import org.elasticsearch.index.query.MultiMatchQueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.SearchHits; +import org.elasticsearch.search.builder.SearchSourceBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 文章查询相关服务类 + * + * @author louzai + * @date 2022-07-20 + */ +@Slf4j +@Service +public class ArticleReadServiceImpl implements ArticleReadService { + + @Autowired + private ArticleDao articleDao; + + @Autowired + private ArticleTagDao articleTagDao; + + @Autowired + private CategoryService categoryService; + /** + * 在一个项目中,UserFootService 就是内部服务调用 + * 拆微服务时,这个会作为远程服务访问 + */ + @Autowired + private UserFootService userFootService; + + @Autowired + private CountService countService; + + @Autowired + private UserService userService; + + // 是否开启ES + @Value("${elasticsearch.open:false}") + private Boolean openES; + + @Override + public ArticleDO queryBasicArticle(Long articleId) { + return articleDao.getById(articleId); + } + + @Override + public String generateSummary(String content) { + return ArticleUtil.pickSummary(content); + } + + @Override + public PageVo queryTagsByArticleId(Long articleId) { + List tagDTOS = articleTagDao.queryArticleTagDetails(articleId); + return PageVo.build(tagDTOS, 1, 10, tagDTOS.size()); + } + + /** + * 查询文章的内容,给ai用于分析 + * + * @param articleId + * @return + */ + @Override + public String queryArticleContentForAI(Long articleId) { + return articleDao.findLatestDetail(articleId).getContent(); + } + + @Override + public ArticleDTO queryDetailArticleInfo(Long articleId) { + ArticleDTO article = articleDao.queryArticleDetail(articleId); + if (article == null) { + throw ExceptionUtil.of(StatusEnum.ARTICLE_NOT_EXISTS, articleId); + } + // 更新分类相关信息 + CategoryDTO category = article.getCategory(); + category.setCategory(categoryService.queryCategoryName(category.getCategoryId())); + + // 更新标签信息 + article.setTags(articleTagDao.queryArticleTagDetails(articleId)); + return article; + } + + /** + * 查询文章所有的关联信息,正文,分类,标签,阅读计数,当前登录用户是否点赞、评论过 + * + * @param articleId + * @param readUser + * @return + */ + @Override + public ArticleDTO queryFullArticleInfo(Long articleId, Long readUser) { + ArticleDTO article = queryDetailArticleInfo(articleId); + + // 文章阅读计数+1 + countService.incrArticleReadCount(article.getAuthor(), articleId); + + // 文章的操作标记 + if (readUser != null) { + // 更新用于足迹,并判断是否点赞、评论、收藏 + UserFootDO foot = userFootService.saveOrUpdateUserFoot(DocumentTypeEnum.ARTICLE, articleId, + article.getAuthor(), readUser, OperateTypeEnum.READ); + article.setPraised(Objects.equals(foot.getPraiseStat(), PraiseStatEnum.PRAISE.getCode())); + article.setCommented(Objects.equals(foot.getCommentStat(), CommentStatEnum.COMMENT.getCode())); + article.setCollected(Objects.equals(foot.getCollectionStat(), CollectionStatEnum.COLLECTION.getCode())); + } else { + // 未登录,全部设置为未处理 + article.setPraised(false); + article.setCommented(false); + article.setCollected(false); + } + + // 更新文章统计计数 + article.setCount(countService.queryArticleStatisticInfo(articleId)); + + // 设置文章的点赞列表 + article.setPraisedUsers(userFootService.queryArticlePraisedUsers(articleId)); + return article; + } + + + /** + * 查询文章列表 + * + * @param categoryId + * @param page + * @return + */ + @Override + public PageListVo queryArticlesByCategory(Long categoryId, PageParam page) { + List records = articleDao.listArticlesByCategoryId(categoryId, page); + return buildArticleListVo(records, page.getPageSize()); + } + + /** + * 查询置顶的文章列表 + * + * @param categoryId + * @return + */ + @Override + public List queryTopArticlesByCategory(Long categoryId) { + PageParam page = PageParam.newPageInstance(PageParam.DEFAULT_PAGE_NUM, PageParam.TOP_PAGE_SIZE); + List articleDTOS = articleDao.listArticlesByCategoryId(categoryId, page); + return articleDTOS.stream().map(this::fillArticleRelatedInfo).collect(Collectors.toList()); + } + + @Override + public Long queryArticleCountByCategory(Long categoryId) { + return articleDao.countArticleByCategoryId(categoryId); + } + + @Override + public Map queryArticleCountsByCategory() { + return articleDao.countArticleByCategoryId(); + } + + @Override + public PageListVo queryArticlesByTag(Long tagId, PageParam page) { + List records = articleDao.listRelatedArticlesOrderByReadCount(null, Arrays.asList(tagId), page); + return buildArticleListVo(records, page.getPageSize()); + } + + @Override + public List querySimpleArticleBySearchKey(String key) { + // todo 当key为空时,返回热门推荐 + if (StringUtils.isBlank(key)) { + return Collections.emptyList(); + } + key = key.trim(); + if (!openES) { + List records = articleDao.listSimpleArticlesByBySearchKey(key); + return records.stream().map(s -> new SimpleArticleDTO().setId(s.getId()).setTitle(s.getTitle())) + .collect(Collectors.toList()); + } + // TODO ES整合 + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(key, + EsFieldConstant.ES_FIELD_TITLE, + EsFieldConstant.ES_FIELD_SHORT_TITLE); + searchSourceBuilder.query(multiMatchQueryBuilder); + + SearchRequest searchRequest = new SearchRequest(new String[]{EsIndexConstant.ES_INDEX_ARTICLE}, + searchSourceBuilder); + SearchResponse searchResponse = null; + try { + searchResponse = SpringUtil.getBean(RestHighLevelClient.class).search(searchRequest, RequestOptions.DEFAULT); + } catch (IOException e) { + log.error("failed to query from es: key", e); + } + SearchHits hits = searchResponse.getHits(); + SearchHit[] hitsList = hits.getHits(); + List ids = new ArrayList<>(); + for (SearchHit documentFields : hitsList) { + ids.add(Integer.parseInt(documentFields.getId())); + } + if (ObjectUtils.isEmpty(ids)) { + return null; + } + List records = articleDao.selectByIds(ids); + return records.stream().map(s -> new SimpleArticleDTO().setId(s.getId()).setTitle(s.getTitle())) + .collect(Collectors.toList()); + } + + @Override + public PageListVo queryArticlesBySearchKey(String key, PageParam page) { + List records = articleDao.listArticlesByBySearchKey(key, page); + return buildArticleListVo(records, page.getPageSize()); + } + + + @Override + public PageListVo queryArticlesByUserAndType(Long userId, PageParam pageParam, HomeSelectEnum select) { + List records = null; + if (select == HomeSelectEnum.ARTICLE) { + // 用户的文章列表 + records = articleDao.listArticlesByUserId(userId, pageParam); + } else if (select == HomeSelectEnum.READ) { + // 用户的阅读记录 + List articleIds = userFootService.queryUserReadArticleList(userId, pageParam); + records = CollectionUtils.isEmpty(articleIds) ? Collections.emptyList() : articleDao.listByIds(articleIds); + records = sortByIds(articleIds, records); + } else if (select == HomeSelectEnum.COLLECTION) { + // 用户的收藏列表 + List articleIds = userFootService.queryUserCollectionArticleList(userId, pageParam); + records = CollectionUtils.isEmpty(articleIds) ? Collections.emptyList() : articleDao.listByIds(articleIds); + records = sortByIds(articleIds, records); + } + + if (CollectionUtils.isEmpty(records)) { + return PageListVo.emptyVo(); + } + return buildArticleListVo(records, pageParam.getPageSize()); + } + + /** + * fixme @楼仔 这个排序逻辑看着像是有问题的样子 + * + * @param articleIds + * @param records + * @return + */ + private List sortByIds(List articleIds, List records) { + List articleDOS = new ArrayList<>(); + Map articleDOMap = records.stream().collect(Collectors.toMap(ArticleDO::getId, t -> t)); + articleIds.forEach(articleId -> { + if (articleDOMap.containsKey(articleId)) { + articleDOS.add(articleDOMap.get(articleId)); + } + }); + return articleDOS; + } + + @Override + public PageListVo buildArticleListVo(List records, long pageSize) { + List result = records.stream().map(this::fillArticleRelatedInfo).collect(Collectors.toList()); + return PageListVo.newVo(result, pageSize); + } + + /** + * 补全文章的阅读计数、作者、分类、标签等信息 + * + * @param record + * @return + */ + private ArticleDTO fillArticleRelatedInfo(ArticleDO record) { + ArticleDTO dto = ArticleConverter.toDto(record); + // 分类信息 + dto.getCategory().setCategory(categoryService.queryCategoryName(record.getCategoryId())); + // 标签列表 + dto.setTags(articleTagDao.queryArticleTagDetails(record.getId())); + // 阅读计数统计 + dto.setCount(countService.queryArticleStatisticInfo(record.getId())); + // 作者信息 + BaseUserInfoDTO author = userService.queryBasicUserInfo(dto.getAuthor()); + dto.setAuthorName(author.getUserName()); + dto.setAuthorAvatar(author.getPhoto()); + return dto; + } + + @Override + public PageListVo queryHotArticlesForRecommend(PageParam pageParam) { + List list = articleDao.listHotArticles(pageParam); + return PageListVo.newVo(list, pageParam.getPageSize()); + } + + @Override + public int queryArticleCount(long authorId) { + return articleDao.countArticleByUser(authorId); + } + + @Override + public Long getArticleCount() { + return articleDao.countArticle(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleRecommendServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleRecommendServiceImpl.java new file mode 100644 index 000000000..11f823663 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleRecommendServiceImpl.java @@ -0,0 +1,61 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ArticleTagDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ArticleTagDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.ArticleRecommendService; +import com.github.paicoding.forum.service.sidebar.service.SidebarService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2022/9/26 + */ +@Service +public class ArticleRecommendServiceImpl implements ArticleRecommendService { + @Autowired + private ArticleDao articleDao; + @Autowired + private ArticleTagDao articleTagDao; + @Autowired + private ArticleReadService articleReadService; + @Autowired + private SidebarService sidebarService; + + /** + * 查询文章关联推荐列表 + * + * @param articleId + * @param page + * @return + */ + @Override + public PageListVo relatedRecommend(Long articleId, PageParam page) { + ArticleDO article = articleDao.getById(articleId); + if (article == null) { + return PageListVo.emptyVo(); + } + List tagIds = articleTagDao.listArticleTags(articleId).stream() + .map(ArticleTagDO::getTagId).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(tagIds)) { + return PageListVo.emptyVo(); + } + + List recommendArticles = articleDao.listRelatedArticlesOrderByReadCount(article.getCategoryId(), tagIds, page); + if (recommendArticles.removeIf(s -> s.getId().equals(articleId))) { + // 移除推荐列表中的当前文章 + page.setPageSize(page.getPageSize() - 1); + } + return articleReadService.buildArticleListVo(recommendArticles, page.getPageSize()); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleSettingServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleSettingServiceImpl.java new file mode 100644 index 000000000..0c3cb574f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleSettingServiceImpl.java @@ -0,0 +1,178 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.github.paicoding.forum.api.model.enums.ArticleEventEnum; +import com.github.paicoding.forum.api.model.enums.OperateArticleEnum; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.event.ArticleMsgEvent; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; +import com.github.paicoding.forum.api.model.vo.article.SearchArticleReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleAdminDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.conveter.ArticleStructMapper; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnArticleDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.params.SearchArticleParams; +import com.github.paicoding.forum.service.article.service.ArticleSettingService; +import com.github.paicoding.forum.service.article.service.SlugGeneratorService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * 文章后台 + * + * @author louzai + * @date 2022-09-19 + */ +@Service +public class ArticleSettingServiceImpl implements ArticleSettingService { + + @Autowired + private ArticleDao articleDao; + + @Autowired + private ColumnArticleDao columnArticleDao; + + @Autowired + private SlugGeneratorService slugGeneratorService; + + @Override + @CacheEvict(key = "'sideBar_' + #req.articleId", cacheManager = "caffeineCacheManager", cacheNames = "article") + public void updateArticle(ArticlePostReq req) { + if (req.getStatus() != PushStatusEnum.OFFLINE.getCode() + && req.getStatus() != PushStatusEnum.ONLINE.getCode() + && req.getStatus() != PushStatusEnum.REVIEW.getCode()) { + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "发布状态不合法!"); + } + ArticleDO article = articleDao.getById(req.getArticleId()); + if (article == null) { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, "文章不存在!"); + } + + if (StringUtils.isNotBlank(req.getTitle())) { + article.setTitle(req.getTitle()); + } + if (StringUtils.isNotBlank(req.getShortTitle())) { + article.setShortTitle(req.getShortTitle()); + } + if (StringUtils.isNotBlank(req.getUrlSlug())) { + article.setUrlSlug(req.getUrlSlug()); + } else { + String titleForSlug = StringUtils.isNotBlank(req.getShortTitle()) ? req.getShortTitle() : req.getTitle(); + try { + article.setUrlSlug(slugGeneratorService.generateSlugWithAI(titleForSlug)); + } catch (Exception e) { + article.setUrlSlug("article-" + System.currentTimeMillis()); + } + } + + ArticleEventEnum operateEvent = null; + if (req.getStatus() != null) { + article.setStatus(req.getStatus()); + if (req.getStatus() == PushStatusEnum.OFFLINE.getCode()) { + operateEvent = ArticleEventEnum.OFFLINE; + } else if (req.getStatus() == PushStatusEnum.REVIEW.getCode()) { + operateEvent = ArticleEventEnum.REVIEW; + } else if (req.getStatus() == PushStatusEnum.ONLINE.getCode()) { + operateEvent = ArticleEventEnum.ONLINE; + } + } + articleDao.updateById(article); + + if (operateEvent != null) { + // 发布文章待审核、上线、下线事件 + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, operateEvent, article)); + } + } + + @Override + public PageVo getArticleList(SearchArticleReq req) { + // 转换参数,从前端获取的参数转换为数据库查询参数 + SearchArticleParams searchArticleParams = ArticleStructMapper.INSTANCE.toSearchParams(req); + + // 查询文章列表,分页 + List articleDTOS = articleDao.listArticlesByParams(searchArticleParams); + + // 查询文章总数 + Long totalCount = articleDao.countArticleByParams(searchArticleParams); + return PageVo.build(articleDTOS, req.getPageSize(), req.getPageNumber(), totalCount); + } + + @Override + public void deleteArticle(Long articleId) { + ArticleDO dto = articleDao.getById(articleId); + if (dto != null && dto.getDeleted() != YesOrNoEnum.YES.getCode()) { + // 查询该文章是否关联了教程,如果已经关联了教程,则不能删除 + long count = columnArticleDao.count( + Wrappers.lambdaQuery().eq(ColumnArticleDO::getArticleId, articleId)); + + if (count > 0) { + throw ExceptionUtil.of(StatusEnum.ARTICLE_RELATION_TUTORIAL, articleId, "请先解除文章与教程的关联关系"); + } + + dto.setDeleted(YesOrNoEnum.YES.getCode()); + articleDao.updateById(dto); + + // 发布文章删除事件 + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.DELETE, dto)); + } else { + throw ExceptionUtil.of(StatusEnum.ARTICLE_NOT_EXISTS, articleId); + } + } + + @Override + public void operateArticle(Long articleId, OperateArticleEnum operate) { + ArticleDO articleDO = articleDao.getById(articleId); + if (articleDO == null) { + throw ExceptionUtil.of(StatusEnum.ARTICLE_NOT_EXISTS, articleId); + } + setArticleStat(articleDO, operate); + articleDao.updateById(articleDO); + } + + private void setArticleStat(ArticleDO articleDO, OperateArticleEnum operate) { + switch (operate) { + case OFFICAL: + case CANCEL_OFFICAL: + compareAndUpdate(articleDO::getOfficalStat, articleDO::setOfficalStat, operate.getDbStatCode()); + return; + case TOPPING: + case CANCEL_TOPPING: + compareAndUpdate(articleDO::getToppingStat, articleDO::setToppingStat, operate.getDbStatCode()); + return; + case CREAM: + case CANCEL_CREAM: + compareAndUpdate(articleDO::getCreamStat, articleDO::setCreamStat, operate.getDbStatCode()); + return; + default: + } + } + + /** + * 相同则直接返回false不用更新;不同则更新,返回true + * + * @param + * @param supplier + * @param consumer + * @param input + */ + private void compareAndUpdate(Supplier supplier, Consumer consumer, T input) { + if (Objects.equals(supplier.get(), input)) { + return; + } + consumer.accept(input); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleWriteServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleWriteServiceImpl.java new file mode 100644 index 000000000..ad4545885 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ArticleWriteServiceImpl.java @@ -0,0 +1,217 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.*; +import com.github.paicoding.forum.api.model.event.ArticleMsgEvent; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.util.id.IdUtil; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ArticleTagDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.service.ArticleWriteService; +import com.github.paicoding.forum.service.article.service.ColumnSettingService; +import com.github.paicoding.forum.service.image.service.ImageService; +import com.github.paicoding.forum.service.user.service.AuthorWhiteListService; +import com.github.paicoding.forum.service.user.service.UserFootService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.util.Date; +import java.util.Objects; +import java.util.Set; + +/** + * 文章操作相关服务类 + * + * @author louzai + * @date 2022-07-20 + */ +@Slf4j +@Service +public class ArticleWriteServiceImpl implements ArticleWriteService { + + private final ArticleDao articleDao; + + private final ArticleTagDao articleTagDao; + + @Autowired + private ColumnSettingService columnSettingService; + + @Autowired + private UserFootService userFootService; + + @Autowired + private ImageService imageService; + + @Resource + private TransactionTemplate transactionTemplate; + + @Autowired + private AuthorWhiteListService articleWhiteListService; + + // 构造方法的注入方式 + public ArticleWriteServiceImpl(ArticleDao articleDao, ArticleTagDao articleTagDao) { + this.articleDao = articleDao; + this.articleTagDao = articleTagDao; + } + + /** + * 保存文章,当articleId存在时,表示更新记录; 不存在时,表示插入 + * + * @param req + * @return + */ + @Override + public Long saveArticle(ArticlePostReq req, Long author) { + ArticleDO article = ArticleConverter.toArticleDo(req, author); + String content = imageService.mdImgReplace(req.getContent()); + return transactionTemplate.execute(new TransactionCallback() { + @Override + public Long doInTransaction(TransactionStatus status) { + Long articleId; + if (NumUtil.nullOrZero(req.getArticleId())) { + articleId = insertArticle(article, content, req.getTagIds()); + log.info("文章发布成功! title={}", req.getTitle()); + } else { + articleId = updateArticle(article, content, req.getTagIds()); + log.info("文章更新成功! title={}", article.getTitle()); + } + if (req.getColumnId() != null) { + // 更新文章对应的专栏信息 + columnSettingService.saveColumnArticle(articleId, req.getColumnId()); + } + return articleId; + } + }); + } + + /** + * 新建文章 + * + * @param article + * @param content + * @param tags + * @return + */ + private Long insertArticle(ArticleDO article, String content, Set tags) { + // article + article_detail + tag 三张表的数据变更 + if (needToReview(article)) { + // 非白名单中的作者发布文章需要进行审核 + article.setStatus(PushStatusEnum.REVIEW.getCode()); + } + + // 1. 保存文章 + // 使用分布式id生成文章主键 + Long articleId = IdUtil.genId(); + article.setId(articleId); + articleDao.saveOrUpdate(article); + + // 2. 保存文章内容 + articleDao.saveArticleContent(articleId, content); + + // 3. 保存文章标签 + articleTagDao.batchSave(articleId, tags); + + // 发布文章,阅读计数+1 + userFootService.saveOrUpdateUserFoot(DocumentTypeEnum.ARTICLE, articleId, article.getUserId(), article.getUserId(), OperateTypeEnum.READ); + + // todo 事件发布这里可以进行优化,一次发送多个事件? 或者借助bit知识点来表示多种事件状态 + // 发布文章创建事件 + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.CREATE, article)); + // 文章直接上线时,发布上线事件 + if (Objects.equals(article.getStatus(), PushStatusEnum.ONLINE.getCode())) { + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.ONLINE, article)); + } else if (Objects.equals(article.getStatus(), PushStatusEnum.REVIEW.getCode())) { + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.REVIEW, article)); + } + return articleId; + } + + /** + * 更新文章 + * + * @param article + * @param content + * @param tags + * @return + */ + private Long updateArticle(ArticleDO article, String content, Set tags) { + // fixme 待补充文章的历史版本支持:若文章处于审核状态,则直接更新上一条记录;否则新插入一条记录 + boolean review = article.getStatus().equals(PushStatusEnum.REVIEW.getCode()); + if (needToReview(article)) { + article.setStatus(PushStatusEnum.REVIEW.getCode()); + } + // 更新文章 + article.setUpdateTime(new Date()); + articleDao.updateById(article); + + // 更新内容 + articleDao.updateArticleContent(article.getId(), content, review); + + // 标签更新 + if (tags != null && !tags.isEmpty()) { + articleTagDao.updateTags(article.getId(), tags); + } + + // 发布文章待审核事件 + if (article.getStatus() == PushStatusEnum.ONLINE.getCode()) { + // 修改之后依然直接上线 (对于白名单作者而言) + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.ONLINE, article)); + } else if (review) { + // 非白名单作者,修改再审核中的文章,依然是待审核状态 + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.REVIEW, article)); + } + return article.getId(); + } + + + /** + * 删除文章 + * + * @param articleId + */ + @Override + public void deleteArticle(Long articleId, Long loginUserId) { + ArticleDO dto = articleDao.getById(articleId); + if (dto != null && !Objects.equals(dto.getUserId(), loginUserId)) { + // 没有权限 + throw ExceptionUtil.of(StatusEnum.FORBID_ERROR_MIXED, "请确认文章是否属于您!"); + } + + if (dto != null && dto.getDeleted() != YesOrNoEnum.YES.getCode()) { + dto.setDeleted(YesOrNoEnum.YES.getCode()); + articleDao.updateById(dto); + + // 发布文章删除事件 + SpringUtil.publishEvent(new ArticleMsgEvent<>(this, ArticleEventEnum.DELETE, dto)); + } + } + + + /** + * 非白名单的用户,发布的文章需要先进行审核 + * + * @param article + * @return + */ + private boolean needToReview(ArticleDO article) { + // 把 admin 用户加入白名单 + BaseUserInfoDTO user = ReqInfoContext.getReqInfo().getUser(); + if (user.getRole() != null && user.getRole().equalsIgnoreCase(UserRole.ADMIN.name())) { + return false; + } + return article.getStatus() == PushStatusEnum.ONLINE.getCode() && !articleWhiteListService.authorInArticleWhiteList(article.getUserId()); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/CategoryServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/CategoryServiceImpl.java new file mode 100644 index 000000000..67b4db389 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/CategoryServiceImpl.java @@ -0,0 +1,98 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.dao.CategoryDao; +import com.github.paicoding.forum.service.article.repository.entity.CategoryDO; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 类目Service + * + * @author louzai + * @date 2022-07-20 + */ +@Service +public class CategoryServiceImpl implements CategoryService { + /** + * 分类数一般不会特别多,如编程领域可以预期的分类将不会超过30,所以可以做一个全量的内存缓存 + * todo 后续可改为Guava -> Redis + */ + private LoadingCache categoryCaches; + + private CategoryDao categoryDao; + + public CategoryServiceImpl(CategoryDao categoryDao) { + this.categoryDao = categoryDao; + } + + @PostConstruct + public void init() { + categoryCaches = CacheBuilder.newBuilder().maximumSize(300).build(new CacheLoader() { + @Override + public CategoryDTO load(@NotNull Long categoryId) throws Exception { + CategoryDO category = categoryDao.getById(categoryId); + if (category == null || category.getDeleted() == YesOrNoEnum.YES.getCode()) { + return CategoryDTO.EMPTY; + } + return new CategoryDTO(categoryId, category.getCategoryName(), category.getRank()); + } + }); + } + + /** + * 查询类目名 + * + * @param categoryId + * @return + */ + @Override + public String queryCategoryName(Long categoryId) { + return categoryCaches.getUnchecked(categoryId).getCategory(); + } + + /** + * 查询所有的分类 + * + * @return + */ + @Override + public List loadAllCategories() { + if (categoryCaches.size() <= 5) { + refreshCache(); + } + List list = new ArrayList<>(categoryCaches.asMap().values()); + list.removeIf(s -> s.getCategoryId() <= 0); + list.sort(Comparator.comparingInt(CategoryDTO::getRank)); + return list; + } + + /** + * 刷新缓存 + */ + @Override + public void refreshCache() { + List list = categoryDao.listAllCategoriesFromDb(); + categoryCaches.invalidateAll(); + categoryCaches.cleanUp(); + list.forEach(s -> categoryCaches.put(s.getId(), ArticleConverter.toDto(s))); + } + + @Override + public Long queryCategoryId(String category) { + return categoryCaches.asMap().values().stream() + .filter(s -> s.getCategory().equalsIgnoreCase(category)) + .findFirst().map(CategoryDTO::getCategoryId).orElse(null); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/CategorySettingServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/CategorySettingServiceImpl.java new file mode 100644 index 000000000..8ec4bc0e6 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/CategorySettingServiceImpl.java @@ -0,0 +1,75 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.CategoryReq; +import com.github.paicoding.forum.api.model.vo.article.SearchCategoryReq; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.conveter.CategoryStructMapper; +import com.github.paicoding.forum.service.article.repository.dao.CategoryDao; +import com.github.paicoding.forum.service.article.repository.entity.CategoryDO; +import com.github.paicoding.forum.service.article.repository.params.SearchCategoryParams; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.article.service.CategorySettingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 分类后台接口 + * + * @author louzai + * @date 2022-09-17 + */ +@Service +public class CategorySettingServiceImpl implements CategorySettingService { + + @Autowired + private CategoryDao categoryDao; + + @Autowired + private CategoryService categoryService; + + @Override + public void saveCategory(CategoryReq categoryReq) { + CategoryDO categoryDO = CategoryStructMapper.INSTANCE.toDO(categoryReq); + if (NumUtil.nullOrZero(categoryReq.getCategoryId())) { + categoryDao.save(categoryDO); + } else { + categoryDO.setId(categoryReq.getCategoryId()); + categoryDao.updateById(categoryDO); + } + categoryService.refreshCache(); + } + + @Override + public void deleteCategory(Integer categoryId) { + CategoryDO categoryDO = categoryDao.getById(categoryId); + if (categoryDO != null){ + categoryDao.removeById(categoryDO); + } + categoryService.refreshCache(); + } + + @Override + public void operateCategory(Integer categoryId, Integer pushStatus) { + CategoryDO categoryDO = categoryDao.getById(categoryId); + if (categoryDO != null){ + categoryDO.setStatus(pushStatus); + categoryDao.updateById(categoryDO); + } + categoryService.refreshCache(); + } + + @Override + public PageVo getCategoryList(SearchCategoryReq req) { + // 转换 + SearchCategoryParams params = CategoryStructMapper.INSTANCE.toSearchParams(req); + // 查询 + List categoryDTOS = categoryDao.listCategory(params); + Long totalCount = categoryDao.countCategory(params); + return PageVo.build(categoryDTOS, params.getPageSize(), params.getPageNum(), totalCount); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ColumnServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ColumnServiceImpl.java new file mode 100644 index 000000000..08fb30e36 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ColumnServiceImpl.java @@ -0,0 +1,151 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.ColumnFootCountDTO; +import com.github.paicoding.forum.service.article.conveter.ColumnConvert; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnDao; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnInfoDO; +import com.github.paicoding.forum.service.article.service.ColumnService; +import com.github.paicoding.forum.service.user.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2022/9/14 + */ +@Service +public class ColumnServiceImpl implements ColumnService { + @Autowired + private ColumnDao columnDao; + @Autowired + private ArticleDao articleDao; + + @Autowired + private ColumnArticleDao columnArticleDao; + + @Autowired + private UserService userService; + + @Override + public ColumnArticleDO getColumnArticleRelation(Long articleId) { + return columnArticleDao.selectColumnArticleByArticleId(articleId); + } + + /** + * 专栏列表 + * + * @return + */ + @Override + public PageListVo listColumn(PageParam pageParam) { + List columnList = columnDao.listOnlineColumns(pageParam); + List result = columnList.stream().map(this::buildColumnInfo).collect(Collectors.toList()); + return PageListVo.newVo(result, pageParam.getPageSize()); + } + + @Override + public ColumnDTO queryBasicColumnInfo(Long columnId) { + // 查找专栏信息 + ColumnInfoDO column = columnDao.getById(columnId); + if (column == null) { + throw ExceptionUtil.of(StatusEnum.COLUMN_NOT_EXISTS, columnId); + } + return ColumnConvert.toDto(column); + } + + @Override + public ColumnDTO queryColumnInfo(Long columnId) { + return buildColumnInfo(queryBasicColumnInfo(columnId)); + } + + private ColumnDTO buildColumnInfo(ColumnInfoDO info) { + return buildColumnInfo(ColumnConvert.toDto(info)); + } + + /** + * 构建专栏详情信息 + * + * @param dto + * @return + */ + private ColumnDTO buildColumnInfo(ColumnDTO dto) { + // 补齐专栏对应的用户信息 + BaseUserInfoDTO user = userService.queryBasicUserInfo(dto.getAuthor()); + dto.setAuthorName(user.getUserName()); + dto.setAuthorAvatar(user.getPhoto()); + dto.setAuthorProfile(user.getProfile()); + + // 统计计数 + ColumnFootCountDTO countDTO = new ColumnFootCountDTO(); + // 更新文章数 + countDTO.setArticleCount(columnDao.countColumnArticles(dto.getColumnId())); + // 专栏阅读人数 + countDTO.setReadCount(columnDao.countColumnReadPeoples(dto.getColumnId())); + // 总的章节数 + countDTO.setTotalNums(dto.getNums()); + dto.setCount(countDTO); + return dto; + } + + + @Override + public ColumnArticleDO queryColumnArticle(long columnId, Integer section) { + ColumnArticleDO article = columnDao.getColumnArticleId(columnId, section); + if (article == null) { + throw ExceptionUtil.of(StatusEnum.ARTICLE_NOT_EXISTS, section); + } + return article; + } + + @Override + public List queryColumnArticles(long columnId) { + List list = columnDao.listColumnArticles(columnId); + long preGroup = -1; + for (SimpleArticleDTO article : list) { + if (preGroup != article.getGroupLevel()) { + preGroup = article.getGroupLevel(); + article.setGroupLevel(groupSectionToLevel(article.getGroupLevel())); + } else { + // 和前面一个是同一层级,则不需要显示分组,直接沿用之前的即可 + article.setGroupLevel(groupSectionToLevel(article.getGroupLevel())); + } + } + return list; + } + + private int groupSectionToLevel(long section) { + // 0 - 1000 是一层, 1000 1000_000 是二层 + if (section < 1000) { + return 1; + } else if (section < 1000_000) { + return 2; + } else if (section < 1000_000_000L) { + return 3; + } else if (section < 1000_000_000_000L) { + return 4; + } else if (section < 1000_000_000_000_000L) { + return 5; + } else { + return 6; + } + } + + @Override + public Long getTutorialCount() { + return this.columnDao.countColumnArticles(); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ColumnSettingServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ColumnSettingServiceImpl.java new file mode 100644 index 000000000..507a4c5f1 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/ColumnSettingServiceImpl.java @@ -0,0 +1,705 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import cn.hutool.core.collection.CollUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.enums.column.MovePositionEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.*; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleGroupDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleColumnDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.article.conveter.ColumnArticleStructMapper; +import com.github.paicoding.forum.service.article.conveter.ColumnStructMapper; +import com.github.paicoding.forum.service.article.helper.TreeBuilder; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnArticleGroupDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleGroupDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnInfoDO; +import com.github.paicoding.forum.service.article.repository.params.SearchColumnArticleParams; +import com.github.paicoding.forum.service.article.repository.params.SearchColumnParams; +import com.github.paicoding.forum.service.article.service.ColumnSettingService; +import com.github.paicoding.forum.service.user.service.UserService; +import org.apache.http.util.Asserts; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 专栏后台接口 + * + * @author louzai + * @date 2022-09-19 + */ +@Service +public class ColumnSettingServiceImpl implements ColumnSettingService { + + @Autowired + private UserService userService; + + @Autowired + private ColumnArticleDao columnArticleDao; + + @Autowired + private ColumnArticleGroupDao columnArticleGroupDao; + + @Autowired + private ColumnDao columnDao; + + @Autowired + private ArticleDao articleDao; + + @Autowired + private ColumnStructMapper columnStructMapper; + + @Override + public void saveColumn(ColumnReq req) { + ColumnInfoDO columnInfoDO = columnStructMapper.toDo(req); + if (req.getFreeStartTime() <= 0) { + // 兼容日期数据 + columnInfoDO.setFreeStartTime(new Date(1000)); + } + if (req.getFreeEndTime() <= 0) { + columnInfoDO.setFreeEndTime(new Date(1000)); + } + + if (NumUtil.nullOrZero(req.getColumnId())) { + columnDao.save(columnInfoDO); + } else { + columnInfoDO.setId(req.getColumnId()); + columnDao.updateById(columnInfoDO); + } + } + + /** + * section 的排序规则 + * 1. 以父节点的 section * 1000 为前缀,然后在同一个父节点中,按照顺序找位置(新增,则放在最后,修改则放在对应位置,并将其之后的进行顺移) + * + * @param req + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void saveColumnArticleGroup(ColumnArticleGroupReq req) { + ColumnArticleGroupDO groupDO = columnStructMapper.toGroupDO(req); + if (!NumUtil.nullOrZero(req.getId())) { + // 更新分组 + groupDO.setId(req.getId()); + } else { + // 表示新增 + groupDO.setId(null); + } + if (!firstAddGroup(groupDO)) { + this.autoCalculateGroupSection(groupDO); + } + } + + private boolean firstAddGroup(ColumnArticleGroupDO groupDO) { + // 如果这个分组是当前专栏的第一个分组;那么就直接新增分组;并将所有未分组的教程全部挂在这个分组下面 + List dbRecords = columnArticleGroupDao.selectByColumnId(groupDO.getColumnId()); + if (!CollectionUtils.isEmpty(dbRecords)) { + return false; + } + + groupDO.setSection(1L); + columnArticleGroupDao.save(groupDO); + + // 刷新这个专栏下所有教程的groupId + columnArticleDao.updateColumnGroupId(groupDO.getId(), groupDO.getColumnId()); + return true; + } + + /** + * 这里是兼容老的,直接通过修改分组的sort值来改变分组排序的场景;整个逻辑相对复杂、不容易理解; + * 可以结合 拖拽排序 moveColumnGroup 的实现进行对照,最终的表现一致,但是实现层和理解层差别还是很大的;一个好的接口设计,可以有效的降低实现复杂度 + * + * @param currentGroup + */ + private void autoCalculateGroupSection(ColumnArticleGroupDO currentGroup) { + long baseSection = 0; + if (NumUtil.nullOrZero(currentGroup.getParentGroupId())) { + // 当前节点为顶级节点 + currentGroup.setParentGroupId(0L); + } else { + // 找到父节点 + ColumnArticleGroupDO parentGroup = columnArticleGroupDao.getById(currentGroup.getParentGroupId()); + Asserts.notNull(parentGroup, "父节点非法"); + baseSection = parentGroup.getSection() * ColumnArticleGroupDao.SECTION_STEP; + } + + // 找同一个父节点的子节点 + List list = columnArticleGroupDao.selectColumnGroupsBySameParent(currentGroup.getColumnId(), currentGroup.getParentGroupId()); + if (CollectionUtils.isEmpty(list)) { + // 没有兄弟节点,当前为第一个 + currentGroup.setSection(baseSection + 1L); + + if (!NumUtil.nullOrZero(currentGroup.getId())) { + // 更新节点,需要同步更新这个节点下面的所有子节点的顺序 + updateGroupSection(currentGroup); + } else { + // 新增节点 + columnArticleGroupDao.saveOrUpdate(currentGroup); + } + return; + } + + + // 当前为新增节点时,找最大的一个顺序,进行 + 1即可 + if (NumUtil.nullOrZero(currentGroup.getId())) { + // 新增节点 + currentGroup.setSection(list.get(list.size() - 1).getSection() + 1L); + columnArticleGroupDao.saveOrUpdate(currentGroup); + return; + } + + // 当前节点为更新时,则需要找到当前节点,并更新前/后的节点顺序 + int oldIndex = -1; // 原来在的位置 + int newIndex = 0; // 按照新的排序,应该插入的位置 + for (int i = 0; i < list.size(); i++) { + ColumnArticleGroupDO item = list.get(i); + if (item.getId().equals(currentGroup.getId())) { + oldIndex = i; + if (item.getSection().equals(currentGroup.getSection())) { + // 排序没有改变;只需要更新当前节点的信息即可 + columnArticleGroupDao.saveOrUpdate(currentGroup); + return; + } + } + + if (currentGroup.getSection() > item.getSection()) { + newIndex = i + 1; + } + } + + if (oldIndex == newIndex) { + // 没有改变位置,只需要更新当前节点的子节点顺序 + updateGroupSection(currentGroup); + return; + } + + if (oldIndex == -1) { + // 表示节点为其他的父节点移动过来的, newIndex后面的节点都需要向后移动一位 + long oldSection = currentGroup.getSection() + 1; + for (int i = newIndex; i < list.size(); i++) { + ColumnArticleGroupDO item = list.get(i); + item.setSection(oldSection); + oldSection += 1; + updateGroupSection(item); + } + } else if (newIndex > oldIndex) { + // 更新节点,当前节点向后移动了 + long oldSection = list.get(oldIndex).getSection(); + for (int i = oldIndex + 1; i < newIndex; i++) { + ColumnArticleGroupDO item = list.get(i); + item.setSection(oldSection); + oldSection += 1; + updateGroupSection(item); + } + } else { + // 更新节点,当前节点向前移动了 + long oldSection = currentGroup.getSection() + 1; + for (int i = newIndex; i < oldIndex; i++) { + ColumnArticleGroupDO item = list.get(i); + item.setSection(oldSection); + oldSection += 1; + updateGroupSection(item); + } + } + } + + + /** + * 将文章保存到对应的专栏中 + * + * @param articleId + * @param columnId + */ + public void saveColumnArticle(Long articleId, Long columnId) { + // 转换参数 + // 插入的时候,需要判断是否已经存在 + ColumnArticleDO exist = columnArticleDao.getOne(Wrappers.lambdaQuery() + .eq(ColumnArticleDO::getArticleId, articleId)); + if (exist != null) { + if (!Objects.equals(columnId, exist.getColumnId())) { + // 更新 + exist.setColumnId(columnId); + columnArticleDao.updateById(exist); + } + } else { + // 将文章保存到专栏中,章节序号+1 + ColumnArticleDO columnArticleDO = new ColumnArticleDO(); + columnArticleDO.setColumnId(columnId); + columnArticleDO.setArticleId(articleId); + // section 自增+1 + Integer maxSection = columnArticleDao.selectMaxSection(columnId); + columnArticleDO.setSection(maxSection + 1); + columnArticleDao.save(columnArticleDO); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveColumnArticle(ColumnArticleReq req) { + // 转换参数 + ColumnArticleDO columnArticleDO = ColumnArticleStructMapper.INSTANCE.reqToDO(req); + if (NumUtil.nullOrZero(columnArticleDO.getId())) { + // 插入的时候,需要判断是否已经存在 + ColumnArticleDO exist = columnArticleDao.getOne(Wrappers.lambdaQuery() + .eq(ColumnArticleDO::getColumnId, columnArticleDO.getColumnId()) + .eq(ColumnArticleDO::getArticleId, columnArticleDO.getArticleId())); + if (exist != null) { + throw ExceptionUtil.of(StatusEnum.COLUMN_ARTICLE_EXISTS, "请勿重复添加"); + } + + // section 自增+1 + Integer maxSection = columnArticleDao.selectMaxSection(columnArticleDO.getColumnId()); + columnArticleDO.setSection(maxSection + 1); + columnArticleDao.save(columnArticleDO); + } else { + columnArticleDao.updateById(columnArticleDO); + } + + // 同时,更新 article 的 shortTitle 短标题 + if (req.getShortTitle() != null) { + ArticleDO articleDO = new ArticleDO(); + articleDO.setShortTitle(req.getShortTitle()); + articleDO.setId(req.getArticleId()); + articleDao.updateById(articleDO); + } + } + + @Override + public void deleteColumn(Long columnId) { + columnDao.deleteColumn(columnId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteColumnArticle(Long id) { + ColumnArticleDO columnArticleDO = columnArticleDao.getById(id); + if (columnArticleDO != null) { + columnArticleDao.removeById(id); + // 删除的时候,批量更新 section,比如说原来是 1,2,3,4,5,6,7,8,9,10,删除 5,那么 6-10 的 section 都要减 1 + columnArticleDao.update(null, Wrappers.lambdaUpdate().setSql("section = section - 1") + .eq(ColumnArticleDO::getColumnId, columnArticleDO.getColumnId()) + // section 大于 1 + .gt(ColumnArticleDO::getSection, 1).gt(ColumnArticleDO::getSection, columnArticleDO.getSection())); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void sortColumnArticleApi(SortColumnArticleReq req) { + // 根据 req 的两个 ID 调换两篇文章的顺序 + ColumnArticleDO activeDO = columnArticleDao.getById(req.getActiveId()); + ColumnArticleDO overDO = columnArticleDao.getById(req.getOverId()); + if (activeDO != null && overDO != null && !activeDO.getId().equals(overDO.getId())) { + Integer activeSection = activeDO.getSection(); + Integer overSection = overDO.getSection(); + // 假如原始顺序为1、2、3、4 + // + //把 1 拖到 4 后面 2 变 1 3 变 2 4 变 3 1 变 4 + //把 1 拖到 3 后面 2 变 1 3 变 2 4 不变 1 变 3 + //把 1 拖到 2 后面 2 变 1 3 不变 4 不变 1 变 2 + //把 2 拖到 4 后面 1 不变 3 变 2 4 变 3 2 变 4 + //把 2 拖到 3 后面 1 不变 3 变 2 4 不变 2 变 3 + //把 3 拖到 4 后面 1 不变 2 不变 4 变 3 3 变 4 + //把 4 拖到 1 前面 1 变 2 2 变 3 3 变 4 + //把 4 拖到 2 前面 1 不变 2 变 3 3 变 4 4 变 1 + //把 4 拖到 3 前面 1 不变 2 不变 3 变 4 4 变 1 + //把 3 拖到 1 前面 1 变 2 2 变 3 3 变 4 4 变 1 + //依次类推 + // 1. 如果 activeSection > overSection,那么 activeSection - 1 到 overSection 的 section 都要 +1 + // 向上拖动 + if (activeSection > overSection) { + // 当 activeSection 大于 overSection 时,表示文章被向上拖拽。 + // 需要将 activeSection 到 overSection(不包括 activeSection 本身)之间的所有文章的 section 加 1, + // 并将 activeSection 设置为 overSection。 + columnArticleDao.update(null, Wrappers.lambdaUpdate() + .setSql("section = section + 1") // 将符合条件的记录的 section 字段的值增加 1 + .eq(ColumnArticleDO::getColumnId, overDO.getColumnId()) // 指定要更新记录的 columnId 条件 + .ge(ColumnArticleDO::getSection, overSection) // 指定 section 字段的下限(包含此值) + .lt(ColumnArticleDO::getSection, activeSection)); // 指定 section 字段的上限 + + // 将 activeDO 的 section 设置为 overSection + activeDO.setSection(overSection); + columnArticleDao.updateById(activeDO); + } else { + // 2. 如果 activeSection < overSection, + // 那么 activeSection + 1 到 overSection 的 section 都要 -1 + // 向下拖动 + // 需要将 activeSection 到 overSection(包括 overSection)之间的所有文章的 section 减 1 + columnArticleDao.update(null, Wrappers.lambdaUpdate() + .setSql("section = section - 1") // 将符合条件的记录的 section 字段的值减少 1 + .eq(ColumnArticleDO::getColumnId, overDO.getColumnId()) // 指定要更新记录的 columnId 条件 + .gt(ColumnArticleDO::getSection, activeSection) // 指定 section 字段的下限(不包含此值) + .le(ColumnArticleDO::getSection, overSection)); // 指定 section 字段的上限(包含此值) + + // 将 activeDO 的 section 设置为 overSection -1 + activeDO.setSection(overSection); + columnArticleDao.updateById(activeDO); + + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void sortColumnArticleByIDApi(SortColumnArticleByIDReq req) { + // 获取要重新排序的专栏文章 + ColumnArticleDO columnArticleDO = columnArticleDao.getById(req.getId()); + // 不等于空 + if (columnArticleDO == null) { + throw ExceptionUtil.of(StatusEnum.COLUMN_ARTICLE_EXISTS, "教程不存在"); + } + // 如果顺序没变 + if (req.getSort().equals(columnArticleDO.getSection())) { + return; + } + // 获取教程可以调整的最大顺序 + Integer maxSection = columnArticleDao.selectMaxSection(columnArticleDO.getColumnId()); + // 如果输入的顺序大于最大顺序,提示错误 + if (req.getSort() > maxSection) { + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "顺序超出范围"); + } + // 查看输入的顺序是否存在 + ColumnArticleDO changeColumnArticleDO = columnArticleDao.selectBySection(columnArticleDO.getColumnId(), req.getSort()); + // 如果存在,交换顺序 + if (changeColumnArticleDO != null) { + // 交换顺序 + columnArticleDao.update(null, Wrappers.lambdaUpdate() + .set(ColumnArticleDO::getSection, columnArticleDO.getSection()) + .eq(ColumnArticleDO::getId, changeColumnArticleDO.getId())); + columnArticleDao.update(null, Wrappers.lambdaUpdate() + .set(ColumnArticleDO::getSection, changeColumnArticleDO.getSection()) + .eq(ColumnArticleDO::getId, columnArticleDO.getId())); + } else { + // 如果不存在,直接修改顺序 + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "输入的顺序不存在,无法完成交换"); + } + } + + @Override + public PageVo getColumnList(SearchColumnReq req) { + // 转换参数 + ColumnStructMapper mapper = ColumnStructMapper.INSTANCE; + SearchColumnParams params = mapper.reqToSearchParams(req); + // 查询 + List columnList = columnDao.listColumnsByParams(params, PageParam.newPageInstance(req.getPageNumber(), req.getPageSize())); + // 转属性 + List columnDTOS = mapper.infoToDtos(columnList); + + // 进行优化,由原来的多次查询用户信息,改为一次查询用户信息 + // 获取所有需要的用户id + // 判断 columnDTOS 是否为空 + if (CollUtil.isNotEmpty(columnDTOS)) { + List userIds = columnDTOS.stream().map(ColumnDTO::getAuthor).collect(Collectors.toList()); + + // 查询所有的用户信息 + List users = userService.batchQueryBasicUserInfo(userIds); + + // 创建一个id到用户信息的映射 + Map userMap = users.stream() + .collect(Collectors.toMap(BaseUserInfoDTO::getId, Function.identity())); + + // 设置作者信息 + columnDTOS.forEach(columnDTO -> { + BaseUserInfoDTO user = userMap.get(columnDTO.getAuthor()); + columnDTO.setAuthorName(user.getUserName()); + columnDTO.setAuthorAvatar(user.getPhoto()); + columnDTO.setAuthorProfile(user.getProfile()); + }); + } + + Integer totalCount = columnDao.countColumnsByParams(params); + return PageVo.build(columnDTOS, req.getPageSize(), req.getPageNumber(), totalCount); + } + + @Override + public PageVo getColumnArticleList(SearchColumnArticleReq req) { + // 转换参数 + ColumnArticleStructMapper mapper = ColumnArticleStructMapper.INSTANCE; + SearchColumnArticleParams params = mapper.toSearchParams(req); + // 查询 + List simpleArticleDTOS = columnDao.listColumnArticlesDetail(params, PageParam.newPageInstance(req.getPageNumber(), req.getPageSize())); + int totalCount = columnDao.countColumnArticles(params); + return PageVo.build(simpleArticleDTOS, req.getPageSize(), req.getPageNumber(), totalCount); + } + + @Override + public List listSimpleColumnBySearchKey(String key) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.select(ColumnInfoDO::getId, ColumnInfoDO::getColumnName, ColumnInfoDO::getCover) + .and(!StringUtils.isEmpty(key), v -> v.like(ColumnInfoDO::getColumnName, key)) + .orderByDesc(ColumnInfoDO::getId); + List articleDOS = columnDao.list(query); + return ColumnStructMapper.INSTANCE.infoToSimpleDtos(articleDOS); + } + + @Override + public List getColumnGroups(Long columnId) { + List entityList = columnArticleGroupDao.selectByColumnId(columnId); + if (CollectionUtils.isEmpty(entityList)) { + return Collections.emptyList(); + } + List dtoList = ColumnStructMapper.INSTANCE.toGroupDTOList(entityList); + return TreeBuilder.buildTree(dtoList); + } + + + public List getColumnGroupAndArticles(Long columnId) { + List entityList = columnArticleGroupDao.selectByColumnId(columnId); + List dtoList = new ArrayList<>(); + if (!CollectionUtils.isEmpty(entityList)) { + dtoList = ColumnStructMapper.INSTANCE.toGroupDTOList(entityList); + } + // 分组,并设置一个未分组的默认分组项,用于挂文章没有指定分组的场景 + Map groupMap = dtoList.stream() + .collect(Collectors.toMap(ColumnArticleGroupDTO::getGroupId, v -> v)); + ColumnArticleGroupDTO defaultGroup = ColumnArticleGroupDTO.newDefaultGroup(columnId); + + + // 查询所有文章 + SearchColumnArticleParams params = new SearchColumnArticleParams(); + params.setColumnId(columnId); + List articleList = columnDao.listColumnArticlesDetail(params, PageParam.newPageInstance(1, Integer.MAX_VALUE)); + // 将文章放入分组中 + for (ColumnArticleDTO article : articleList) { + ColumnArticleGroupDTO group = groupMap.getOrDefault(article.getGroupId(), defaultGroup); + if (group.getArticles() == null) { + group.setArticles(new ArrayList<>()); + } + group.getArticles().add(article); + } + if (CollectionUtils.isNotEmpty(defaultGroup.getArticles())) { + dtoList.add(0, defaultGroup); + } + return TreeBuilder.buildTree(dtoList); + } + + + /** + * 删除专栏内的文章分组 + * + * @param groupId 分组id + * @return true 表示删除成功, false 表示删除失败 + */ + @Override + public boolean deleteColumnGroup(Long groupId) { + ColumnArticleGroupDO group = columnArticleGroupDao.getById(groupId); + if (group == null) { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, groupId); + } + + // 判断这个分组下是否有子分组,如果有,则不允许直接删除 + ColumnArticleGroupDO sub = columnArticleGroupDao.selectByParentGroupId(groupId); + if (sub != null) { + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "存在子分组,不支持直接删除当前分组!"); + } + + // 判断分组下是否存在文章,如果存在,也不允许删除 + ColumnArticleDO article = columnArticleDao.selectOneByGroupId(groupId); + if (article != null) { + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "分组下已经有文章了,不支持直接删除当前分组!"); + } + + // 直接删除 + group.setDeleted(YesOrNoEnum.YES.getCode()); + group.setUpdateTime(new Date()); + return columnArticleGroupDao.updateById(group); + } + + @Override + @Transactional + public boolean moveColumnArticleOrGroup(MoveColumnArticleOrGroupReq req) { + if (NumUtil.upZero(req.getMoveArticleId())) { + // 移动教程 + return moveColumnArticle(req); + } else { + // 移动分组 + return moveColumnGroup(req); + } + } + + private boolean moveColumnArticle(MoveColumnArticleOrGroupReq req) { + if (NumUtil.upZero(req.getTargetGroupId())) { + // 移动到目标分组的前后,则表示将当前教程移动到目标分组的父分组下,作为第一篇教程 + // 将当前教程的排序设置为1,父分组下其他教程的排序 + 1 + ColumnArticleGroupDO targetGroup = columnArticleGroupDao.getById(req.getTargetGroupId()); + if (targetGroup == null) { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, req.getTargetGroupId()); + } + + if (Objects.equals(req.getMovePosition(), MovePositionEnum.IN.getCode())) { + // 移动到目标分组里面,将这个分组中所有的教程排序 + 1 + columnArticleDao.updateColumnArticleGESectionToAdd(req.getColumnId(), targetGroup.getId(), 1, 1); + // 将当前教程设置为第一篇 + columnArticleDao.updateColumnArticleSection(req.getColumnId(), req.getMoveArticleId(), targetGroup.getId(), 1); + } else { + // 移动到目标分组的父分组下,则表示将当前教程移动到目标分组的父分组下,作为第一篇教程 + columnArticleDao.updateColumnArticleGESectionToAdd(req.getColumnId(), targetGroup.getParentGroupId(), 1, 1); + columnArticleDao.updateColumnArticleSection(req.getColumnId(), req.getMoveArticleId(), targetGroup.getParentGroupId(), 1); + } + } else { + // 移动到目标教程的前后,则需要更新groupId, + // 如果是后,将这个分组中,教程后面的所有教程的排序 + 2,当前教程的排序 = 目标教程的排序 + 1 + // 如果是前,将这个分组中,目标教程及之后的所有教程排序 + 1,当前教程的排序 = 目标教程的排序 + ColumnArticleDO targetArticle = columnArticleDao.selectColumnArticle(req.getColumnId(), req.getTargetArticleId()); + if (targetArticle == null) { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, req.getTargetArticleId()); + } + + if (Objects.equals(req.getMovePosition(), MovePositionEnum.BEFORE.getCode())) { + // 移动到目标教程前,表示替换目标教程的顺序 + columnArticleDao.updateColumnArticleGESectionToAdd(req.getColumnId(), targetArticle.getGroupId(), targetArticle.getSection(), 1); + columnArticleDao.updateColumnArticleSection(req.getColumnId(), req.getMoveArticleId(), targetArticle.getGroupId(), targetArticle.getSection()); + } else { + // 移动到目标教程后 + columnArticleDao.updateColumnArticleGESectionToAdd(req.getColumnId(), targetArticle.getGroupId(), targetArticle.getSection() + 1, 2); + columnArticleDao.updateColumnArticleSection(req.getColumnId(), req.getMoveArticleId(), targetArticle.getGroupId(), targetArticle.getSection() + 1); + } + } + + // 专栏内教程顺序重排 + this.autoUpdateColumnArticleSections(req.getColumnId()); + return true; + } + + /** + * 前台在显示文章时,是按照顺序递增的方式进行访问的,因此拖拽教程之后,我们对专栏内的教程做一次重排 + * + * @param columnId + */ + private void autoUpdateColumnArticleSections(Long columnId) { + SearchColumnArticleParams params = new SearchColumnArticleParams(); + params.setColumnId(columnId); + List articleList = columnDao.listColumnArticlesDetail(params, PageParam.newPageInstance(1, Integer.MAX_VALUE)); + int section = 1; + for (ColumnArticleDTO item : articleList) { + columnArticleDao.updateColumnArticleSection(item.getId(), section); + section += 1; + } + } + + /** + * 将一个教程分组,移动到另一个教程分组前、后、里 + * + * @param req + * @return + */ + private boolean moveColumnGroup(MoveColumnArticleOrGroupReq req) { + ColumnArticleGroupDO currentGroup = columnArticleGroupDao.getById(req.getMoveGroupId()); + ColumnArticleGroupDO targetGroup = columnArticleGroupDao.getById(req.getTargetGroupId()); + + if (Objects.equals(req.getMovePosition(), MovePositionEnum.IN.getCode())) { + // 移动到目标分组内,作为该分组的第一个;目标分组中的其他分组全部往后移动一位 + List subGroups = columnArticleGroupDao.selectColumnGroupsBySameParent(req.getColumnId(), targetGroup.getId()); + AtomicLong baseSection = new AtomicLong(targetGroup.getSection() * ColumnArticleGroupDao.SECTION_STEP + 1); + currentGroup.setParentGroupId(targetGroup.getId()); + currentGroup.setSection(baseSection.get()); + this.updateGroupSection(currentGroup); + subGroups.forEach(sub -> { + if (!sub.getId().equals(currentGroup.getId())) { + baseSection.addAndGet(1); + sub.setSection(baseSection.get()); + updateGroupSection(sub); + } + }); + } else if (Objects.equals(req.getMovePosition(), MovePositionEnum.BEFORE.getCode())) { + // 移动到目标分组前,则将目标分组的排序 + 1,当前分组的教程排序 = 目标分组的排序 + List subGroups = columnArticleGroupDao.selectColumnGroupsBySameParent(req.getColumnId(), targetGroup.getParentGroupId()); + + // 首先将当前分组,从列表中移除 + subGroups.removeIf(item -> item.getId().equals(currentGroup.getId())); + + // 找到目标分组,在列表中的位置 + long baseSection = -1; + for (int i = 0; i < subGroups.size(); i++) { + ColumnArticleGroupDO item = subGroups.get(i); + if (item.getId().equals(targetGroup.getId())) { + baseSection = item.getSection(); + currentGroup.setParentGroupId(targetGroup.getParentGroupId()); + currentGroup.setSection(baseSection); + this.updateGroupSection(currentGroup); + + baseSection += 1; + item.setSection(baseSection); + this.updateGroupSection(item); + baseSection += 1; + continue; + } + if (baseSection > 0) { + // 表示已经找到了目标分组 + item.setSection(baseSection); + baseSection += 1; + this.updateGroupSection(item); + } + } + } else { + // 移动到目标分组后,则将目标分组后面的分组排序 + 1,当前分组的教程排序 = 目标分组的排序 + 1 + List subGroups = columnArticleGroupDao.selectColumnGroupsBySameParent(req.getColumnId(), targetGroup.getParentGroupId()); + + // 首先将当前分组,从列表中移除 + subGroups.removeIf(item -> item.getId().equals(currentGroup.getId())); + + // 找到目标分组,在列表中的位置 + long baseSection = -1; + for (int i = 0; i < subGroups.size(); i++) { + ColumnArticleGroupDO item = subGroups.get(i); + if (item.getId().equals(targetGroup.getId())) { + baseSection = item.getSection() + 1; + currentGroup.setParentGroupId(targetGroup.getParentGroupId()); + currentGroup.setSection(baseSection); + this.updateGroupSection(currentGroup); + + baseSection += 1; + continue; + } + if (baseSection > 0) { + // 表示已经找到了目标分组 + item.setSection(baseSection); + baseSection += 1; + this.updateGroupSection(item); + } + } + } + // 专栏内教程顺序重排 + this.autoUpdateColumnArticleSections(req.getColumnId()); + return true; + } + + /** + * 更新当前节点,和对应子节点的顺序 + * + * @param groupDO + */ + private void updateGroupSection(ColumnArticleGroupDO groupDO) { + // 更新当前节点的顺序 + columnArticleGroupDao.updateById(groupDO); + // 更新子节点的顺序 + List children = columnArticleGroupDao.selectColumnGroupsBySameParent(groupDO.getColumnId(), groupDO.getId()); + long baseSection = groupDO.getSection() * ColumnArticleGroupDao.SECTION_STEP; + for (int i = 0; i < children.size(); i++) { + ColumnArticleGroupDO item = children.get(i); + item.setSection(baseSection + i + 1); + updateGroupSection(item); + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/TagServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/TagServiceImpl.java new file mode 100644 index 000000000..5981cccb4 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/TagServiceImpl.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.service.article.repository.dao.TagDao; +import com.github.paicoding.forum.service.article.service.TagService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 标签Service + * + * @author louzai + * @date 2022-07-20 + */ +@Service +public class TagServiceImpl implements TagService { + private final TagDao tagDao; + + public TagServiceImpl(TagDao tagDao) { + this.tagDao = tagDao; + } + + @Override + public PageVo queryTags(String key, PageParam pageParam) { + List tagDTOS = tagDao.listOnlineTag(key, pageParam); + Integer totalCount = tagDao.countOnlineTag(key); + return PageVo.build(tagDTOS, pageParam.getPageSize(), pageParam.getPageNum(), totalCount); + } + + @Override + public Long queryTagId(String tag) { + return tagDao.selectTagIdByTag(tag); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/TagSettingServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/TagSettingServiceImpl.java new file mode 100644 index 000000000..7593c0c4b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/article/service/impl/TagSettingServiceImpl.java @@ -0,0 +1,112 @@ +package com.github.paicoding.forum.service.article.service.impl; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.article.SearchTagReq; +import com.github.paicoding.forum.api.model.vo.article.TagReq; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.article.conveter.TagStructMapper; +import com.github.paicoding.forum.service.article.repository.dao.TagDao; +import com.github.paicoding.forum.service.article.repository.entity.TagDO; +import com.github.paicoding.forum.service.article.repository.params.SearchTagParams; +import com.github.paicoding.forum.service.article.service.TagSettingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 标签后台接口 + * + * @author louzai + * @date 2022-09-17 + */ +@Service +public class TagSettingServiceImpl implements TagSettingService { + + private static final String CACHE_TAG_PRE = "cache_tag_pre_"; + + private static final Long CACHE_TAG_EXPRIE_TIME = 100L; + + @Autowired + private TagDao tagDao; + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveTag(TagReq tagReq) { + TagDO tagDO = TagStructMapper.INSTANCE.toDO(tagReq); + + // 先写 MySQL + if (NumUtil.nullOrZero(tagReq.getTagId())) { + tagDao.save(tagDO); + } else { + tagDO.setId(tagReq.getTagId()); + tagDao.updateById(tagDO); + } + + // 再删除 Redis + String redisKey = CACHE_TAG_PRE + tagDO.getId(); + RedisClient.del(redisKey); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTag(Integer tagId) { + TagDO tagDO = tagDao.getById(tagId); + if (tagDO != null){ + // 先写 MySQL + tagDao.removeById(tagId); + + // 再删除 Redis + String redisKey = CACHE_TAG_PRE + tagDO.getId(); + RedisClient.del(redisKey); + } + } + + @Override + public void operateTag(Integer tagId, Integer pushStatus) { + TagDO tagDO = tagDao.getById(tagId); + if (tagDO != null){ + + // 先写 MySQL + tagDO.setStatus(pushStatus); + tagDao.updateById(tagDO); + + // 再删除 Redis + String redisKey = CACHE_TAG_PRE + tagDO.getId(); + RedisClient.del(redisKey); + } + } + + @Override + public PageVo getTagList(SearchTagReq req) { + // 转换 + SearchTagParams params = TagStructMapper.INSTANCE.toSearchParams(req); + // 查询 + List tagDTOS = TagStructMapper.INSTANCE.toDTOs(tagDao.listTag(params)); + Long totalCount = tagDao.countTag(params); + return PageVo.build(tagDTOS, params.getPageSize(), params.getPageNum(), totalCount); + } + + @Override + public TagDTO getTagById(Long tagId) { + + String redisKey = CACHE_TAG_PRE + tagId; + + // 先查询缓存,如果有就直接返回 + String tagInfoStr = RedisClient.getStr(redisKey); + if (tagInfoStr != null && !tagInfoStr.isEmpty()) { + return JsonUtil.toObj(tagInfoStr, TagDTO.class); + } + + // 如果未查询到,需要先查询 DB ,再写入缓存 + TagDTO tagDTO = tagDao.selectById(tagId); + tagInfoStr = JsonUtil.toStr(tagDTO); + RedisClient.setStrWithExpire(redisKey, tagInfoStr, CACHE_TAG_EXPRIE_TIME); + + return tagDTO; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/ChatFacade.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/ChatFacade.java new file mode 100644 index 000000000..a952b6517 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/ChatFacade.java @@ -0,0 +1,185 @@ +package com.github.paicoding.forum.service.chatai; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.chatai.service.ChatServiceFactory; +import com.github.paicoding.forum.service.chatai.service.impl.ali.AliIntegration; +import com.github.paicoding.forum.service.chatai.service.impl.chatgpt.ChatGptIntegration; +import com.github.paicoding.forum.service.chatai.service.impl.xunfei.XunFeiIntegration; +import com.github.paicoding.forum.service.chatai.service.impl.zhipu.ZhipuIntegration; +import com.github.paicoding.forum.service.user.service.conf.AiConfig; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.common.collect.Sets; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * 聊天的门面类 + * + * @author YiHui + * @date 2023/6/9 + */ +@Slf4j +@Service +public class ChatFacade { + + @Autowired + private AiConfig aiConfig; + @Autowired + private ChatServiceFactory chatServiceFactory; + + /** + * 基于Guava的单实例缓存 + */ + private Supplier aiSourceCache; + + /** + * 返回推荐的AI模型 + * + * @return + */ + public AISourceEnum getRecommendAiSource() { + if (aiSourceCache == null) { + refreshAiSourceCache(Collections.emptySet()); + } + AISourceEnum sourceEnum = aiSourceCache.get(); + if (sourceEnum == null) { + refreshAiSourceCache(getRecommendAiSource(Collections.emptySet())); + } + return aiSourceCache.get(); + } + + public void refreshAiSourceCache(AISourceEnum ai) { + aiSourceCache = Suppliers.memoizeWithExpiration(() -> ai, 10, TimeUnit.MINUTES); + } + + public void refreshAiSourceCache(Set except) { + refreshAiSourceCache(getRecommendAiSource(except)); + } + + /** + * 返回推荐的AI模型 + * + * @param except 不选择的AI模型 + * @return + */ + private AISourceEnum getRecommendAiSource(Set except) { + AISourceEnum source; + try { + ChatGptIntegration.ChatGptConfig config = SpringUtil.getBean(ChatGptIntegration.ChatGptConfig.class); + if (!except.contains(AISourceEnum.CHAT_GPT_3_5) && !CollectionUtils.isEmpty(config.getConf() + .get(config.getMain()).getKeys())) { + source = AISourceEnum.CHAT_GPT_3_5; + } else if (!except.contains(AISourceEnum.ZHI_PU_AI) && StringUtils.isNotBlank(SpringUtil.getBean(ZhipuIntegration.ZhipuConfig.class) + .getApiSecretKey())) { + source = AISourceEnum.ZHI_PU_AI; + } else if (!except.contains(AISourceEnum.XUN_FEI_AI) && StringUtils.isNotBlank(SpringUtil.getBean(XunFeiIntegration.XunFeiConfig.class) + .getApiKey())) { + source = AISourceEnum.XUN_FEI_AI; + } else if (!except.contains(AISourceEnum.ALI_AI)) { + source = AISourceEnum.ALI_AI; + } else if(!except.contains(AISourceEnum.DEEP_SEEK)) { + source = AISourceEnum.DEEP_SEEK; + } else if(!except.contains(AISourceEnum.DOU_BAO_AI)) { + source = AISourceEnum.DOU_BAO_AI; + } else { + source = AISourceEnum.PAI_AI; + } + } catch (Exception e) { + source = AISourceEnum.PAI_AI; + } + + if (source != AISourceEnum.PAI_AI && !aiConfig.getSource().contains(source)) { + Set totalExcepts = Sets.newHashSet(except); + totalExcepts.add(source); + return getRecommendAiSource(totalExcepts); + } + log.info("当前选中的AI模型:{}", source); + return source; + } + + /** + * 高度封装的AI聊天访问入口,对于使用这而言,只需要提问,定义接收返回结果的回调即可 + * + * @param question 提出的问题 + * @param callback 定义异步聊天接口返回时的回调策略 + * @return 表示同步直接返回的结果 + */ + public ChatRecordsVo autoChat(String question, Consumer callback) { + AISourceEnum source = getRecommendAiSource(); + return autoChat(source, question, callback); + } + + + /** + * 自动根据AI的支持方式,选择同步/异步的交互方式 + * + * @param source + * @param question + * @param callback + * @return + */ + public ChatRecordsVo autoChat(AISourceEnum source, String question, Consumer callback) { + if (source.asyncSupport() && chatServiceFactory.getChatService(source).asyncFirst()) { + // 支持异步且异步优先的场景下,自动选择异步方式进行聊天 + return asyncChat(source, question, callback); + } + return chat(source, question, callback); + } + + /** + * 开始聊天 + * + * @param question + * @param source + * @return + */ + public ChatRecordsVo chat(AISourceEnum source, String question) { + return chatServiceFactory.getChatService(source).chat(ReqInfoContext.getReqInfo().getUserId(), question); + } + + /** + * 开始聊天 + * + * @param question + * @param source + * @return + */ + public ChatRecordsVo chat(AISourceEnum source, String question, Consumer callback) { + return chatServiceFactory.getChatService(source) + .chat(ReqInfoContext.getReqInfo().getUserId(), question, callback); + } + + /** + * 异步聊天的方式 + * + * @param source + * @param question + */ + public ChatRecordsVo asyncChat(AISourceEnum source, String question, Consumer callback) { + return chatServiceFactory.getChatService(source) + .asyncChat(ReqInfoContext.getReqInfo().getUserId(), question, callback); + } + + /** + * 返回历史聊天记录 + * + * @param source + * @return + */ + public ChatRecordsVo history(AISourceEnum source) { + source = source == null ? getRecommendAiSource() : source; + return chatServiceFactory.getChatService(source).getChatHistory(ReqInfoContext.getReqInfo().getUserId(), source); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/bot/AiBotService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/bot/AiBotService.java new file mode 100644 index 000000000..d51a362e5 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/bot/AiBotService.java @@ -0,0 +1,125 @@ +package com.github.paicoding.forum.service.chatai.bot; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiBotEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.chatai.ChatFacade; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.service.RegisterService; +import com.github.paicoding.forum.service.user.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * 基于大模型的杠精机器人 + * + * @author YiHui + * @date 2025/2/24 + */ +@Component +public class AiBotService { + + @Autowired + private ChatFacade chatFacade; + + @Autowired + private UserService userService; + + @Autowired + private RegisterService registerService; + + @Autowired + private UserDao userDao; + + private Map botUsers = new HashMap<>(); + + /** + * 在应用完全启动后初始化 AI 机器人用户 + * 使用 ApplicationReadyEvent 确保 Liquibase 已完成数据库表创建 + */ + @EventListener(ApplicationReadyEvent.class) + public void initBotUser() { + String ossPrefix = SpringUtil.getConfig("view.site.oss", ""); + for (AiBotEnum bot : AiBotEnum.values()) { + BaseUserInfoDTO user = userService.queryUserByLoginName(bot.getUserName()); + String avatarUrl = ossPrefix + bot.getAvatar(); + + if (user == null) { + Long userId = registerService.registerSystemUser(bot.getUserName(), bot.getNickName(), avatarUrl); + user = userService.queryBasicUserInfo(userId); + } else { + UserInfoDO userInfoDO = userDao.getByUserId(user.getUserId()); + if (!avatarUrl.equals(userInfoDO.getPhoto())) { + userInfoDO.setPhoto(avatarUrl); + userDao.updateUserInfo(userInfoDO); + user = userService.queryBasicUserInfo(user.getUserId()); + } + } + botUsers.put(bot, user); + } + } + + /** + * 触发AI机器人 + * + * @param question + * @return + */ + public void trigger(AiBotEnum bot, String question, String sourceBizId, Consumer consumer) { + BaseUserInfoDTO user = botUsers.get(bot); + AsyncUtil.execute(() -> { + // 设置AI机器人问答上下文 + ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo(); + reqInfo.setUser(user); + reqInfo.setUserId(user.getUserId()); + reqInfo.setChatId(sourceBizId); + ReqInfoContext.addReqInfo(reqInfo); + + // 机器人,默认使用智谱模型 + chatFacade.autoChat(AISourceEnum.ZHI_PU_AI, question, vo -> { + ChatItemVo item = vo.getRecords().get(0); + if (item.getAnswerType() == ChatAnswerTypeEnum.JSON + || item.getAnswerType() == ChatAnswerTypeEnum.TEXT + || item.getAnswerType() == ChatAnswerTypeEnum.STREAM_END) { + try { + consumer.accept(item.getAnswer()); + } finally { + // 清空上下文信息 + ReqInfoContext.clear(); + } + } + }); + }); + } + + /** + * 获取杠精机器人相关信息 + * + * @return + */ + public AiBotEnum getBotEnumByUserId(Long userId) { + for (Map.Entry entry : botUsers.entrySet()) { + if (Objects.equals(entry.getValue().getUserId(), userId)) { + return entry.getKey(); + } + } + return null; + } + + public BaseUserInfoDTO getBotEnumByUserId(AiBotEnum bot) { + return botUsers.get(bot); + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/bot/AiBots.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/bot/AiBots.java new file mode 100644 index 000000000..13c29c6c1 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/bot/AiBots.java @@ -0,0 +1,99 @@ +package com.github.paicoding.forum.service.chatai.bot; + +import com.github.paicoding.forum.api.model.enums.ai.AiBotEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * @author YiHui + * @date 2025/2/24 + */ +@Service +public class AiBots { + @Autowired + private AiBotService botService; + + /** + * 系统提示词缓存 + */ + private static final LoadingCache, Supplier> systemPromptCache = CacheBuilder.newBuilder() + .maximumSize(100) + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(new CacheLoader, Supplier>() { + @Override + public Supplier load(ImmutablePair key) throws Exception { + return () -> key.getLeft().getPrompt(); + } + }); + + /** + * 判断目标用户是否为AI机器人 + * + * @param userId + * @return + */ + public boolean aiBots(Long userId) { + return botService.getBotEnumByUserId(userId) != null; + } + + /** + * 自动补齐AI机器人的提示词 + * + * @param userId + * @return + */ + public ChatItemVo autoBuildPrompt(Long userId, String chatId) { + AiBotEnum bot = botService.getBotEnumByUserId(userId); + if (bot == null) { + return null; + } + + // 构建系统提示词 + String promptContent = systemPromptCache.getUnchecked(ImmutablePair.of(bot, chatId)).get(); + String prompt = ChatConstants.PROMPT_TAG + promptContent; + return new ChatItemVo().setQuestion(prompt); + } + + + /** + * 判断是否触发了AI机器人的关键词;如果触发,则返回对应的Ai机器人 + * + * @param comment 评论的文本内容 + * @return null 表示没有触发;否则表示已经触发 + */ + public AiBotEnum triggerAiBotKeyWord(String comment) { + for (AiBotEnum bot : AiBotEnum.values()) { + String tag = "@" + bot.getNickName(); + if (comment.contains(tag)) { + return bot; + } + } + return null; + } + + public AiBotEnum getAiBotByUserId(Long userId) { + return botService.getBotEnumByUserId(userId); + } + + public BaseUserInfoDTO getBotUser(AiBotEnum bot) { + return botService.getBotEnumByUserId(bot); + } + + public void trigger(AiBotEnum bot, String question, String sourceBizId, Consumer consumer, Supplier systemPromptGenerator) { + // 支持自定义的ai机器人系统提示词注入 + systemPromptCache.put(ImmutablePair.of(bot, sourceBizId), systemPromptGenerator); + // 触发AI机器人的交互 + botService.trigger(bot, question, sourceBizId, consumer); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/constants/ChatConstants.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/constants/ChatConstants.java new file mode 100644 index 000000000..9f578ebb0 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/constants/ChatConstants.java @@ -0,0 +1,123 @@ +package com.github.paicoding.forum.service.chatai.constants; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * @author YiHui + * @date 2023/6/2 + */ +public final class ChatConstants { + /** + * 记录每个用户的使用次数 + */ + public static String getAiRateKey(AISourceEnum ai) { + return "chat.rates." + ai.name().toLowerCase(); + } + + public static String getAiRateKeyPerDay(AISourceEnum ai) { + return "chat.rates." + ai.name().toLowerCase() + "-" + LocalDate.now(); + } + + /** + * 对话列表缓存 + * + * @param ai + * @param user + * @return + */ + public static String getAiChatListKey(AISourceEnum ai, Long user) { + return "chat.list." + ai.name().toLowerCase() + "." + user; + } + + /** + * 聊天历史记录 + * + * @param ai + * @param user + * @return + */ + public static String getAiHistoryRecordsKey(AISourceEnum ai, Long user) { + return "chat.history." + ai.name().toLowerCase() + "." + user; + } + + /** + * 聊天历史记录 + * + * @param ai + * @param user + * @return + */ + public static String getAiHistoryRecordsKey(AISourceEnum ai, String user) { + return "chat.history." + ai.name().toLowerCase() + "." + user; + } + + /** + * 聊天历史构建问答上下问 + * + * @param chatList 聊天记录,包含历史聊天内容,最新的提问在前面 + * @param function 实体转换方式 + * @param + * @return + */ + public static List toMsgList(List chatList, Function> function) { + int qaCnt = chatList.size(); + List ans = new ArrayList<>(qaCnt << 1); + for (int i = qaCnt - 1; i >= 0; i--) { + ans.addAll(function.apply(chatList.get(i))); + } + return ans; + } + + /** + * 每个用户的最大使用次数 + */ + public static final int MAX_CHATGPT_QAS_CNT = 10; + + /** + * 最多保存的历史聊天记录 + */ + public static final int MAX_HISTORY_RECORD_ITEMS = 500; + + /** + * 两次提问的间隔时间,要求20s + */ + public static final long QAS_TIME_INTERVAL = 20_000; + + + public static final String CHAT_REPLY_RECOMMEND = "请注册技术派之后再来体验吧,技术派官网: \n https://paicoding.com"; + public static final String CHAT_REPLY_BEGIN = "让我们开始体验ChatGPT的魅力吧~"; + public static final String CHAT_REPLY_OVER = "体验结束,让我们下次再见吧~"; + public static final String CHAT_REPLY_CNT_OVER = "次数使用完了哦,勾搭一下群主,多申请点使用次数吧~\n微信:itwanger"; + + + public static final String CHAT_REPLY_TIME_WAITING = "chatgpt还在努力回答中,请等待几秒之后再问一次吧...."; + public static final String CHAT_REPLY_QAS_TOO_FAST = "提问太频繁了,喝一杯咖啡,暂缓一下..."; + + + public static final String TOKEN_OVER = "您的免费次数已经使用完毕了!"; + + /** + * 异步聊天时返回得提示文案 + */ + public static final String ASYNC_CHAT_TIP = "小派正在努力回答中, 耐心等待一下吧..."; + + /** + * 请切换到其他大模型 + */ + public static final String SWITCH_TO_OTHER_MODEL = "当前模型还在开发当中,请右上角下拉框切换到其他模型"; + + + public static final String SENSITIVE_QUESTION = "提问中包含敏感词:%s,请微信联系二哥「itwanger」加入白名单!"; + + /** + * 提示词标识 + */ + public static final String PROMPT_TAG = "prompt-"; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/package-info.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/package-info.java new file mode 100644 index 000000000..c50aef44e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/package-info.java @@ -0,0 +1,13 @@ +/** + * @author YiHui + * @date 2023/7/9 + */ +package com.github.paicoding.forum.service.chatai; + + +/* + 本包下,主要为派聪明相关的实现,强烈推荐配合相关的教程进行理解 + 1. https://www.yuque.com/itwanger/az7yww/oobmcdkym1232f6k?singleDoc# 《✅技术派实现自定义配置注入与动态刷新》 + 2. https://www.yuque.com/itwanger/az7yww/gegzgwh2t6zsutf3?singleDoc# 《✅技术派设计模式之策略模式在派聪明的实战演练》 + 3. https://www.yuque.com/itwanger/az7yww/dr4ga8zwraw9yopu?singleDoc# 《✅技术派设计模式之抽象设计模式在派聪明的实战演练 + */ \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/AbsChatService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/AbsChatService.java new file mode 100644 index 000000000..60bda70bb --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/AbsChatService.java @@ -0,0 +1,292 @@ +package com.github.paicoding.forum.service.chatai.service; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.senstive.SensitiveService; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.chatai.ChatFacade; +import com.github.paicoding.forum.service.chatai.bot.AiBots; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.github.paicoding.forum.service.user.service.UserAiService; +import com.google.common.collect.Sets; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * 聊天的抽象模板类 + * + * @author YiHui + * @date 2023/6/9 + */ +@Slf4j +@Service +public abstract class AbsChatService implements ChatService { + @Autowired + private UserAiService userAiService; + @Autowired + private SensitiveService sensitiveService; + @Autowired + private ChatHistoryService chatHistoryService; + + @Value("${ai.maxNum.historyContextCnt:10}") + protected Integer chatHistoryContextNum; + + + /** + * 查询已经使用的次数 + * + * @param user + * @return + */ + protected int queryUserdCnt(Long user) { + Integer cnt = RedisClient.hGet(ChatConstants.getAiRateKeyPerDay(source()), String.valueOf(user), Integer.class); + if (cnt == null) { + cnt = 0; + } + return cnt; + } + + + /** + * 使用次数+1 + * + * @param user + * @return + */ + protected Long incrCnt(Long user) { + String key = ChatConstants.getAiRateKeyPerDay(source()); + Long cnt = RedisClient.hIncr(key, String.valueOf(user), 1); + if (cnt == 1L) { + // 做一个简单的判定,如果是某个用户的第一次提问,那就刷新一下这个缓存的有效期 + // fixme 这里有个不太优雅的地方:每新来一个用户,会导致这个有效期重新刷一边,可以通过再查一下hash的key个数,如果只有一个才进行重置有效期;这里出于简单考虑,省了这一步 + RedisClient.expire(key, 86400L); + } + return cnt; + } + + /** + * 保存聊天记录 + */ + protected void recordChatItem(Long user, ChatItemVo item) { + // 保存聊天记录 + chatHistoryService.saveRecord(source(), user, ReqInfoContext.getReqInfo().getChatId(), item); + } + + /** + * 查询用户的聊天历史 + * + * @return + */ + public ChatRecordsVo getChatHistory(Long user, AISourceEnum aiSource) { + if (aiSource == null) { + aiSource = source(); + } + List chats = chatHistoryService.listHistory(source(), user, ReqInfoContext.getReqInfo().getChatId(), null); + chats.add(0, new ChatItemVo().initAnswer(String.format("开始你和派聪明(%s-大模型)的AI之旅吧!", aiSource.getName()))); + ChatRecordsVo vo = new ChatRecordsVo(); + vo.setMaxCnt(getMaxQaCnt(user)); + vo.setUsedCnt(queryUserdCnt(user)); + vo.setSource(source()); + vo.setRecords(chats); + return vo; + } + + @Override + public ChatRecordsVo chat(Long user, String question) { + // 构建提问、返回的实体类,计算使用次数,最大次数 + ChatRecordsVo res = initResVo(user, question); + if (!res.hasQaCnt()) { + return res; + } + + // 执行提问 + answer(user, res); + // 返回AI应答结果 + return res; + } + + @Override + public ChatRecordsVo chat(Long user, String question, Consumer consumer) { + ChatRecordsVo res = initResVo(user, question); + if (!res.hasQaCnt()) { + return res; + } + + // 同步聊天时,直接返回结果 + answer(user, res); + consumer.accept(res); + return res; + } + + private ChatRecordsVo initResVo(Long user, String question) { + ChatRecordsVo res = new ChatRecordsVo(); + res.setSource(source()); + int maxCnt = getMaxQaCnt(user); + int usedCnt = queryUserdCnt(user); + res.setMaxCnt(maxCnt); + res.setUsedCnt(usedCnt); + + ChatItemVo item = new ChatItemVo().initQuestion(question); + if (!res.hasQaCnt()) { + // 次数已经使用完毕,不需要再与AI进行交互了;直接返回 + item.initAnswer(ChatConstants.TOKEN_OVER); + res.setRecords(Arrays.asList(item)); + return res; + } + + // 构建多轮对话的聊天上下文 + List history = buildChatContext(user); + history.add(0, item); + res.setRecords(history); + return res; + } + + /** + * 构建聊天上下文 + * 该方法旨在为用户构建一个聊天上下文,基于用户的聊天历史记录 + * 特别注意,对于多轮对话,我们仅取最近的十条记录作为上下文,如果聊天中存在提示词,则提示词之前的聊天全部丢掉 + * + * @param user 用户ID,用于识别和获取特定用户的聊天历史 + * @return 返回一个包含聊天上下文的ChatItemVo对象列表 + */ + private List buildChatContext(Long user) { + // 用于多轮对话,我们这里只取最近的十条作为上下文传参 + List history = chatHistoryService.listHistory(source(), user, ReqInfoContext.getReqInfo().getChatId(), chatHistoryContextNum); + if (CollectionUtils.isEmpty(history) || history.size() == 1) { + return history; + } + + // 过滤掉提示词之前的消息 + Iterator iterator = history.iterator(); + boolean toRemove = false; + while (iterator.hasNext()) { + ChatItemVo tmp = iterator.next(); + if (!toRemove) { + if (tmp.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // 找到提示词,之后的全部删除 + toRemove = true; + } + } else { + iterator.remove(); + } + } + return history; + } + + protected AiChatStatEnum answer(Long user, ChatRecordsVo res) { + ChatItemVo itemVo = res.getRecords().get(0); + AiChatStatEnum ans; + List sensitiveWords = sensitiveService.contains(itemVo.getQuestion()); + if (!CollectionUtils.isEmpty(sensitiveWords)) { + itemVo.initAnswer(String.format(ChatConstants.SENSITIVE_QUESTION, sensitiveWords)); + ans = AiChatStatEnum.ERROR; + } else { + ans = doAnswer(user, itemVo); + if (ans == AiChatStatEnum.END) { + processAfterSuccessedAnswered(user, res); + } + } + return ans; + } + + /** + * 提问,并将结果写入chat + * + * @param user + * @param chat + * @return true 表示正确回答了; false 表示回答出现异常 + */ + public abstract AiChatStatEnum doAnswer(Long user, ChatItemVo chat); + + /** + * 成功返回之后的后置操作 + * + * @param user + * @param response + */ + protected void processAfterSuccessedAnswered(Long user, ChatRecordsVo response) { + // 回答成功,保存聊天记录,剩余次数-1 + response.setUsedCnt(incrCnt(user).intValue()); + recordChatItem(user, response.getRecords().get(0)); + } + + /** + * 异步聊天,即提问并不要求直接得到接口;等后台准备完毕之后再写入对应的结果 + * + * @param user + * @param question + * @param consumer 执行成功之后,直接异步回调的通知 + * @return + */ + @Override + public ChatRecordsVo asyncChat(Long user, String question, Consumer consumer) { + ChatRecordsVo res = initResVo(user, question); + if (!res.hasQaCnt()) { + // 次数使用完毕 + consumer.accept(res); + return res; + } + + List sensitiveWord = sensitiveService.contains(res.getRecords().get(0).getQuestion()); + if (!CollectionUtils.isEmpty(sensitiveWord) && !SpringUtil.getBean(AiBots.class).aiBots(user)) { + // 机器人不进行敏感词校验 + // 包含敏感词的提问,直接返回异常 + res.getRecords().get(0).initAnswer(String.format(ChatConstants.SENSITIVE_QUESTION, sensitiveWord)); + consumer.accept(res); + } else { + final ChatRecordsVo newRes = res.clone(); + AiChatStatEnum needReturn = doAsyncAnswer(user, newRes, (ans, vo) -> { + if (ans == AiChatStatEnum.END) { + // 只有最后一个会话,即ai的回答结束,才需要进行持久化,并计数 + processAfterSuccessedAnswered(user, newRes); + } else if (ans == AiChatStatEnum.ERROR) { + // 执行异常,更新AI模型 + SpringUtil.getBean(ChatFacade.class).refreshAiSourceCache(Sets.newHashSet(source())); + } + // ai异步返回结果之后,我们将结果推送给前端用户 + consumer.accept(newRes); + }); + + if (needReturn.needResponse()) { + // 异步响应时,为了避免长时间的等待,这里直接响应用户的提问,返回一个稍等得提示文案 + ChatItemVo nowItem = res.getRecords().get(0); + nowItem.initAnswer(ChatConstants.ASYNC_CHAT_TIP); + consumer.accept(res); + } + } + return res; + } + + /** + * 异步返回结果 + * + * @param user + * @param response 保存提问 & 返回的结果,最终会返回给前端用户 + * @param consumer 具体将 response 写回前端的实现策略 + * @return 返回的会话状态,控制是否需要将结果直接返回给前端 + */ + public abstract AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo response, BiConsumer consumer); + + /** + * 查询当前用户最多可提问的次数 + * + * @param user + * @return + */ + protected int getMaxQaCnt(Long user) { + return userAiService.getMaxChatCnt(user); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatHistoryService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatHistoryService.java new file mode 100644 index 000000000..bb8b9bcaa --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatHistoryService.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.service.chatai.service; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatSessionItemVo; + +import java.util.List; + +/** + * 对话会话记录服务 + * + * @author YiHui + * @date 2025/2/7 + */ +public interface ChatHistoryService { + /** + * 获取对话列表 + * + * @param source AI模型 + * @return + */ + List listChatSessions(AISourceEnum source, Long userId); + + /** + * 获取对话记录 + * + * @param source AI模型 + * @param chatId 对话id + * @param size 记录条数 + * @return 对话记录 + */ + List listHistory(AISourceEnum source, Long userId, String chatId, Integer size); + + /** + * 保存最新的一条对话内容 + * + * @param source AI模型 + * @param chatId 对话id + * @param item 对话内容 + */ + void saveRecord(AISourceEnum source, Long userId, String chatId, ChatItemVo item); + + + Boolean updateChatSessionName(AISourceEnum source, String chatId, String title, Long userId); + + Boolean removeChatSession(AISourceEnum source, String chatId, Long userId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatService.java new file mode 100644 index 000000000..1e171ac69 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatService.java @@ -0,0 +1,69 @@ +package com.github.paicoding.forum.service.chatai.service; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; + +import java.util.function.Consumer; + +/** + * @author YiHui + * @date 2023/6/9 + */ +public interface ChatService { + + /** + * 具体AI选择 + * + * @return + */ + AISourceEnum source(); + + /** + * 是否时异步优先 + * + * @return + */ + default boolean asyncFirst() { + return true; + } + + /** + * 开始进入聊天 + * + * @param user 提问人 + * @param question 聊天的问题 + * @return 返回的结果 + */ + ChatRecordsVo chat(Long user, String question); + + /** + * 开始进入聊天 + * + * @param user 提问人 + * @param question 聊天的问题 + * @param consumer 接收到AI返回之后可执行的回调 + * @return 同步直接返回的结果 + */ + ChatRecordsVo chat(Long user, String question, Consumer consumer); + + /** + * 异步聊天 + * + * @param user + * @param question + * @param consumer 执行成功之后,直接异步回调的通知 + * @return 同步直接返回的结果 + */ + ChatRecordsVo asyncChat(Long user, String question, Consumer consumer); + + + /** + * 查询聊天历史 + * + * @param user + * @param aiSource + * @return + */ + ChatRecordsVo getChatHistory(Long user, AISourceEnum aiSource); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatServiceFactory.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatServiceFactory.java new file mode 100644 index 000000000..4a026857a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatServiceFactory.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.service.chatai.service; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.google.common.collect.Maps; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * @author YiHui + * @date 2023/7/2 + */ +@Component +public class ChatServiceFactory { + private final Map chatServiceMap; + + + public ChatServiceFactory(List chatServiceList) { + chatServiceMap = Maps.newHashMapWithExpectedSize(chatServiceList.size()); + for (ChatService chatService : chatServiceList) { + chatServiceMap.put(chatService.source(), chatService); + } + } + + public ChatService getChatService(AISourceEnum aiSource) { + return chatServiceMap.get(aiSource); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatgptService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatgptService.java new file mode 100644 index 000000000..79cf3c6e8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/ChatgptService.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.service.chatai.service; + +/** + * @author YiHui + * @date 2023/6/2 + */ +public interface ChatgptService { + + /** + * 判断是否在会话中 + * + * @param wxUuid + * @return + */ + boolean inChat(String wxUuid, String content); + + /** + * 开始进入聊天 + * + * @param content 输入的内容 + * @return chatgpt返回的结果 + */ + String chat(String wxUuid, String content); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/history/ChatHistoryServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/history/ChatHistoryServiceImpl.java new file mode 100644 index 000000000..bc52e30e3 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/history/ChatHistoryServiceImpl.java @@ -0,0 +1,161 @@ +package com.github.paicoding.forum.service.chatai.service.history; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatSessionItemVo; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.service.chatai.bot.AiBots; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.github.paicoding.forum.service.chatai.service.ChatHistoryService; +import com.github.paicoding.forum.service.user.service.UserAiService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 对话历史记录 + * + * @author YiHui + * @date 2025/2/7 + */ +@Service +public class ChatHistoryServiceImpl implements ChatHistoryService { + @Autowired + private UserAiService userAiService; + @Autowired + private AiBots aiBots; + + /** + * 列出聊天会话 + *

+ * 根据用户ID和AI源枚举获取聊天会话列表从Redis中通过哈希结构存储的键值对获取所有会话项, + * 并按更新时间降序排序返回 + * + * @param source AI源枚举,用于区分不同的AI来源 + * @param userId 用户ID,用于获取特定用户的聊天会话 + * @return 返回一个ChatSessionItemVo对象列表,包含用户的聊天会话项 + */ + @Override + public List listChatSessions(AISourceEnum source, Long userId) { + // 构造Redis中哈希结构的键 + String key = ChatConstants.getAiChatListKey(source, userId); + // 从Redis中获取所有会话项,使用hGetAll方法获取哈希表中所有的字段和值 + Map map = RedisClient.hGetAll(key, ChatSessionItemVo.class); + // 将Map中的值转换为List + List list = new ArrayList<>(map.values()); + // 对列表按更新时间降序排序 + list.sort((o1, o2) -> o2.getUpdateTime().compareTo(o1.getUpdateTime())); + // 返回排序后的列表 + return list; + } + + @Override + public List listHistory(AISourceEnum source, Long userId, String chatId, Integer size) { + size = size == null ? 50 : size; + List list = RedisClient.lRange(getChatIdKey(source, userId, chatId), 0, size, ChatItemVo.class); + + // 对于特殊的交互机器人,自动补齐相关的提示词 + ChatItemVo prompt = aiBots.autoBuildPrompt(userId, chatId); + if (prompt != null) { + list.add(0, prompt); + } + return list; + } + + /** + * 保存聊天记录 + * + * @param source 聊天来源,用于区分不同的聊天场景或平台 + * @param userId 用户ID,用于关联用户信息 + * @param chatId 聊天ID,用于标识特定的聊天会话 + * @param item 聊天项内容,包括用户的问题和AI的回答 + */ + @Override + public void saveRecord(AISourceEnum source, Long userId, String chatId, ChatItemVo item) { + // 写入 MySQL + userAiService.pushChatItem(source, userId, item); + + // 更新redis缓存数据 + String key = getChatIdKey(source, userId, chatId); + RedisClient.lPush(key, item); + + // 维护对话记录 + String sessionKey = ChatConstants.getAiChatListKey(source, userId); + ChatSessionItemVo session = RedisClient.hGet(sessionKey, chatId, ChatSessionItemVo.class); + if (session == null) { + // 如果当前会话不存在,则创建新会话记录 + session = new ChatSessionItemVo(); + session.setChatId(chatId); + session.setTitle(!item.getQuestion().startsWith(ChatConstants.PROMPT_TAG) ? item.getQuestion() : item.getQuestion().substring(ChatConstants.PROMPT_TAG.length())); + session.setCreatTime(System.currentTimeMillis()); + session.setUpdateTime(session.getCreatTime()); + session.setQasCnt(1); + } else { + // 如果会话已存在,则更新会话记录 + session.setUpdateTime(System.currentTimeMillis()); + session.setQasCnt(session.getQasCnt() + 1); + } + RedisClient.hSet(sessionKey, chatId, session); + + // 限制对话记录数量,最多保存五百条历史聊天记录 + if (session.getQasCnt() > ChatConstants.MAX_HISTORY_RECORD_ITEMS) { + RedisClient.lTrim(key, 0, ChatConstants.MAX_HISTORY_RECORD_ITEMS); + } + + } + + /** + * 更新聊天会话的名称 + * + * @param source 聊天会话的来源,用于区分不同的AI来源 + * @param chatId 聊天会话的唯一标识符 + * @param title 新的聊天会话名称 + * @param userId 用户的唯一标识符 + * @return 如果会话名称被更新,则返回true;否则返回false + */ + @Override + public Boolean updateChatSessionName(AISourceEnum source, String chatId, String title, Long userId) { + // 构造Redis中存储聊天列表的键 + String key = ChatConstants.getAiChatListKey(source, userId); + + // 从Redis中获取指定聊天会话的详细信息 + ChatSessionItemVo item = RedisClient.hGet(key, chatId, ChatSessionItemVo.class); + + // 检查获取到的聊天会话信息是否不为空,并且新标题与旧标题不同 + if (item != null && !Objects.equals(item.getTitle(), title)) { + // 更新聊天会话的标题 + item.setTitle(title); + + // 将更新后的聊天会话信息保存回Redis + return RedisClient.hSet(key, chatId, item); + } + // 如果聊天会话信息未更改,则直接返回true + return true; + } + + /** + * 重写移除聊天会话的方法 + * + * @param source 数据源枚举,用于区分不同的AI来源 + * @param chatId 聊天会话的唯一标识符 + * @param userId 用户的唯一标识符 + * @return 返回操作的布尔结果,表示是否成功移除会话 + */ + @Override + public Boolean removeChatSession(AISourceEnum source, String chatId, Long userId) { + // 构造Redis中AI聊天列表的键 + String key = ChatConstants.getAiChatListKey(source, userId); + // 使用Redis的hDel命令移除指定的聊天会话,并返回操作结果 + RedisClient.hDel(key, chatId); + return true; + } + + private String getChatIdKey(AISourceEnum source, Long userId, String chatId) { + return StringUtils.isBlank(chatId) ? ChatConstants.getAiHistoryRecordsKey(source, userId) : ChatConstants.getAiHistoryRecordsKey(source, userId + ":" + chatId); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/ali/AliAiServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/ali/AliAiServiceImpl.java new file mode 100644 index 000000000..b78582b42 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/ali/AliAiServiceImpl.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.service.chatai.service.impl.ali; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import com.github.paicoding.forum.service.chatai.service.impl.zhipu.ZhipuIntegration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +@Slf4j +@Service +public class AliAiServiceImpl extends AbsChatService { + + @Autowired + private AliIntegration aliIntegration; + + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + if (aliIntegration.directReturn(user, chat)) { + return AiChatStatEnum.END; + } + return AiChatStatEnum.ERROR; + } + + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo chatRes, BiConsumer consumer) { + aliIntegration.streamReturn(user, chatRes, consumer); + return AiChatStatEnum.IGNORE; + } + + @Override + public AISourceEnum source() { + return AISourceEnum.ALI_AI; + } + + @Override + public boolean asyncFirst() { + // true 表示优先使用异步返回; false 表示同步等待结果 + return true; + } + +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/ali/AliIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/ali/AliIntegration.java new file mode 100644 index 000000000..9fe66119a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/ali/AliIntegration.java @@ -0,0 +1,148 @@ +package com.github.paicoding.forum.service.chatai.service.impl.ali; + +import cn.idev.excel.util.StringUtils; +import com.alibaba.dashscope.aigc.generation.Generation; +import com.alibaba.dashscope.aigc.generation.GenerationParam; +import com.alibaba.dashscope.aigc.generation.GenerationResult; +import com.alibaba.dashscope.common.Message; +import com.alibaba.dashscope.common.ResultCallback; +import com.alibaba.dashscope.common.Role; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.utils.JsonUtils; +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.zhipu.oapi.service.v4.model.ChatMessageAccumulator; +import com.zhipu.oapi.service.v4.model.ModelData; +import io.reactivex.Flowable; +import lombok.Data; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.function.BiConsumer; + +@Slf4j +@Setter +@Component +public class AliIntegration { + @Autowired + private AliConfig config; + + public void streamReturn(Long user, ChatRecordsVo chatRecord, BiConsumer callback) { + try { + ChatItemVo item = chatRecord.getRecords().get(0); + + Generation gen = new Generation(); + // 支持上下文的多轮聊天 + List userMsgList = ChatConstants.toMsgList(chatRecord.getRecords(), this::toMsg); + GenerationParam param = GenerationParam.builder() + .model(config.getModel()) + .messages(userMsgList) + .resultFormat(GenerationParam.ResultFormat.MESSAGE) + .incrementalOutput(true) + .build(); + Semaphore semaphore = new Semaphore(0); + StringBuilder fullContent = new StringBuilder(); + + gen.streamCall(param, new ResultCallback() { + @Override + public void onEvent(GenerationResult message) { + String content = message.getOutput().getChoices().get(0).getMessage().getContent(); + fullContent.append(content); + log.info("Received message: {}", JsonUtils.toJson(message)); + item.appendAnswer(content); + callback.accept(AiChatStatEnum.MID, chatRecord); + } + + @Override + public void onError(Exception err) { + callback.accept(AiChatStatEnum.ERROR, chatRecord); + log.error("Exception occurred: {}", err.getMessage()); + semaphore.release(); + } + + @Override + public void onComplete() { + item.setAnswerType(ChatAnswerTypeEnum.STREAM_END); + callback.accept(AiChatStatEnum.END, chatRecord); + log.info("Completed"); + semaphore.release(); + } + }); + + semaphore.acquire(); + log.info("Full content: \n{}", fullContent.toString()); + } catch (ApiException | NoApiKeyException | InputRequiredException | InterruptedException e) { + log.error("An exception occurred: {}", e.getMessage()); + } + } + + @Component + @ConfigurationProperties(prefix = "ali") + @Data + public static class AliConfig { + public String model; + } + + public boolean directReturn(Long user, ChatItemVo chat) { + Generation gen = new Generation(); + Message systemMsg = Message.builder() + .role(Role.SYSTEM.getValue()) + .content("You are a helpful assistant.") + .build(); + Message userMsg = Message.builder() + .role(Role.USER.getValue()) + .content(chat.getQuestion()) + .build(); + GenerationParam param = GenerationParam.builder() + .model(config.getModel()) + .messages(Arrays.asList(systemMsg, userMsg)) + .resultFormat(GenerationParam.ResultFormat.MESSAGE) + .build(); + + try { + GenerationResult invokeModelApiResp = gen.call(param); + + chat.initAnswer(JsonUtil.toStr(invokeModelApiResp), ChatAnswerTypeEnum.JSON); + log.info("阿里 AI 试用! 传参:{}, 返回:{}", chat, invokeModelApiResp); + } catch (NoApiKeyException | InputRequiredException e) { + throw new RuntimeException(e); + } + + return true; + } + + public static Flowable mapStreamToAccumulator(Flowable flowable) { + return flowable.map(chunk -> { + return new ChatMessageAccumulator(chunk.getChoices().get(0).getDelta(), null, chunk.getChoices().get(0), chunk.getUsage(), chunk.getCreated(), chunk.getId()); + }); + } + + private List toMsg(ChatItemVo item) { + List list = new ArrayList<>(2); + if (item.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // 提示词消息 + list.add(Message.builder().role(Role.SYSTEM.getValue()).content(item.getQuestion().substring(ChatConstants.PROMPT_TAG.length())).build()); + return list; + } + + // 用户问答 + list.add(Message.builder().role(Role.USER.getValue()).content(item.getQuestion()).build()); + if (StringUtils.isNotBlank(item.getAnswer())) { + list.add(Message.builder().role(Role.ASSISTANT.getValue()).content(item.getAnswer()).build()); + } + return list; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptAiServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptAiServiceImpl.java new file mode 100644 index 000000000..9f7a8cc62 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptAiServiceImpl.java @@ -0,0 +1,97 @@ +package com.github.paicoding.forum.service.chatai.service.impl.chatgpt; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import com.plexpt.chatgpt.listener.AbstractStreamListener; +import lombok.extern.slf4j.Slf4j; +import okhttp3.sse.EventSource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +/** + * @author YiHui + * @date 2023/6/12 + */ +@Slf4j +@Service +public class ChatGptAiServiceImpl extends AbsChatService { + @Autowired + private ChatGptIntegration chatGptIntegration; + + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + if (chatGptIntegration.directReturn(user, chat)) { + return AiChatStatEnum.END; + } + return AiChatStatEnum.ERROR; + } + + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo chatRes, BiConsumer consumer) { + ChatItemVo item = chatRes.getRecords().get(0); + AbstractStreamListener listener = new AbstractStreamListener() { + @Override + public void onMsg(String message) { + // 成功返回结果的场景 + if (StringUtils.isNotBlank(message)) { + item.appendAnswer(message); + consumer.accept(AiChatStatEnum.MID, chatRes); + if (log.isDebugEnabled()) { + log.debug("ChatGpt返回内容: {}", lastMessage); + } + } + } + + @Override + public void onClosed(EventSource eventSource) { + super.onClosed(eventSource); + // 检查是否正常结束对话 + if (item.getAnswerType() != ChatAnswerTypeEnum.STREAM_END) { + // 主动结束这一次的对话 + if (StringUtils.isBlank(lastMessage)) { + item.appendAnswer("大模型超时未返回结果,主动关闭会话;请重新提问吧\n").setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.ERROR, chatRes); + } else { + item.appendAnswer("\n").setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.END, chatRes); + } + } + } + + @Override + public void onError(Throwable throwable, String response) { + // 返回异常的场景 + item.appendAnswer("Error:" + (StringUtils.isBlank(response) ? throwable.getMessage() : response)) + .setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.ERROR, chatRes); + } + }; + + // 注册回答结束的回调钩子 + listener.setOnComplate((s) -> { + item.appendAnswer("\n") + .setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.END, chatRes); + }); + chatGptIntegration.streamReturn(user, chatRes.getRecords(), listener); + return AiChatStatEnum.IGNORE; + } + + @Override + public AISourceEnum source() { + return AISourceEnum.CHAT_GPT_3_5; + } + + @Override + public boolean asyncFirst() { + // true 表示优先使用异步返回; false 表示同步等待结果 + return true; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptIntegration.java new file mode 100644 index 000000000..bcd8bdbd7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptIntegration.java @@ -0,0 +1,277 @@ +package com.github.paicoding.forum.service.chatai.service.impl.chatgpt; + +import cn.hutool.core.util.RandomUtil; +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.core.net.ProxyCenter; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.plexpt.chatgpt.ChatGPT; +import com.plexpt.chatgpt.ChatGPTStream; +import com.plexpt.chatgpt.entity.billing.CreditGrantsResponse; +import com.plexpt.chatgpt.entity.chat.ChatChoice; +import com.plexpt.chatgpt.entity.chat.ChatCompletion; +import com.plexpt.chatgpt.entity.chat.ChatCompletionResponse; +import com.plexpt.chatgpt.entity.chat.Message; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import okhttp3.sse.EventSourceListener; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.net.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + + +/** + * chatgpt的交互封装集成 + * + * @author YiHui + * @date 2023/4/20 + */ +@Slf4j +@Service +public class ChatGptIntegration { + @Autowired + private ChatGptConfig config; + + @Data + @Configuration + @ConfigurationProperties(prefix = "chatgpt") + public static class ChatGptConfig { + /** + * 默认的模型 + */ + private AISourceEnum main; + private Map conf; + } + + @Data + public static class GptConf { + private List keys; + private boolean proxy; + private String apiHost; + private int timeOut; + private int maxToken; + + public String fetchKey() { + int index = RandomUtil.randomInt(keys.size()); + return keys.get(index); + } + } + + public static ChatCompletion.Model parse2GptMode(AISourceEnum model) { + if (model == AISourceEnum.CHAT_GPT_4) { + return ChatCompletion.Model.GPT_4; + } + return ChatCompletion.Model.GPT_3_5_TURBO; + } + + @PostConstruct + public void init() { + log.info("ChatGpt配置初始化完成: {}", config); + } + + /** + * 每个用户的会话缓存 + */ + public LoadingCache, ImmutablePair> cacheStream; + + @PostConstruct + public void initKey() { + cacheStream = CacheBuilder.newBuilder().expireAfterWrite(300, TimeUnit.SECONDS) + .build(new CacheLoader, ImmutablePair>() { + @Override + public ImmutablePair load(ImmutablePair s) throws Exception { + return ImmutablePair.of(null, null); + } + }); + } + + /** + * 基于routingkey进行路由,创建一个简单的GPTClient + * + * @param routingKey + * @return + */ + private ChatGPT simpleGPT(Long routingKey, AISourceEnum model) { + GptConf conf = config.getConf().getOrDefault(model, config.getConf().get(config.getMain())); + Proxy proxy = conf.isProxy() ? ProxyCenter.loadProxy(String.valueOf(routingKey)) : Proxy.NO_PROXY; + + return ChatGPT.builder().apiKeyList(conf.getKeys()).proxy(proxy).apiHost(conf.getApiHost()) //反向代理地址 + .timeout(conf.getTimeOut()).build().init(); + } + + /** + * 基于routingkey进行路由,创建一个简单的流式GPTClientStream + * + * @param routingKey + * @return + */ + private ChatGPTStream simpleStreamGPT(Long routingKey, AISourceEnum model) { + GptConf conf = config.getConf().getOrDefault(model, config.getConf().get(config.getMain())); + Proxy proxy = conf.isProxy() ? ProxyCenter.loadProxy(String.valueOf(routingKey)) : Proxy.NO_PROXY; + + return ChatGPTStream.builder().timeout(conf.getTimeOut()).apiKey(conf.fetchKey()).proxy(proxy) + .apiHost(conf.getApiHost()).build().init(); + } + + public ChatGPT getGpt(Long routingKey, AISourceEnum model) { + ImmutablePair key = ImmutablePair.of(routingKey, model); + ImmutablePair pair = cacheStream.getUnchecked(key); + ChatGPT gpt = pair.left; + if (gpt == null) { + gpt = simpleGPT(routingKey, model); + cacheStream.put(key, ImmutablePair.of(gpt, pair.right)); + } + return gpt; + } + + public ChatGPTStream getGptStream(Long routingKey, AISourceEnum model) { + ImmutablePair key = ImmutablePair.of(routingKey, model); + ImmutablePair pair = cacheStream.getUnchecked(key); + ChatGPTStream gpt = pair.right; + if (gpt == null) { + gpt = simpleStreamGPT(routingKey, model); + cacheStream.put(key, ImmutablePair.of(pair.left, gpt)); + } + return gpt; + } + + /** + * 账户信息 + * + * @return + */ + public CreditGrantsResponse creditInfo(AISourceEnum model) { + CreditGrantsResponse response = getGpt(0L, model).creditGrants(); + return response; + } + + public boolean directReturn(Long routingKey, ChatItemVo chat) { + + AISourceEnum selectModel = config.getMain(); + GptConf conf = config.getConf().getOrDefault(selectModel, config.getConf().get(config.getMain())); + ChatGPT gpt = getGpt(routingKey, config.getMain()); + try { + ChatCompletion chatCompletion = ChatCompletion.builder().model(parse2GptMode(selectModel).getName()) + .messages(Arrays.asList(Message.of(chat.getQuestion()))).maxTokens(conf.getMaxToken()).build(); + ChatCompletionResponse response = gpt.chatCompletion(chatCompletion); + List list = response.getChoices(); + chat.initAnswer(JsonUtil.toStr(list), ChatAnswerTypeEnum.JSON); + log.info("chatgpt试用! 传参:{}, 返回:{}", chat, list); + return true; + } catch (Exception e) { + // 对于系统异常,不用继续等待了 + chat.initAnswer(e.getMessage()); + log.info("chatgpt执行异常! key:{}", chat, e); + return false; + } + } + + /** + * 异步流式返回 + * + * @param routingKey + * @param chat + * @param listener + * @return + */ + public boolean streamReturn(Long routingKey, ChatItemVo chat, EventSourceListener listener) { + AISourceEnum selectModel = config.getMain(); + GptConf conf = config.getConf().getOrDefault(selectModel, config.getConf().get(config.getMain())); + ChatGPTStream chatGPTStream = simpleStreamGPT(routingKey, selectModel); + + ChatCompletion chatCompletion = ChatCompletion.builder().model(parse2GptMode(selectModel).getName()) + .messages(toMsg(chat)).maxTokens(conf.getMaxToken()).build(); + chatGPTStream.streamChatCompletion(chatCompletion, listener); + return true; + } + + /** + * 多轮对话,传递历史聊天上下文 + *

+ * 该方法负责将给定的聊天记录列表转换为消息列表,并使用选定的模型进行流式聊天完成处理 + * + * @param routingKey 路由键,用于选择不同的代理 + * @param chatList 聊天记录列表,包含多个ChatItemVo对象,最新的问答在前面 + * @param listener 事件源监听器,用于处理流式处理过程中的事件 + * @return 总是返回true,表示方法执行过程中的一个固定行为 + */ + public boolean streamReturn(Long routingKey, List chatList, EventSourceListener listener) { + // 选择要使用的模型 + AISourceEnum selectModel = config.getMain(); + // 获取配置,如果未找到对应模型的配置,则使用主配置 + GptConf conf = config.getConf().getOrDefault(selectModel, config.getConf().get(config.getMain())); + // 创建一个流式聊天GPT实例 + ChatGPTStream chatGPTStream = simpleStreamGPT(routingKey, selectModel); + + // 构建多轮聊天的上下文 + List msgList = ChatConstants.toMsgList(chatList, this::toMsg); + // 构建聊天完成对象,包括选定的模型、消息列表和最大令牌数配置 + ChatCompletion chatCompletion = ChatCompletion.builder().model(parse2GptMode(selectModel).getName()) + .messages(msgList).maxTokens(conf.getMaxToken()).build(); + + // 使用流式处理聊天完成,并通过监听器处理事件 + chatGPTStream.streamChatCompletion(chatCompletion, listener); + + // 固定返回值,表示方法执行完毕 + return true; + } + + /** + * 一个基础的chatgpt问答, 给微信公众号自动问答使用 + * + * @param routingKey + * @param record + * @return + */ + public boolean directReturn(Long routingKey, ChatRecordWxVo record) { + AISourceEnum selectModel = config.getMain(); + GptConf conf = config.getConf().getOrDefault(selectModel, config.getConf().get(config.getMain())); + ChatGPT gpt = getGpt(routingKey, config.getMain()); + try { + ChatCompletion chatCompletion = ChatCompletion.builder().model(parse2GptMode(selectModel).getName()) + .messages(Arrays.asList(Message.of(record.getQas()))).maxTokens(conf.getMaxToken()).build(); + ChatCompletionResponse response = gpt.chatCompletion(chatCompletion); + List list = response.getChoices(); + log.info("chatgpt试用! 传参:{}, 返回:{}", record.getQas(), list); + record.setRes(list); + return true; + } catch (Exception e) { + // 对于系统异常,不用继续等待了 + record.setSysErr(e.getMessage()); + log.info("chatgpt执行异常! key:{}", record.getQas(), e); + return false; + } + } + + private List toMsg(ChatItemVo item) { + List list = new ArrayList<>(2); + if (item.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // 提示词,构建完之后直接返回 + list.add(Message.ofSystem(item.getQuestion().substring(ChatConstants.PROMPT_TAG.length()))); + return list; + } + + // 用户问答消息 + list.add(Message.of(item.getQuestion())); + if (StringUtils.isNotBlank(item.getAnswer())) { + list.add(Message.ofAssistant(item.getAnswer())); + } + return list; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptWxServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptWxServiceImpl.java new file mode 100644 index 000000000..698195d9f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatGptWxServiceImpl.java @@ -0,0 +1,174 @@ +package com.github.paicoding.forum.service.chatai.service.impl.chatgpt; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.github.paicoding.forum.service.chatai.service.ChatgptService; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.service.UserService; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * chatgpt 服务: + *

+ * 1. 同一个用户,只有50次的提问机会,采用redis计数 + * 2. 因为微信有5s的自动回复超时,因此需要做一个容错兼容,当执行超过3.5s就提前返回,将结果保存到内存中,等待下次交互再进行返回 + * + * @author YiHui + * @date 2023/6/2 + */ +@Slf4j +@Service +public class ChatGptWxServiceImpl implements ChatgptService { + @Autowired + private ChatGptIntegration chatGptHelper; + private LoadingCache chatCache = CacheBuilder.newBuilder() + .expireAfterWrite(10, TimeUnit.MINUTES) + .maximumSize(50).build(new CacheLoader() { + @Override + public ChatRecordWxVo load(Long userId) throws Exception { + return new ChatRecordWxVo(); + } + }); + + private boolean rateLimit(Long userId) { + Integer cnt = RedisClient.hGet(ChatConstants.getAiRateKey(AISourceEnum.CHAT_GPT_3_5), String.valueOf(userId), Integer.class); + if (cnt == null) { + cnt = 0; + } + return cnt < ChatConstants.MAX_CHATGPT_QAS_CNT; + } + + private Long incrCnt(Long userId) { + return RedisClient.hIncr(ChatConstants.getAiRateKey(AISourceEnum.CHAT_GPT_3_5), String.valueOf(userId), 1); + } + + @Autowired + private UserService userService; + + + @Override + public boolean inChat(String wxUuid, String content) { + if (content != null && content.toLowerCase().trim().startsWith("chat")) { + return true; + } + + UserDO user = userService.getWxUser(wxUuid); + if (user == null) { + return false; + } + + // 存在会话记录,表示在会话中 + ReqInfoContext.getReqInfo().setUserId(user.getId()); + chatCache.cleanUp(); + return chatCache.getIfPresent(user.getId()) != null; + } + + @Override + public String chat(String wxUuid, String content) { + if (content.toLowerCase().trim().startsWith("chat")) { + // 开始会话 + UserDO user = userService.getWxUser(wxUuid); + if (user == null) { + return ChatConstants.CHAT_REPLY_RECOMMEND; + } + + chatCache.put(user.getId(), new ChatRecordWxVo()); + return ChatConstants.CHAT_REPLY_BEGIN; + } + + // 正常对话 + Long userId = ReqInfoContext.getReqInfo().getUserId(); + if (content.toLowerCase().trim().equalsIgnoreCase("end") || content.trim().startsWith("结束")) { + // 结束会话 + chatCache.invalidate(userId); + chatCache.cleanUp(); + return ChatConstants.CHAT_REPLY_OVER; + } + + if (!rateLimit(userId)) { + // 次数已经用完了,直接返回 + chatCache.cleanUp(); + return ChatConstants.CHAT_REPLY_CNT_OVER; + } + + // 判断用户的上一次访问结果有没有正确返回,如果没有,那么这一次的交互不响应,直接返回上一次的返回结果; + ChatRecordWxVo chatRecord = chatCache.getUnchecked(userId); + if (System.currentTimeMillis() - chatRecord.getQasTime() < ChatConstants.QAS_TIME_INTERVAL) { + // 限制交互频率 + if (chatRecord.canReply()) { + // 上次没有回复时;如果现在有结论了,那就回复一下 + return chatRecord.reply(); + } else { + return ChatConstants.CHAT_REPLY_QAS_TOO_FAST; + } + } + + + // 执行正常的提问、应答; 针对上次结果还没有拿到的场景做一个兼容,只有拿到结果之后,才继续响应后续的问答 + if (StringUtils.isBlank(chatRecord.getQas()) || chatRecord.isLastReturn()) { + // 首次提问 或者上次的提问正确返回了结果 + ChatRecordWxVo newRecord = doQuery(userId, content, chatRecord); + if (newRecord.canReply()) { + return chatRecord.reply(); + } else { + // 只有超时没拿到结果的场景,会走这里 + return ChatConstants.CHAT_REPLY_TIME_WAITING; + } + } else if (chatRecord.canReply()) { + // 判断上次的结果是否已经获取到了 + return chatRecord.reply(); + } else { + // 结果还没有拿到,继续等待 + return ChatConstants.CHAT_REPLY_TIME_WAITING; + } + } + + /** + * 执行具体的chatgpt请求,并做一个超时的限制 + * + * @param userId + * @param content + * @param currentChat + * @return + */ + private ChatRecordWxVo doQuery(Long userId, String content, ChatRecordWxVo currentChat) { + // 访问计数+1 + Long cnt = incrCnt(userId); + + // 重新构建当前的聊天记录 + ChatRecordWxVo newRecord = new ChatRecordWxVo().setPre(currentChat) + .setQasIndex(Optional.ofNullable(cnt).orElse(1L).intValue()); + newRecord.setQas(content).setLastReturn(false).setQasTime(System.currentTimeMillis()); + currentChat.setNext(newRecord); + chatCache.put(userId, newRecord); + + + try { + AsyncUtil.callWithTimeLimit(3500, TimeUnit.MILLISECONDS, () -> chatGptHelper.directReturn(userId, newRecord)); + } catch (TimeoutException | InterruptedException e) { + // 超时中断的场景 + newRecord.setLastReturn(false); + } catch (Exception e) { + log.warn("chatgpt出现了非预期异常! content:{}", content, e); + newRecord.setLastReturn(true); + if (newRecord.getSysErr() == null) { + newRecord.setSysErr(e.getMessage()); + } + } + return newRecord; + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatRecordWxVo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatRecordWxVo.java new file mode 100644 index 000000000..f2c15bc4b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/chatgpt/ChatRecordWxVo.java @@ -0,0 +1,66 @@ +package com.github.paicoding.forum.service.chatai.service.impl.chatgpt; + +import com.plexpt.chatgpt.entity.chat.ChatChoice; +import lombok.Data; +import lombok.experimental.Accessors; +import org.springframework.util.CollectionUtils; + +import java.util.List; + +@Data +@Accessors(chain = true) +public class ChatRecordWxVo { + /** + * 提问的次数 + */ + private int qasIndex; + /** + * 提问内容 + */ + private String qas; + /** + * 提问时间 + */ + private Long qasTime; + private List res; + private ChatRecordWxVo next; + private ChatRecordWxVo pre; + private volatile boolean lastReturn; + private String sysErr; + + public ChatRecordWxVo() { + qasTime = 0L; + sysErr = null; + lastReturn = false; + } + + /** + * 之前没有回复过,且chatgpt出错,或者有结果了,才能继续回复 + * + * @return + */ + public boolean canReply() { + return !lastReturn && (sysErr != null || res != null); + } + + public String reply() { + lastReturn = true; + if (!CollectionUtils.isEmpty(res)) { + return buildResPrefix() + buildRes(); + } + + return buildResPrefix() + sysErr; + } + + private String buildResPrefix() { + return qasIndex + "/50: " + qas + "\n================\n"; + } + + private String buildRes() { + StringBuilder builder = new StringBuilder(); + for (ChatChoice choice : res) { + builder.append(choice.getMessage().getContent()).append("\n--------------\n\n"); + } + return builder.toString(); + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/deepseek/DeepSeekChatServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/deepseek/DeepSeekChatServiceImpl.java new file mode 100644 index 000000000..009716ba8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/deepseek/DeepSeekChatServiceImpl.java @@ -0,0 +1,135 @@ +package com.github.paicoding.forum.service.chatai.service.impl.deepseek; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import com.plexpt.chatgpt.listener.AbstractStreamListener; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Response; +import okhttp3.sse.EventSource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +/** + * deepSeek 聊天接入 + * + * @author YiHui + * @date 2025/2/6 + */ +@Slf4j +@Service +public class DeepSeekChatServiceImpl extends AbsChatService { + @Autowired + private DeepSeekIntegration deepSeekIntegration; + + /** + * 同步的响应返回结果 + * + * @param user + * @param chat + * @return + */ + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + if (deepSeekIntegration.directReturn(chat)) { + return AiChatStatEnum.END; + } + return AiChatStatEnum.ERROR; + } + + /** + * 异步流式的返回结果 + * + * @param user 用户ID,用于标识提问的用户 + * @param response 保存提问 & 返回的结果,最终会返回给前端用户 + * @param consumer 具体将 response 写回前端的实现策略 + * @return 返回聊天的状态枚举 + */ + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo response, BiConsumer consumer) { + // 获取问答中的最新的记录,用于问答 + ChatItemVo item = response.getRecords().get(0); + // 创建一个抽象流监听器来处理流式返回的结果 + AbstractStreamListener listener = new AbstractStreamListener() { + // 当连接打开时的处理 + @Override + public void onOpen(EventSource eventSource, Response response) { + super.onOpen(eventSource, response); + if (log.isDebugEnabled()) { + log.debug("正确建立了连接: {}, res: {}", eventSource, response); + } + } + + // 当连接关闭时的处理 + @Override + public void onClosed(EventSource eventSource) { + super.onClosed(eventSource); + if (log.isDebugEnabled()) { + log.debug("已经关闭了连接: {}", eventSource); + } + // 检查是否正常结束对话 + if (item.getAnswerType() != ChatAnswerTypeEnum.STREAM_END) { + // 主动结束这一次的对话 + if (StringUtils.isBlank(lastMessage)) { + item.appendAnswer("大模型超时未返回结果,主动关闭会话;请重新提问吧\n") + .setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.ERROR, response); + } else { + item.appendAnswer("\n") + .setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.END, response); + } + } + } + + // 当接收到消息时的处理 + @Override + public void onMsg(String message) { + // 成功返回结果的场景, 过滤掉开头的空行 + if (StringUtils.isNotBlank(lastMessage)) { + item.appendAnswer(message); + consumer.accept(AiChatStatEnum.MID, response); + if (log.isDebugEnabled()) { + log.debug("DeepSeek返回内容: {}", lastMessage); + } + } + } + + // 当遇到错误时的处理 + @Override + public void onError(Throwable throwable, String res) { + // 返回异常的场景 + item.appendAnswer("Error:" + (StringUtils.isBlank(res) ? throwable.getMessage() : res)) + .setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.ERROR, response); + if (log.isDebugEnabled()) { + log.debug("DeepSeek返回异常: {}", lastMessage); + } + } + }; + + // 注册回答结束的回调钩子 + listener.setOnComplate((s) -> { + if (log.isDebugEnabled()) { + log.debug("这一轮对话聊天已结束,完整的返回结果是:{}", s); + } + item.appendAnswer("\n") + .setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.END, response); + }); + // 调用深度寻求流式返回的方法 + deepSeekIntegration.streamReturn(response.getRecords(), listener); + return AiChatStatEnum.IGNORE; + } + + @Override + public AISourceEnum source() { + return AISourceEnum.DEEP_SEEK; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/deepseek/DeepSeekIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/deepseek/DeepSeekIntegration.java new file mode 100644 index 000000000..ff2a5b0af --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/deepseek/DeepSeekIntegration.java @@ -0,0 +1,194 @@ +package com.github.paicoding.forum.service.chatai.service.impl.deepseek; + +import cn.hutool.http.ContentType; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import okhttp3.sse.EventSources; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * DeepSeek的集成类,主要负责与DeepSeek进行交互 + * + * @author YiHui + * @date 2025/2/6 + */ +@Slf4j +@Component +public class DeepSeekIntegration { + @Autowired + private DeepSeekConf deepSeekConf; + + private OkHttpClient okHttpClient; + + @PostConstruct + public void init() { + this.okHttpClient = new OkHttpClient.Builder() + .connectTimeout(deepSeekConf.getTimeout(), TimeUnit.SECONDS) // 建立连接的超时时间 + .readTimeout(deepSeekConf.getTimeout(), TimeUnit.SECONDS) // 建立连接后读取数据的超时时间 + .writeTimeout(deepSeekConf.getTimeout(), TimeUnit.SECONDS) + .build(); + } + + /** + * 一次性返回的交互方式 + * todo 待实现; 目前技术派主推流式交互,暂无下面的应用场景,留待有缘人补全 + */ + public boolean directReturn(ChatItemVo item) { + return false; + } + + /** + * todo: 如果希望进行多轮对话,或者对上一次的对话进行补全,则可以在这里进行扩展,将历史的聊天记录传递给机器人,以获取更好的结果 + *

+ * 流式的操作交互方式 + * + * @param item 聊天项目,包含了用户的问题或消息 + * @param listener 事件源监听器,用于处理流式返回的数据 + */ + public void streamReturn(ChatItemVo item, EventSourceListener listener) { + // 创建一个新的聊天消息对象,设置角色为用户,并填充用户的问题 + List msg = toMsg(item); + // 执行流式聊天,传入包含用户消息的消息列表和监听器 + this.executeStreamChat(msg, listener); + } + + /** + * 多轮对话的场景,将历史聊天记录,传递给聊天机器人,以获取更好的结果 + * + * @param list 包含历史聊天记录的列表,用于构建对话上下文 + * @param listener 事件源监听器,用于处理聊天机器人的响应事件 + */ + public void streamReturn(List list, EventSourceListener listener) { + // 构建多轮聊天的会话上下文 + List msgList = ChatConstants.toMsgList(list, this::toMsg); + // 执行流式聊天,将构建好的对话上下文传递给聊天机器人,并监听响应事件 + this.executeStreamChat(msgList, listener); + } + + + /** + * 使用流式聊天接口发送聊天请求 + * 该方法将聊天请求转换为流式请求,并使用EventSource监听器处理响应 + * + * @param req 聊天请求对象,包含聊天所需的参数 + * @param listener EventSource监听器,用于处理服务器发送的事件 + */ + private void executeStreamChat(ChatReq req, EventSourceListener listener) { + // 设置请求为流式请求 + req.setStream(true); + + try { + // 创建EventSource工厂,用于生成EventSource对象 + EventSource.Factory factory = EventSources.createFactory(okHttpClient); + + // 将聊天请求对象转换为JSON字符串 + String body = JsonUtil.toStr(req); + // 构建请求对象,指定URL、认证头、内容类型头以及请求体 + Request request = new Request.Builder() + .url(deepSeekConf.getApiHost() + "/chat/completions") + .addHeader("Authorization", "Bearer " + deepSeekConf.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse(ContentType.JSON.getValue()), body)) + .build(); + // 使用工厂创建新的EventSource,并传入请求和监听器 + factory.newEventSource(request, listener); + } catch (Exception e) { + // 记录请求失败的日志 + log.error("deepseek联调请求失败: {}", req, e); + } + } + + private void executeStreamChat(List list, EventSourceListener listener) { + ChatReq req = new ChatReq(); + req.setModel("deepseek-chat"); + req.setMessages(list); + this.executeStreamChat(req, listener); + } + + @Data + @Component + @ConfigurationProperties(prefix = "deepseek") + private class DeepSeekConf { + private String apiKey; + private String apiHost; + private Long timeout; + } + + + /** + * 提问的请求实体 + * todo: 这里只封装了最基础的请求传参,更多的参数可以根据官方文档进行补全 + * + */ + @Data + public static class ChatReq { + /** + * 模型,官方当前支持两个: + * 1. deepseek-chat + * 2. deepseek-reasoner --> 推理模型 + */ + private String model; + + /** + * true 来使用流式输出 + */ + private boolean stream; + + /** + * 对话内容 + */ + private List messages; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ChatMsg { + /** + * 角色:用于AI了解它应该如何行为以及谁在发起调用 + * - system: 了解它应该如何行为以及谁在发起调用, 如 content = 你现在是一个资深后端java工程师 + * - user: 消息/提示来自最终用户或人类 + * - assistant: 消息是助手(聊天模型)的响应 --> 即ai的返回结果,在多轮对话中,我们需要将之前的聊天记录传递给机器人,以获取更好的结果 + */ + private String role; + + /** + * 具体的内容 + */ + private String content; + } + + private List toMsg(ChatItemVo item) { + List list = new ArrayList<>(2); + if (item.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // 提示词 + list.add(new ChatMsg("system", item.getQuestion().substring(ChatConstants.PROMPT_TAG.length()))); + } else { + // 用户问答 + list.add(new ChatMsg("user", item.getQuestion())); + if (StringUtils.isNotBlank(item.getAnswer())) { + list.add(new ChatMsg("assistant", item.getAnswer())); + } + } + return list; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoAiServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoAiServiceImpl.java new file mode 100644 index 000000000..628b4b8c9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoAiServiceImpl.java @@ -0,0 +1,54 @@ +package com.github.paicoding.forum.service.chatai.service.impl.doubao; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import com.volcengine.ApiException; +import com.volcengine.ark.runtime.model.completion.chat.ChatCompletionRequest; +import com.volcengine.ark.runtime.model.completion.chat.ChatMessage; +import com.volcengine.ark.runtime.model.completion.chat.ChatMessageRole; +import com.volcengine.ark.runtime.service.ArkService; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.DependsOn; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +@Slf4j +@Service +public class DoubaoAiServiceImpl extends AbsChatService { + @Autowired + private DoubaoIntegration doubaoIntegration; + + + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + return doubaoIntegration.directAnswer(user, chat); + } + + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo chatRes, BiConsumer consumer) { + return doubaoIntegration.streamAsyncAnswer(user, chatRes, consumer); + } + + @Override + public AISourceEnum source() { + return AISourceEnum.DOU_BAO_AI; + } + + @Override + public boolean asyncFirst() { + return true; + } + + +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoConfig.java new file mode 100644 index 000000000..8b16c10d7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoConfig.java @@ -0,0 +1,21 @@ +package com.github.paicoding.forum.service.chatai.service.impl.doubao; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; + +@Data +@Configuration +@ConfigurationProperties(prefix = "doubao") +public class DoubaoConfig{ + + @Value("${doubao.api-key}") + private String apiKey; + @Value("${doubao.api-host}") + private String apiHost; + @Value("${doubao.end-point}") + private String endPoint; +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoIntegration.java new file mode 100644 index 000000000..b0ae6801b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/doubao/DoubaoIntegration.java @@ -0,0 +1,150 @@ +package com.github.paicoding.forum.service.chatai.service.impl.doubao; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.volcengine.ApiException; +import com.volcengine.ark.runtime.model.completion.chat.ChatCompletionRequest; +import com.volcengine.ark.runtime.model.completion.chat.ChatMessage; +import com.volcengine.ark.runtime.model.completion.chat.ChatMessageRole; +import com.volcengine.ark.runtime.service.ArkService; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +@Slf4j +@Service +public class DoubaoIntegration { + @Autowired + private final DoubaoConfig doubaoConfig; + private final ArkService service; + + + public DoubaoIntegration(DoubaoConfig doubaoConfig) { + this.doubaoConfig = doubaoConfig; + String baseUrl = "https://ark.cn-beijing.volces.com/api/v3"; + if (!StringUtils.hasText(doubaoConfig.getApiKey())) { + log.info("豆包API KEY 未配置,停止初始化DoubaoIntegration"); + this.service = null; + return; + } + if(StringUtils.hasText(doubaoConfig.getApiHost()) ){ + baseUrl = this.doubaoConfig.getApiHost(); + }else { + log.warn("豆包API HOST 未配置,使用默认值"); + } + this.service = ArkService.builder() + .baseUrl(baseUrl) + .apiKey(this.doubaoConfig.getApiKey()) + .build(); + } + + + + + public AiChatStatEnum directAnswer(Long user, ChatItemVo chat) { + if (service == null) { + log.warn("豆包ai服务未初始化成功 目前apikey:{},目前apiHost:{}",doubaoConfig.getApiKey(),doubaoConfig.getApiHost()); + chat.initAnswer("Service not initialized"); + return AiChatStatEnum.ERROR; + } + List messages = new ArrayList<>(); + messages.add(ChatMessage.builder().role(ChatMessageRole.SYSTEM).content("你是豆包,是由字节跳动开发的 AI 人工智能助手").build()); + messages.add(ChatMessage.builder().role(ChatMessageRole.USER).content(chat.getQuestion()).build()); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model(doubaoConfig.getEndPoint()) + .messages(messages) + .build(); + + try { + String response = (String) service.createChatCompletion(request).getChoices().get(0).getMessage().getContent(); + chat.initAnswer(response, ChatAnswerTypeEnum.TEXT); + return AiChatStatEnum.END; + } catch (Exception e) { + chat.initAnswer("Error: " + e.getMessage()); + return AiChatStatEnum.ERROR; + } + } + + public AiChatStatEnum streamAsyncAnswer(Long user, ChatRecordsVo chatRes, BiConsumer consumer) { + if (service == null) { + log.warn("豆包ai服务未初始化成功 目前apikey:{},目前apiHost:{}",doubaoConfig.getApiKey(),doubaoConfig.getApiHost()); + ChatItemVo item = chatRes.getRecords().get(0); + item.appendAnswer("Service not initialized").setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.ERROR, chatRes); + return AiChatStatEnum.ERROR; + } + ChatItemVo item = chatRes.getRecords().get(0); + List messages = ChatConstants.toMsgList(chatRes.getRecords(), this::toMsg); + + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model(doubaoConfig.getEndPoint()) + .messages(messages) + .build(); + // 异步返回 + Disposable disposable = service.streamChatCompletion(request) + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.computation()) // 如果不耗时 可以更换成Schedulers.single() 减少切换上下文的开销 + .doFinally(() -> { + // 流结束的逻辑 + if(item.getAnswerType() != ChatAnswerTypeEnum.STREAM_END) { + // 检查下是不是已经结束了。 + item.appendAnswer("\n").setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.END, chatRes); + } + }) + .subscribe(choice -> { + if (!choice.getChoices().isEmpty()) { + item.appendAnswer((String) choice.getChoices().get(0).getMessage().getContent()); + consumer.accept(AiChatStatEnum.MID, chatRes); + } + }, throwable -> { + String errorMessage = "Error: " + throwable.getMessage(); + if (throwable instanceof ApiException) { + ApiException apiException = (ApiException) throwable; + errorMessage = String.format("Error: %s, Code: %s, Param: %s", + apiException.getMessage(), apiException.getCode(), apiException.getCause()); + } + item.appendAnswer(errorMessage).setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.ERROR, chatRes); + } + ); + + + return AiChatStatEnum.IGNORE; + } + + + private List toMsg(ChatItemVo item) { + List list = new ArrayList<>(2); + if (item.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // Prompt + list.add(ChatMessage.builder().role(ChatMessageRole.SYSTEM).content(item.getQuestion().substring(ChatConstants.PROMPT_TAG.length())).build()); + } else { + // 用户提问和回答 + list.add(ChatMessage.builder().role(ChatMessageRole.USER).content(item.getQuestion()).build()); + if (StringUtils.hasText(item.getAnswer())) { + list.add(ChatMessage.builder().role(ChatMessageRole.ASSISTANT).content(item.getAnswer()).build()); + } + } + return list; + } + + +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/pai/PaiAiDemoServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/pai/PaiAiDemoServiceImpl.java new file mode 100644 index 000000000..f80e54a6a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/pai/PaiAiDemoServiceImpl.java @@ -0,0 +1,63 @@ +package com.github.paicoding.forum.service.chatai.service.impl.pai; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +/** + * 技术派价值一个亿的AI + * + * @author YiHui + * @date 2023/6/9 + */ +@Service +public class PaiAiDemoServiceImpl extends AbsChatService { + + @Override + public AISourceEnum source() { + return AISourceEnum.PAI_AI; + } + + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + chat.initAnswer(qa(chat.getQuestion())); + return AiChatStatEnum.END; + } + + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo response, BiConsumer consumer) { + AsyncUtil.execute(() -> { + AsyncUtil.sleep(1500); + ChatItemVo item = response.getRecords().get(0); + item.appendAnswer(qa(item.getQuestion())); + consumer.accept(AiChatStatEnum.FIRST, response); + + AsyncUtil.sleep(1200); + item.appendAnswer("\n" + ChatConstants.SWITCH_TO_OTHER_MODEL); + item.setAnswerType(ChatAnswerTypeEnum.STREAM_END); + consumer.accept(AiChatStatEnum.END, response); + }); + return AiChatStatEnum.END; + } + + private String qa(String q) { + String ans = q.replace("吗", ""); + ans = StringUtils.replace(ans, "?", "!"); + ans = StringUtils.replace(ans, "?", "!"); + return ans; + } + + @Override + protected int getMaxQaCnt(Long user) { + return 65535; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/xunfei/XunFeiAiServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/xunfei/XunFeiAiServiceImpl.java new file mode 100644 index 000000000..1c97b342b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/xunfei/XunFeiAiServiceImpl.java @@ -0,0 +1,194 @@ +package com.github.paicoding.forum.service.chatai.service.impl.xunfei; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.WsConnectStateEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +/** + * 讯飞星火大模型 + * + * + * @author YiHui + * @date 2023/6/12 + */ +@Slf4j +@Service +public class XunFeiAiServiceImpl extends AbsChatService { + + @Autowired + private XunFeiIntegration xunFeiIntegration; + + /** + * 不支持同步提问 + * + * @param user + * @param chat + * @return + */ + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + return AiChatStatEnum.IGNORE; + } + + /** + * 异步回答提问 + * + * @param user + * @param chatRes 保存提问 & 返回的结果,最终会返回给前端用户 + * @param consumer 具体将 response 写回前端的实现策略 + */ + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo chatRes, BiConsumer consumer) { + XunFeiChatWrapper chat = new XunFeiChatWrapper(String.valueOf(user), chatRes, consumer); + chat.initAndQuestion(); + return AiChatStatEnum.IGNORE; + } + + @Override + public AISourceEnum source() { + return AISourceEnum.XUN_FEI_AI; + } + + /** + * 一个简单的ws装饰器,用于包装一下讯飞长连接的交互情况 + * 比较蛋疼的是讯飞建立连接60s没有返回主动断开,问了一次返回结果之后也主动断开,下次需要重连 + */ + @Data + public class XunFeiChatWrapper { + private OkHttpClient client; + private WebSocket webSocket; + private Request request; + + private BiConsumer onMsg; + + private XunFeiMsgListener listener; + + private ChatItemVo item; + + public XunFeiChatWrapper(String uid, ChatRecordsVo chatRes, BiConsumer consumer) { + client = xunFeiIntegration.getOkHttpClient(); + String url = xunFeiIntegration.buildXunFeiUrl(); + request = new Request.Builder().url(url).build(); + listener = new XunFeiMsgListener(uid, chatRes, consumer); + } + + /** + * 首次使用时,开启提问 + */ + public void initAndQuestion() { + webSocket = client.newWebSocket(request, listener); + } + + /** + * 追加的提问, 主要是为了复用websocket的构造参数 + */ + public void appendQuestion(String uid, ChatRecordsVo chatRes, BiConsumer consumer) { + listener = new XunFeiMsgListener(uid, chatRes, consumer); + webSocket = client.newWebSocket(request, listener); + } + + } + + @Getter + @Setter + public class XunFeiMsgListener extends WebSocketListener { + private volatile WsConnectStateEnum connectState; + + private String user; + + private ChatRecordsVo chatRecord; + + private BiConsumer callback; + + public XunFeiMsgListener(String user, ChatRecordsVo chatRecord, BiConsumer callback) { + this.connectState = WsConnectStateEnum.INIT; + this.user = user; + this.chatRecord = chatRecord; + this.callback = callback; + } + + //重写onopen + @Override + public void onOpen(WebSocket webSocket, Response response) { + super.onOpen(webSocket, response); + connectState = WsConnectStateEnum.CONNECTED; + // 连接成功之后,发送消息buildSendMsg + webSocket.send(xunFeiIntegration.buildSendMsg(user, chatRecord.getRecords())); + } + + @Override + public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) { + super.onMessage(webSocket, text); + ChatItemVo item = chatRecord.getRecords().get(0); + XunFeiIntegration.ResponseData responseData = xunFeiIntegration.parse2response(text); + if (responseData.successReturn()) { + // 真正的回答 + StringBuilder msg = new StringBuilder(); + XunFeiIntegration.Payload pl = responseData.getPayload(); + pl.getChoices().getText().forEach(s -> { + if (s.getReasoning_content() != null) { + msg.append(s.getReasoning_content()); + } + if (s.getContent() != null) { + msg.append(s.getContent()); + } + }); + item.appendAnswer(msg.toString()); + + if (responseData.firstResonse()) { + callback.accept(AiChatStatEnum.FIRST, chatRecord); + } else if (responseData.endResponse()) { + // 标记流式回答已完成 + item.setAnswerType(ChatAnswerTypeEnum.STREAM_END); + // 最后一次返回结果时,打印一下剩余的tokens + XunFeiIntegration.UsageText tokens = pl.getUsage().getText(); + log.info("使用tokens:\n" + tokens); + webSocket.close(1001, "会话结束"); + callback.accept(AiChatStatEnum.END, chatRecord); + } else { + callback.accept(AiChatStatEnum.MID, chatRecord); + } + } else { + item.initAnswer("AI返回异常:" + responseData.getHeader()); + callback.accept(AiChatStatEnum.ERROR, chatRecord); + webSocket.close(responseData.getHeader().getCode(), responseData.getHeader().getMessage()); + } + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + super.onFailure(webSocket, t, response); + log.warn("websocket 连接失败! {}", response, t); + connectState = WsConnectStateEnum.FAILED; + chatRecord.getRecords().get(0).initAnswer("讯飞AI连接失败了!" + t.getMessage()); + callback.accept(AiChatStatEnum.ERROR, chatRecord); + } + + @Override + public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + super.onClosed(webSocket, code, reason); + if (log.isDebugEnabled()) { + log.debug("连接中断! code={}, reason={}", code, reason); + } + connectState = WsConnectStateEnum.CLOSED; + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/xunfei/XunFeiIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/xunfei/XunFeiIntegration.java new file mode 100644 index 000000000..8c4304003 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/xunfei/XunFeiIntegration.java @@ -0,0 +1,342 @@ +package com.github.paicoding.forum.service.chatai.service.impl.xunfei; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URL; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +/** + * 主体来自讯飞官方java sdk + * + * + * + * @author YiHui + * @date 2023/6/12 + */ +@Slf4j +@Setter +@Component +public class XunFeiIntegration { + + @Autowired + private XunFeiConfig xunFeiConfig; + + @Getter + private OkHttpClient okHttpClient; + + @PostConstruct + public void init() { + okHttpClient = new OkHttpClient.Builder().build(); + } + + public String buildXunFeiUrl() { + try { + String authUrl = getAuthorizationUrl(xunFeiConfig.hostUrl, xunFeiConfig.apiKey, xunFeiConfig.apiSecret); + String url = authUrl.replace("https://", "wss://").replace("http://", "ws://"); + return url; + } catch (Exception e) { + log.warn("讯飞url创建失败", e); + return null; + } + } + + /** + * 构建授权url + * + * @param hostUrl + * @param apikey + * @param apisecret + * @return + * @throws Exception + */ + public String getAuthorizationUrl(String hostUrl, String apikey, String apisecret) throws Exception { + //获取host + URL url = new URL(hostUrl); + //获取鉴权时间 date + SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("GMT")); + String date = format.format(new Date()); + //获取signature_origin字段 + String builder = "host: " + url.getHost() + "\n" + + "date: " + date + "\n" + + "GET " + url.getPath() + " HTTP/1.1"; + //获得signatue + Charset charset = StandardCharsets.UTF_8; + Mac mac = Mac.getInstance("hmacsha256"); + SecretKeySpec sp = new SecretKeySpec(apisecret.getBytes(charset), "hmacsha256"); + mac.init(sp); + String signature = Base64.getEncoder().encodeToString(mac.doFinal(builder.getBytes(charset))); + //获得 authorization_origin + String authorizationOrigin = String.format("api_key=\"%s\",algorithm=\"%s\",headers=\"%s\",signature=\"%s\"", apikey, "hmac-sha256", "host date request-line", signature); + //获得authorization + String authorization = Base64.getEncoder().encodeToString(authorizationOrigin.getBytes(charset)); + //获取httpurl + HttpUrl httpUrl = HttpUrl.parse("https://" + url.getHost() + url.getPath()).newBuilder(). + addQueryParameter("authorization", authorization). + addQueryParameter("date", date). + addQueryParameter("host", url.getHost()). + build(); + return httpUrl.toString(); + } + + public String buildSendMsg(String uid, String question) { + JsonObject frame = new JsonObject(); + JsonObject header = new JsonObject(); + JsonObject chat = new JsonObject(); + JsonObject parameter = new JsonObject(); + JsonObject payload = new JsonObject(); + JsonObject message = new JsonObject(); + JsonObject text = new JsonObject(); + JsonArray ja = new JsonArray(); + + //填充header + header.addProperty("app_id", xunFeiConfig.appId); + header.addProperty("uid", uid); + //填充parameter + chat.addProperty("domain", xunFeiConfig.domain); + chat.addProperty("random_threshold", 0); + chat.addProperty("max_tokens", 1024); + chat.addProperty("auditing", "default"); + parameter.add("chat", chat); + //填充payload + text.addProperty("role", "user"); + text.addProperty("content", question); + ja.add(text); + message.add("text", ja); + payload.add("message", message); + frame.add("header", header); + frame.add("parameter", parameter); + frame.add("payload", payload); + return frame.toString(); + } + + /** + * 结合上下文的回答 + * + * @param uid + * @param items + * @return + */ + public String buildSendMsg(String uid, List items) { + JsonObject frame = new JsonObject(); + JsonObject header = new JsonObject(); + JsonObject chat = new JsonObject(); + JsonObject parameter = new JsonObject(); + JsonObject payload = new JsonObject(); + JsonObject message = new JsonObject(); + JsonArray ja = new JsonArray(); + + //填充header + header.addProperty("app_id", xunFeiConfig.appId); + header.addProperty("uid", uid); + //填充parameter + chat.addProperty("domain", xunFeiConfig.domain); + chat.addProperty("random_threshold", 0); + chat.addProperty("max_tokens", 2048); + chat.addProperty("auditing", "default"); + parameter.add("chat", chat); + + //填充payload + for (int i = items.size() - 1; i >= 0; i--) { + ChatItemVo item = items.get(i); + ja.addAll(toText(item)); + } + + message.add("text", ja); + payload.add("message", message); + frame.add("header", header); + frame.add("parameter", parameter); + frame.add("payload", payload); + return frame.toString(); + } + + /** + * 构建提问消息 + * + * @param item + * @return + */ + private static JsonArray toText(ChatItemVo item) { + JsonArray ary = new JsonArray(); + + if (item.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // 提示词 + JsonObject obj = new JsonObject(); + obj.addProperty("role", "user"); + obj.addProperty("content", item.getQuestion().substring(ChatConstants.PROMPT_TAG.length())); + ary.add(obj); + return ary; + } + + // 用户问答消息 + JsonObject obj = new JsonObject(); + obj.addProperty("role", "user"); + obj.addProperty("content", item.getQuestion()); + ary.add(obj); + if (StringUtils.isNotBlank(item.getAnswer())) { + JsonObject obj2 = new JsonObject(); + obj2.addProperty("role", "assistant"); + obj2.addProperty("content", item.getAnswer()); + ary.add(obj); + } + return ary; + } + + public ResponseData parse2response(String text) { + return JsonUtil.toObj(text, ResponseData.class); + } + + + @Component + @ConfigurationProperties(prefix = "xunfei") + @Data + public static class XunFeiConfig { + public String hostUrl = "http://spark-api.xf-yun.com/v1.1/chat"; + public String appId = ""; + public String apiKey = ""; + public String apiSecret = ""; + public String APIPassword = ""; + // 指定访问的领域,general指向V1.5版本 generalv2指向V2版本。注意:不同的取值对应的url也不一样! + public String domain = "general"; + } + + @Data + public static class ResponseData { + private Header header; + private Payload payload; + + public boolean successReturn() { + return header != null && header.code == 0; + } + + /** + * 首次返回结果 + * + * @return + */ + public boolean firstResonse() { + return header != null && "0".equalsIgnoreCase(header.status); + } + + /** + * 判断是否是最后一次返回的结果 + * + * @return + */ + public boolean endResponse() { + return header != null && "2".equalsIgnoreCase(header.status); + } + } + + @Data + public static class Header { + /** + * 错误码,0表示正常,非0表示出错;详细释义可在接口说明文档最后的错误码说明了解 + */ + private int code; + /** + * 会话是否成功的描述信息 + */ + private String message; + /** + * 会话的唯一id,用于讯飞技术人员查询服务端会话日志使用,出现调用错误时建议留存该字段 + */ + private String sid; + /** + * 会话状态,取值为[0,1,2];0代表首次结果;1代表中间结果;2代表最后一个结果 + */ + private String status; + } + + @Data + public static class Payload { + private Choices choices; + private Usage usage; + } + + @Data + public static class Choices { + /** + * 文本响应状态,取值为[0,1,2]; 0代表首个文本结果;1代表中间文本结果;2代表最后一个文本结果 + */ + private int status; + /** + * 返回的数据序号,取值为[0,9999999] + */ + private int seq; + + private List text; + } + + @Data +public static class ChoicesText { + /** + * 结果序号,取值为[0,10]; 当前为保留字段,开发者可忽略 + */ + private int index; + /** + * 角色标识,固定为assistant,标识角色为AI + */ + private String role; + /** + * AI的回答内容 + */ + private String content; + + private String reasoning_content; + } + + @Data + public static class Usage { + private UsageText text; + } + + @Data + public static class UsageText { + /** + * 保留字段,可忽略 + */ + @JsonAlias("question_tokens") + private int questionTokens; + /** + * 包含历史问题的总tokens大小 + */ + @JsonAlias("prompt_tokens") + private int promptTokens; + /** + * 回答的tokens大小 + */ + @JsonAlias("completion_tokens") + private int completionTokens; + /** + * prompt_tokens和completion_tokens的和,也是本次交互计费的tokens大小 + */ + @JsonAlias("total_tokens") + private int totalTokens; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/zhipu/ZhipuAiServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/zhipu/ZhipuAiServiceImpl.java new file mode 100644 index 000000000..3afa6d435 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/zhipu/ZhipuAiServiceImpl.java @@ -0,0 +1,55 @@ +package com.github.paicoding.forum.service.chatai.service.impl.zhipu; + +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.WsConnectStateEnum; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.service.chatai.service.AbsChatService; +import com.github.paicoding.forum.service.chatai.service.impl.xunfei.XunFeiAiServiceImpl; +import com.github.paicoding.forum.service.chatai.service.impl.xunfei.XunFeiIntegration; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +@Slf4j +@Service +public class ZhipuAiServiceImpl extends AbsChatService { + + @Autowired + private ZhipuIntegration zhipuIntegration; + + @Override + public AiChatStatEnum doAnswer(Long user, ChatItemVo chat) { + if (zhipuIntegration.directReturn(user, chat)) { + return AiChatStatEnum.END; + } + return AiChatStatEnum.ERROR; + } + + @Override + public AiChatStatEnum doAsyncAnswer(Long user, ChatRecordsVo chatRes, BiConsumer consumer) { + zhipuIntegration.streamReturn(user, chatRes, consumer); + return AiChatStatEnum.IGNORE; + } + + @Override + public AISourceEnum source() { + return AISourceEnum.ZHI_PU_AI; + } + + @Override + public boolean asyncFirst() { + // true 表示优先使用异步返回; false 表示同步等待结果 + return true; + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/zhipu/ZhipuIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/zhipu/ZhipuIntegration.java new file mode 100644 index 000000000..9ab2110f6 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/chatai/service/impl/zhipu/ZhipuIntegration.java @@ -0,0 +1,234 @@ +package com.github.paicoding.forum.service.chatai.service.impl.zhipu; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.paicoding.forum.api.model.enums.ChatAnswerTypeEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiChatStatEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.chatai.constants.ChatConstants; +import com.zhipu.oapi.ClientV4; +import com.zhipu.oapi.Constants; +import com.zhipu.oapi.service.v4.deserialize.MessageDeserializeFactory; +import com.zhipu.oapi.service.v4.model.ChatCompletionRequest; +import com.zhipu.oapi.service.v4.model.ChatMessage; +import com.zhipu.oapi.service.v4.model.ChatMessageAccumulator; +import com.zhipu.oapi.service.v4.model.ChatMessageRole; +import com.zhipu.oapi.service.v4.model.ChatTool; +import com.zhipu.oapi.service.v4.model.Choice; +import com.zhipu.oapi.service.v4.model.ModelApiResponse; +import com.zhipu.oapi.service.v4.model.ModelData; +import com.zhipu.oapi.service.v4.model.WebSearch; +import io.reactivex.Flowable; +import lombok.Data; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +@Slf4j +@Setter +@Component +public class ZhipuIntegration { + @Autowired + private ZhipuConfig config; + + public void streamReturn(Long user, ChatRecordsVo chatRecord, BiConsumer callback) { + List messages = ChatConstants.toMsgList(chatRecord.getRecords(), this::toMsg); + + ChatItemVo item = chatRecord.getRecords().get(0); + String requestId = String.format(config.requestIdTemplate, System.currentTimeMillis()); + // 函数调用参数构建部分 + List chatToolList = new ArrayList<>(); + ChatTool chatTool = new ChatTool(); + + chatTool.setType("web_search"); +// Retrieval retrieval = new Retrieval(); +// retrieval.setKnowledge_id("1826571496106102784"); + WebSearch webSearch = new WebSearch(); + webSearch.setEnable(Boolean.TRUE); + chatTool.setWeb_search(webSearch); +// chatTool.setType("code_interpreter"); + chatToolList.add(chatTool); + + // 请求参数封装 + ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() + .model(config.getModel()) + .stream(Boolean.TRUE) + .invokeMethod(Constants.invokeMethod) + .messages(messages) + .tools(chatToolList) + .userId("paicoding-" + String.valueOf(user)) + .toolChoice("auto") + .requestId(requestId) + .build(); + ClientV4 client = new ClientV4.Builder(config.apiSecretKey) + .networkConfig(300, 100, 100, 100, TimeUnit.SECONDS) + .connectionPool(new okhttp3.ConnectionPool(8, 1, TimeUnit.SECONDS)) + .build(); + + // 调用模型接口 + ModelApiResponse sseModelApiResp = client.invokeModelApi(chatCompletionRequest); + + // 序列化输出 + ObjectMapper mapper = MessageDeserializeFactory.defaultObjectMapper(); + // 忽略未知字段 + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // 处理返回结果 + if (sseModelApiResp.isSuccess()) { + AtomicBoolean isFirst = new AtomicBoolean(true); + List choices = new ArrayList<>(); + AtomicReference lastAccumulator = new AtomicReference<>(); + + mapStreamToAccumulator(sseModelApiResp.getFlowable()).doOnNext(accumulator -> { + { + if (isFirst.getAndSet(false)) { + log.info("智谱大模型开始返回结果 -> "); + } + if (accumulator.getDelta() != null && accumulator.getDelta().getTool_calls() != null) { + accumulator.getDelta().getTool_calls().forEach(toolCall -> { + log.info("tool_call: {}", toolCall); + JsonNode codeInterpreter = toolCall.get("code_interpreter"); + if (codeInterpreter != null) { + // 检查并处理 outputs 字段 + JsonNode outputs = codeInterpreter.get("outputs"); + if (outputs != null) { + outputs.forEach(output -> { + log.info("output: {}", output); + if (output.has("type") && output.get("type").asText().equals("file")) { + log.info("output file: {}", output.get("file")); + // 组装成 Markdown 返回 + String content = "![file](" + output.get("file").asText() + ")"; + item.appendAnswer(content); + callback.accept(AiChatStatEnum.MID, chatRecord); + } + }); + } + } + }); + String jsonString = mapper.writeValueAsString(accumulator.getDelta().getTool_calls()); + if (log.isDebugEnabled()) { + log.info("tool_calls: {}", jsonString); + } + } + if (accumulator.getDelta() != null && accumulator.getDelta().getContent() != null) { + String content = accumulator.getDelta().getContent(); + item.appendAnswer(content); + callback.accept(AiChatStatEnum.MID, chatRecord); + log.info("回复内容: {}", content); + } + choices.add(accumulator.getChoice()); + lastAccumulator.set(accumulator); + } + }) + .doOnComplete(() -> { + log.info("Stream completed."); + item.setAnswerType(ChatAnswerTypeEnum.STREAM_END); + callback.accept(AiChatStatEnum.END, chatRecord); + }) + .doOnError(throwable -> { + log.error("Error:", throwable); + callback.accept(AiChatStatEnum.ERROR, chatRecord); + }) // Handle errors + .blockingSubscribe();// Use blockingSubscribe instead of blockingGet() + + ChatMessageAccumulator chatMessageAccumulator = lastAccumulator.get(); + ModelData data = new ModelData(); + data.setChoices(choices); + if (chatMessageAccumulator != null) { + data.setUsage(chatMessageAccumulator.getUsage()); + data.setId(chatMessageAccumulator.getId()); + data.setCreated(chatMessageAccumulator.getCreated()); + } + data.setRequestId(chatCompletionRequest.getRequestId()); + sseModelApiResp.setFlowable(null);// 打印前置空 + sseModelApiResp.setData(data); + } + try { + log.info("model output: {}", mapper.writeValueAsString(sseModelApiResp)); + } catch (JsonProcessingException e) { + log.error("An exception occurred: {}", e.getMessage()); + throw new RuntimeException(e); + } + client.getConfig().getHttpClient().dispatcher().executorService().shutdown(); + + client.getConfig().getHttpClient().connectionPool().evictAll(); + // List all active threads + for (Thread t : Thread.getAllStackTraces().keySet()) { + log.info("Thread: " + t.getName() + " State: " + t.getState()); + } + } + + @Component + @ConfigurationProperties(prefix = "zhipu") + @Data + public static class ZhipuConfig { + public String requestIdTemplate; + public String apiSecretKey; + public String model; + } + + public boolean directReturn(Long user, ChatItemVo chat) { + List messages = new ArrayList<>(); + ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), chat.getQuestion()); + messages.add(chatMessage); + String requestId = String.format(config.requestIdTemplate, user + System.currentTimeMillis()); + + ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() + .model(Constants.ModelChatGLM4) + .stream(Boolean.FALSE) + .invokeMethod(Constants.invokeMethod) + .messages(messages) + .requestId(requestId) + .build(); + ClientV4 client = new ClientV4.Builder(config.apiSecretKey) + .networkConfig(300, 100, 100, 100, TimeUnit.SECONDS) + .connectionPool(new okhttp3.ConnectionPool(8, 1, TimeUnit.SECONDS)) + .build(); + ModelApiResponse invokeModelApiResp = client.invokeModelApi(chatCompletionRequest); + if (invokeModelApiResp.isSuccess()) { + invokeModelApiResp.getData().getChoices().forEach(choice -> { + chat.initAnswer(JsonUtil.toStr(choice.getMessage().getContent()), ChatAnswerTypeEnum.JSON); + log.info("智谱 AI 试用! 传参:{}, 返回:{}", chat, invokeModelApiResp); + }); + } + + + return true; + } + + public static Flowable mapStreamToAccumulator(Flowable flowable) { + return flowable.map(chunk -> { + return new ChatMessageAccumulator(chunk.getChoices().get(0).getDelta(), null, chunk.getChoices().get(0), chunk.getUsage(), chunk.getCreated(), chunk.getId()); + }); + } + + private List toMsg(ChatItemVo item) { + List list = new ArrayList<>(2); + if (item.getQuestion().startsWith(ChatConstants.PROMPT_TAG)) { + // 提示词消息 + list.add(new ChatMessage(ChatMessageRole.SYSTEM.value(), item.getQuestion().substring(ChatConstants.PROMPT_TAG.length()))); + return list; + } + + // 用户问答 + list.add(new ChatMessage(ChatMessageRole.USER.value(), item.getQuestion())); + if (StringUtils.isNotBlank(item.getAnswer())) { + list.add(new ChatMessage(ChatMessageRole.ASSISTANT.value(), item.getAnswer())); + } + return list; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/converter/CommentConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/converter/CommentConverter.java new file mode 100644 index 000000000..c8260fb66 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/converter/CommentConverter.java @@ -0,0 +1,70 @@ +package com.github.paicoding.forum.service.comment.converter; + +import com.github.paicoding.forum.api.model.vo.comment.CommentSaveReq; +import com.github.paicoding.forum.api.model.vo.comment.dto.BaseCommentDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.HighlightDto; +import com.github.paicoding.forum.api.model.vo.comment.dto.SubCommentDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; + +import java.util.ArrayList; + +/** + * 评论转换 + * + * @author louzai + * @date 2022-07-20 + */ +@Slf4j +public class CommentConverter { + + public static CommentDO toDo(CommentSaveReq req) { + if (req == null) { + return null; + } + CommentDO commentDO = new CommentDO(); + commentDO.setId(req.getCommentId()); + commentDO.setArticleId(req.getArticleId()); + commentDO.setUserId(req.getUserId()); + commentDO.setContent(req.getCommentContent()); + commentDO.setParentCommentId(req.getParentCommentId() == null ? 0L : req.getParentCommentId()); + commentDO.setTopCommentId(req.getTopCommentId() == null ? 0L : req.getTopCommentId()); + if (req.getHighlight() != null) { + commentDO.setHighlightInfo(JsonUtil.toStr(req.getHighlight())); + } else { + commentDO.setHighlightInfo("{}"); + } + return commentDO; + } + + private static void parseDto(CommentDO comment, T sub) { + sub.setCommentId(comment.getId()); + sub.setUserId(comment.getUserId()); + sub.setCommentContent(comment.getContent()); + sub.setCommentTime(comment.getCreateTime().getTime()); + sub.setPraiseCount(0); + if (StringUtils.isNotBlank(comment.getHighlightInfo())) { + try { + sub.setHighlight(JsonUtil.toObj(comment.getHighlightInfo(), HighlightDto.class)); + } catch (Exception e) { + log.error("反序列化异常~: {}", comment, e); + } + } + } + + public static TopCommentDTO toTopDto(CommentDO commentDO) { + TopCommentDTO dto = new TopCommentDTO(); + parseDto(commentDO, dto); + dto.setChildComments(new ArrayList<>()); + return dto; + } + + public static SubCommentDTO toSubDto(CommentDO comment) { + SubCommentDTO sub = new SubCommentDTO(); + parseDto(comment, sub); + return sub; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/package-info.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/package-info.java new file mode 100644 index 000000000..79486fe98 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/package-info.java @@ -0,0 +1,7 @@ +/** + * 评论相关服务包 + * + * @author YiHui + * @date 2022/7/6 + */ +package com.github.paicoding.forum.service.comment; \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/dao/CommentDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/dao/CommentDao.java new file mode 100644 index 000000000..ebe9735ef --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/dao/CommentDao.java @@ -0,0 +1,89 @@ +package com.github.paicoding.forum.service.comment.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.comment.repository.mapper.CommentMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.CollectionUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Repository +public class CommentDao extends ServiceImpl { + /** + * 获取划线评论 + * + * @param articleId + * @return + */ + public List listHighlightCommentList(Long articleId) { + return lambdaQuery() + .eq(CommentDO::getTopCommentId, 0) + .eq(CommentDO::getArticleId, articleId) + .eq(CommentDO::getDeleted, YesOrNoEnum.NO.getCode()) + .isNotNull(CommentDO::getHighlightInfo) + .list(); + } + + /** + * 获取评论列表 + * + * @param pageParam + * @return + */ + public List listTopCommentList(Long articleId, PageParam pageParam) { + return lambdaQuery() + .eq(CommentDO::getTopCommentId, 0) + .eq(CommentDO::getArticleId, articleId) + .eq(CommentDO::getDeleted, YesOrNoEnum.NO.getCode()) + .last(PageParam.getLimitSql(pageParam)) + .orderByDesc(CommentDO::getId).list(); + } + + /** + * 查询所有的子评论 + * + * @param articleId + * @return + */ + public List listSubCommentIdMappers(Long articleId, Collection topCommentIds) { + return lambdaQuery() + .in(CommentDO::getTopCommentId, topCommentIds) + .eq(CommentDO::getArticleId, articleId) + .eq(CommentDO::getDeleted, YesOrNoEnum.NO.getCode()).list(); + } + + + /** + * 查询有效评论数 + * + * @param articleId + * @return + */ + public int commentCount(Long articleId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda() + .eq(CommentDO::getArticleId, articleId) + .eq(CommentDO::getDeleted, YesOrNoEnum.NO.getCode()); + return baseMapper.selectCount(queryWrapper).intValue(); + } + + public CommentDO getHotComment(Long articleId) { + Map map = baseMapper.getHotTopCommentId(articleId); + if (CollectionUtils.isEmpty(map)) { + return null; + } + + return baseMapper.selectById(Long.parseLong(String.valueOf(map.get("top_comment_id")))); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/entity/CommentDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/entity/CommentDO.java new file mode 100644 index 000000000..e2e26cbfd --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/entity/CommentDO.java @@ -0,0 +1,57 @@ +package com.github.paicoding.forum.service.comment.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.core.senstive.ano.SensitiveField; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 评论表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("comment") +public class CommentDO extends BaseDO { + + private static final long serialVersionUID = 1L; + + /** + * 文章ID + */ + private Long articleId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 评论内容 + */ + @SensitiveField(bind = "content") + private String content; + + /** + * 父评论ID + */ + private Long parentCommentId; + + /** + * 顶级评论ID + */ + private Long topCommentId; + + /** + * 0未删除 1 已删除 + */ + private Integer deleted; + + /** + * 划线高亮选中的内容 + */ + private String highlightInfo; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/mapper/CommentMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/mapper/CommentMapper.java new file mode 100644 index 000000000..ac5a12626 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/repository/mapper/CommentMapper.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.comment.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import org.apache.ibatis.annotations.Param; + +import java.util.Map; + +/** + * 评论mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface CommentMapper extends BaseMapper { + Map getHotTopCommentId(@Param("articleId") Long articleId); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/CommentReadService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/CommentReadService.java new file mode 100644 index 000000000..3fbe33bac --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/CommentReadService.java @@ -0,0 +1,67 @@ +package com.github.paicoding.forum.service.comment.service; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; + +import java.util.List; + +/** + * 评论Service接口 + * + * @author louzai + * @date 2022-07-24 + */ +public interface CommentReadService { + + /** + * 根据评论id查询评论信息 + * + * @param commentId + * @return + */ + CommentDO queryComment(Long commentId); + + /** + * 查询文章评论列表 + * + * @param articleId + * @param page + * @return + */ + List getArticleComments(Long articleId, PageParam page); + + /** + * 查询热门评论 + * + * @param articleId + * @return + */ + TopCommentDTO queryHotComment(Long articleId); + + + /** + * 查询文章的划线评论 + * + * @param articleId + * @return + */ + List queryHighlightComments(Long articleId); + + + /** + * 查询顶级评论及之下的所有评论 + * + * @param commentId + * @return + */ + TopCommentDTO queryTopComments(Long commentId); + + /** + * 文章的有效评论数 + * + * @param articleId + * @return + */ + int queryCommentCount(Long articleId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/CommentWriteService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/CommentWriteService.java new file mode 100644 index 000000000..dd8354331 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/CommentWriteService.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.service.comment.service; + +import com.github.paicoding.forum.api.model.vo.comment.CommentSaveReq; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; + +/** + * 评论Service接口 + * + * @author louzai + * @date 2022-07-24 + */ +public interface CommentWriteService { + + /** + * 更新/保存评论 + * + * @param commentSaveReq + * @return + */ + Long saveComment(CommentSaveReq commentSaveReq); + + /** + * 删除评论 + * + * @param commentId + * @throws Exception + */ + void deleteComment(Long commentId, Long userId); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/impl/CommentReadServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/impl/CommentReadServiceImpl.java new file mode 100644 index 000000000..e551c2fde --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/impl/CommentReadServiceImpl.java @@ -0,0 +1,218 @@ +package com.github.paicoding.forum.service.comment.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.PraiseStatEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.comment.dto.BaseCommentDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.SubCommentDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.util.MapUtils; +import com.github.paicoding.forum.service.comment.converter.CommentConverter; +import com.github.paicoding.forum.service.comment.repository.dao.CommentDao; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.statistics.service.CountService; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.service.UserFootService; +import com.github.paicoding.forum.service.user.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 评论Service + * + * @author louzai + * @date 2022-07-24 + */ +@Service +public class CommentReadServiceImpl implements CommentReadService { + + @Autowired + private CommentDao commentDao; + + @Autowired + private UserService userService; + + @Autowired + private CountService countService; + + @Autowired + private UserFootService userFootService; + + @Override + public CommentDO queryComment(Long commentId) { + return commentDao.getById(commentId); + } + + @Override + public List getArticleComments(Long articleId, PageParam page) { + // 1.查询一级评论 + List comments = commentDao.listTopCommentList(articleId, page); + if (CollectionUtils.isEmpty(comments)) { + return Collections.emptyList(); + } + // map 存 commentId -> 评论 + Map topComments = comments.stream().collect(Collectors.toMap(CommentDO::getId, CommentConverter::toTopDto)); + + // 2.查询非一级评论 + List subComments = commentDao.listSubCommentIdMappers(articleId, topComments.keySet()); + + // 3.构建一级评论的子评论 + buildCommentRelation(subComments, topComments); + + // 4.挑出需要返回的数据,排序,并补齐对应的用户信息,最后排序返回 + List result = new ArrayList<>(); + comments.forEach(comment -> { + TopCommentDTO dto = topComments.get(comment.getId()); + fillTopCommentInfo(dto); + result.add(dto); + }); + + // 返回结果根据时间进行排序 + Collections.sort(result); + return result; + } + + /** + * 构建父子评论关系 + */ + private void buildCommentRelation(List subComments, Map topComments) { + Map subCommentMap = subComments.stream().collect(Collectors.toMap(CommentDO::getId, CommentConverter::toSubDto)); + subComments.forEach(comment -> { + TopCommentDTO top = topComments.get(comment.getTopCommentId()); + if (top == null) { + return; + } + SubCommentDTO sub = subCommentMap.get(comment.getId()); + top.getChildComments().add(sub); + if (Objects.equals(comment.getTopCommentId(), comment.getParentCommentId())) { + return; + } + + SubCommentDTO parent = subCommentMap.get(comment.getParentCommentId()); + sub.setParentContent(parent == null ? "~~已删除~~" : parent.getCommentContent()); + }); + } + + /** + * 填充评论对应的信息 + * + * @param comment + */ + private void fillTopCommentInfo(TopCommentDTO comment) { + fillCommentInfo(comment); + comment.getChildComments().forEach(this::fillCommentInfo); + Collections.sort(comment.getChildComments()); + } + + /** + * 填充评论对应的信息,如用户信息,点赞数等 + * + * @param comment + */ + private void fillCommentInfo(BaseCommentDTO comment) { + BaseUserInfoDTO userInfoDO = userService.queryBasicUserInfo(comment.getUserId()); + if (userInfoDO == null) { + // 如果用户注销,给一个默认的用户 + comment.setUserName("默认用户"); + comment.setUserPhoto(""); + if (comment instanceof TopCommentDTO) { + ((TopCommentDTO) comment).setCommentCount(0); + } + } else { + comment.setUserName(userInfoDO.getUserName()); + comment.setUserPhoto(userInfoDO.getPhoto()); + if (comment instanceof TopCommentDTO) { + ((TopCommentDTO) comment).setCommentCount(((TopCommentDTO) comment).getChildComments().size()); + } + } + + // 查询点赞数 + Long praiseCount = countService.queryCommentPraiseCount(comment.getCommentId()); + comment.setPraiseCount(praiseCount.intValue()); + + // 查询当前登录用于是否点赞过 + Long loginUserId = ReqInfoContext.getReqInfo().getUserId(); + if (loginUserId != null) { + // 判断当前用户是否点过赞 + UserFootDO foot = userFootService.queryUserFoot(comment.getCommentId(), DocumentTypeEnum.COMMENT.getCode(), loginUserId); + comment.setPraised(foot != null && Objects.equals(foot.getPraiseStat(), PraiseStatEnum.PRAISE.getCode())); + } else { + comment.setPraised(false); + } + } + + /** + * 查询回帖最多的评论 + * + * @param articleId + * @return + */ + @Override + public TopCommentDTO queryHotComment(Long articleId) { + CommentDO comment = commentDao.getHotComment(articleId); + if (comment == null) { + return null; + } + + return buildTopCommentInfo(comment); + } + + + @Override + public List queryHighlightComments(Long articleId) { + List comments = commentDao.listHighlightCommentList(articleId); + if (CollectionUtils.isEmpty(comments)) { + return Collections.emptyList(); + } + return comments.stream().map(CommentConverter::toTopDto).collect(Collectors.toList()); + } + + @Override + public int queryCommentCount(Long articleId) { + return commentDao.commentCount(articleId); + } + + + /** + * 查询顶级评论及之下的所有评论 + * + * @param commentId 评论id + * @return 顶级评论及之下的所有评论 + */ + @Override + public TopCommentDTO queryTopComments(Long commentId) { + CommentDO topComment = commentDao.getById(commentId); + if (topComment == null) { + return null; + } + return buildTopCommentInfo(topComment); + } + + private TopCommentDTO buildTopCommentInfo(CommentDO topComment) { + // 1.获取顶级评论id + Long commentId = topComment.getId(); + // 2.查询非一级评论 + List subComments = commentDao.listSubCommentIdMappers(topComment.getArticleId(), Collections.singletonList(commentId)); + + // 3.构建顶级评论实体 + TopCommentDTO top = CommentConverter.toTopDto(topComment); + + // 4.将非一级评论对象添加到顶级评论中 + Map topComments = MapUtils.create(top.getCommentId(), top); + buildCommentRelation(subComments, topComments); + TopCommentDTO dto = topComments.get(commentId); + fillTopCommentInfo(dto); + return top; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/impl/CommentWriteServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/impl/CommentWriteServiceImpl.java new file mode 100644 index 000000000..becfc95e9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/comment/service/impl/CommentWriteServiceImpl.java @@ -0,0 +1,237 @@ +package com.github.paicoding.forum.service.comment.service.impl; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.enums.ai.AiBotEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.comment.CommentSaveReq; +import com.github.paicoding.forum.api.model.vo.comment.dto.HighlightDto; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.chatai.bot.AiBots; +import com.github.paicoding.forum.service.comment.converter.CommentConverter; +import com.github.paicoding.forum.service.comment.repository.dao.CommentDao; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.comment.service.CommentWriteService; +import com.github.paicoding.forum.service.user.service.UserFootService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * 评论Service + * + * @author louzai + * @date 2022-07-24 + */ +@Slf4j +@Service +public class CommentWriteServiceImpl implements CommentWriteService { + + @Autowired + private CommentDao commentDao; + + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private UserFootService userFootWriteService; + @Autowired + private AiBots aiBots; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long saveComment(CommentSaveReq commentSaveReq) { + // 保存评论 + CommentDO comment; + if (NumUtil.nullOrZero(commentSaveReq.getCommentId())) { + comment = addComment(commentSaveReq); + } else { + comment = updateComment(commentSaveReq); + } + return comment.getId(); + } + + private CommentDO addComment(CommentSaveReq commentSaveReq) { + // 0.获取父评论信息,校验是否存在 + CommentDO parentComment = getParentCommentUser(commentSaveReq.getParentCommentId()); + Long parentUser = parentComment == null ? null : parentComment.getUserId(); + + // 1. 保存评论内容 + CommentDO commentDO = CommentConverter.toDo(commentSaveReq); + Date now = new Date(); + commentDO.setCreateTime(now); + commentDO.setUpdateTime(now); + commentDao.save(commentDO); + + // 2. 保存足迹信息 : 文章的已评信息 + 评论的已评信息 + ArticleDO article = articleReadService.queryBasicArticle(commentSaveReq.getArticleId()); + if (article == null) { + throw ExceptionUtil.of(StatusEnum.ARTICLE_NOT_EXISTS, commentSaveReq.getArticleId()); + } + userFootWriteService.saveCommentFoot(commentDO, article.getUserId(), parentUser); + + // 3. 触发杠精机器人 + this.aiBotTrigger(commentDO, parentComment); + + // 4. 发布添加/回复评论事件 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, NotifyTypeEnum.COMMENT, commentDO)); + if (NumUtil.upZero(parentUser)) { + // 评论回复事件 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, NotifyTypeEnum.REPLY, commentDO)); + } + return commentDO; + } + + private CommentDO updateComment(CommentSaveReq commentSaveReq) { + // 更新评论 + CommentDO commentDO = commentDao.getById(commentSaveReq.getCommentId()); + if (commentDO == null) { + throw ExceptionUtil.of(StatusEnum.COMMENT_NOT_EXISTS, commentSaveReq.getCommentId()); + } + commentDO.setContent(commentSaveReq.getCommentContent()); + commentDao.updateById(commentDO); + return commentDO; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteComment(Long commentId, Long userId) { + CommentDO commentDO = commentDao.getById(commentId); + // 1.校验评论,是否越权,文章是否存在 + if (commentDO == null) { + throw ExceptionUtil.of(StatusEnum.COMMENT_NOT_EXISTS, "评论ID=" + commentId); + } + if (!Objects.equals(commentDO.getUserId(), userId)) { + throw ExceptionUtil.of(StatusEnum.FORBID_ERROR_MIXED, "无权删除评论"); + } + // 获取文章信息 + ArticleDO article = articleReadService.queryBasicArticle(commentDO.getArticleId()); + if (article == null) { + throw ExceptionUtil.of(StatusEnum.ARTICLE_NOT_EXISTS, commentDO.getArticleId()); + } + + // 2.删除评论、足迹 + commentDO.setDeleted(YesOrNoEnum.YES.getCode()); + commentDao.updateById(commentDO); + CommentDO parentComment = getParentCommentUser(commentDO.getParentCommentId()); + userFootWriteService.removeCommentFoot(commentDO, article.getUserId(), parentComment == null ? null : parentComment.getUserId()); + + // 3. 发布删除评论事件 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, NotifyTypeEnum.DELETE_COMMENT, commentDO)); + if (NumUtil.upZero(commentDO.getParentCommentId())) { + // 评论 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, NotifyTypeEnum.DELETE_REPLY, commentDO)); + } + } + + + private CommentDO getParentCommentUser(Long parentCommentId) { + if (NumUtil.nullOrZero(parentCommentId)) { + return null; + + } + CommentDO parent = commentDao.getById(parentCommentId); + if (parent == null) { + throw ExceptionUtil.of(StatusEnum.COMMENT_NOT_EXISTS, "父评论=" + parentCommentId); + } + return parent; + } + + + /** + * 机器人回复 + * + * @param comment 当前评论内容 + * @param parent 当前评论的父评论 + */ + private void aiBotTrigger(CommentDO comment, CommentDO parent) { + boolean trigger = false; + Long topCommentId = 0L; + AiBotEnum botEnum = null; + if (parent == null) { + // 当前的评论就是顶级评论,根据回复内容是否有触发词来决定是否需要进行触发 + botEnum = aiBots.triggerAiBotKeyWord(comment.getContent()); + if (botEnum != null) { + String tag = "@" + botEnum.getNickName(); + comment.setContent(StringUtils.replace(comment.getContent(), tag, "")); + trigger = true; + } + topCommentId = comment.getId(); + } else { + botEnum = aiBots.getAiBotByUserId(parent.getUserId()); + // 回复内容,根据回复的用户是否为机器人,来判定是否需要进行触发 + if (botEnum != null) { + trigger = true; + } + topCommentId = comment.getTopCommentId(); + } + + // 评论中,@了机器人,那么开启评论对线模式 + if (trigger) { + log.info("评论「{}」 开启了AI机器人:{}", comment, botEnum); + // sourceBizId: 主要用于构建聊天对话,以顶级评论 + 用户id作为唯一标识 + // 避免出现一个顶级评论开启对线,后续的回复中有其他用户参与进来时,因为用户id不同,这样传递给大模型的上下文就不会出现交叉 + AiBotEnum finalBotEnum = botEnum; + aiBots.trigger(botEnum, initQAUserPrompt(botEnum, comment) + , "comment:" + topCommentId + "_" + comment.getUserId() + , reply -> aiReply(finalBotEnum, reply, comment) + , initQABotSystemPrompt(botEnum, comment)); + log.info("任务已完成提交~"); + } + } + + + private String initQAUserPrompt(AiBotEnum bot, CommentDO comment) { + if (bot == AiBotEnum.QA_BOT) { + String prefix = ""; + if (StringUtils.isNotBlank(comment.getHighlightInfo())) { + HighlightDto highlightDto = JsonUtil.toObj(comment.getHighlightInfo(), HighlightDto.class); + if (StringUtils.isNotBlank(highlightDto.getSelectedText())) { + prefix = "这是我从参考资料中选择的一段文本:\"" + highlightDto.getSelectedText() + "\"\n"; + } + } + + return prefix + comment.getContent(); + } else { + return comment.getContent(); + } + } + + /** + * 初始化AI机器人的系统提示词 + * + * @param bot 机器人枚举 + * @param comment 评论 + * @return 系统提示词 + */ + private Supplier initQABotSystemPrompt(AiBotEnum bot, CommentDO comment) { + if (bot == AiBotEnum.QA_BOT) { + String article = articleReadService.queryArticleContentForAI(comment.getArticleId()); + return () -> bot.getPrompt() + "\n\n" + article; + } else { + return bot::getPrompt; + } + } + + private void aiReply(AiBotEnum aiBot, String replyContent, CommentDO parentComment) { + CommentSaveReq save = new CommentSaveReq(); + save.setArticleId(parentComment.getArticleId()); + save.setCommentContent(replyContent); + save.setUserId(aiBots.getBotUser(aiBot).getUserId()); + save.setParentCommentId(parentComment.getId()); + save.setTopCommentId(NumUtil.upZero(parentComment.getTopCommentId()) ? parentComment.getTopCommentId() : parentComment.getId()); + SpringUtil.getBean(CommentWriteService.class).saveComment(save); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/ConfigConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/ConfigConverter.java new file mode 100644 index 000000000..6e84e8099 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/ConfigConverter.java @@ -0,0 +1,61 @@ +package com.github.paicoding.forum.service.config.converter; + +import com.github.paicoding.forum.api.model.vo.banner.ConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.service.config.repository.entity.ConfigDO; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Banner转换 + * + * @author louzai + * @date 2022-09-20 + */ +public class ConfigConverter { + + public static List toDTOS(List records) { + if (CollectionUtils.isEmpty(records)) { + return Collections.emptyList(); + } + return records.stream().map(ConfigConverter::toDTO).collect(Collectors.toList()); + } + + public static ConfigDTO toDTO(ConfigDO configDO) { + if (configDO == null) { + return null; + } + ConfigDTO configDTO = new ConfigDTO(); + configDTO.setType(configDO.getType()); + configDTO.setName(configDO.getName()); + configDTO.setBannerUrl(configDO.getBannerUrl()); + configDTO.setJumpUrl(configDO.getJumpUrl()); + configDTO.setContent(configDO.getContent()); + configDTO.setRank(configDO.getRank()); + configDTO.setStatus(configDO.getStatus()); + configDTO.setId(configDO.getId()); + configDTO.setTags(configDO.getTags()); + configDTO.setExtra(configDO.getExtra()); + configDTO.setCreateTime(configDO.getCreateTime()); + configDTO.setUpdateTime(configDO.getUpdateTime()); + return configDTO; + } + + public static ConfigDO toDO(ConfigReq configReq) { + if (configReq == null) { + return null; + } + ConfigDO configDO = new ConfigDO(); + configDO.setType(configReq.getType()); + configDO.setName(configReq.getName()); + configDO.setBannerUrl(configReq.getBannerUrl()); + configDO.setJumpUrl(configReq.getJumpUrl()); + configDO.setContent(configReq.getContent()); + configDO.setRank(configReq.getRank()); + configDO.setTags(configReq.getTags()); + return configDO; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/ConfigStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/ConfigStructMapper.java new file mode 100644 index 000000000..2f1298fb9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/ConfigStructMapper.java @@ -0,0 +1,50 @@ +package com.github.paicoding.forum.service.config.converter; + +import com.github.paicoding.forum.api.model.vo.banner.ConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.SearchConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.api.model.vo.config.GlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.SearchGlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.dto.GlobalConfigDTO; +import com.github.paicoding.forum.service.config.repository.entity.ConfigDO; +import com.github.paicoding.forum.service.config.repository.entity.GlobalConfigDO; +import com.github.paicoding.forum.service.config.repository.params.SearchConfigParams; +import com.github.paicoding.forum.service.config.repository.params.SearchGlobalConfigParams; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper +public interface ConfigStructMapper { + // instance + ConfigStructMapper INSTANCE = Mappers.getMapper( ConfigStructMapper.class ); + + // req to params + @Mapping(source = "pageNumber", target = "pageNum") + SearchConfigParams toSearchParams(SearchConfigReq req); + + // req to params + @Mapping(source = "pageNumber", target = "pageNum") + // key to keywords + @Mapping(source = "keywords", target = "key") + SearchGlobalConfigParams toSearchGlobalParams(SearchGlobalConfigReq req); + + // do to dto + ConfigDTO toDTO(ConfigDO configDO); + + List toDTOS(List configDOS); + + ConfigDO toDO(ConfigReq configReq); + + // do to dto + // key to keywords + @Mapping(source = "key", target = "keywords") + GlobalConfigDTO toGlobalDTO(GlobalConfigDO configDO); + + List toGlobalDTOS(List configDOS); + + @Mapping(source = "keywords", target = "key") + GlobalConfigDO toGlobalDO(GlobalConfigReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/DictCommonConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/DictCommonConverter.java new file mode 100644 index 000000000..36a84bf00 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/converter/DictCommonConverter.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.service.config.converter; + +import com.github.paicoding.forum.api.model.vo.article.dto.DictCommonDTO; +import com.github.paicoding.forum.service.config.repository.entity.DictCommonDO; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Banner转换 + * + * @author louzai + * @date 2022-09-20 + */ +public class DictCommonConverter { + + public static List toDTOS(List records) { + if (CollectionUtils.isEmpty(records)) { + return Collections.emptyList(); + } + return records.stream().map(DictCommonConverter::toDTO).collect(Collectors.toList()); + } + + public static DictCommonDTO toDTO(DictCommonDO dictCommonDO) { + if (dictCommonDO == null) { + return null; + } + DictCommonDTO dictCommonDTO = new DictCommonDTO(); + dictCommonDTO.setTypeCode(dictCommonDO.getTypeCode()); + dictCommonDTO.setDictCode(dictCommonDO.getDictCode()); + dictCommonDTO.setDictDesc(dictCommonDO.getDictDesc()); + dictCommonDTO.setSortNo(dictCommonDO.getSortNo()); + return dictCommonDTO; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/es/ElasticsearchConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/es/ElasticsearchConfig.java new file mode 100644 index 000000000..99fc7fe02 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/es/ElasticsearchConfig.java @@ -0,0 +1,102 @@ +package com.github.paicoding.forum.service.config.es; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.client.RestClientBuilder; +import org.elasticsearch.client.RestHighLevelClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * es配置类 + * + * @author ygl + * @since 2023-05-25 + **/ +@Slf4j +@Data +@Configuration +// 下面这个表示只有 elasticsearch.open = true 时,采进行es的配置初始化;当不使用es时,则不会实例 RestHighLevelClient +@ConditionalOnProperty(prefix = "elasticsearch", name = "open") +@ConfigurationProperties(prefix = "elasticsearch") +public class ElasticsearchConfig { + + // 是否开启ES + private Boolean open; + + // es host ip 地址(集群) + private String hosts; + + // es用户名 + private String userName; + + // es密码 + private String password; + + // es 请求方式 + private String scheme; + + // es集群名称 + private String clusterName; + + // es 连接超时时间 + private int connectTimeOut; + + // es socket 连接超时时间 + private int socketTimeOut; + + // es 请求超时时间 + private int connectionRequestTimeOut; + + // es 最大连接数 + private int maxConnectNum; + + // es 每个路由的最大连接数 + private int maxConnectNumPerRoute; + + + /** + * 如果@Bean没有指定bean的名称,那么这个bean的名称就是方法名 + */ + @Bean(name = "restHighLevelClient") + public RestHighLevelClient restHighLevelClient() { + + // 此处为单节点es + String host = hosts.split(":")[0]; + String port = hosts.split(":")[1]; + HttpHost httpHost = new HttpHost(host, Integer.parseInt(port)); + + // 构建连接对象 + RestClientBuilder builder = RestClient.builder(httpHost); + + // 设置用户名、密码 + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(userName, password)); + + // 连接延时配置 + builder.setRequestConfigCallback(requestConfigBuilder -> { + requestConfigBuilder.setConnectTimeout(connectTimeOut); + requestConfigBuilder.setSocketTimeout(socketTimeOut); + requestConfigBuilder.setConnectionRequestTimeout(connectionRequestTimeOut); + return requestConfigBuilder; + }); + // 连接数配置 + builder.setHttpClientConfigCallback(httpClientBuilder -> { + httpClientBuilder.setMaxConnTotal(maxConnectNum); + httpClientBuilder.setMaxConnPerRoute(maxConnectNumPerRoute); + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + return httpClientBuilder; + }); + + return new RestHighLevelClient(builder); + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/dao/ConfigDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/dao/ConfigDao.java new file mode 100644 index 000000000..39d00117d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/dao/ConfigDao.java @@ -0,0 +1,191 @@ +package com.github.paicoding.forum.service.config.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.ConfigTypeEnum; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.service.config.converter.ConfigConverter; +import com.github.paicoding.forum.service.config.converter.ConfigStructMapper; +import com.github.paicoding.forum.service.config.repository.entity.ConfigDO; +import com.github.paicoding.forum.service.config.repository.entity.GlobalConfigDO; +import com.github.paicoding.forum.service.config.repository.mapper.ConfigMapper; +import com.github.paicoding.forum.service.config.repository.mapper.GlobalConfigMapper; +import com.github.paicoding.forum.service.config.repository.params.SearchConfigParams; +import com.github.paicoding.forum.service.config.repository.params.SearchGlobalConfigParams; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.util.Date; +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Repository +public class ConfigDao extends ServiceImpl { + @Resource + private GlobalConfigMapper globalConfigMapper; + + /** + * 根据类型获取配置列表(无需分页) + * + * @param type + * @return + */ + public List listConfigByType(Integer type) { + List configDOS = lambdaQuery() + .eq(ConfigDO::getType, type) + .eq(ConfigDO::getStatus, PushStatusEnum.ONLINE.getCode()) + .eq(ConfigDO::getDeleted, YesOrNoEnum.NO.getCode()) + .orderByAsc(ConfigDO::getRank) + .list(); + return ConfigConverter.toDTOS(configDOS); + } + + private LambdaQueryChainWrapper createConfigQuery(SearchConfigParams params) { + return lambdaQuery() + .eq(ConfigDO::getDeleted, YesOrNoEnum.NO.getCode()) + .like(StringUtils.isNotBlank(params.getName()), ConfigDO::getName, params.getName()) + .eq(params.getType() != null && params.getType() != -1, ConfigDO::getType, params.getType()); + } + + /** + * 获取所有 Banner 列表(分页) + * + * @return + */ + public List listBanner(SearchConfigParams params) { + List configDOS = createConfigQuery(params) + .orderByDesc(ConfigDO::getUpdateTime) + .orderByAsc(ConfigDO::getRank) + .last(PageParam.getLimitSql( + PageParam.newPageInstance(params.getPageNum(), params.getPageSize()))) + .list(); + return ConfigStructMapper.INSTANCE.toDTOS(configDOS); + } + + /** + * 获取所有 Banner 总数(分页) + * + * @return + */ + public Long countConfig(SearchConfigParams params) { + return createConfigQuery(params) + .count(); + } + + /** + * 获取所有公告列表(分页) + * + * @return + */ + public List listNotice(PageParam pageParam) { + List configDOS = lambdaQuery() + .eq(ConfigDO::getType, ConfigTypeEnum.NOTICE.getCode()) + .eq(ConfigDO::getDeleted, YesOrNoEnum.NO.getCode()) + .orderByDesc(ConfigDO::getCreateTime) + .last(PageParam.getLimitSql(pageParam)) + .list(); + return ConfigConverter.toDTOS(configDOS); + } + + /** + * 获取所有公告总数(分页) + * + * @return + */ + public Integer countNotice() { + return lambdaQuery() + .eq(ConfigDO::getType, ConfigTypeEnum.NOTICE.getCode()) + .eq(ConfigDO::getDeleted, YesOrNoEnum.NO.getCode()) + .count() + .intValue(); + } + + /** + * 更新阅读相关计数 + */ + public void updatePdfConfigVisitNum(long configId, String extra) { + lambdaUpdate().set(ConfigDO::getExtra, extra) + .eq(ConfigDO::getId, configId) + .update(); + } + + public List listGlobalConfig(SearchGlobalConfigParams params) { + LambdaQueryWrapper query = buildQuery(params); + query.select(GlobalConfigDO::getId, + GlobalConfigDO::getKey, + GlobalConfigDO::getValue, + GlobalConfigDO::getComment); + return globalConfigMapper.selectList(query); + } + + public Long countGlobalConfig(SearchGlobalConfigParams params) { + return globalConfigMapper.selectCount(buildQuery(params)); + } + + private LambdaQueryWrapper buildQuery(SearchGlobalConfigParams params) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + + query.and(!StringUtils.isEmpty(params.getKey()), + k -> k.like(GlobalConfigDO::getKey, params.getKey())) + .and(!StringUtils.isEmpty(params.getValue()), + v -> v.like(GlobalConfigDO::getValue, params.getValue())) + .and(!StringUtils.isEmpty(params.getComment()), + c -> c.like(GlobalConfigDO::getComment, params.getComment())) + .eq(GlobalConfigDO::getDeleted, YesOrNoEnum.NO.getCode()) + .orderByDesc(GlobalConfigDO::getUpdateTime); + return query; + } + + public void save(GlobalConfigDO globalConfigDO) { + globalConfigMapper.insert(globalConfigDO); + } + + public void updateById(GlobalConfigDO globalConfigDO) { + globalConfigDO.setUpdateTime(new Date()); + globalConfigMapper.updateById(globalConfigDO); + } + + /** + * 根据id查询全局配置 + * + * @param id + * @return + */ + public GlobalConfigDO getGlobalConfigById(Long id) { + // 查询的时候 deleted 为 0 + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.select(GlobalConfigDO::getId, GlobalConfigDO::getKey, GlobalConfigDO::getValue, GlobalConfigDO::getComment) + .eq(GlobalConfigDO::getId, id) + .eq(GlobalConfigDO::getDeleted, YesOrNoEnum.NO.getCode()); + return globalConfigMapper.selectOne(query); + } + + /** + * 根据key查询全局配置 + * + * @param key + * @return + */ + public GlobalConfigDO getGlobalConfigByKey(String key) { + // 查询的时候 deleted 为 0 + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.select(GlobalConfigDO::getId, GlobalConfigDO::getKey, GlobalConfigDO::getValue, GlobalConfigDO::getComment) + .eq(GlobalConfigDO::getKey, key) + .eq(GlobalConfigDO::getDeleted, YesOrNoEnum.NO.getCode()); + return globalConfigMapper.selectOne(query); + } + + public void delete(GlobalConfigDO globalConfigDO) { + globalConfigDO.setDeleted(YesOrNoEnum.YES.getCode()); + globalConfigMapper.updateById(globalConfigDO); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/dao/DictCommonDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/dao/DictCommonDao.java new file mode 100644 index 000000000..7e85efd76 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/dao/DictCommonDao.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.service.config.repository.dao; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.vo.article.dto.DictCommonDTO; +import com.github.paicoding.forum.service.config.converter.DictCommonConverter; +import com.github.paicoding.forum.service.config.repository.entity.DictCommonDO; +import com.github.paicoding.forum.service.config.repository.mapper.DictCommonMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * @author Louzai + * @date 2022/9/2 + */ +@Repository +public class DictCommonDao extends ServiceImpl { + + /** + * 获取所有字典列表 + * @return + */ + public List getDictList() { + List list = lambdaQuery().list(); + return DictCommonConverter.toDTOS(list); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/ConfigDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/ConfigDO.java new file mode 100644 index 000000000..398b26214 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/ConfigDO.java @@ -0,0 +1,76 @@ +package com.github.paicoding.forum.service.config.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.ConfigTagEnum; +import com.github.paicoding.forum.api.model.enums.ConfigTypeEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 评论表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("config") +public class ConfigDO extends BaseDO { + private static final long serialVersionUID = -6122208316544171303L; + /** + * 类型 + * @see ConfigTypeEnum#getCode() + */ + private Integer type; + + /** + * 名称 + */ + @TableField("`name`") + private String name; + + /** + * 图片链接 + */ + private String bannerUrl; + + /** + * 跳转链接 + */ + private String jumpUrl; + + /** + * 内容 + */ + private String content; + + /** + * 排序 + */ + @TableField("`rank`") + private Integer rank; + + /** + * 状态:0-未发布,1-已发布 + */ + private Integer status; + + /** + * 0未删除 1 已删除 + */ + private Integer deleted; + + /** + * 配置对应的标签,英文逗号分隔 + * + * @see ConfigTagEnum#getCode() + */ + private String tags; + + /** + * 扩展信息,如记录 评分,阅读人数,下载次数等 + */ + private String extra; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/DictCommonDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/DictCommonDO.java new file mode 100644 index 000000000..e0b735e07 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/DictCommonDO.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.service.config.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/** + *

+ * 通用数据字典 + *

+ * + * @author liudongshan + * @since 2021-05-31 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@TableName("dict_common") +public class DictCommonDO extends BaseDO { + /** + * 字典类型 + */ + @TableField("type_code") + private String typeCode; + + /** + * 字典类型的值编码 + */ + @TableField("dict_code") + private String dictCode; + + /** + * 字典类型的值描述 + */ + @TableField("dict_desc") + private String dictDesc; + + /** + * 排序编号 + */ + @TableField("sort_no") + private Integer sortNo; + + /** + * 备注 + */ + @TableField("remark") + private String remark; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/GlobalConfigDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/GlobalConfigDO.java new file mode 100644 index 000000000..4d4b050c9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/entity/GlobalConfigDO.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.service.config.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 评论表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("global_conf") +public class GlobalConfigDO extends BaseDO { + private static final long serialVersionUID = -6122208316544171301L; + + // 配置项名称 + @TableField("`key`") + private String key; + // 配置项值 + private String value; + // 备注 + private String comment; + // 删除 + private Integer deleted; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/ConfigMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/ConfigMapper.java new file mode 100644 index 000000000..ee1925689 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/ConfigMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.config.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.config.repository.entity.ConfigDO; + +/** + * 配置mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface ConfigMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/DictCommonMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/DictCommonMapper.java new file mode 100644 index 000000000..d7bb7b927 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/DictCommonMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.config.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.config.repository.entity.DictCommonDO; + +/** + * 字典mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface DictCommonMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/GlobalConfigMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/GlobalConfigMapper.java new file mode 100644 index 000000000..bdebda343 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/mapper/GlobalConfigMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.config.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.config.repository.entity.GlobalConfigDO; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +public interface GlobalConfigMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/params/SearchConfigParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/params/SearchConfigParams.java new file mode 100644 index 000000000..fd6c71c08 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/params/SearchConfigParams.java @@ -0,0 +1,14 @@ +package com.github.paicoding.forum.service.config.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchConfigParams extends PageParam { + // 类型 + private Integer type; + // 名称 + private String name; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/params/SearchGlobalConfigParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/params/SearchGlobalConfigParams.java new file mode 100644 index 000000000..957323385 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/repository/params/SearchGlobalConfigParams.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.service.config.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchGlobalConfigParams extends PageParam { + // 配置项名称 + private String key; + // 配置项值 + private String value; + // 备注 + private String comment; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/security/SecurityConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/security/SecurityConfig.java new file mode 100644 index 000000000..29393c000 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/security/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.github.paicoding.forum.service.config.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/ConfigService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/ConfigService.java new file mode 100644 index 000000000..438a7d72c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/ConfigService.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.service.config.service; + +import com.github.paicoding.forum.api.model.enums.ConfigTypeEnum; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; + +import java.util.List; + +/** + * Banner前台接口 + * + * @author louzai + * @date 2022-07-24 + */ +public interface ConfigService { + + /** + * 获取 Banner 列表 + * + * @param configTypeEnum + * @return + */ + List getConfigList(ConfigTypeEnum configTypeEnum); + + /** + * 阅读次数+1 + * + * @param configId + * @param extra + */ + void updateVisit(long configId, String extra); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/ConfigSettingService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/ConfigSettingService.java new file mode 100644 index 000000000..23fd09c50 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/ConfigSettingService.java @@ -0,0 +1,50 @@ +package com.github.paicoding.forum.service.config.service; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.banner.ConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.SearchConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; + +/** + * Banner后台接口 + * + * @author louzai + * @date 2022-07-24 + */ +public interface ConfigSettingService { + + /** + * 保存 + * + * @param configReq + */ + void saveConfig(ConfigReq configReq); + + /** + * 删除 + * + * @param bannerId + */ + void deleteConfig(Integer bannerId); + + /** + * 操作(上线/下线) + * + * @param bannerId + */ + void operateConfig(Integer bannerId, Integer pushStatus); + + /** + * 获取 Banner 列表 + */ + PageVo getConfigList(SearchConfigReq params); + + /** + * 获取公告列表 + * + * @param pageParam + * @return + */ + PageVo getNoticeList(PageParam pageParam); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/DictCommonService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/DictCommonService.java new file mode 100644 index 000000000..1eec856be --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/DictCommonService.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.config.service; + +import java.util.Map; + +/** + * 字典Service + * + * @author louzai + * @date 2022-07-20 + */ +public interface DictCommonService { + + /** + * 获取字典值 + * @return + */ + Map getDict(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/GlobalConfigService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/GlobalConfigService.java new file mode 100644 index 000000000..eb568d4ed --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/GlobalConfigService.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.service.config.service; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.config.GlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.SearchGlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.dto.GlobalConfigDTO; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +public interface GlobalConfigService { + PageVo getList(SearchGlobalConfigReq req); + + void save(GlobalConfigReq req); + + void delete(Long id); + + /** + * 添加敏感词白名单 + * + * @param word + */ + void addSensitiveWhiteWord(String word); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/ConfigServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/ConfigServiceImpl.java new file mode 100644 index 000000000..5214e4908 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/ConfigServiceImpl.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.service.config.service.impl; + +import com.github.paicoding.forum.api.model.enums.ConfigTypeEnum; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.service.config.repository.dao.ConfigDao; +import com.github.paicoding.forum.service.config.service.ConfigService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Banner前台接口 + * + * @author louzai + * @date 2022-07-24 + */ +@Service +public class ConfigServiceImpl implements ConfigService { + + @Autowired + private ConfigDao configDao; + + @Override + public List getConfigList(ConfigTypeEnum configTypeEnum) { + return configDao.listConfigByType(configTypeEnum.getCode()); + } + + /** + * 配置发生变更之后,失效本地缓存,这里主要是配合 SidebarServiceImpl 中的缓存使用 + * + * @param configId + * @param extra + */ + @Override + public void updateVisit(long configId, String extra) { + configDao.updatePdfConfigVisitNum(configId, extra); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/ConfigSettingServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/ConfigSettingServiceImpl.java new file mode 100644 index 000000000..41a89ee8a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/ConfigSettingServiceImpl.java @@ -0,0 +1,77 @@ +package com.github.paicoding.forum.service.config.service.impl; + +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.banner.ConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.SearchConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.config.converter.ConfigStructMapper; +import com.github.paicoding.forum.service.config.repository.dao.ConfigDao; +import com.github.paicoding.forum.service.config.repository.entity.ConfigDO; +import com.github.paicoding.forum.service.config.repository.params.SearchConfigParams; +import com.github.paicoding.forum.service.config.service.ConfigSettingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Banner后台接口 + * + * @author louzai + * @date 2022-07-24 + */ +@Service +public class ConfigSettingServiceImpl implements ConfigSettingService { + + @Autowired + private ConfigDao configDao; + + @Override + public void saveConfig(ConfigReq configReq) { + ConfigDO configDO = ConfigStructMapper.INSTANCE.toDO(configReq); + if (NumUtil.nullOrZero(configReq.getConfigId())) { + configDao.save(configDO); + } else { + configDO.setId(configReq.getConfigId()); + configDao.updateById(configDO); + } + } + + @Override + public void deleteConfig(Integer configId) { + ConfigDO configDO = configDao.getById(configId); + if (configDO != null){ + configDO.setDeleted(YesOrNoEnum.YES.getCode()); + configDao.updateById(configDO); + } + } + + @Override + public void operateConfig(Integer configId, Integer pushStatus) { + ConfigDO configDO = configDao.getById(configId); + if (configDO != null){ + configDO.setStatus(pushStatus); + configDao.updateById(configDO); + } + } + + @Override + public PageVo getConfigList(SearchConfigReq req) { + // 转换 + SearchConfigParams params = ConfigStructMapper.INSTANCE.toSearchParams(req); + // 查询 + List configDTOS = configDao.listBanner(params); + Long totalCount = configDao.countConfig(params); + return PageVo.build(configDTOS, params.getPageSize(), params.getPageNum(), totalCount); + } + + @Override + public PageVo getNoticeList(PageParam pageParam) { + List configDTOS = configDao.listNotice(pageParam); + Integer totalCount = configDao.countNotice(); + return PageVo.build(configDTOS, pageParam.getPageSize(), pageParam.getPageNum(), totalCount); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/DictCommonServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/DictCommonServiceImpl.java new file mode 100644 index 000000000..ceab1aee9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/DictCommonServiceImpl.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.service.config.service.impl; + +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.DictCommonDTO; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.config.repository.dao.DictCommonDao; +import com.github.paicoding.forum.service.config.service.DictCommonService; +import com.google.common.collect.Maps; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 字典Service + * + * @author louzai + * @date 2022-07-20 + */ +@Service +public class DictCommonServiceImpl implements DictCommonService { + + @Resource + private DictCommonDao dictCommonDao; + + @Autowired + private CategoryService categoryService; + + @Override + public Map getDict() { + Map result = Maps.newLinkedHashMap(); + + List dictCommonList = dictCommonDao.getDictList(); + + Map> dictCommonMap = Maps.newLinkedHashMap(); + for (DictCommonDTO dictCommon : dictCommonList) { + Map codeMap = dictCommonMap.get(dictCommon.getTypeCode()); + if (codeMap == null || codeMap.isEmpty()) { + codeMap = Maps.newLinkedHashMap(); + dictCommonMap.put(dictCommon.getTypeCode(), codeMap); + } + codeMap.put(dictCommon.getDictCode(), dictCommon.getDictDesc()); + } + + // 获取分类的字典信息 + List categoryDTOS = categoryService.loadAllCategories(); + Map codeMap = new HashMap<>(); + categoryDTOS.forEach(categoryDTO -> codeMap.put(categoryDTO.getCategoryId().toString(), categoryDTO.getCategory())); + dictCommonMap.put("CategoryType", codeMap); + + result.putAll(dictCommonMap); + return result; + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/GlobalConfigServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/GlobalConfigServiceImpl.java new file mode 100644 index 000000000..5fe3f1c42 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/config/service/impl/GlobalConfigServiceImpl.java @@ -0,0 +1,98 @@ +package com.github.paicoding.forum.service.config.service.impl; + +import com.github.paicoding.forum.api.model.event.ConfigRefreshEvent; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.config.GlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.SearchGlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.dto.GlobalConfigDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.senstive.SensitiveProperty; +import com.github.paicoding.forum.core.senstive.SensitiveService; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.config.converter.ConfigStructMapper; +import com.github.paicoding.forum.service.config.repository.dao.ConfigDao; +import com.github.paicoding.forum.service.config.repository.entity.GlobalConfigDO; +import com.github.paicoding.forum.service.config.repository.params.SearchGlobalConfigParams; +import com.github.paicoding.forum.service.config.service.GlobalConfigService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/30/23 + */ +@Service +public class GlobalConfigServiceImpl implements GlobalConfigService { + @Autowired + private ConfigDao configDao; + + @Override + public PageVo getList(SearchGlobalConfigReq req) { + ConfigStructMapper mapper = ConfigStructMapper.INSTANCE; + // 转换 + SearchGlobalConfigParams params = mapper.toSearchGlobalParams(req); + // 查询 + List list = configDao.listGlobalConfig(params); + // 总数 + Long total = configDao.countGlobalConfig(params); + + return PageVo.build(mapper.toGlobalDTOS(list), params.getPageSize(), params.getPageNum(), total); + } + + @Override + public void save(GlobalConfigReq req) { + GlobalConfigDO globalConfigDO = ConfigStructMapper.INSTANCE.toGlobalDO(req); + // id 不为空 + if (NumUtil.nullOrZero(globalConfigDO.getId())) { + configDao.save(globalConfigDO); + } else { + configDao.updateById(globalConfigDO); + } + + // 配置更新之后,主动触发配置的动态加载 + SpringUtil.publishEvent(new ConfigRefreshEvent(this, req.getKeywords(), req.getValue())); + } + + @Override + public void delete(Long id) { + GlobalConfigDO globalConfigDO = configDao.getGlobalConfigById(id); + if (globalConfigDO != null) { + configDao.delete(globalConfigDO); + } else { + throw ExceptionUtil.of(StatusEnum.RECORDS_NOT_EXISTS, "记录不存在"); + } + } + + /** + * 添加敏感词白名单 + * + * @param word + */ + @Override + public void addSensitiveWhiteWord(String word) { + String key = SensitiveProperty.SENSITIVE_KEY_PREFIX + ".allow"; + GlobalConfigReq req = new GlobalConfigReq(); + req.setKeywords(key); + + GlobalConfigDO config = configDao.getGlobalConfigByKey(key); + if (config == null) { + req.setValue(word); + req.setComment("敏感词白名单"); + } else { + req.setValue(config.getValue() + "," + word); + req.setComment(config.getComment()); + req.setId(config.getId()); + } + // 更新敏感词白名单 + save(req); + + // 移除敏感词记录 + SpringUtil.getBean(SensitiveService.class).removeSensitiveWord(word); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/constant/EsFieldConstant.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/constant/EsFieldConstant.java new file mode 100644 index 000000000..60280c6c3 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/constant/EsFieldConstant.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.service.constant; + +/** + * ES 过滤字段常量 + * + * @ClassName: EsFieldConstant + * @Author: ygl + * @Date: 2023/5/26 09:39 + * @Version: 1.0 + */ +public class EsFieldConstant { + + /** + * title字段 + */ + public static final String ES_FIELD_TITLE = "title"; + + /** + * short_title字段 + */ + public static final String ES_FIELD_SHORT_TITLE = "short_title"; + + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/constant/EsIndexConstant.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/constant/EsIndexConstant.java new file mode 100644 index 000000000..0e2877e70 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/constant/EsIndexConstant.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.constant; + +/** + * ES index + * + * @ClassName: EsFieldConstant + * @Author: ygl + * @Date: 2023/5/26 09:39 + * @Version: 1.0 + */ +public class EsIndexConstant { + + /** + * article索引 + */ + public static final String ES_INDEX_ARTICLE = "article"; + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/ImageUploader.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/ImageUploader.java new file mode 100644 index 000000000..4023d2981 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/ImageUploader.java @@ -0,0 +1,56 @@ +package com.github.paicoding.forum.service.image.oss; + +import com.github.hui.quick.plugin.base.constants.MediaType; +import com.github.hui.quick.plugin.base.file.FileReadUtil; +import org.apache.commons.lang3.StringUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * @author YiHui + * @date 2023/1/12 + */ +public interface ImageUploader { + String DEFAULT_FILE_TYPE = "txt"; + Set STATIC_IMG_TYPE = new HashSet<>(Arrays.asList(MediaType.ImagePng, MediaType.ImageJpg, MediaType.ImageWebp, MediaType.ImageGif)); + + /** + * 文件上传 + * + * @param input + * @param fileType + * @return + */ + String upload(InputStream input, String fileType); + + /** + * 判断外网图片是否依然需要处理 + * + * @param fileUrl + * @return true 表示忽略,不需要转存 + */ + boolean uploadIgnore(String fileUrl); + + /** + * 获取文件类型 + * + * @param input + * @param fileType + * @return + */ + default String getFileType(ByteArrayInputStream input, String fileType) { + if (StringUtils.isNotBlank(fileType)) { + return fileType; + } + + MediaType type = MediaType.typeOfMagicNum(FileReadUtil.getMagicNum(input)); + if (STATIC_IMG_TYPE.contains(type)) { + return type.getExt(); + } + return DEFAULT_FILE_TYPE; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/AliOssWrapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/AliOssWrapper.java new file mode 100644 index 000000000..878086bbd --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/AliOssWrapper.java @@ -0,0 +1,165 @@ +package com.github.paicoding.forum.service.image.oss.impl; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.OSSException; +import com.aliyun.oss.model.PutObjectRequest; +import com.aliyun.oss.model.PutObjectResult; +import com.github.paicoding.forum.core.autoconf.DynamicConfigContainer; +import com.github.paicoding.forum.core.config.ImageProperties; +import com.github.paicoding.forum.core.util.Md5Util; +import com.github.paicoding.forum.core.util.StopWatchUtil; +import com.github.paicoding.forum.service.image.oss.ImageUploader; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +/** + * 阿里云oss文件上传 + * + * @author YiHui + * @date 2023/1/12 + */ +@Slf4j +@ConditionalOnExpression(value = "#{'ali'.equals(environment.getProperty('image.oss.type'))}") +@Component +public class AliOssWrapper implements ImageUploader, InitializingBean, DisposableBean { + private static final int SUCCESS_CODE = 200; + @Autowired + @Setter + @Getter + private ImageProperties properties; + private OSS ossClient; + + @Autowired + private DynamicConfigContainer dynamicConfigContainer; + + public String upload(InputStream input, String fileType) { + try { + // 创建PutObjectRequest对象。 + byte[] bytes = StreamUtils.copyToByteArray(input); + return upload(bytes, fileType); + } catch (OSSException oe) { + log.error("Oss rejected with an error response! msg:{}, code:{}, reqId:{}, host:{}", oe.getErrorMessage(), oe.getErrorCode(), oe.getRequestId(), oe.getHostId()); + return ""; + } catch (Exception ce) { + log.error("Caught an ClientException, which means the client encountered " + + "a serious internal problem while trying to communicate with OSS, " + + "such as not being able to access the network. {}", ce.getMessage()); + return ""; + } + } + + public String upload(byte[] bytes, String fileType) { + StopWatchUtil stopWatchUtil = StopWatchUtil.init("图片上传"); + try { + // 计算md5作为文件名,避免重复上传 + String fileName = stopWatchUtil.record("md5计算", () -> Md5Util.encode(bytes)); + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + fileName = properties.getOss().getPrefix() + fileName + "." + getFileType(input, fileType); + // 创建PutObjectRequest对象。 + PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getOss().getBucket(), fileName, input); + // 设置该属性可以返回response。如果不设置,则返回的response为空。 + putObjectRequest.setProcess("true"); + + // 上传文件 + PutObjectResult result = stopWatchUtil.record("文件上传", () -> ossClient.putObject(putObjectRequest)); + if (SUCCESS_CODE == result.getResponse().getStatusCode()) { + return properties.getOss().getHost() + fileName; + } else { + log.error("upload to oss error! response:{}", result.getResponse().getStatusCode()); + // Guava 不允许回传 null + return ""; + } + } catch (OSSException oe) { + log.error("Oss rejected with an error response! msg:{}, code:{}, reqId:{}, host:{}", oe.getErrorMessage(), oe.getErrorCode(), oe.getRequestId(), oe.getHostId()); + return ""; + } catch (Exception ce) { + log.error("Caught an ClientException, which means the client encountered " + + "a serious internal problem while trying to communicate with OSS, " + + "such as not being able to access the network. {}", ce.getMessage()); + return ""; + } finally { + if (log.isDebugEnabled()) { + log.debug("upload image size:{} cost: {}", bytes.length, stopWatchUtil.prettyPrint()); + } + } + } + + public String uploadWithFileName(byte[] bytes, String fileName) { + StopWatchUtil stopWatchUtil = StopWatchUtil.init("图片上传"); + try { + // 计算md5作为文件名,避免重复上传 + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + fileName = properties.getOss().getPrefix() + fileName; + // 创建PutObjectRequest对象。 + PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getOss().getBucket(), fileName, input); + // 设置该属性可以返回response。如果不设置,则返回的response为空。 + putObjectRequest.setProcess("true"); + + // 上传文件 + PutObjectResult result = stopWatchUtil.record("文件上传", () -> ossClient.putObject(putObjectRequest)); + if (SUCCESS_CODE == result.getResponse().getStatusCode()) { + return properties.getOss().getHost() + fileName; + } else { + log.error("upload to oss error! response:{}", result.getResponse().getStatusCode()); + // Guava 不允许回传 null + return ""; + } + } catch (OSSException oe) { + log.error("Oss rejected with an error response! msg:{}, code:{}, reqId:{}, host:{}", oe.getErrorMessage(), oe.getErrorCode(), oe.getRequestId(), oe.getHostId()); + return ""; + } catch (Exception ce) { + log.error("Caught an ClientException, which means the client encountered " + + "a serious internal problem while trying to communicate with OSS, " + + "such as not being able to access the network. {}", ce.getMessage()); + return ""; + } finally { + if (log.isDebugEnabled()) { + log.debug("upload image size:{} cost: {}", bytes.length, stopWatchUtil.prettyPrint()); + } + } + } + + @Override + public boolean uploadIgnore(String fileUrl) { + if (StringUtils.isNotBlank(properties.getOss().getHost()) && fileUrl.startsWith(properties.getOss().getHost())) { + return true; + } + + return !fileUrl.startsWith("http"); + } + + @Override + public void destroy() { + if (ossClient != null) { + ossClient.shutdown(); + } + } + + private void init() { + // 创建OSSClient实例。 + log.info("init ossClient"); + ossClient = new OSSClientBuilder().build(properties.getOss().getEndpoint(), properties.getOss().getAk(), properties.getOss().getSk()); + } + + @Override + public void afterPropertiesSet() { + init(); +// // 监听配置变更,然后重新初始化OSSClient实例 +// dynamicConfigContainer.registerRefreshCallback(properties, () -> { +// init(); +// log.info("ossClient refreshed!"); +// }); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/LocalStorageWrapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/LocalStorageWrapper.java new file mode 100644 index 000000000..6b3f04e8b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/LocalStorageWrapper.java @@ -0,0 +1,96 @@ +package com.github.paicoding.forum.service.image.oss.impl; + +import com.github.hui.quick.plugin.base.file.FileWriteUtil; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.config.ImageProperties; +import com.github.paicoding.forum.core.util.StopWatchUtil; +import com.github.paicoding.forum.service.image.oss.ImageUploader; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Random; + +/** + * 本地保存上传文件 + * + * @author YiHui + * @date 2023/1/12 + */ +@Slf4j +@ConditionalOnExpression(value = "#{'local'.equals(environment.getProperty('image.oss.type'))}") +@Component +public class LocalStorageWrapper implements ImageUploader { + @Autowired + private ImageProperties imageProperties; + private Random random; + + public LocalStorageWrapper() { + random = new Random(); + } + + @Override + public String upload(InputStream input, String fileType) { + // 记录耗时分布 + StopWatchUtil stopWatchUtil = StopWatchUtil.init("图片上传"); + try { + if (fileType == null) { + // 根据魔数判断文件类型 + InputStream finalInput = input; + byte[] bytes = stopWatchUtil.record("流转字节", () -> StreamUtils.copyToByteArray(finalInput)); + input = new ByteArrayInputStream(bytes); + fileType = getFileType((ByteArrayInputStream) input, fileType); + } + + String path = imageProperties.getAbsTmpPath() + imageProperties.getWebImgPath(); + String fileName = genTmpFileName(); + + InputStream finalInput = input; + String finalFileType = fileType; + FileWriteUtil.FileInfo file = stopWatchUtil.record("存储", () -> FileWriteUtil.saveFileByStream(finalInput, path, fileName, finalFileType)); + return imageProperties.buildImgUrl(imageProperties.getWebImgPath() + file.getFilename() + "." + file.getFileType()); + } catch (Exception e) { + log.error("Parse img from httpRequest to BufferedImage error! e:", e); + throw ExceptionUtil.of(StatusEnum.UPLOAD_PIC_FAILED); + } finally { + log.info("图片上传耗时: {}", stopWatchUtil.prettyPrint()); + } + } + + /** + * 获取文件临时名称 + * + * @return + */ + private String genTmpFileName() { + return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddhhmmssSSS")) + "_" + random.nextInt(100); + } + + /** + * 外网图片转存判定,对于没有转存过的,且是http开头的网络图片时,才需要进行转存 + * + * @param img + * @return true 表示不需要转存 + */ + @Override + public boolean uploadIgnore(String img) { + if (StringUtils.isNotBlank(imageProperties.getCdnHost()) && img.startsWith(imageProperties.getCdnHost())) { + return true; + } + + // 如果是oss的图片,也不需要转存 + if (StringUtils.isNotBlank(imageProperties.getOss().getHost()) && img.startsWith(imageProperties.getOss().getHost())) { + return true; + } + + return !img.startsWith("http"); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/RestOssWrapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/RestOssWrapper.java new file mode 100644 index 000000000..b76188eff --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/RestOssWrapper.java @@ -0,0 +1,57 @@ +package com.github.paicoding.forum.service.image.oss.impl; + +import com.github.paicoding.forum.core.config.ImageProperties; +import com.github.paicoding.forum.core.net.HttpRequestHelper; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.StopWatchUtil; +import com.github.paicoding.forum.service.image.oss.ImageUploader; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +/** + * 基于http的文件上传 + * + * @author YiHui + * @date 2023/11/10 + */ +@Slf4j +@Component +@ConditionalOnExpression(value = "#{'rest'.equals(environment.getProperty('image.oss.type'))}") +public class RestOssWrapper implements ImageUploader { + @Autowired + private ImageProperties properties; + + @Override + public String upload(InputStream input, String fileType) { + StopWatchUtil stopWatchUtil = StopWatchUtil.init("图片上传"); + try { + byte[] bytes = stopWatchUtil.record("转字节", () -> StreamUtils.copyToByteArray(input)); + String res = stopWatchUtil.record("上传", () -> HttpRequestHelper.upload(properties.getOss().getEndpoint(), "image", "img." + fileType, bytes)); + HashMap map = JsonUtil.toObj(res, HashMap.class); + return (String) ((Map) map.get("result")).get("imagePath"); + } catch (Exception e) { + log.error("upload image error response! uri:{}", properties.getOss().getEndpoint(), e); + return null; + } finally { + if (log.isDebugEnabled()) { + log.debug("upload Image cost: {}", stopWatchUtil.prettyPrint()); + } + } + } + + @Override + public boolean uploadIgnore(String fileUrl) { + if (StringUtils.isNotBlank(properties.getOss().getHost()) && fileUrl.startsWith(properties.getOss().getHost())) { + return true; + } + return !fileUrl.startsWith("http"); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageService.java new file mode 100644 index 000000000..41b8a9673 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageService.java @@ -0,0 +1,33 @@ +package com.github.paicoding.forum.service.image.service; + +import javax.servlet.http.HttpServletRequest; + +/** + * @author LouZai + * @date 2022/9/7 + */ +public interface ImageService { + /** + * 图片转存 + * @param content + * @return + */ + String mdImgReplace(String content); + + + /** + * 外网图片转存 + * + * @param img + * @return + */ + String saveImg(String img); + + /** + * 保存图片 + * + * @param request + * @return + */ + String saveImg(HttpServletRequest request); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageServiceImpl.java new file mode 100644 index 000000000..5f897663e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageServiceImpl.java @@ -0,0 +1,246 @@ +package com.github.paicoding.forum.service.image.service; + +import com.github.hui.quick.plugin.base.constants.MediaType; +import com.github.hui.quick.plugin.base.file.FileReadUtil; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.async.AsyncExecute; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.mdc.MdcDot; +import com.github.paicoding.forum.core.util.MdImgLoader; +import com.github.paicoding.forum.service.image.oss.ImageUploader; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import javax.servlet.http.HttpServletRequest; +import java.io.*; +import java.net.URI; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * @author LouZai + * @date 2022/9/7 + */ +@Slf4j +@Service +public class ImageServiceImpl implements ImageService { + + @Autowired + private ImageUploader imageUploader; + + /** + * 外网图片转存缓存 + */ + private Cache imgReplaceCache = CacheBuilder + .newBuilder() + .maximumSize(300) + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(); + + @Override + public String saveImg(HttpServletRequest request) { + MultipartFile file = null; + if (request instanceof MultipartHttpServletRequest) { + file = ((MultipartHttpServletRequest) request).getFile("image"); + } + if (file == null) { + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "缺少需要上传的图片"); + } + + // 目前只支持 jpg, png, webp 等静态图片格式 + String fileType = validateStaticImg(file.getContentType()); + if (fileType == null) { + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "图片只支持png,jpg,gif"); + } + + try { + // 先获取图像摘要,根据摘要确定缓存中是否已经包含图像。 + String digest = calculateSHA256(file.getInputStream()); + String ans = imgReplaceCache.getIfPresent(digest); + if (StringUtils.isBlank(ans)) { + ans = imageUploader.upload(file.getInputStream(), fileType); + imgReplaceCache.put(digest, ans); + } + return ans; + } catch (IOException | NoSuchAlgorithmException e) { + log.error("Parse img from httpRequest to BufferedImage error! e:", e); + throw ExceptionUtil.of(StatusEnum.UPLOAD_PIC_FAILED); + } + } + + /** + * 外网图片转存 + * + * @param img + * @return + */ + @Override + public String saveImg(String img) { + if (imageUploader.uploadIgnore(img)) { + // 已经转存过,不需要再次转存;非http图片,不处理 + return img; + } + + try { + InputStream stream = FileReadUtil.getStreamByFileName(img); + URI uri = URI.create(img); + String path = uri.getPath(); + + int index = path.lastIndexOf("."); + String fileType = null; + if (index > 0) { + // 从url中获取文件类型 + fileType = path.substring(index + 1); + } + String digest = calculateSHA256(stream); + String ans = imgReplaceCache.getIfPresent(digest); + if (StringUtils.isBlank(ans)) { + ans = imageUploader.upload(stream, fileType); + imgReplaceCache.put(digest, ans); + } + if (StringUtils.isBlank(ans)) { + return buildUploadFailImgUrl(img); + } + return ans; + } catch (Exception e) { + log.error("外网图片转存异常! img:{}", img, e); + return buildUploadFailImgUrl(img); + } + } + + /** + * 外网图片自动转存,添加了执行日志,超时限制;避免出现因为超时导致发布文章异常 + * + * @param content + * @return + */ + @Override + @MdcDot + @AsyncExecute(timeOutRsp = "#content") + public String mdImgReplace(String content) { + List imgList = MdImgLoader.loadImgs(content); + if (CollectionUtils.isEmpty(imgList)) { + return content; + } + + if (imgList.size() == 1) { + // 只有一张图片时,没有必要走异步,直接转存并返回 + MdImgLoader.MdImg img = imgList.get(0); + String newImg = saveImg(img.getUrl()); + return StringUtils.replace(content, img.getOrigin(), "![" + img.getDesc() + "](" + newImg + ")"); + } + + // 超过1张图片时,做并发的图片转存,提升性能 + Map imgReplaceMap = new ConcurrentHashMap<>(); + try(AsyncUtil.CompletableFutureBridge bridge = AsyncUtil.concurrentExecutor("MdImgReplace")) { + for (MdImgLoader.MdImg img : imgList) { + bridge.async(() -> { + imgReplaceMap.put(img, saveImg(img.getUrl())); + }, img.getUrl()); + } + } + + // 图片替换 + for (Map.Entry entry : imgReplaceMap.entrySet()) { + MdImgLoader.MdImg img = entry.getKey(); + String newImg = entry.getValue(); + content = StringUtils.replace(content, img.getOrigin(), "![" + img.getDesc() + "](" + newImg + ")"); + } + return content; + } + + private String buildUploadFailImgUrl(String img) { + return img.contains("saveError") ? img : img + "?&cause=saveError!"; + } + + /** + * 图片格式校验 + * + * @param mime + * @return + */ + private String validateStaticImg(String mime) { + if ("svg".equalsIgnoreCase(mime)) { + // fixme 上传文件保存到服务器本地时,做好安全保护, 避免上传了要给攻击性的脚本 + return "svg"; + } + + if (mime.contains(MediaType.ImageJpg.getExt())) { + mime = mime.replace("jpg", "jpeg"); + } + for (MediaType type : ImageUploader.STATIC_IMG_TYPE) { + if (type.getMime().equals(mime)) { + return type.getExt(); + } + } + return null; + } + + /** + * 图片摘要生成 + * + * @param inputStream + * @return + */ + private String calculateSHA256(InputStream inputStream) throws NoSuchAlgorithmException, IOException { + + inputStream = toByteArrayInputStream(inputStream); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[1024]; + int bytesRead; + + // 读取 InputStream 并更新到 MessageDigest + while ((bytesRead = inputStream.read(buffer)) != -1) { + md.update(buffer, 0, bytesRead); + } + + // 获取摘要并将其转换为十六进制字符串 + byte[] digest = md.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + hexString.append(String.format("%02x", b)); + } + inputStream.reset(); + return hexString.toString(); + } + + /** + * 转换为字节数组输入流,可以重复消费流中数据 + * + * @param inputStream + * @return + * @throws IOException + */ + public ByteArrayInputStream toByteArrayInputStream(InputStream inputStream) throws IOException { + if (inputStream instanceof ByteArrayInputStream) { + return (ByteArrayInputStream) inputStream; + } + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + BufferedInputStream br = new BufferedInputStream(inputStream); + byte[] b = new byte[1024]; + for (int c; (c = br.read(b)) != -1; ) { + bos.write(b, 0, c); + } + // 主动告知回收 + b = null; + br.close(); + inputStream.close(); + return new ByteArrayInputStream(bos.toByteArray()); + } + } + + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/config/RabbitMqAutoConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/config/RabbitMqAutoConfig.java new file mode 100644 index 000000000..c2e3586f5 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/config/RabbitMqAutoConfig.java @@ -0,0 +1,42 @@ +package com.github.paicoding.forum.service.notify.config; + +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.config.RabbitmqProperties; +import com.github.paicoding.forum.core.rabbitmq.RabbitmqConnectionPool; +import com.github.paicoding.forum.service.notify.service.RabbitmqService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.Resource; + +/** + * @author YiHui + * @date 2023/6/9 + */ +@Configuration +@ConditionalOnProperty(value = "rabbitmq.switchFlag") +@EnableConfigurationProperties(RabbitmqProperties.class) +public class RabbitMqAutoConfig implements ApplicationRunner { + @Resource + private RabbitmqService rabbitmqService; + + @Autowired + private RabbitmqProperties rabbitmqProperties; + + + @Override + public void run(ApplicationArguments args) throws Exception { + String host = rabbitmqProperties.getHost(); + Integer port = rabbitmqProperties.getPort(); + String userName = rabbitmqProperties.getUsername(); + String password = rabbitmqProperties.getPassport(); + String virtualhost = rabbitmqProperties.getVirtualhost(); + Integer poolSize = rabbitmqProperties.getPoolSize(); + RabbitmqConnectionPool.initRabbitmqConnectionPool(host, port, userName, password, virtualhost, poolSize); + AsyncUtil.execute(() -> rabbitmqService.processConsumerMsg()); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/help/MsgNotifyHelper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/help/MsgNotifyHelper.java new file mode 100644 index 000000000..2ca816f33 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/help/MsgNotifyHelper.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.service.notify.help; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.core.util.SpringUtil; +import org.springframework.stereotype.Service; + +/** + * @author YiHui + * @date 2024/11/27 + */ +@Service +public class MsgNotifyHelper { + + /** + * 消息广播通知 + * + * @param type 消息类型 + * @param content 消息内容 + * @param 消息类型 + */ + public void publishMsg(NotifyTypeEnum type, T content) { + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, type, content)); + } + + + /** + * 静态方法使用方式,简化调用方使用 + * + * @param type 消息类型 + * * @param content 消息内容 + * * @param 消息类型 + */ + public static void publish(NotifyTypeEnum type, T content) { + SpringUtil.getBean(MsgNotifyHelper.class).publishMsg(type, content); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/dao/NotifyMsgDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/dao/NotifyMsgDao.java new file mode 100644 index 000000000..f1a7a7803 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/dao/NotifyMsgDao.java @@ -0,0 +1,113 @@ +package com.github.paicoding.forum.service.notify.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.NotifyStatEnum; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.notify.dto.NotifyMsgDTO; +import com.github.paicoding.forum.service.notify.repository.entity.NotifyMsgDO; +import com.github.paicoding.forum.service.notify.repository.mapper.NotifyMsgMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.CollectionUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Repository +public class NotifyMsgDao extends ServiceImpl { + + /** + * 查询消息记录,用于幂等过滤 + * + * @param msg + * @return + */ + public NotifyMsgDO getByUserIdRelatedIdAndType(NotifyMsgDO msg) { + List list = lambdaQuery().eq(NotifyMsgDO::getNotifyUserId, msg.getNotifyUserId()) + .eq(NotifyMsgDO::getOperateUserId, msg.getOperateUserId()) + .eq(NotifyMsgDO::getType, msg.getType()) + .eq(NotifyMsgDO::getRelatedId, msg.getRelatedId()) + .orderByDesc(NotifyMsgDO::getId) + .page(new Page<>(0, 1)) + .getRecords(); + if (CollectionUtils.isEmpty(list)) { + return null; + } + return list.get(0); + } + + + /** + * 查询用户的消息通知数量 + * + * @param userId + * @return + */ + public int countByUserIdAndStat(long userId, Integer stat) { + return lambdaQuery() + .eq(NotifyMsgDO::getNotifyUserId, userId) + .eq(stat != null, NotifyMsgDO::getState, stat) + .count().intValue(); + } + + /** + * 查询用户各类型的未读消息数量 + * + * @param userId + * @return + */ + public Map groupCountByUserIdAndStat(long userId, Integer stat) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.select("type, count(*) as cnt"); + wrapper.eq("notify_user_id", userId); + if (stat != null) { + wrapper.eq("state", stat); + } + wrapper.groupBy("type"); + List> map = listMaps(wrapper); + Map result = new HashMap<>(); + map.forEach(s -> { + result.put(Integer.valueOf(s.get("type").toString()), Integer.valueOf(s.get("cnt").toString())); + }); + return result; + } + + /** + * 查询用户消息列表 + * + * @param userId + * @param type + * @return + */ + public List listNotifyMsgByUserIdAndType(long userId, NotifyTypeEnum type, PageParam page) { + switch (type) { + case REPLY: + case COMMENT: + case COLLECT: + case PRAISE: + return baseMapper.listArticleRelatedNotices(userId, type.getType(), page); + default: + return baseMapper.listNormalNotices(userId, type.getType(), page); + } + } + + /** + * 设置消息为已读 + * + * @param list + */ + public void updateNotifyMsgToRead(List list) { + List ids = list.stream().filter(s -> s.getState() == NotifyStatEnum.UNREAD.getStat()).map(NotifyMsgDTO::getMsgId).collect(Collectors.toList()); + if (!ids.isEmpty()) { + baseMapper.updateNoticeRead(ids); + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/entity/NotifyMsgDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/entity/NotifyMsgDO.java new file mode 100644 index 000000000..076323595 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/entity/NotifyMsgDO.java @@ -0,0 +1,59 @@ +package com.github.paicoding.forum.service.notify.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Data +@Accessors(chain = true) +@TableName("notify_msg") +public class NotifyMsgDO extends BaseDO { + private static final long serialVersionUID = -4043774744889659100L; + + /** + * 消息关联的主体 + * - 如文章收藏、评论、回复评论、点赞消息,这里存文章ID; + * - 如系统通知消息时,这里存的是系统通知消息正文主键,也可以是0 + * - 如关注,这里就是0 + */ + private Long relatedId; + + /** + * 关联的评论ID + * - 用于回复通知时,存储被回复的评论ID,方便跳转到具体评论位置 + */ + private Long commentId; + + /** + * 消息内容 + */ + private String msg; + + /** + * 消息通知的用户id + */ + private Long notifyUserId; + + /** + * 触发这个消息的用户id + */ + private Long operateUserId; + + /** + * 消息类型 + * + * @see NotifyTypeEnum#getType() + */ + private Integer type; + + /** + * 0 未查看 1 已查看 + */ + private Integer state; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/mapper/NotifyMsgMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/mapper/NotifyMsgMapper.java new file mode 100644 index 000000000..f29ed2193 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/repository/mapper/NotifyMsgMapper.java @@ -0,0 +1,43 @@ +package com.github.paicoding.forum.service.notify.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.notify.dto.NotifyMsgDTO; +import com.github.paicoding.forum.service.notify.repository.entity.NotifyMsgDO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/3 + */ +public interface NotifyMsgMapper extends BaseMapper { + + /** + * 查询文章相关的通知列表 + * + * @param userId + * @param type + * @param page 分页 + * @return + */ + List listArticleRelatedNotices(@Param("userId") long userId, @Param("type") int type, @Param("pageParam") PageParam page); + + /** + * 查询关注、系统等没有关联id的通知列表 + * + * @param userId + * @param type + * @param page 分页 + * @return + */ + List listNormalNotices(@Param("userId") long userId, @Param("type") int type, @Param("pageParam") PageParam page); + + /** + * 标记消息为已阅读 + * + * @param ids + */ + void updateNoticeRead(@Param("ids") List ids); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/NotifyService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/NotifyService.java new file mode 100644 index 000000000..977d80221 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/NotifyService.java @@ -0,0 +1,83 @@ +package com.github.paicoding.forum.service.notify.service; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.notify.dto.NotifyMsgDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; + +import java.util.Map; + +/** + * 消息通知服务类 + * + * @author YiHui + * @date 2022/9/3 + */ +public interface NotifyService { + public static String NOTIFY_TOPIC = "/msg"; + + + /** + * 查询用户未读消息数量 + * + * @param userId + * @return + */ + int queryUserNotifyMsgCount(Long userId); + + /** + * 查询通知列表 + * + * @param userId + * @param type + * @param page + * @return + */ + PageListVo queryUserNotices(Long userId, NotifyTypeEnum type, PageParam page); + + /** + * 查询未读消息数 + * + * @param userId + * @return + */ + Map queryUnreadCounts(long userId); + + /** + * 保存通知 + * + * @param foot + * @param notifyTypeEnum + */ + void saveArticleNotify(UserFootDO foot, NotifyTypeEnum notifyTypeEnum); + + + // -------------------------------------------- 下面是与用户的websocket长连接维护相关实现 ------------------------- + + /** + * ws: 给用户发送消息通知 + * + * @param userId 用户id + * @param msg 通知内容 + */ + void notifyToUser(Long userId, String msg); + + /** + * ws: 给用户发送消息通知(带类型) + * + * @param userId 用户id + * @param type 通知类型 + * @param msg 通知内容 + */ + void notifyToUser(Long userId, NotifyTypeEnum type, String msg); + + + /** + * ws: 维护与用户的长连接通道 + * + * @param accessor + */ + void notifyChannelMaintain(StompHeaderAccessor accessor); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/RabbitmqService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/RabbitmqService.java new file mode 100644 index 000000000..d9f7efef8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/RabbitmqService.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.service.notify.service; + +import com.rabbitmq.client.BuiltinExchangeType; + +import java.io.IOException; +import java.util.concurrent.TimeoutException; + +/** + * @author YiHui + * @date 2022/9/3 + */ +public interface RabbitmqService { + + boolean enabled(); + + /** + * 发布消息 + * + * @param exchange + * @param exchangeType + * @param toutingKey + * @param message + * @throws IOException + * @throws TimeoutException + */ + void publishMsg(String exchange, + BuiltinExchangeType exchangeType, + String toutingKey, + String message); + + + /** + * 消费消息 + * + * @param exchange + * @param queue + * @param routingKey + * @throws IOException + * @throws TimeoutException + */ + void consumerMsg(String exchange, + String queue, + String routingKey) throws IOException, TimeoutException; + + + void processConsumerMsg(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyMsgListener.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyMsgListener.java new file mode 100644 index 000000000..b683a293f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyMsgListener.java @@ -0,0 +1,328 @@ +package com.github.paicoding.forum.service.notify.service.impl; + +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.NotifyStatEnum; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.notify.repository.dao.NotifyMsgDao; +import com.github.paicoding.forum.service.notify.repository.entity.NotifyMsgDO; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import com.github.paicoding.forum.service.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Slf4j +@Async +@Service +public class NotifyMsgListener implements ApplicationListener> { + private static final Long ADMIN_ID = 1L; + private final ArticleReadService articleReadService; + + private final CommentReadService commentReadService; + + private final NotifyMsgDao notifyMsgDao; + + private final NotifyService notifyService; + + private final UserService userService; + + public NotifyMsgListener(ArticleReadService articleReadService, + CommentReadService commentReadService, + NotifyService notifyService, + NotifyMsgDao notifyMsgDao, + UserService userService) { + this.articleReadService = articleReadService; + this.commentReadService = commentReadService; + this.notifyService = notifyService; + this.notifyMsgDao = notifyMsgDao; + this.userService = userService; + } + + @SuppressWarnings("unchecked") + @Override + public void onApplicationEvent(NotifyMsgEvent msgEvent) { + switch (msgEvent.getNotifyType()) { + case COMMENT: + saveCommentNotify((NotifyMsgEvent) msgEvent); + break; + case REPLY: + saveReplyNotify((NotifyMsgEvent) msgEvent); + break; + case PRAISE: + case COLLECT: + saveArticleNotify((NotifyMsgEvent) msgEvent); + break; + case CANCEL_PRAISE: + case CANCEL_COLLECT: + removeArticleNotify((NotifyMsgEvent) msgEvent); + break; + case FOLLOW: + saveFollowNotify((NotifyMsgEvent) msgEvent); + break; + case CANCEL_FOLLOW: + removeFollowNotify((NotifyMsgEvent) msgEvent); + break; + case LOGIN: + // todo 用户登录,判断是否需要插入新的通知消息,暂时先不做 + break; + case REGISTER: + // 首次注册,插入一个欢迎的消息 + saveRegisterSystemNotify((Long) msgEvent.getContent()); + break; + case PAYING: + case PAY: + // 文章支付回调/支付中的消息通知 + savePayNotify((NotifyMsgEvent) msgEvent); + default: + // todo 系统消息 + } + } + + /** + * 评论 + 回复 + * + * @param event + */ + private void saveCommentNotify(NotifyMsgEvent event) { + NotifyMsgDO msg = new NotifyMsgDO(); + CommentDO comment = event.getContent(); + ArticleDO article = articleReadService.queryBasicArticle(comment.getArticleId()); + msg.setNotifyUserId(article.getUserId()) + .setOperateUserId(comment.getUserId()) + .setRelatedId(article.getId()) + .setType(event.getNotifyType().getType()) + .setState(NotifyStatEnum.UNREAD.getStat()).setMsg(comment.getContent()); + // 对于评论而言,支持多次评论;因此若之前有也不删除 + notifyMsgDao.save(msg); + + // 消息通知 + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.COMMENT, + String.format("您的文章《%s》收到一个新的评论,快去看看吧", article.getTitle())); + } + + /** + * 评论回复消息 + * + * @param event + */ + private void saveReplyNotify(NotifyMsgEvent event) { + NotifyMsgDO msg = new NotifyMsgDO(); + CommentDO comment = event.getContent(); + CommentDO parent = commentReadService.queryComment(comment.getParentCommentId()); + msg.setNotifyUserId(parent.getUserId()) + .setOperateUserId(comment.getUserId()) + .setRelatedId(comment.getArticleId()) + .setCommentId(comment.getId()) + .setType(event.getNotifyType().getType()) + .setState(NotifyStatEnum.UNREAD.getStat()).setMsg(comment.getContent()); + // 回复同样支持多次回复,不做幂等校验 + notifyMsgDao.save(msg); + + // 消息通知 + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.REPLY, + String.format("您的评价《%s》收到一个新的回复,快去看看吧", parent.getContent())); + } + + /** + * 点赞 + 收藏 + * + * @param event + */ + private void saveArticleNotify(NotifyMsgEvent event) { + UserFootDO foot = event.getContent(); + NotifyMsgDO msg = new NotifyMsgDO().setRelatedId(foot.getDocumentId()) + .setNotifyUserId(foot.getDocumentUserId()) + .setOperateUserId(foot.getUserId()) + .setType(event.getNotifyType().getType()) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg(""); + if (Objects.equals(foot.getDocumentType(), DocumentTypeEnum.COMMENT.getCode())) { + // 点赞评论时,详情内容中显示评论信息 + CommentDO comment = commentReadService.queryComment(foot.getDocumentId()); + ArticleDO article = articleReadService.queryBasicArticle(comment.getArticleId()); + msg.setMsg(String.format("赞了您在文章 %s 下的评论 %s", article.getId(), article.getTitle(), comment.getContent())); + } + + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record == null) { + // 若之前已经有对应的通知,则不重复记录;因为一个用户对一篇文章,可以重复的点赞、取消点赞,但是最终我们只通知一次 + notifyMsgDao.save(msg); + // 消息通知 + notifyService.notifyToUser(msg.getNotifyUserId(), event.getNotifyType(), + String.format("太棒了,您的%s %s数+1!!!", + Objects.equals(foot.getDocumentType(), DocumentTypeEnum.ARTICLE.getCode()) ? "文章" : "评论", + event.getNotifyType().getMsg())); + } + } + + public void saveArticleNotify(UserFootDO foot, NotifyTypeEnum notifyTypeEnum) { + NotifyMsgDO msg = new NotifyMsgDO().setRelatedId(foot.getDocumentId()) + .setNotifyUserId(foot.getDocumentUserId()) + .setOperateUserId(foot.getUserId()) + .setType(notifyTypeEnum.getType()) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg(""); + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record == null) { + // 若之前已经有对应的通知,则不重复记录;因为一个用户对一篇文章,可以重复的点赞、取消点赞,但是最终我们只通知一次 + notifyMsgDao.save(msg); + } + } + + /** + * 取消点赞,取消收藏 + * + * @param event + */ + private void removeArticleNotify(NotifyMsgEvent event) { + UserFootDO foot = event.getContent(); + NotifyMsgDO msg = new NotifyMsgDO() + .setRelatedId(foot.getDocumentId()) + .setNotifyUserId(foot.getDocumentUserId()) + .setOperateUserId(foot.getUserId()) + .setType(event.getNotifyType().getType()) + .setMsg(""); + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record != null) { + notifyMsgDao.removeById(record.getId()); + } + } + + /** + * 关注 + * + * @param event + */ + private void saveFollowNotify(NotifyMsgEvent event) { + UserRelationDO relation = event.getContent(); + NotifyMsgDO msg = new NotifyMsgDO().setRelatedId(0L) + .setNotifyUserId(relation.getUserId()) + .setOperateUserId(relation.getFollowUserId()) + .setType(event.getNotifyType().getType()) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg(""); + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record == null) { + // 若之前已经有对应的通知,则不重复记录;因为用户的关注是一对一的,可以重复的关注、取消,但是最终我们只通知一次 + notifyMsgDao.save(msg); + + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.FOLLOW, "恭喜您获得一枚新粉丝~"); + } + } + + /** + * 取消关注 + * + * @param event + */ + private void removeFollowNotify(NotifyMsgEvent event) { + UserRelationDO relation = event.getContent(); + NotifyMsgDO msg = new NotifyMsgDO() + .setRelatedId(0L) + .setNotifyUserId(relation.getUserId()) + .setOperateUserId(relation.getFollowUserId()) + .setType(event.getNotifyType().getType()) + .setMsg(""); + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record != null) { + notifyMsgDao.removeById(record.getId()); + } + } + + private void saveRegisterSystemNotify(Long userId) { + NotifyMsgDO msg = new NotifyMsgDO().setRelatedId(0L) + .setNotifyUserId(userId) + .setOperateUserId(ADMIN_ID) + .setType(NotifyTypeEnum.REGISTER.getType()) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg(SpringUtil.getConfig("view.site.welcomeInfo")); + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record == null) { + // 若之前已经有对应的通知,则不重复记录;因为用户的关注是一对一的,可以重复的关注、取消,但是最终我们只通知一次 + notifyMsgDao.save(msg); + + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.SYSTEM, "您有一个新的系统通知消息,请注意查收"); + } + } + + private void savePayNotify(NotifyMsgEvent pay) { + ArticlePayRecordDO record = pay.getContent(); + ArticleDO article = articleReadService.queryBasicArticle(record.getArticleId()); + + NotifyMsgDO msg; + PayStatusEnum payStatus = PayStatusEnum.statusOf(record.getPayStatus()); + if (PayStatusEnum.PAYING == payStatus) { + // 支付中,给作者发起一个通知 + BaseUserInfoDTO payUser = userService.queryBasicUserInfo(record.getPayUserId()); + + msg = new NotifyMsgDO().setRelatedId(record.getArticleId()) + .setNotifyUserId(record.getReceiveUserId()) + .setOperateUserId(record.getPayUserId()) + .setType(NotifyTypeEnum.PAY.getType()) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg(String.format("您的文章 %s 收到一份来自 %s 的 [%s] 打赏,点击 去确认~", + record.getArticleId(), article.getTitle(), + payUser.getUserId(), payUser.getUserName(), + StringUtils.isBlank(record.getPayWay()) || Objects.equals(record.getPayWay(), ThirdPayWayEnum.EMAIL.getPay()) ? "个人收款码" : "微信支付", + record.getId())); + } else { + // 作者执行的支付结果回调通知付费用户 + msg = new NotifyMsgDO().setRelatedId(record.getArticleId()) + .setNotifyUserId(record.getPayUserId()) + .setOperateUserId(record.getReceiveUserId()) + .setType(NotifyTypeEnum.PAY.getType()) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg( + PayStatusEnum.SUCCEED == payStatus + ? String.format("您对 %s 的支付已完成~", record.getArticleId(), article.getTitle()) + : String.format("您对 %s 的支付未完成哦~", record.getArticleId(), article.getTitle()) + ); + } + + NotifyMsgDO dbMsg = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (dbMsg == null) { + // 未通知过,则新增一条通知记录 + notifyMsgDao.save(msg); + } else if (!Objects.equals(dbMsg.getMsg(), msg.getMsg())) { + // 由于可能出现第一次支付失败,然后第二次支付成功的场景,因此我们需要再新增一个消息通知 + notifyMsgDao.save(msg); + } else if (payStatus == PayStatusEnum.PAYING && Objects.equals(dbMsg.getState(), NotifyStatEnum.UNREAD.getStat())) { + // 根据作者是否看过通知,来决定是否需要重新给作者发送一个消息通知 + notifyMsgDao.save(msg); + } + + if (payStatus == PayStatusEnum.PAYING) { + // 支付中 + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.SYSTEM, + String.format("您的文章《%s》收到一份打赏,请及时确认~", article.getTitle())); + } else if (payStatus == PayStatusEnum.SUCCEED) { + // 支付成功 + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.SYSTEM, + String.format("您对文章《%s》的支付已完成,刷新即可阅读全文哦~", article.getTitle())); + } else if (payStatus == PayStatusEnum.FAIL) { + // 支付失败 + notifyService.notifyToUser(msg.getNotifyUserId(), NotifyTypeEnum.SYSTEM, + String.format("您对文章《%s》的支付未成功,请重试一下吧~", article.getTitle())); + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyServiceImpl.java new file mode 100644 index 000000000..94ba1260e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyServiceImpl.java @@ -0,0 +1,223 @@ +package com.github.paicoding.forum.service.notify.service.impl; + +import com.beust.jcommander.internal.Sets; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.NotifyStatEnum; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.notify.dto.NotifyMsgDTO; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.core.ws.WebSocketResponseUtil; +import com.github.paicoding.forum.service.notify.repository.dao.NotifyMsgDao; +import com.github.paicoding.forum.service.notify.repository.entity.NotifyMsgDO; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.service.UserRelationService; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import javax.annotation.PostConstruct; +import javax.annotation.Resource; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2022/9/4 + */ +@Slf4j +@Service +public class NotifyServiceImpl implements NotifyService { + @Resource + private NotifyMsgDao notifyMsgDao; + + @Resource + private UserRelationService userRelationService; + + /** + * 记录用户与对应的jwt token之间的缓存关系;用于websocket的广播通知 + */ + private LoadingCache> wsUserSessionCache; + + @PostConstruct + public void init() { + wsUserSessionCache = CacheBuilder.newBuilder() + .maximumSize(500) + .expireAfterAccess(1, TimeUnit.HOURS) + .build(new CacheLoader>() { + @Override + public Set load(Long aLong) throws Exception { + return new HashSet<>(); + } + }); + } + + @Override + public int queryUserNotifyMsgCount(Long userId) { + return notifyMsgDao.countByUserIdAndStat(userId, NotifyStatEnum.UNREAD.getStat()); + } + + /** + * 查询消息通知列表 + * + * @return + */ + @Override + public PageListVo queryUserNotices(Long userId, NotifyTypeEnum type, PageParam page) { + List list = notifyMsgDao.listNotifyMsgByUserIdAndType(userId, type, page); + if (CollectionUtils.isEmpty(list)) { + return PageListVo.emptyVo(); + } + + // 设置消息为已读状态 + notifyMsgDao.updateNotifyMsgToRead(list); + // 更新全局总的消息数 + ReqInfoContext.getReqInfo().setMsgNum(queryUserNotifyMsgCount(userId)); + // 更新当前登录用户对粉丝的关注状态 + updateFollowStatus(userId, list); + return PageListVo.newVo(list, page.getPageSize()); + } + + private void updateFollowStatus(Long userId, List list) { + List targetUserIds = list.stream().filter(s -> s.getType() == NotifyTypeEnum.FOLLOW.getType()).map(NotifyMsgDTO::getOperateUserId).collect(Collectors.toList()); + if (targetUserIds.isEmpty()) { + return; + } + + // 查询userId已经关注过的用户列表;并将对应的msg设置为true,表示已经关注过了;不需要再关注 + Set followedUserIds = userRelationService.getFollowedUserId(targetUserIds, userId); + list.forEach(notify -> { + if (followedUserIds.contains(notify.getOperateUserId())) { + notify.setMsg("true"); + } else { + notify.setMsg("false"); + } + }); + } + + @Override + public Map queryUnreadCounts(long userId) { + Map map = Collections.emptyMap(); + if (ReqInfoContext.getReqInfo() != null && NumUtil.upZero(ReqInfoContext.getReqInfo().getMsgNum())) { + map = notifyMsgDao.groupCountByUserIdAndStat(userId, NotifyStatEnum.UNREAD.getStat()); + } + // 指定先后顺序 + Map ans = new LinkedHashMap<>(); + initCnt(NotifyTypeEnum.COMMENT, map, ans); + initCnt(NotifyTypeEnum.REPLY, map, ans); + initCnt(NotifyTypeEnum.PRAISE, map, ans); + initCnt(NotifyTypeEnum.COLLECT, map, ans); + initCnt(NotifyTypeEnum.FOLLOW, map, ans); + initCnt(NotifyTypeEnum.SYSTEM, map, ans); + return ans; + } + + private void initCnt(NotifyTypeEnum type, Map map, Map result) { + result.put(type.name().toLowerCase(), map.getOrDefault(type.getType(), 0)); + } + + @Override + public void saveArticleNotify(UserFootDO foot, NotifyTypeEnum notifyTypeEnum) { + NotifyMsgDO msg = new NotifyMsgDO().setRelatedId(foot.getDocumentId()) + .setNotifyUserId(foot.getDocumentUserId()) + .setOperateUserId(foot.getUserId()) + .setType(notifyTypeEnum.getType() ) + .setState(NotifyStatEnum.UNREAD.getStat()) + .setMsg(""); + NotifyMsgDO record = notifyMsgDao.getByUserIdRelatedIdAndType(msg); + if (record == null) { + // 若之前已经有对应的通知,则不重复记录;因为一个用户对一篇文章,可以重复的点赞、取消点赞,但是最终我们只通知一次 + notifyMsgDao.save(msg); + } + } + + // -------------------------------------------- 下面是与用户的websocket长连接维护相关实现 ------------------------- + + /** + * x用户发送 + * @param userId 用户id + * @param msg 通知内容 + */ + @Override + public void notifyToUser(Long userId, String msg) { + wsUserSessionCache.getUnchecked(userId).forEach(s -> { + WebSocketResponseUtil.sendMsgToUser(s, NOTIFY_TOPIC, msg); + }); + } + + /** + * 给用户发送消息通知(带类型) + * @param userId 用户id + * @param type 通知类型 + * @param msg 通知内容 + */ + @Override + public void notifyToUser(Long userId, NotifyTypeEnum type, String msg) { + // 组装 JSON 格式的消息:{"type":"reply","message":"您的评价收到一个新的回复"} + String jsonMsg = String.format("{\"type\":\"%s\",\"message\":\"%s\"}", + type.name().toLowerCase(), + msg.replace("\"", "\\\"")); + wsUserSessionCache.getUnchecked(userId).forEach(s -> { + WebSocketResponseUtil.sendMsgToUser(s, NOTIFY_TOPIC, jsonMsg); + }); + } + + /** + * 用户建立连接时,添加用户信息 + * + * @param userId 用户id + * @param session jwt token + */ + private void addUserToken(Long userId, String session) { + wsUserSessionCache.getUnchecked(userId).add(session); + } + + /** + * 断开连接时,移除用户信息 + * + * @param userId 用户id + * @param session jwt token + */ + private void releaseUserToken(Long userId, String session) { + wsUserSessionCache.getUnchecked(userId).remove(session); + } + + /** + * WebSocket通道管理 + * + * @param accessor + */ + @Override + public void notifyChannelMaintain(StompHeaderAccessor accessor) { + String destination = accessor.getDestination(); + if (StringUtils.isBlank(destination) || accessor.getCommand() == null) { + return; + } + + + // 全局私信、通知长连接入口 + ReqInfoContext.ReqInfo user = (ReqInfoContext.ReqInfo) accessor.getUser(); + if (user == null) { + log.info("websocket用户未登录! {}", accessor); + return; + } + switch (accessor.getCommand()) { + case SUBSCRIBE: + // 建立用户通信通道 + addUserToken(user.getUserId(), user.getSession()); + break; + case DISCONNECT: + // 中断链接,去掉用户的长连接会话 + releaseUserToken(user.getUserId(), user.getSession()); + break; + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/RabbitmqServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/RabbitmqServiceImpl.java new file mode 100644 index 000000000..f7741a609 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/RabbitmqServiceImpl.java @@ -0,0 +1,124 @@ +package com.github.paicoding.forum.service.notify.service.impl; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.core.common.CommonConstants; +import com.github.paicoding.forum.core.rabbitmq.RabbitmqConnection; +import com.github.paicoding.forum.core.rabbitmq.RabbitmqConnectionPool; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.service.notify.service.RabbitmqService; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.BuiltinExchangeType; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.Consumer; +import com.rabbitmq.client.DefaultConsumer; +import com.rabbitmq.client.Envelope; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.concurrent.TimeoutException; + +@Slf4j +@Service +public class RabbitmqServiceImpl implements RabbitmqService { + + @Autowired + private NotifyService notifyService; + + @Override + public boolean enabled() { + return "true".equalsIgnoreCase(SpringUtil.getConfig("rabbitmq.switchFlag")); + } + + @Override + public void publishMsg(String exchange, + BuiltinExchangeType exchangeType, + String routingKey, + String message) { + try { + //创建连接 + RabbitmqConnection rabbitmqConnection = RabbitmqConnectionPool.getConnection(); + Connection connection = rabbitmqConnection.getConnection(); + //创建消息通道 + Channel channel = connection.createChannel(); + // 声明exchange中的消息为可持久化,不自动删除 + channel.exchangeDeclare(exchange, exchangeType, true, false, null); + // 发布消息 + channel.basicPublish(exchange, routingKey, null, message.getBytes()); + log.info("Publish msg: {}", message); + channel.close(); + RabbitmqConnectionPool.returnConnection(rabbitmqConnection); + } catch (InterruptedException | IOException | TimeoutException e) { + log.error("rabbitMq消息发送异常: exchange: {}, msg: {}", exchange, message, e); + } + + } + + @Override + public void consumerMsg(String exchange, + String queueName, + String routingKey) { + + try { + //创建连接 + RabbitmqConnection rabbitmqConnection = RabbitmqConnectionPool.getConnection(); + Connection connection = rabbitmqConnection.getConnection(); + //创建消息信道 + final Channel channel = connection.createChannel(); + //消息队列 + channel.queueDeclare(queueName, true, false, false, null); + //绑定队列到交换机 + channel.queueBind(queueName, exchange, routingKey); + + Consumer consumer = new DefaultConsumer(channel) { + @Override + public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, + byte[] body) throws IOException { + String message = new String(body, "UTF-8"); + log.info("Consumer msg: {}", message); + + // 获取Rabbitmq消息,并保存到DB + // 说明:这里仅作为示例,如果有多种类型的消息,可以根据消息判定,简单的用 if...else 处理,复杂的用工厂 + 策略模式 + notifyService.saveArticleNotify(JsonUtil.toObj(message, UserFootDO.class), NotifyTypeEnum.PRAISE); + + channel.basicAck(envelope.getDeliveryTag(), false); + } + }; + // 取消自动ack + channel.basicConsume(queueName, false, consumer); + channel.close(); + RabbitmqConnectionPool.returnConnection(rabbitmqConnection); + } catch (InterruptedException | IOException | TimeoutException e) { + e.printStackTrace(); + } + } + + @Override + public void processConsumerMsg() { + log.info("Begin to processConsumerMsg."); + + Integer stepTotal = 1; + Integer step = 0; + + // TODO: 这种方式非常 Low,后续会改造成阻塞 I/O 模式 + while (true) { + step++; + try { + log.info("processConsumerMsg cycle."); + consumerMsg(CommonConstants.EXCHANGE_NAME_DIRECT, CommonConstants.QUERE_NAME_PRAISE, + CommonConstants.QUERE_KEY_PRAISE); + if (step.equals(stepTotal)) { + Thread.sleep(10000); + step = 0; + } + } catch (Exception e) { + + } + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/openapi/oauth/OpenApiSilentLoginService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/openapi/oauth/OpenApiSilentLoginService.java new file mode 100644 index 000000000..5c1477e82 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/openapi/oauth/OpenApiSilentLoginService.java @@ -0,0 +1,64 @@ +package com.github.paicoding.forum.service.openapi.oauth; + +import com.github.paicoding.forum.api.model.enums.RoleEnum; +import com.github.paicoding.forum.api.model.openapi.user.OpenApiUserDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.service.user.service.help.UserSessionHelper; +import org.springframework.stereotype.Service; + +/** + * 静默登录的开放平台接口(授权的第三方平台,可以根据用户技术派的TOKEN来获取用户信息,实现静默登录) + * + * @author YiHui + * @date 2025/9/15 + */ +@Service +public class OpenApiSilentLoginService { + + private final UserSessionHelper userSessionHelper; + private final UserService userService; + + public OpenApiSilentLoginService(UserSessionHelper userSessionHelper, UserService userService) { + this.userSessionHelper = userSessionHelper; + this.userService = userService; + } + + /** + * 使用技术派的Token换取用户信息,从而实现静默登录 + * + * @param session + * @return + */ + public OpenApiUserDTO silentLogin(String session) { + Long userId = userSessionHelper.getUserIdBySession(session); + if (userId == null) { + return null; + } + + UserDO loginInfo = userService.getUserDO(userId); + UserInfoDO userInfo = userService.getUserInfo(userId); + UserAiDO zsxqInfo = userService.getUserAiDO(userId); + + OpenApiUserDTO res = new OpenApiUserDTO(); + res.setUserId(loginInfo.getId()); + res.setLoginName(loginInfo.getUserName()); + res.setWxId(loginInfo.getThirdAccountId()); + res.setUserName(userInfo.getUserName()); + res.setProfile(userInfo.getProfile()); + res.setPhoto(userInfo.getPhoto()); + res.setCompany(userInfo.getCompany()); + res.setPosition(userInfo.getPosition()); + res.setEmail(userInfo.getEmail()); + res.setRole(RoleEnum.role(userInfo.getUserRole())); + if (zsxqInfo != null) { + res.setZsxqId(zsxqInfo.getStarNumber()); + res.setZsxqExpireTime(zsxqInfo.getStarExpireTime() == null ? 0L : zsxqInfo.getStarExpireTime().getTime()); + } + + return res; + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/openapi/package-info.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/openapi/package-info.java new file mode 100644 index 000000000..8d386d112 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/openapi/package-info.java @@ -0,0 +1,7 @@ +/** + * 开放平台接口 + * + * @author YiHui + * @date 2025/9/15 + */ +package com.github.paicoding.forum.service.openapi; \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/PayService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/PayService.java new file mode 100644 index 000000000..206d290bd --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/PayService.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.service.pay; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.pay.dto.PayInfoDTO; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.wechat.pay.java.service.refund.model.RefundNotification; +import org.springframework.http.ResponseEntity; + +import javax.servlet.http.HttpServletRequest; +import java.util.function.Function; + +/** + * 技术派的支付服务接口 + * + * @author YiHui + * @date 2024/12/9 + */ +public interface PayService { + + boolean support(ThirdPayWayEnum payWay); + + PayInfoDTO toPay(ArticlePayRecordDO record, boolean needRefresh); + + /** + * 前端告知后端,将支付状态更新为支付中时,支付服务的处理逻辑 + * + * @param record + * @return true 执行成功,record记录有变更,需要执行保存操作 false 无需变更 + */ + boolean paying(ArticlePayRecordDO record); + + + /** + * 支付结果回调 + * + * @param request + * @param payCallback + * @return + */ + ResponseEntity payCallback(HttpServletRequest request, Function payCallback); + + + /** + * 退款结果回调 + * + * @param request + * @param payCallback + * @return + */ + ResponseEntity refundCallback(HttpServletRequest request, Function payCallback); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/PayServiceFactory.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/PayServiceFactory.java new file mode 100644 index 000000000..d3a33751f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/PayServiceFactory.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.service.pay; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 技术派的支付服务接口 + * + * @author YiHui + * @date 2024/12/9 + */ +@Service +public class PayServiceFactory { + + @Autowired + private List payServiceList; + + public PayService getPayService(ThirdPayWayEnum payWay) { + for (PayService payService : payServiceList) { + if (payService.support(payWay)) { + return payService; + } + } + + return null; + } + + + // fixme 对于支付状态为支付中的场景,根据notify_time来判断,是否需要重新给作者发送邮件通知 / 或者查询微信的订单状态,避免出现支付状态一直不更新的问题 + public void autoUpdatePayingStatus() { + // 1. 查出 支付状态 == 支付中, notifyTime = null 或者 notifyTime 距离当前时间超过30分钟的数据 + // 2. 重新调用一下 payService.paying 实现给作者发邮件 or 查三方支付状态 + // 3. 更新校验时间 + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/config/WxPayConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/config/WxPayConfig.java new file mode 100644 index 000000000..1e8be3a59 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/config/WxPayConfig.java @@ -0,0 +1,49 @@ +package com.github.paicoding.forum.service.pay.config; + +import com.github.hui.quick.plugin.base.file.FileReadUtil; +import lombok.Data; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 微信支付配置 + * + * @author YiHui + * @date 2024/12/3 + */ +@Data +@Component +@ConditionalOnProperty(value = "wx.pay.enable") +@ConfigurationProperties(prefix = "wx.pay") +public class WxPayConfig { + //APPID + private String appId; + //mchid + private String merchantId; + //商户API私钥 + private String privateKey; + //商户证书序列号 + private String merchantSerialNumber; + //商户APIv3密钥 + private String apiV3Key; + //支付通知地址 + private String payNotifyUrl; + //退款通知地址 + private String refundNotifyUrl; + + /** + * 获取私钥信息 + * + * @return 私钥内容 + */ + public String getPrivateKeyContent() { + try { + return FileReadUtil.readAll(privateKey); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/PayCallbackBo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/PayCallbackBo.java new file mode 100644 index 000000000..0e943d994 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/PayCallbackBo.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.service.pay.model; + +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 支付回调通知业务对象 + * + * @author YiHui + * @date 2024/12/6 + */ +@Data +@Accessors(chain = true) +public class PayCallbackBo { + /** + * 传递给支付系统的唯一外部单号 + */ + private String outTradeNo; + /** + * 对应系统内的业务支付id + */ + private Long payId; + /** + * 支付成功时间 + */ + private Long successTime; + + /** + * 三方流水编号 + */ + private String thirdTransactionId; + + /** + * 支付状态 + */ + private PayStatusEnum payStatus; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/PrePayInfoResBo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/PrePayInfoResBo.java new file mode 100644 index 000000000..635cb05c4 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/PrePayInfoResBo.java @@ -0,0 +1,55 @@ +package com.github.paicoding.forum.service.pay.model; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * @author YiHui + * @date 2024/12/3 + */ +@Data +@Accessors(chain = true) +public class PrePayInfoResBo { + /** + * 支付方式 wx-jsapi, wx-h5, wx-native + * + * @see ThirdPayWayEnum#getPay() + */ + private ThirdPayWayEnum payWay; + + /** + * 传递给三方的外部系统编号 + */ + private String outTradeNo; + + /** + * 应用: appId + */ + private String appId; + + /** + * 时间戳信息 + */ + private String nonceStr; + + private String prePackage; + + private String paySign; + + private String timeStamp; + + private String signType; + + /** + * jsapi:返回的是用于唤起支付的 prePayId + * h5: 返回的是微信收银台中间页 url,用于访问之后唤起微信客户端的支付页面 + * native: 返回的是形如 weixin:// 的文本,用于生成二维码给微信扫一扫支付 + */ + private String prePayId; + + /** + * prePayId的失效的时间戳 + */ + private Long expireTime; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/ThirdPayOrderReqBo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/ThirdPayOrderReqBo.java new file mode 100644 index 000000000..b23874eff --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/model/ThirdPayOrderReqBo.java @@ -0,0 +1,53 @@ +package com.github.paicoding.forum.service.pay.model; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 向三方支付平台下单的请求业务参数 + * + * @author YiHui + * @date 2024/12/3 + */ +@Data +@Accessors(chain = true) +public class ThirdPayOrderReqBo { + /** + * 订单号(业务) + */ + String outTradeNo; + /** + * 用户openId, 对于h5支付场景下,没有这个参数 + */ + String openId; + /** + * 订单描述 + */ + String description; + /** + * 订单总金额,单位为分 + */ + int total; + + /** + * 支付方式 + */ + ThirdPayWayEnum payWay; + + public ThirdPayOrderReqBo() { + } + + public ThirdPayOrderReqBo(String outTradeNo, String description, int total) { + this.outTradeNo = outTradeNo; + this.description = description; + this.total = total; + } + + public ThirdPayOrderReqBo(String outTradeNo, String openId, String description, int total) { + this.outTradeNo = outTradeNo; + this.openId = openId; + this.description = description; + this.total = total; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/EmailPayServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/EmailPayServiceImpl.java new file mode 100644 index 000000000..53783fe9f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/EmailPayServiceImpl.java @@ -0,0 +1,127 @@ +package com.github.paicoding.forum.service.pay.service; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.dto.PayConfirmDTO; +import com.github.paicoding.forum.api.model.vo.pay.dto.PayInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.util.EmailUtil; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.util.id.IdUtil; +import com.github.paicoding.forum.service.article.conveter.PayConverter; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.service.pay.PayService; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.github.paicoding.forum.service.user.service.UserService; +import com.wechat.pay.java.service.refund.model.RefundNotification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring5.SpringTemplateEngine; + +import javax.servlet.http.HttpServletRequest; +import java.util.Date; +import java.util.function.Function; + +/** + * 个人收款码-基于邮件的支付流程 + * + * @author YiHui + * @date 2024/12/9 + */ +@Slf4j +@Service +public class EmailPayServiceImpl implements PayService { + @Autowired + private UserService userService; + + @Autowired + private SpringTemplateEngine springTemplateEngine; + + @Autowired + private ThirdPayHandler thirdPayFacade; + + @Override + public boolean support(ThirdPayWayEnum payWay) { + return payWay == ThirdPayWayEnum.EMAIL; + } + + /** + * 唤起支付,主要返回作者的收款码 + * + * @param record + * @param needRefresh true 表示需要刷新用户的收款码信息 + * @return + */ + @Override + public PayInfoDTO toPay(ArticlePayRecordDO record, boolean needRefresh) { + ThirdPayOrderReqBo req = new ThirdPayOrderReqBo() + .setOutTradeNo(record.getVerifyCode()) + .setOpenId(record.getReceiveUserId() + "") + .setPayWay(ThirdPayWayEnum.EMAIL); + + PrePayInfoResBo bo = thirdPayFacade.createPayOrder(req); + PayInfoDTO payInfo = new PayInfoDTO(); + payInfo.setPrePayId(bo.getPrePayId()); + payInfo.setPayQrCodeMap(PayConverter.formatPayCode(bo.getPrePayId())); + if (needRefresh) { + // 需要刷新的场景时,才重置失效时间 + payInfo.setPrePayExpireTime(System.currentTimeMillis() + ThirdPayWayEnum.EMAIL.getExpireTimePeriod()); + } + return payInfo; + } + + /** + * 给作者发送支付确认邮件 + * + * @param record + */ + @Override + public boolean paying(ArticlePayRecordDO record) { + if (record.getNotifyTime() != null && System.currentTimeMillis() - record.getNotifyTime().getTime() < 180_000) { + // 两次通知时间,小于10分钟,则直接幂等 + log.info("上次邮件确认时间是: {} 忽略本次通知! {}", record.getNotifyTime(), JsonUtil.toStr(record)); + return false; + } + + try { + record.setVerifyCode(IdUtil.genPayCode(ThirdPayWayEnum.ofPay(record.getPayWay()), record.getId())); + + PayConfirmDTO confirm = SpringUtil.getBean(ArticlePayService.class).buildPayConfirmInfo(record.getId(), record); + Context context = new Context(); + context.setVariable("vo", confirm); + String confirmHtmlContent = springTemplateEngine.process("PayConfirm", context); + log.info("输出邮件内容: \n {} \n", confirmHtmlContent); + + // 给作者发送邮件通知 + BaseUserInfoDTO author = userService.queryBasicUserInfo(record.getReceiveUserId()); + EmailUtil.sendMail(String.format("【%s】收到【%s】的打赏,请确认", confirm.getTitle(), confirm.getPayUser()), author.getEmail(), confirmHtmlContent); + + // 邮件发送成功,更新通知时间 + 次数 + 验证码 + record.setNotifyTime(new Date()); + record.setNotifyCnt(record.getNotifyCnt() + 1); + record.setUpdateTime(new Date()); + } catch (Exception e) { + log.error("发送邮件确认通知失败: {}", record, e); + } + return true; + } + + @Override + public ResponseEntity payCallback(HttpServletRequest request, Function payCallback) { + PayCallbackBo bo = thirdPayFacade.payCallback(request, ThirdPayWayEnum.EMAIL); + boolean ans = payCallback.apply(bo); + return ResponseEntity.ok(ResVo.ok(ans)); + } + + @Override + public ResponseEntity refundCallback(HttpServletRequest request, Function payCallback) { + return thirdPayFacade.refundCallback(request, ThirdPayWayEnum.EMAIL, payCallback); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/OnlinePayServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/OnlinePayServiceImpl.java new file mode 100644 index 000000000..37c56e81f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/OnlinePayServiceImpl.java @@ -0,0 +1,127 @@ +package com.github.paicoding.forum.service.pay.service; + +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.pay.dto.PayInfoDTO; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.PriceUtil; +import com.github.paicoding.forum.core.util.StrUtil; +import com.github.paicoding.forum.service.article.conveter.PayConverter; +import com.github.paicoding.forum.service.article.repository.entity.ArticlePayRecordDO; +import com.github.paicoding.forum.service.pay.PayService; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.wechat.pay.java.service.refund.model.RefundNotification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletRequest; +import java.util.Date; +import java.util.function.Function; + +/** + * 在线支付流程 + * + * @author YiHui + * @date 2024/12/9 + */ +@Slf4j +@Service +public class OnlinePayServiceImpl implements PayService { + @Autowired + private ThirdPayHandler thirdPayFacade; + + @Override + public boolean support(ThirdPayWayEnum payWay) { + return payWay != ThirdPayWayEnum.EMAIL; + } + + @Override + public PayInfoDTO toPay(ArticlePayRecordDO record, boolean needRefresh) { + if (!needRefresh) { + // 不需要刷新时,直接根据数据库中缓存的进行返回 + PayInfoDTO payInfo = new PayInfoDTO(); + payInfo.setPrePayId(PayConverter.genQrCode(record.getPrePayId())); + payInfo.setPayWay(record.getPayWay()); + payInfo.setPrePayExpireTime(record.getPrePayExpireTime().getTime()); + payInfo.setPayAmount(PriceUtil.toYuanPrice(record.getPayAmount())); + return payInfo; + } + + // 需要像微信重新创建支付订单,并且将结果反写到支付记录中 + ThirdPayOrderReqBo req = new ThirdPayOrderReqBo(); + req.setTotal(record.getPayAmount()); + req.setOutTradeNo(record.getVerifyCode()); + req.setDescription(StrUtil.pickWxSupportTxt(record.getNotes())); + req.setPayWay(ThirdPayWayEnum.ofPay(record.getPayWay())); + PrePayInfoResBo res = thirdPayFacade.createPayOrder(req); + + PayInfoDTO payInfo = new PayInfoDTO(); + if (res != null) { + // 回写微信支付信息到支付记录中,用于下次唤起支付使用 + record.setPrePayId(res.getPrePayId()); + record.setPrePayExpireTime(new Date(res.getExpireTime())); + + payInfo.setPrePayId(PayConverter.genQrCode(res.getPrePayId())); + payInfo.setPayWay(record.getPayWay()); + payInfo.setPrePayExpireTime(res.getExpireTime()); + payInfo.setPayAmount(PriceUtil.toYuanPrice(record.getPayAmount())); + } + return payInfo; + } + + /** + * 主动查询一下支付状态 + * + * @param dbRecord + * @return + */ + @Override + public boolean paying(ArticlePayRecordDO dbRecord) { + // 主动查询一下支付状态 + try { + PayCallbackBo bo = thirdPayFacade.queryOrder(dbRecord.getVerifyCode(), ThirdPayWayEnum.ofPay(dbRecord.getPayWay())); + if (bo.getPayStatus() == PayStatusEnum.SUCCEED || bo.getPayStatus() == PayStatusEnum.FAIL) { + // 实际结果是支付成功/支付失败时,刷新下record对应的内容 + // 更新原来的支付状态为最新的结果 + dbRecord.setPayStatus(bo.getPayStatus().getStatus()); + dbRecord.setPayCallbackTime(new Date(bo.getSuccessTime())); + dbRecord.setUpdateTime(new Date()); + dbRecord.setThirdTransCode(bo.getThirdTransactionId()); + } + } catch (Exception e) { + log.error("查询三方支付状态出现异常: {}", JsonUtil.toStr(dbRecord), e); + } + + // 依然返回true,将支付状态设置为true + return true; + } + + @Override + public ResponseEntity payCallback(HttpServletRequest request, Function payCallback) { + try { + PayCallbackBo bo = thirdPayFacade.payCallback(request, ThirdPayWayEnum.WX_NATIVE); + boolean ans = payCallback.apply(bo); + if (ans) { + // 处理成功,返回 200 OK 状态码 + return ResponseEntity.status(HttpStatus.OK).build(); + } else { + // 处理异常,返回 500 服务器内部异常 状态码 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } catch (Exception e) { + log.error("微信支付回调v3java失败={}", e.getMessage(), e); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + + } + + @Override + public ResponseEntity refundCallback(HttpServletRequest request, Function payCallback) { + return thirdPayFacade.refundCallback(request, ThirdPayWayEnum.WX_NATIVE, payCallback); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/ThirdPayHandler.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/ThirdPayHandler.java new file mode 100644 index 000000000..76909290c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/ThirdPayHandler.java @@ -0,0 +1,53 @@ + +package com.github.paicoding.forum.service.pay.service; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.github.paicoding.forum.service.pay.service.integration.ThirdPayIntegrationApi; +import com.github.paicoding.forum.service.pay.service.integration.email.EmailPayIntegration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.function.Function; + +/** + * 与三方支付服务交互的门面类 + * + * @author YiHui + * @date 2024/12/6 + */ +@Service +public class ThirdPayHandler { + @Autowired + private List payServiceList; + + private ThirdPayIntegrationApi getPayService(ThirdPayWayEnum payWay) { + return payServiceList.stream().filter(s -> s.support(payWay)).findFirst() + .orElse(SpringUtil.getBean(EmailPayIntegration.class)); + } + + public PrePayInfoResBo createPayOrder(ThirdPayOrderReqBo payReq) { + return getPayService(payReq.getPayWay()).createOrder(payReq); + } + + public PayCallbackBo queryOrder(String outTradeNo, ThirdPayWayEnum payWay) { + return getPayService(payWay).queryOrder(outTradeNo); + } + + @Transactional + public PayCallbackBo payCallback(HttpServletRequest request, ThirdPayWayEnum payWay) { + return getPayService(payWay).payCallback(request); + } + + @Transactional + public ResponseEntity refundCallback(HttpServletRequest request, ThirdPayWayEnum payWay, Function refundCallback) { + return getPayService(payWay).refundCallback(request, refundCallback); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/ThirdPayIntegrationApi.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/ThirdPayIntegrationApi.java new file mode 100644 index 000000000..b23309f25 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/ThirdPayIntegrationApi.java @@ -0,0 +1,67 @@ +package com.github.paicoding.forum.service.pay.service.integration; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import org.springframework.http.ResponseEntity; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.function.Function; + +/** + * 对接三方支付的API定义 + * + * @author YiHui + * @date 2024/12/6 + */ +public interface ThirdPayIntegrationApi { + + boolean support(ThirdPayWayEnum payWay); + + /** + * 下单 + * + * @param payReq + * @return + */ + PrePayInfoResBo createOrder(ThirdPayOrderReqBo payReq); + + + /** + * 查询订单 + * + * @param outTradeNo + * @return + */ + PayCallbackBo queryOrder(String outTradeNo); + + + /** + * 支付回调 + * + * @param request + * @return + */ + PayCallbackBo payCallback(HttpServletRequest request); + + /** + * 关单 + * + * @param outTradeNo + */ + void closeOrder(String outTradeNo); + + /** + * 退款回调 + * + * @param request 携带回传的请求参数 + * @param refundCallback 退款结果回调执行业务逻辑 + * @return + * @throws IOException + */ + default ResponseEntity refundCallback(HttpServletRequest request, Function refundCallback) { + return ResponseEntity.ok(true); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/email/EmailPayIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/email/EmailPayIntegration.java new file mode 100644 index 000000000..ec7ca5006 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/email/EmailPayIntegration.java @@ -0,0 +1,65 @@ +package com.github.paicoding.forum.service.pay.service.integration.email; + +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.github.paicoding.forum.service.pay.service.integration.ThirdPayIntegrationApi; +import com.github.paicoding.forum.service.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletRequest; +import java.util.Objects; + +/** + * 个人收款码,基于微信的支付方式 + * + * @author YiHui + * @date 2024/12/6 + */ +@Slf4j +@Service +public class EmailPayIntegration implements ThirdPayIntegrationApi { + @Autowired + private UserService userService; + + @Override + public boolean support(ThirdPayWayEnum payWay) { + return payWay == ThirdPayWayEnum.EMAIL; + } + + @Override + public PrePayInfoResBo createOrder(ThirdPayOrderReqBo payReq) { + PrePayInfoResBo resBo = new PrePayInfoResBo(); + resBo.setPayWay(ThirdPayWayEnum.EMAIL); + resBo.setOutTradeNo(payReq.getOutTradeNo()); + + BaseUserInfoDTO receiveUserInfo = userService.queryBasicUserInfo(Long.parseLong(payReq.getOpenId())); + resBo.setPrePayId(receiveUserInfo.getPayCode()); + resBo.setExpireTime(System.currentTimeMillis() + ThirdPayWayEnum.EMAIL.getExpireTimePeriod()); + return resBo; + } + + @Override + public void closeOrder(String outTradeNo) { + } + + @Override + public PayCallbackBo queryOrder(String outTradeNo) { + return new PayCallbackBo().setOutTradeNo(outTradeNo); + } + + + @Override + public PayCallbackBo payCallback(HttpServletRequest request) { + String outTradeNo = request.getParameter("verifyCode"); + Long payId = Long.parseLong(request.getParameter("payId")); + PayStatusEnum payStatus = Objects.equals("true", request.getParameter("succeed")) ? PayStatusEnum.SUCCEED : PayStatusEnum.FAIL; + return new PayCallbackBo().setPayId(payId).setOutTradeNo(outTradeNo).setPayStatus(payStatus) + .setSuccessTime(System.currentTimeMillis()); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/AbsWxPayIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/AbsWxPayIntegration.java new file mode 100644 index 000000000..b1e33262d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/AbsWxPayIntegration.java @@ -0,0 +1,167 @@ +package com.github.paicoding.forum.service.pay.service.integration.wx; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.github.paicoding.forum.api.model.enums.pay.PayStatusEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.core.net.HttpRequestHelper; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.id.IdUtil; +import com.github.paicoding.forum.service.pay.config.WxPayConfig; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.github.paicoding.forum.service.pay.service.integration.ThirdPayIntegrationApi; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.core.exception.ValidationException; +import com.wechat.pay.java.core.notification.NotificationConfig; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.core.notification.RequestParam; +import com.wechat.pay.java.service.payments.model.Transaction; +import com.wechat.pay.java.service.refund.model.RefundNotification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; + +import javax.servlet.http.HttpServletRequest; +import java.util.function.Function; + +/** + * @author YiHui + * @date 2024/12/6 + */ +@Slf4j +public abstract class AbsWxPayIntegration implements ThirdPayIntegrationApi { + public WxPayConfig wxPayConfig; + + public abstract String createPayOrder(ThirdPayOrderReqBo payReq); + + /** + * 补齐支付信息 + * + * @param payReq 支付请求参数 + * @param prePayId 微信返回的支付唤起code + */ + protected PrePayInfoResBo buildPayInfo(ThirdPayOrderReqBo payReq, String prePayId) { + // 结果封装返回 + ThirdPayWayEnum payWay = payReq.getPayWay(); + PrePayInfoResBo prePay = new PrePayInfoResBo(); + prePay.setPayWay(payWay); + prePay.setOutTradeNo(payReq.getOutTradeNo()); + prePay.setAppId(wxPayConfig.getAppId()); + prePay.setPrePayId(prePayId); + prePay.setExpireTime(System.currentTimeMillis() + payWay.getExpireTimePeriod()); + return prePay; + } + + /** + * 唤起支付 + * JSAPI调起支付 + * + * @return + */ + public PrePayInfoResBo createOrder(ThirdPayOrderReqBo payReq) { + log.info("微信支付 >>>>>>>>>>>>>>>>> 请求:{}", JsonUtil.toStr(payReq)); + // 微信下单 + String prePayId = createPayOrder(payReq); + return buildPayInfo(payReq, prePayId); + } + + protected PayCallbackBo toBo(Transaction transaction) { + String outTradeNo = transaction.getOutTradeNo(); + PayStatusEnum payStatus; + switch (transaction.getTradeState()) { + case SUCCESS: + payStatus = PayStatusEnum.SUCCEED; + break; + case NOTPAY: + payStatus = PayStatusEnum.NOT_PAY; + break; + case USERPAYING: + payStatus = PayStatusEnum.PAYING; + break; + default: + payStatus = PayStatusEnum.FAIL; + } + Long payId = IdUtil.getPayIdFromPayCode(outTradeNo); + Long payTime = transaction.getSuccessTime() != null ? DateUtil.wxDayToTimestamp(transaction.getSuccessTime()) : null; + return new PayCallbackBo() + .setPayStatus(payStatus) + .setOutTradeNo(outTradeNo) + .setPayId(payId) + .setThirdTransactionId(transaction.getTransactionId()) + .setSuccessTime(payTime); + } + + @Override + public PayCallbackBo payCallback(HttpServletRequest request) { + RequestParam requestParam = new RequestParam.Builder() + .serialNumber(request.getHeader("Wechatpay-Serial")) + .nonce(request.getHeader("Wechatpay-Nonce")) + .timestamp(request.getHeader("Wechatpay-Timestamp")) + .signature(request.getHeader("Wechatpay-Signature")) + .body(HttpRequestHelper.readReqData(request)) + .build(); + log.info("微信回调v3 >>>>>>>>>>>>>>>>> {}", JSONObject.toJSONString(requestParam)); + + NotificationConfig config = new RSAAutoCertificateConfig.Builder() + .merchantId(wxPayConfig.getMerchantId()) + .privateKey(wxPayConfig.getPrivateKeyContent()) + .merchantSerialNumber(wxPayConfig.getMerchantSerialNumber()) + .apiV3Key(wxPayConfig.getApiV3Key()) + .build(); + + NotificationParser parser = new NotificationParser(config); + // 验签、解密并转换成 Transaction(返回参数对象) + Transaction transaction = parser.parse(requestParam, Transaction.class); + log.info("微信支付回调 成功,解析: {}", JSON.toJSONString(transaction)); + return toBo(transaction); + } + + /** + * 微信退款回调 + * - 技术派目前没有实现退款流程,下面只是实现了回调,没有具体的业务场景 + * + * @param request + * @return + */ + @Transactional + public ResponseEntity refundCallback(HttpServletRequest request, Function refundCallback) { + RequestParam requestParam = new RequestParam.Builder() + .serialNumber(request.getHeader("Wechatpay-Serial")) + .nonce(request.getHeader("Wechatpay-Nonce")) + .timestamp(request.getHeader("Wechatpay-Timestamp")) + .signature(request.getHeader("Wechatpay-Signature")) + .body(HttpRequestHelper.readReqData(request)) + .build(); + log.info("微信退款回调v3 >>>>>>>>>>>>>>>>> {}", JSONObject.toJSONString(requestParam)); + + NotificationConfig config = new RSAAutoCertificateConfig.Builder() + .merchantId(wxPayConfig.getMerchantId()) + .privateKey(wxPayConfig.getPrivateKeyContent()) + .merchantSerialNumber(wxPayConfig.getMerchantSerialNumber()) + .apiV3Key(wxPayConfig.getApiV3Key()) + .build(); + + NotificationParser parser = new NotificationParser(config); + + try { + // 验签、解密并转换成 Transaction(返回参数对象) + RefundNotification refundNotify = parser.parse(requestParam, RefundNotification.class); + log.info("微信退款回调 成功,解析: {}", JSON.toJSONString(refundNotify)); + boolean ans = refundCallback.apply((T) refundNotify); + if (ans) { + // 处理成功,返回 200 OK 状态码 + return ResponseEntity.status(HttpStatus.OK).build(); + } else { + // 处理异常,返回 500 服务器内部异常 状态码 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } catch (ValidationException e) { + log.error("微信退款回调v3java失败=" + e.getMessage(), e); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/H5WxPayIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/H5WxPayIntegration.java new file mode 100644 index 000000000..39afdc97f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/H5WxPayIntegration.java @@ -0,0 +1,96 @@ +package com.github.paicoding.forum.service.pay.service.integration.wx; + +import com.alibaba.fastjson.JSONObject; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.pay.config.WxPayConfig; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.service.payments.h5.H5Service; +import com.wechat.pay.java.service.payments.h5.model.*; +import com.wechat.pay.java.service.payments.model.Transaction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; + +/** + * @author YiHui + * @date 2024/12/4 + */ +@Slf4j +@Service +@ConditionalOnBean(WxPayConfig.class) +public class H5WxPayIntegration extends AbsWxPayIntegration { + private H5Service h5Service; + + public H5WxPayIntegration(WxPayConfig wxPayConfig) { + this.wxPayConfig = wxPayConfig; + Config config = new RSAAutoCertificateConfig.Builder() + .merchantId(wxPayConfig.getMerchantId()) + .privateKey(wxPayConfig.getPrivateKeyContent()) + .merchantSerialNumber(wxPayConfig.getMerchantSerialNumber()) + .apiV3Key(wxPayConfig.getApiV3Key()) + .build(); + h5Service = new H5Service.Builder().config(config).build(); + } + + @Override + public boolean support(ThirdPayWayEnum payWay) { + return ThirdPayWayEnum.WX_H5 == payWay; + } + + /** + * h5支付,生成微信支付收银台中间页,适用于拿不到微信给与的用户 OpenId 场景 + * + * @return + */ + public String createPayOrder(ThirdPayOrderReqBo payReq) { + PrepayRequest request = new PrepayRequest(); + request.setAppid(wxPayConfig.getAppId()); + request.setMchid(wxPayConfig.getMerchantId()); + request.setDescription(payReq.getDescription()); + request.setNotifyUrl(wxPayConfig.getPayNotifyUrl()); + request.setOutTradeNo(payReq.getOutTradeNo()); + + Amount amount = new Amount(); + amount.setTotal(payReq.getTotal()); + amount.setCurrency("CNY"); + request.setAmount(amount); + + SceneInfo sceneInfo = new SceneInfo(); + sceneInfo.setPayerClientIp(ReqInfoContext.getReqInfo().getClientIp()); + H5Info h5Info = new H5Info(); + h5Info.setAppName("技术派"); + h5Info.setAppUrl("https://paicoding.com"); + h5Info.setType("PC"); + sceneInfo.setH5Info(h5Info); + request.setSceneInfo(sceneInfo); + + log.info("微信h5下单, 微信请求参数: {}", JsonUtil.toStr(request)); + PrepayResponse response = h5Service.prepay(request); + log.info("微信支付 >>>>>>>>>>>> 返回: {}", response.getH5Url()); + return response.getH5Url(); + } + + @Override + public void closeOrder(String outTradeNo) { + CloseOrderRequest closeRequest = new CloseOrderRequest(); + closeRequest.setMchid(wxPayConfig.getMerchantId()); + closeRequest.setOutTradeNo(outTradeNo); + h5Service.closeOrder(closeRequest); + } + + @Override + public PayCallbackBo queryOrder(String outTradeNo) { + QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest(); + request.setMchid(wxPayConfig.getMerchantId()); + request.setOutTradeNo(outTradeNo); + Transaction transaction = h5Service.queryOrderByOutTradeNo(request); + return toBo(transaction); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/JsapiWxPayIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/JsapiWxPayIntegration.java new file mode 100644 index 000000000..5a8efb118 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/JsapiWxPayIntegration.java @@ -0,0 +1,133 @@ + +package com.github.paicoding.forum.service.pay.service.integration.wx; + +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.RandUtil; +import com.github.paicoding.forum.service.pay.config.WxPayConfig; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.core.util.PemUtil; +import com.wechat.pay.java.service.payments.jsapi.JsapiService; +import com.wechat.pay.java.service.payments.jsapi.model.Amount; +import com.wechat.pay.java.service.payments.jsapi.model.CloseOrderRequest; +import com.wechat.pay.java.service.payments.jsapi.model.Payer; +import com.wechat.pay.java.service.payments.jsapi.model.PrepayRequest; +import com.wechat.pay.java.service.payments.jsapi.model.QueryOrderByOutTradeNoRequest; +import com.wechat.pay.java.service.payments.model.Transaction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.Signature; +import java.util.Base64; + +/** + * @author YiHui + * @date 2024/12/4 + */ +@Slf4j +@Service +@ConditionalOnBean(WxPayConfig.class) +public class JsapiWxPayIntegration extends AbsWxPayIntegration { + private JsapiService jsapiService; + + public JsapiWxPayIntegration(WxPayConfig wxPayConfig) { + this.wxPayConfig = wxPayConfig; + Config config = new RSAAutoCertificateConfig.Builder() + .merchantId(wxPayConfig.getMerchantId()) + .privateKey(wxPayConfig.getPrivateKeyContent()) + .merchantSerialNumber(wxPayConfig.getMerchantSerialNumber()) + .apiV3Key(wxPayConfig.getApiV3Key()) + .build(); + jsapiService = new JsapiService.Builder().config(config).build(); + } + + @Override + public boolean support(ThirdPayWayEnum payWay) { + return ThirdPayWayEnum.WX_JSAPI == payWay; + } + + + /** + * jsApi微信支付 -- 适用于小程序、公众号等方式的支付场景: 需要拿到用户的openId + */ + public String createPayOrder(ThirdPayOrderReqBo payReq) { + PrepayRequest request = new PrepayRequest(); + request.setAppid(wxPayConfig.getAppId()); + request.setMchid(wxPayConfig.getMerchantId()); + request.setDescription(payReq.getDescription()); + request.setNotifyUrl(wxPayConfig.getPayNotifyUrl()); + request.setOutTradeNo(payReq.getOutTradeNo()); + + Amount amount = new Amount(); + amount.setTotal(payReq.getTotal()); + request.setAmount(amount); + + Payer payer = new Payer(); + payer.setOpenid(payReq.getOpenId()); + request.setPayer(payer); + + log.info("微信JsApi下单, 请求参数: {}", JsonUtil.toStr(request)); + com.wechat.pay.java.service.payments.jsapi.model.PrepayResponse response = jsapiService.prepay(request); + log.info("微信支付 >>>>>>>>>>>> 返回: {}", response.getPrepayId()); + return response.getPrepayId(); + } + + @Override + protected PrePayInfoResBo buildPayInfo(ThirdPayOrderReqBo payReq, String prePayId) { + PrePayInfoResBo payRes = super.buildPayInfo(payReq, prePayId); + long now = System.currentTimeMillis(); + // 官方说明有效期为两小时,我们设置为1.8小时之后失效 + payRes.setExpireTime(now + ThirdPayWayEnum.WX_JSAPI.getExpireTimePeriod()); + String timeStamp = String.valueOf(now / 1000); + //随机字符串,要求小于32位 + String nonceStr = RandUtil.random(30); + String packageStr = "prepay_id=" + payRes.getPrePayId(); + + // 不能去除'.append("\n")',否则失败 + String signStr = wxPayConfig.getAppId() + "\n" + + timeStamp + "\n" + + nonceStr + "\n" + + packageStr + "\n"; + + byte[] message = signStr.getBytes(StandardCharsets.UTF_8); + try { + Signature sign = Signature.getInstance("SHA256withRSA"); + sign.initSign(PemUtil.loadPrivateKeyFromString(wxPayConfig.getPrivateKeyContent())); + sign.update(message); + String signStrBase64 = Base64.getEncoder().encodeToString(sign.sign()); + + // 拼装返回结果 + payRes.setNonceStr(nonceStr); + payRes.setPrePackage(packageStr); + payRes.setSignType("RSA"); + payRes.setTimeStamp(timeStamp); + payRes.setPaySign(signStrBase64); + return payRes; + } catch (Exception e) { + log.error("唤醒支付签名异常: {} - {}", payRes.getPrePayId(), payRes.getOutTradeNo(), e); + return null; + } + } + + public void closeOrder(String outTradeNo) { + CloseOrderRequest closeRequest = new CloseOrderRequest(); + closeRequest.setMchid(wxPayConfig.getMerchantId()); + closeRequest.setOutTradeNo(outTradeNo); + jsapiService.closeOrder(closeRequest); + } + + + public PayCallbackBo queryOrder(String outTradeNo) { + QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest(); + request.setMchid(wxPayConfig.getMerchantId()); + request.setOutTradeNo(outTradeNo); + Transaction transaction = jsapiService.queryOrderByOutTradeNo(request); + return toBo(transaction); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/NativeWxPayIntegration.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/NativeWxPayIntegration.java new file mode 100644 index 000000000..ffac6235c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/wx/NativeWxPayIntegration.java @@ -0,0 +1,91 @@ +package com.github.paicoding.forum.service.pay.service.integration.wx; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.pay.config.WxPayConfig; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.service.payments.model.Transaction; +import com.wechat.pay.java.service.payments.nativepay.NativePayService; +import com.wechat.pay.java.service.payments.nativepay.model.Amount; +import com.wechat.pay.java.service.payments.nativepay.model.CloseOrderRequest; +import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest; +import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse; +import com.wechat.pay.java.service.payments.nativepay.model.QueryOrderByOutTradeNoRequest; +import com.wechat.pay.java.service.payments.nativepay.model.SceneInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; + +/** + * @author YiHui + * @date 2024/12/4 + */ +@Slf4j +@Service +@ConditionalOnBean(WxPayConfig.class) +public class NativeWxPayIntegration extends AbsWxPayIntegration { + private NativePayService nativePayService; + + public NativeWxPayIntegration(WxPayConfig wxPayConfig) { + this.wxPayConfig = wxPayConfig; + Config config = new RSAAutoCertificateConfig.Builder() + .merchantId(wxPayConfig.getMerchantId()) + .privateKey(wxPayConfig.getPrivateKeyContent()) + .merchantSerialNumber(wxPayConfig.getMerchantSerialNumber()) + .apiV3Key(wxPayConfig.getApiV3Key()) + .build(); + nativePayService = new NativePayService.Builder().config(config).build(); + } + + @Override + public boolean support(ThirdPayWayEnum payWay) { + return ThirdPayWayEnum.WX_NATIVE == payWay; + } + + /** + * native 支付,生成扫描支付二维码唤起微信支付页面 + * + * @return 形如 wx://xxx 的支付二维码 + */ + public String createPayOrder(ThirdPayOrderReqBo payReq) { + PrepayRequest request = new PrepayRequest(); + request.setAppid(wxPayConfig.getAppId()); + request.setMchid(wxPayConfig.getMerchantId()); + request.setDescription(payReq.getDescription()); + request.setNotifyUrl(wxPayConfig.getPayNotifyUrl()); + request.setOutTradeNo(payReq.getOutTradeNo()); + + Amount amount = new Amount(); + amount.setTotal(payReq.getTotal()); + amount.setCurrency("CNY"); + request.setAmount(amount); + + SceneInfo sceneInfo = new SceneInfo(); + sceneInfo.setPayerClientIp(ReqInfoContext.getReqInfo().getClientIp()); + request.setSceneInfo(sceneInfo); + + log.info("微信native下单, 微信请求参数: {}", JsonUtil.toStr(request)); + PrepayResponse response = nativePayService.prepay(request); + log.info("微信支付 >>>>>>>>>>>> 返回: {}", response.getCodeUrl()); + return response.getCodeUrl(); + } + + public void closeOrder(String outTradeNo) { + CloseOrderRequest closeRequest = new CloseOrderRequest(); + closeRequest.setMchid(wxPayConfig.getMerchantId()); + closeRequest.setOutTradeNo(outTradeNo); + nativePayService.closeOrder(closeRequest); + } + + public PayCallbackBo queryOrder(String outTradeNo) { + QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest(); + request.setMchid(wxPayConfig.getMerchantId()); + request.setOutTradeNo(outTradeNo); + Transaction transaction = nativePayService.queryOrderByOutTradeNo(request); + return toBo(transaction); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/package-info.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/package-info.java new file mode 100644 index 000000000..ee0778f24 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/package-info.java @@ -0,0 +1,7 @@ +/** + * 排行榜 + * + * @author YiHui + * @date 2023/8/19 + */ +package com.github.paicoding.forum.service.rank; \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/UserActivityRankService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/UserActivityRankService.java new file mode 100644 index 000000000..d000b546e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/UserActivityRankService.java @@ -0,0 +1,40 @@ +package com.github.paicoding.forum.service.rank.service; + +import com.github.paicoding.forum.api.model.enums.rank.ActivityRankTimeEnum; +import com.github.paicoding.forum.api.model.vo.rank.dto.RankItemDTO; +import com.github.paicoding.forum.service.rank.service.model.ActivityScoreBo; + +import java.util.List; + +/** + * 用户活跃排行榜 + * + * @author YiHui + * @date 2023/8/19 + */ +public interface UserActivityRankService { + /** + * 添加活跃分 + * + * @param userId + * @param activityScore + */ + void addActivityScore(Long userId, ActivityScoreBo activityScore); + + /** + * 查询用户的活跃信息 + * + * @param userId + * @param time + * @return + */ + RankItemDTO queryRankInfo(Long userId, ActivityRankTimeEnum time); + + /** + * 查询活跃度排行榜 + * + * @param time + * @return + */ + List queryRankList(ActivityRankTimeEnum time, int size); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/impl/UserActivityRankServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/impl/UserActivityRankServiceImpl.java new file mode 100644 index 000000000..e28a56941 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/impl/UserActivityRankServiceImpl.java @@ -0,0 +1,189 @@ +package com.github.paicoding.forum.service.rank.service.impl; + +import com.github.paicoding.forum.api.model.enums.rank.ActivityRankTimeEnum; +import com.github.paicoding.forum.api.model.vo.rank.dto.RankItemDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.rank.service.UserActivityRankService; +import com.github.paicoding.forum.service.rank.service.model.ActivityScoreBo; +import com.github.paicoding.forum.service.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * @author YiHui + * @date 2023/8/19 + */ +@Slf4j +@Service +public class UserActivityRankServiceImpl implements UserActivityRankService { + private static final String ACTIVITY_SCORE_KEY = "activity_rank_"; + + @Autowired + private UserService userService; + + /** + * 当天活跃度排行榜 + * + * @return 当天排行榜key + */ + private String todayRankKey() { + return ACTIVITY_SCORE_KEY + DateUtil.format(DateTimeFormatter.ofPattern("yyyyMMdd"), System.currentTimeMillis()); + } + + /** + * 本月排行榜 + * + * @return 月度排行榜key + */ + private String monthRankKey() { + return ACTIVITY_SCORE_KEY + DateUtil.format(DateTimeFormatter.ofPattern("yyyyMM"), System.currentTimeMillis()); + } + + /** + * 添加活跃分 + * + * @param userId 用于更新活跃积分的用户 + * @param activityScore 触发活跃积分的时间类型 + */ + @Override + public void addActivityScore(Long userId, ActivityScoreBo activityScore) { + if (userId == null) { + return; + } + + // 1. 计算活跃度(正为加活跃,负为减活跃) + String field; + int score = 0; + if (activityScore.getPath() != null) { + field = "path_" + activityScore.getPath(); + score = 1; + } else if (activityScore.getArticleId() != null) { + field = activityScore.getArticleId() + "_"; + if (activityScore.getPraise() != null) { + field += "praise"; + score = BooleanUtils.isTrue(activityScore.getPraise()) ? 2 : -2; + } else if (activityScore.getCollect() != null) { + field += "collect"; + score = BooleanUtils.isTrue(activityScore.getCollect()) ? 2 : -2; + } else if (activityScore.getRate() != null) { + // 评论回复 + field += "rate"; + score = BooleanUtils.isTrue(activityScore.getRate()) ? 3 : -3; + } else if (BooleanUtils.isTrue(activityScore.getPublishArticle())) { + // 发布文章 + field += "publish"; + score += 10; + } + } else if (activityScore.getFollowedUserId() != null) { + // 关注添加积分 + field = activityScore.getFollowedUserId() + "_follow"; + score = BooleanUtils.isTrue(activityScore.getFollow()) ? 2 : -2; + } else { + return; + } + + final String todayRankKey = todayRankKey(); + final String monthRankKey = monthRankKey(); + // 2. 幂等:判断之前是否有更新过相关的活跃度信息 + final String userActionKey = ACTIVITY_SCORE_KEY + userId + DateUtil.format(DateTimeFormatter.ofPattern("yyyyMMdd"), System.currentTimeMillis()); + Integer ans = RedisClient.hGet(userActionKey, field, Integer.class); + if (ans == null) { + // 2.1 之前没有加分记录,执行具体的加分 + if (score > 0) { + // 记录加分记录 + RedisClient.hSet(userActionKey, field, score); + // 个人用户的操作记录,保存一个月的有效期,方便用户查询自己最近31天的活跃情况 + RedisClient.expire(userActionKey, 31 * DateUtil.ONE_DAY_SECONDS); + + // 更新当天和当月的活跃度排行榜 + Double newAns = RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score); + RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score); + if (log.isDebugEnabled()) { + log.info("活跃度更新加分! key#field = {}#{}, add = {}, newScore = {}", todayRankKey, userId, score, newAns); + } + if (newAns <= score) { + // 由于上面只实现了日/月活跃度的增加,但是没有设置对应的有效期;为了避免持久保存导致redis占用较高;因此这里设定了缓存的有效期 + // 日活跃榜单,保存31天;月活跃榜单,保存1年 + // 为什么是 newAns <= score 才设置有效期呢? + // 因为 newAns 是用户当天的活跃度,如果发现和需要增加的活跃度 scopre 相等,则表明是今天的首次添加记录,此时设置有效期就比较符合预期了 + // 但是请注意,下面的实现有两个缺陷: + // 1. 对于月的有效期,就变成了本月,每天的首次增加活跃度时,都会重新刷一下它的有效期,这样就和预期中的首次添加缓存时,设置有效期不符 + // 2. 若先增加活跃度1,再减少活跃度1,然后再加活跃度1,同样会导致重新算了有效期 + // 严谨一些的写法,应该是 先判断 key 的 ttl, 对于没有设置的才进行设置有效期,如下 + Long ttl = RedisClient.ttl(todayRankKey); + if (!NumUtil.upZero(ttl)) { + RedisClient.expire(todayRankKey, 31 * DateUtil.ONE_DAY_SECONDS); + } + ttl = RedisClient.ttl(monthRankKey); + if (!NumUtil.upZero(ttl)) { + RedisClient.expire(monthRankKey, 12 * DateUtil.ONE_MONTH_SECONDS); + } + } + } + } else if (ans > 0) { + // 2.2 之前已经加过分,因此这次减分可以执行 + if (score < 0) { + // 移除用户的活跃执行记录 --> 即移除用来做防重复添加活跃度的幂等键 + Boolean oldHave = RedisClient.hDel(userActionKey, field); + if (BooleanUtils.isTrue(oldHave)) { + Double newAns = RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score); + RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score); + if (log.isDebugEnabled()) { + log.info("活跃度更新减分! key#field = {}#{}, add = {}, newScore = {}", todayRankKey, userId, score, newAns); + } + } + } + } + } + + @Override + public RankItemDTO queryRankInfo(Long userId, ActivityRankTimeEnum time) { + RankItemDTO item = new RankItemDTO(); + item.setUser(userService.querySimpleUserInfo(userId)); + + String rankKey = time == ActivityRankTimeEnum.DAY ? todayRankKey() : monthRankKey(); + ImmutablePair rank = RedisClient.zRankInfo(rankKey, String.valueOf(userId)); + item.setRank(rank.getLeft()); + item.setScore(rank.getRight().intValue()); + return item; + } + + @Override + public List queryRankList(ActivityRankTimeEnum time, int size) { + String rankKey = time == ActivityRankTimeEnum.DAY ? todayRankKey() : monthRankKey(); + // 1. 获取topN的活跃用户 + List> rankList = RedisClient.zTopNScore(rankKey, size); + if (CollectionUtils.isEmpty(rankList)) { + return Collections.emptyList(); + } + + // 2. 查询用户对应的基本信息 + // 构建userId -> 活跃评分的map映射,用于补齐用户信息 + Map userScoreMap = rankList.stream().collect(Collectors.toMap(s -> Long.valueOf(s.getLeft()), s -> s.getRight().intValue())); + List users = userService.batchQuerySimpleUserInfo(userScoreMap.keySet()); + + // 3. 根据评分进行排序 + List rank = users.stream() + .map(user -> new RankItemDTO().setUser(user).setScore(userScoreMap.getOrDefault(user.getUserId(), 0))) + .sorted((o1, o2) -> Integer.compare(o2.getScore(), o1.getScore())) + .collect(Collectors.toList()); + + // 4. 补齐每个用户的排名 + IntStream.range(0, rank.size()).forEach(i -> rank.get(i).setRank(i + 1)); + return rank; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/listener/UserActivityListener.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/listener/UserActivityListener.java new file mode 100644 index 000000000..530c45799 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/listener/UserActivityListener.java @@ -0,0 +1,85 @@ +package com.github.paicoding.forum.service.rank.service.listener; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ArticleEventEnum; +import com.github.paicoding.forum.api.model.event.ArticleMsgEvent; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.rank.service.UserActivityRankService; +import com.github.paicoding.forum.service.rank.service.model.ActivityScoreBo; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 用户活跃相关的消息监听器 + * + * @author YiHui + * @date 2023/8/19 + */ +@Component +public class UserActivityListener { + @Autowired + private UserActivityRankService userActivityRankService; + + /** + * 用户操作行为,增加对应的积分 + * + * @param msgEvent + */ + @EventListener(classes = NotifyMsgEvent.class) + @Async + public void notifyMsgListener(NotifyMsgEvent msgEvent) { + switch (msgEvent.getNotifyType()) { + case COMMENT: + case REPLY: + CommentDO comment = (CommentDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setRate(true).setArticleId(comment.getArticleId())); + break; + case COLLECT: + UserFootDO foot = (UserFootDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setCollect(true).setArticleId(foot.getDocumentId())); + break; + case CANCEL_COLLECT: + foot = (UserFootDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setCollect(false).setArticleId(foot.getDocumentId())); + break; + case PRAISE: + foot = (UserFootDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPraise(true).setArticleId(foot.getDocumentId())); + break; + case CANCEL_PRAISE: + foot = (UserFootDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPraise(false).setArticleId(foot.getDocumentId())); + break; + case FOLLOW: + UserRelationDO relation = (UserRelationDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setFollow(true).setFollowedUserId(relation.getUserId())); + break; + case CANCEL_FOLLOW: + relation = (UserRelationDO) msgEvent.getContent(); + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setFollow(false).setFollowedUserId(relation.getUserId())); + break; + default: + } + } + + /** + * 发布文章,更新对应的积分 + * + * @param event + */ + @Async + @EventListener(ArticleMsgEvent.class) + public void publishArticleListener(ArticleMsgEvent event) { + ArticleEventEnum type = event.getType(); + if (type == ArticleEventEnum.ONLINE) { + userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPublishArticle(true).setArticleId(event.getContent().getId())); + } + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/model/ActivityScoreBo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/model/ActivityScoreBo.java new file mode 100644 index 000000000..a4f7f1aa4 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/rank/service/model/ActivityScoreBo.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.service.rank.service.model; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * @author YiHui + * @date 2023/8/19 + */ +@Data +@Accessors(chain = true) +public class ActivityScoreBo { + /** + * 访问页面增加活跃度 + */ + private String path; + + /** + * 目标文章 + */ + private Long articleId; + + /** + * 评论增加活跃度 + */ + private Boolean rate; + + /** + * 点赞增加活跃度 + */ + private Boolean praise; + + /** + * 收藏增加活跃度 + */ + private Boolean collect; + + /** + * 发布文章增加活跃度 + */ + private Boolean publishArticle; + + /** + * 被关注的用户 + */ + private Long followedUserId; + + /** + * 关注增加活跃度 + */ + private Boolean follow; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/help/ShortCodeGenerator.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/help/ShortCodeGenerator.java new file mode 100644 index 000000000..f83c4f018 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/help/ShortCodeGenerator.java @@ -0,0 +1,62 @@ +package com.github.paicoding.forum.service.shortlink.help; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.TimeUnit; + +public class ShortCodeGenerator { + + private static final String BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static final int BASE62_LENGTH = BASE62.length(); + private static final int HASH_LENGTH = 5; + + + private static final Cache existingShortCodes = CacheBuilder.newBuilder() + .maximumSize(10000) + .expireAfterWrite(24, TimeUnit.HOURS) + .build(); + public static String generateShortCode(String longUrl) throws NoSuchAlgorithmException { + String shortCode = generateHash(longUrl); + final int MAX_ATTEMPTS = 10; + int attempts = 0; + while (existingShortCodes.getIfPresent(shortCode) != null) { + if (attempts >= MAX_ATTEMPTS) { + throw new RuntimeException("生成唯一短链接代码失败,已尝试 " + MAX_ATTEMPTS + " 次"); + } + shortCode = generateHash(longUrl + System.nanoTime()); + attempts++; + } + + existingShortCodes.put(shortCode, Boolean.TRUE); + return shortCode; + } + + private static String generateHash(String input) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(input.getBytes()); + StringBuilder hashString = new StringBuilder(); + + for (int i = 0; i < hash.length && hashString.length() < ShortCodeGenerator.HASH_LENGTH; i++) { + int index = (hash[i] & 0xFF) % BASE62_LENGTH; + hashString.append(BASE62.charAt(index)); + } + + return hashString.toString(); + } + + public static void main(String[] args) { + try { + String longUrl = "http://example.com"; + String shortCode = generateShortCode(longUrl); + System.out.println("The First Short code for " + longUrl + " is " + shortCode); + + String repeatShortCode = generateShortCode(longUrl); + System.out.println("The Second Short code for " + longUrl + " is " + repeatShortCode); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/help/SourceDetector.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/help/SourceDetector.java new file mode 100644 index 000000000..d5f3ad205 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/help/SourceDetector.java @@ -0,0 +1,89 @@ +package com.github.paicoding.forum.service.shortlink.help; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SourceDetector { + + private static final String MOBILE_PATTERN = "(Android|iPhone|iPad|iPod|Windows Phone|Mobile)"; + private static final String DESKTOP_PATTERN = "(Windows NT|Macintosh|Linux)"; + private static final String BOT_PATTERN = "(bot|spider|crawler|curl|wget)"; + + /** + * 根据 User-Agent 和 Referer 判断请求来源 + * + * @return 来源字符串 (WeChat, QQ, Mobile, Desktop, Bot, Unknown) + */ + public static String detectSource() { + String userAgent = ReqInfoContext.getReqInfo().getUserAgent(); + String referer = ReqInfoContext.getReqInfo().getReferer(); + + // 1. 优先判断 Referer (更可靠,但可能为空) + if (referer != null && !referer.isEmpty()) { + if (referer.contains("servicewechat.com") || referer.contains("weixin")) { + return "WeChat"; + } else if (referer.contains("qq.com") || referer.contains("mobile.qq.com") || referer.contains("connect.qq.com")) { + return "QQ"; + } + } + + // 2. 如果 Referer 无法判断,则根据 User-Agent 判断 + if (userAgent != null && !userAgent.isEmpty()) { + + // 2.1 微信 (User-Agent 中包含 MicroMessenger) + if (userAgent.contains("MicroMessenger")) { + return "WeChat"; + } + + // 2.2 QQ (User-Agent 中包含 QQ 或 MQQBrowser) + Pattern qqPattern = Pattern.compile("(QQ|MQQBrowser)", Pattern.CASE_INSENSITIVE); + Matcher qqMatcher = qqPattern.matcher(userAgent); + if (qqMatcher.find()) { + return "QQ"; + } + + // 2.3 浏览器判断 (常见浏览器 User-Agent 特征) + if (userAgent.contains("Edg")) { //Edge 浏览器需要在 Chrome 之前判断,因为Edge的UA字符串也包含 Chrome + return "Edge"; + } else if (userAgent.contains("Chrome")) { + return "Chrome"; + } else if (userAgent.contains("Safari") && !userAgent.contains("Chrome") && !userAgent.contains("Edg")) { // 排除 Chrome 和 Edge + return "Safari"; + } else if (userAgent.contains("Firefox")) { + return "Firefox"; + } else if (userAgent.contains("MSIE") || userAgent.contains("Trident")) { + return "IE"; // Internet Explorer + } else if (userAgent.contains("Opera") || userAgent.contains("OPR")) { + return "Opera"; + } + + // 2.3 移动设备 (常见移动设备 User-Agent 特征) + Pattern mobilePattern = Pattern.compile(MOBILE_PATTERN, Pattern.CASE_INSENSITIVE); // 增加更多移动设备 + Matcher mobileMatcher = mobilePattern.matcher(userAgent); + if (mobileMatcher.find()) { + return "Mobile"; + } + + + // 2.4 桌面设备 (常见桌面设备 User-Agent 特征) + Pattern desktopPattern = Pattern.compile(DESKTOP_PATTERN, Pattern.CASE_INSENSITIVE); + Matcher desktopMatcher = desktopPattern.matcher(userAgent); + if (desktopMatcher.find()) { + return "Desktop"; + } + + // 2.5 爬虫/机器人 (常见爬虫 User-Agent 特征) + Pattern botPattern = Pattern.compile(BOT_PATTERN, Pattern.CASE_INSENSITIVE); + Matcher botMatcher = botPattern.matcher(userAgent); + if (botMatcher.find()) { + return "Bot"; + } + + } + + // 3. 无法识别 + return "Unknown"; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/entity/ShortLinkDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/entity/ShortLinkDO.java new file mode 100644 index 000000000..3546dbb67 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/entity/ShortLinkDO.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.service.shortlink.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 短链接数据库对象 + * + * @author betasecond + * @date 2025-02-13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("short_link") +public class ShortLinkDO extends BaseDO { + + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId + private Long id; + + /** + * 原始URL + */ + private String originalUrl; + + /** + * 短链接代码 + */ + private String shortCode; + + /** + * 删除标记 + */ + private Integer deleted; + + +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/entity/ShortLinkRecordDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/entity/ShortLinkRecordDO.java new file mode 100644 index 000000000..4fda16b08 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/entity/ShortLinkRecordDO.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.service.shortlink.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 短链接记录数据库对象 + * + * @author betasecond + * @date 2025-02-13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("short_link_record") +public class ShortLinkRecordDO extends BaseDO { + + private static final long serialVersionUID = 1L; + + /** + * 短链接代码 + */ + private String shortCode; + + /** + * 用户ID + */ + private String userId; + + /** + * 访问时间 + */ + private Long accessTime; + + /** + * IP地址 + */ + private String ipAddress; + + /** + * 登录方式 (如:微信、QQ、微博等)。 + */ + private String loginMethod; + + /** + * 访问来源(如:网页、移动端等)。 + */ + private String accessSource; +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/mapper/ShortLinkMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/mapper/ShortLinkMapper.java new file mode 100644 index 000000000..d625760f9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/mapper/ShortLinkMapper.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.service.shortlink.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.shortlink.repository.entity.ShortLinkDO; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface ShortLinkMapper extends BaseMapper { + @Select("SELECT * FROM short_link WHERE short_code = #{shortCode} LIMIT 1") + ShortLinkDO getByShortCode(@Param("shortCode") String shortCode); + + @Insert("INSERT INTO short_link (original_url, short_code, deleted, create_time, update_time) VALUES (#{originalUrl}, #{shortCode}, #{deleted}, #{createTime}, #{updateTime})") + @Options(useGeneratedKeys = true, keyProperty = "id") + int getIdAfterInsert(ShortLinkDO shortLinkDO); +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/mapper/ShortLinkRecordMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/mapper/ShortLinkRecordMapper.java new file mode 100644 index 000000000..492fd40cb --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/repository/mapper/ShortLinkRecordMapper.java @@ -0,0 +1,7 @@ +package com.github.paicoding.forum.service.shortlink.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.shortlink.repository.entity.ShortLinkRecordDO; + +public interface ShortLinkRecordMapper extends BaseMapper { +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/service/ShortLinkService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/service/ShortLinkService.java new file mode 100644 index 000000000..a97ae3382 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/service/ShortLinkService.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.service.shortlink.service; + +import com.github.paicoding.forum.api.model.vo.shortlink.dto.ShortLinkDTO; +import com.github.paicoding.forum.api.model.vo.shortlink.ShortLinkVO; + +import java.security.NoSuchAlgorithmException; + +public interface ShortLinkService { + + + /** + * 创建短链接 + * + * @param shortLinkDTO 包含原始URL和用户信息的数据传输对象 + * @return 包含短链接和原始URL的ShortLinkVO对象 + * @throws NoSuchAlgorithmException 如果生成短码时发生错误 + */ + ShortLinkVO createShortLink(ShortLinkDTO shortLinkDTO) throws NoSuchAlgorithmException; + + /** + * 获取原始URL + * + * @param shortCode 短码 + * @return 包含原始URL的ShortLinkVO对象 + */ + ShortLinkVO getOriginalLink(String shortCode); +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/service/impl/ShortLinkServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/service/impl/ShortLinkServiceImpl.java new file mode 100644 index 000000000..a6a2d4384 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/shortlink/service/impl/ShortLinkServiceImpl.java @@ -0,0 +1,248 @@ +package com.github.paicoding.forum.service.shortlink.service.impl; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.service.shortlink.repository.entity.ShortLinkDO; +import com.github.paicoding.forum.api.model.vo.shortlink.dto.ShortLinkDTO; +import com.github.paicoding.forum.service.shortlink.repository.entity.ShortLinkRecordDO; +import com.github.paicoding.forum.api.model.vo.shortlink.ShortLinkVO; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.service.shortlink.help.ShortCodeGenerator; +import com.github.paicoding.forum.service.shortlink.help.SourceDetector; +import com.github.paicoding.forum.service.shortlink.repository.mapper.ShortLinkMapper; +import com.github.paicoding.forum.service.shortlink.repository.mapper.ShortLinkRecordMapper; +import com.github.paicoding.forum.service.shortlink.service.ShortLinkService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.annotation.Resource; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.List; + +@Slf4j +@Service +public class ShortLinkServiceImpl implements ShortLinkService { + + + // Redis中短链接的前缀 + private static final String REDIS_SHORT_LINK_PREFIX = "short_link:"; + + @Resource + private ShortLinkMapper shortLinkMapper; + + @Resource + private ShortLinkRecordMapper shortLinkRecordMapper; + + @Value("${view.site.host:https://paicoding.com}") + private String host; + + public ShortLinkServiceImpl(ShortLinkMapper shortLinkMapper, ShortLinkRecordMapper shortLinkRecordMapper) { + this.shortLinkMapper = shortLinkMapper; + this.shortLinkRecordMapper = shortLinkRecordMapper; + } + + + // 域名白名单 + @Value("#{'${short-link.whitelist:}'.split(',')}") + private List domainWhitelist; + + + /** + * 创建短链接 + * + * @param shortLinkDTO 包含原始URL和用户信息的数据传输对象 + * @return 包含短链接和原始URL的ShortLinkVO对象 + * @throws NoSuchAlgorithmException 如果生成短码时发生错误 + */ + @Override + public ShortLinkVO createShortLink(ShortLinkDTO shortLinkDTO) throws NoSuchAlgorithmException { + if (log.isDebugEnabled()) { + log.debug("Creating short link for URL: {}", shortLinkDTO.getOriginalUrl()); + } + + // 验证域名是否在白名单中 + if (!isUrlInWhitelist(shortLinkDTO.getOriginalUrl())) { + log.warn("域名不在白名单中: {}", shortLinkDTO.getOriginalUrl()); + throw new RuntimeException("不允许为该域名创建短链接"); + } + + // 从原始URL中提取路径部分 + // ^(https?://|http://[^/]+) - 匹配URL开头的协议和域名部分 + String path = shortLinkDTO.getOriginalUrl().replaceAll("^(https?://|http://[^/]+)(/.*)?$", "$2"); + + String shortCode = generateUniqueShortCode(path); + + ShortLinkDO shortLinkDO = createShortLinkDO(shortLinkDTO, shortCode); + + // 保存原始链接--短链接映射到DB与Cache + int shortLinkId = shortLinkMapper.getIdAfterInsert(shortLinkDO); + if (log.isDebugEnabled()) { + log.debug("Short link created with ID: {}", shortLinkId); + } + RedisClient.hSet(REDIS_SHORT_LINK_PREFIX + shortCode, shortLinkDO.getOriginalUrl(), String.class); + + // 保存记录到DB + ShortLinkRecordDO shortLinkRecordDO = createShortLinkRecordDO(shortLinkDO.getShortCode(), shortLinkDTO); + shortLinkRecordMapper.insert(shortLinkRecordDO); + + if (log.isDebugEnabled()) { + log.debug("Short link record saved for short code: {}", shortCode); + } + return createShortLinkVO(shortLinkDO); + } + + + /** + * 获取原始URL + * + * @param shortCode 短码 + * @return 包含原始URL的ShortLinkVO对象 + */ + @Override + public ShortLinkVO getOriginalLink(String shortCode) { + if (log.isDebugEnabled()) { + log.debug("Fetching original link for short code: {}", shortCode); + } + + String originalUrl = getOriginalUrlFromCacheOrDb(shortCode); + + if (!StringUtils.hasText(originalUrl)) { + log.error("Short link not found for short code: {}", shortCode); + throw new RuntimeException("Short link not found"); + } + String paramUserId = ((null == ReqInfoContext.getReqInfo().getUserId()) ? "0" : ReqInfoContext.getReqInfo().getUserId().toString()); + log.info("Short link retrieved - shortCode: {}, originalUrl: {}, userId: {}", shortCode, originalUrl, paramUserId); + return new ShortLinkVO(originalUrl, originalUrl); + } + + /** + * 将ShortLinkDO对象转换为ShortLinkVO对象 + * + * @param shortLinkDO ShortLinkDO对象 + * @return ShortLinkVO对象 + */ + private ShortLinkVO createShortLinkVO(ShortLinkDO shortLinkDO) { + ShortLinkVO shortLinkVO = new ShortLinkVO(); + shortLinkVO.setShortUrl(host + "/sol/" + shortLinkDO.getShortCode()); + shortLinkVO.setOriginalUrl(shortLinkDO.getOriginalUrl()); + return shortLinkVO; + } + + + /** + * 生成唯一的短码 + * + * @param path URL路径 + * @return 短码 + * @throws NoSuchAlgorithmException 如果生成短码时发生错误 + */ + private String generateUniqueShortCode(String path) throws NoSuchAlgorithmException { + long generateTime = 0; + String shortCode; + do { + shortCode = ShortCodeGenerator.generateShortCode(path); + generateTime++; + } while (null != shortLinkMapper.getByShortCode(shortCode) && generateTime < 3); + return shortCode; + } + + + /** + * 创建ShortLinkDO对象 + * + * @param shortLinkDTO 短链接数据 + * @param shortCode 生成的短码 + * @return 创建的ShortLinkDO对象 + */ + private ShortLinkDO createShortLinkDO(ShortLinkDTO shortLinkDTO, String shortCode) { + long currentTimeMillis = System.currentTimeMillis(); + Date currentDate = new Date(currentTimeMillis); + ShortLinkDO shortLinkDO = new ShortLinkDO(); + shortLinkDO.setOriginalUrl(shortLinkDTO.getOriginalUrl()); + shortLinkDO.setShortCode(shortCode); + shortLinkDO.setCreateTime(currentDate); + shortLinkDO.setUpdateTime(currentDate); + shortLinkDO.setDeleted(0); + return shortLinkDO; + } + + + /** + * 创建ShortLinkRecordDO对象 + * + * @param shortcode 短链接代码 + * @param shortLinkDTO 短链接数据 + * @return 创建的ShortLinkRecordDO对象 + */ + private ShortLinkRecordDO createShortLinkRecordDO(String shortcode, ShortLinkDTO shortLinkDTO) { + ShortLinkRecordDO shortLinkRecordDO = new ShortLinkRecordDO(); + shortLinkRecordDO.setShortCode(shortcode); + shortLinkRecordDO.setUserId(shortLinkDTO.getUserId()); + shortLinkRecordDO.setAccessTime(System.currentTimeMillis()); + // fixme: 目前没有很好的办法获得用户的登陆方式 因为用户都不一定登录了 + shortLinkRecordDO.setLoginMethod("Unknown"); + shortLinkRecordDO.setIpAddress(ReqInfoContext.getReqInfo().getClientIp()); + shortLinkRecordDO.setAccessSource(SourceDetector.detectSource()); + return shortLinkRecordDO; + } + + + /** + * 从Redis缓存或数据库中获取原始URL + * + * @param shortCode 短码 + * @return 原始URL + */ + private String getOriginalUrlFromCacheOrDb(String shortCode) { + String originalUrl = RedisClient.hGet(REDIS_SHORT_LINK_PREFIX + shortCode, "originalUrl", String.class); + if (!StringUtils.hasText(originalUrl)) { + ShortLinkDO shortLinkDO = shortLinkMapper.getByShortCode(shortCode); + if (shortLinkDO != null) { + originalUrl = shortLinkDO.getOriginalUrl(); + } + } + return originalUrl; + } + + /** + * 检查URL是否在白名单中 + * + * @param url 待检查的URL + * @return 是否在白名单中 + */ + private boolean isUrlInWhitelist(String url) { + if (domainWhitelist == null || domainWhitelist.isEmpty()) { + return true; // 如果白名单为空,则允许所有域名 + } + + try { + URI uri = new URI(url); + String hostRaw = uri.getHost(); + if (hostRaw == null) { + log.error("无效的URL格式,缺少host: {}", url); + return false; + } + + // 去掉URL中的协议部分 + String host = hostRaw.replaceAll("^[a-zA-Z]+://", ""); + String hostWithPort = host + (uri.getPort() != -1 ? ":" + uri.getPort() : ""); + + return domainWhitelist.stream() + // 白名单中无协议名,只有域名 + .map(String::trim) + // 检查域名是否在白名单中 + // 精确匹配域名,避免子域名攻击 + .anyMatch(domain -> + hostWithPort.equals(domain) || // 带端口 + host.equals(domain) // 不带端口 + ); + } catch (URISyntaxException e) { + log.error("无效的URL格式: {}", url, e); + return false; + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarService.java new file mode 100644 index 000000000..f260c5225 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarService.java @@ -0,0 +1,36 @@ +package com.github.paicoding.forum.service.sidebar.service; + +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/6 + */ +public interface SidebarService { + + /** + * 查询首页的侧边栏信息 + * + * @return + */ + List queryHomeSidebarList(); + + /** + * 查询教程的侧边栏信息 + * + * @return + */ + List queryColumnSidebarList(); + + /** + * 查询文章详情的侧边栏信息 + * + * @param author 文章作者id + * @param articleId 文章id + * @return + */ + List queryArticleDetailSidebarList(Long author, Long articleId); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarServiceImpl.java new file mode 100644 index 000000000..c7dad419b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarServiceImpl.java @@ -0,0 +1,269 @@ +package com.github.paicoding.forum.service.sidebar.service; + +import com.github.paicoding.forum.api.model.enums.ConfigTagEnum; +import com.github.paicoding.forum.api.model.enums.ConfigTypeEnum; +import com.github.paicoding.forum.api.model.enums.SidebarStyleEnum; +import com.github.paicoding.forum.api.model.enums.rank.ActivityRankTimeEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.api.model.vo.rank.dto.RankItemDTO; +import com.github.paicoding.forum.api.model.vo.recommend.RateVisitDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarItemDTO; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.config.service.ConfigService; +import com.github.paicoding.forum.service.rank.service.UserActivityRankService; +import com.google.common.base.Splitter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2022/9/6 + */ +@Service +public class SidebarServiceImpl implements SidebarService { + + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private ConfigService configService; + + @Autowired + private ArticleDao articleDao; + + /** + * 使用caffeine本地缓存,来处理侧边栏不怎么变动的消息 + *

+ * cacheNames -> 类似缓存前缀的概念 + * key -> SpEL 表达式,可以从传参中获取,来构建缓存的key + * cacheManager -> 缓存管理器,如果全局只有一个时,可以省略 + * + * @return + */ + @Override + @Cacheable(key = "'homeSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "home") + public List queryHomeSidebarList() { + List list = new ArrayList<>(); + list.add(noticeSideBar()); + list.add(hotArticles()); + SideBarDTO bar = rankList(); + if (bar != null) { + list.add(bar); + } + return list; + } + + /** + * 公告信息 + * + * @return + */ + private SideBarDTO noticeSideBar() { + List noticeList = configService.getConfigList(ConfigTypeEnum.NOTICE); + List items = new ArrayList<>(noticeList.size()); + noticeList.forEach(configDTO -> { + List configTags; + if (StringUtils.isBlank(configDTO.getTags())) { + configTags = Collections.emptyList(); + } else { + configTags = Splitter.on(",").splitToStream(configDTO.getTags()).map(s -> Integer.parseInt(s.trim())).collect(Collectors.toList()); + } + items.add(new SideBarItemDTO() + .setName(configDTO.getName()) + .setTitle(configDTO.getContent()) + .setUrl(configDTO.getJumpUrl()) + .setTime(configDTO.getCreateTime().getTime()) + .setTags(configTags) + ); + }); + return new SideBarDTO() + .setTitle("关于技术派") + // TODO 知识星球的 + .setImg("https://cdn.tobebetterjavaer.com/paicoding/main/paicoding-zsxq.jpg") + .setUrl("https://paicoding.com/article/detail/2422000009961473") + .setItems(items) + .setStyle(SidebarStyleEnum.NOTICE.getStyle()); + } + + + /** + * 推荐教程的侧边栏 + * + * @return + */ + private SideBarDTO columnSideBar() { + List columnList = configService.getConfigList(ConfigTypeEnum.COLUMN); + List items = new ArrayList<>(columnList.size()); + columnList.forEach(configDTO -> { + SideBarItemDTO item = new SideBarItemDTO(); + item.setName(configDTO.getName()); + item.setTitle(configDTO.getContent()); + item.setUrl(configDTO.getJumpUrl()); + item.setImg(configDTO.getBannerUrl()); + items.add(item); + }); + return new SideBarDTO().setTitle("精选教程").setItems(items).setStyle(SidebarStyleEnum.COLUMN.getStyle()); + } + + + /** + * 热门文章 + * + * @return + */ + private SideBarDTO hotArticles() { + PageListVo vo = articleReadService.queryHotArticlesForRecommend(PageParam.newPageInstance(1, 8)); + List items = vo.getList().stream().map(s -> new SideBarItemDTO().setTitle(s.getTitle()).setUrl("/article/detail/" + s.getId()).setTime(s.getCreateTime().getTime())).collect(Collectors.toList()); + return new SideBarDTO().setTitle("热门文章").setItems(items).setStyle(SidebarStyleEnum.ARTICLES.getStyle()); + } + + + /** + * 以用户 + 文章维度进行缓存设置 + * + * @param author 文章作者id + * @param articleId 文章id + * @return + */ + @Override + @Cacheable(key = "'sideBar_' + #articleId", cacheManager = "caffeineCacheManager", cacheNames = "article") + public List queryArticleDetailSidebarList(Long author, Long articleId) { + List list = new ArrayList<>(2); + // 不能直接使用 pdfSideBar()的方式调用,会导致缓存不生效 + list.add(SpringUtil.getBean(SidebarServiceImpl.class).pdfSideBar()); + list.add(recommendByAuthor(author, articleId, PageParam.DEFAULT_PAGE_SIZE)); + return list; + } + + /** + * PDF 优质资源 + * + * @return + */ + @Cacheable(key = "'sideBar'", cacheManager = "caffeineCacheManager", cacheNames = "article") + public SideBarDTO pdfSideBar() { + List pdfList = configService.getConfigList(ConfigTypeEnum.PDF); + List items = new ArrayList<>(pdfList.size()); + pdfList.forEach(configDTO -> { + SideBarItemDTO dto = new SideBarItemDTO(); + dto.setName(configDTO.getName()); + dto.setUrl(configDTO.getJumpUrl()); + dto.setImg(configDTO.getBannerUrl()); + RateVisitDTO visit; + if (StringUtils.isNotBlank(configDTO.getExtra())) { + visit = (JsonUtil.toObj(configDTO.getExtra(), RateVisitDTO.class)); + } else { + visit = new RateVisitDTO(); + } + visit.incrVisit(); + // 增加下载次数 + visit.incrDownload(); + // 更新阅读计数 + configService.updateVisit(configDTO.getId(), JsonUtil.toStr(visit)); + dto.setVisit(visit); + items.add(dto); + }); + return new SideBarDTO().setTitle("优质PDF").setItems(items).setStyle(SidebarStyleEnum.PDF.getStyle()); + } + + + /** + * 作者的文章列表推荐 + * + * @param authorId + * @param size + * @return + */ + public SideBarDTO recommendByAuthor(Long authorId, Long articleId, long size) { + List list = articleDao.listAuthorHotArticles(authorId, PageParam.newPageInstance(PageParam.DEFAULT_PAGE_NUM, size)); + List items = list.stream().filter(s -> !s.getId().equals(articleId)) + .map(s -> new SideBarItemDTO() + .setTitle(s.getTitle()).setUrl("/article/detail/" + s.getId()) + .setTime(s.getCreateTime().getTime())) + .collect(Collectors.toList()); + return new SideBarDTO().setTitle("相关文章").setItems(items).setStyle(SidebarStyleEnum.ARTICLES.getStyle()); + } + + + /** + * 查询教程的侧边栏信息 + * + * @return + */ + @Override + @Cacheable(key = "'columnSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "column") + public List queryColumnSidebarList() { + List list = new ArrayList<>(); + list.add(subscribeSideBar()); + return list; + } + + + /** + * 订阅公众号 + * + * @return + */ + private SideBarDTO subscribeSideBar() { + List subscribeList = configService.getConfigList(ConfigTypeEnum.COLUMN); + if (subscribeList.isEmpty()) { + return new SideBarDTO().setTitle("订阅").setSubTitle("楼仔") + .setImg("//cdn.tobebetterjavaer.com/paicoding/a768cfc54f59d4a056f79d1c959dcae9.jpg") + .setContent("10本校招必刷八股文") + .setStyle(SidebarStyleEnum.SUBSCRIBE.getStyle()); + } + ConfigDTO config = subscribeList.get(0); + String tagsDesc = ""; + if (StringUtils.isNotBlank(config.getTags())) { + tagsDesc = Splitter.on(",").splitToStream(config.getTags()) + .map(s -> ConfigTagEnum.formCode(Integer.parseInt(s.trim())).getDesc()) + .collect(Collectors.joining(",")); + } + return new SideBarDTO().setTitle(tagsDesc).setSubTitle(config.getName()) + .setImg(config.getBannerUrl()) + .setContent(config.getContent()) + .setUrl(config.getJumpUrl()) + .setStyle(SidebarStyleEnum.SUBSCRIBE.getStyle()); + } + + + @Autowired + private UserActivityRankService userActivityRankService; + + /** + * 排行榜 + * + * @return + */ + private SideBarDTO rankList() { + List list = userActivityRankService.queryRankList(ActivityRankTimeEnum.MONTH, 8); + if (list.isEmpty()) { + return null; + } + SideBarDTO sidebar = new SideBarDTO().setTitle("月度活跃排行榜").setStyle(SidebarStyleEnum.ACTIVITY_RANK.getStyle()); + List itemList = new ArrayList<>(); + for (RankItemDTO item : list) { + SideBarItemDTO sideItem = new SideBarItemDTO().setName(item.getUser().getName()) + .setUrl(String.valueOf(item.getUser().getUserId())) + .setImg(item.getUser().getAvatar()) + .setTime(item.getScore().longValue()); + itemList.add(sideItem); + } + sidebar.setItems(itemList); + return sidebar; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/constants/SitemapConstants.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/constants/SitemapConstants.java new file mode 100644 index 000000000..3d5b0fc5d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/constants/SitemapConstants.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.service.sitemap.constants; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 站点相关地图 + * + * @author YiHui + * @date 2023/8/22 + */ +public class SitemapConstants { + public static final String SITE_VISIT_KEY = "visit_info"; + + public static String day(LocalDate day) { + return DateTimeFormatter.ofPattern("yyyyMMdd").format(day); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteCntVo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteCntVo.java new file mode 100644 index 000000000..56e16aa0a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteCntVo.java @@ -0,0 +1,32 @@ +package com.github.paicoding.forum.service.sitemap.model; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 站点计数 + * + * @author YiHui + * @date 2023/8/22 + */ +@Data +public class SiteCntVo implements Serializable { + private static final long serialVersionUID = 8747459624770066661L; + /** + * 日期 + */ + private String day; + /** + * 路径,全站时,path为null + */ + private String path; + /** + * 站点page view 点击数 + */ + private Integer pv; + /** + * 站点unique view 点击数 + */ + private Integer uv; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteMapVo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteMapVo.java new file mode 100644 index 000000000..2b1adc37e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteMapVo.java @@ -0,0 +1,45 @@ +package com.github.paicoding.forum.service.sitemap.model; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author YiHui + * @date 2023/2/13 + */ +@Data +@JacksonXmlRootElement(localName = "urlset", namespace = "http://www.sitemaps.org/schemas/sitemap/0.9") +public class SiteMapVo { + + @JacksonXmlProperty(isAttribute = true, localName = "xmlns:news") + private String xmlnsNews = "http://www.google.com/schemas/sitemap-news/0.9"; + + @JacksonXmlProperty(isAttribute = true, localName = "xmlns:xhtml") + private String xmlnsXhtml = "http://www.w3.org/1999/xhtml"; + + @JacksonXmlProperty(isAttribute = true, localName = "xmlns:image") + private String xmlnsImage = "http://www.google.com/schemas/sitemap-image/1.1"; + + @JacksonXmlProperty(isAttribute = true, localName = "xmlns:video") + private String xmlnsVideo = "http://www.google.com/schemas/sitemap-video/1.1"; + + /** + * 将列表数据转为XML节点, useWrapping = false 表示不要外围标签名 + */ + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "url") + private List url; + + public SiteMapVo() { + url = new ArrayList<>(); + } + + public void addUrl(SiteUrlVo xmlUrl) { + url.add(xmlUrl); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteUrlVo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteUrlVo.java new file mode 100644 index 000000000..157cbd8b7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/model/SiteUrlVo.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.service.sitemap.model; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author YiHui + * @date 2023/2/13 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JacksonXmlRootElement(localName = "url") +public class SiteUrlVo { + + @JacksonXmlProperty(localName = "loc") + private String loc; + + @JacksonXmlProperty(localName = "lastmod") + private String lastMod; + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/service/SitemapService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/service/SitemapService.java new file mode 100644 index 000000000..c540e75af --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/service/SitemapService.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.service.sitemap.service; + +import com.github.paicoding.forum.service.sitemap.model.SiteCntVo; +import com.github.paicoding.forum.service.sitemap.model.SiteMapVo; + +import java.time.LocalDate; + +/** + * 站点统计相关服务: + * - 站点地图 + * - pv/uv + * + * @author YiHui + * @date 2023/2/13 + */ +public interface SitemapService { + + /** + * 查询站点地图 + * + * @return + */ + SiteMapVo getSiteMap(); + + /** + * 刷新站点地图 + */ + void refreshSitemap(); + + /** + * 保存用户访问信息 + * + * @param visitIp 访问者ip + * @param path 访问的资源路径 + */ + void saveVisitInfo(String visitIp, String path); + + + /** + * 查询站点某一天or总的访问信息 + * + * @param date 日期,为空时,表示查询所有的站点信息 + * @param path 访问路径,为空时表示查站点信息 + * @return + */ + SiteCntVo querySiteVisitInfo(LocalDate date, String path); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/service/impl/SitemapServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/service/impl/SitemapServiceImpl.java new file mode 100644 index 000000000..7540ec38d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/sitemap/service/impl/SitemapServiceImpl.java @@ -0,0 +1,309 @@ +package com.github.paicoding.forum.service.sitemap.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.github.paicoding.forum.api.model.enums.ArticleEventEnum; +import com.github.paicoding.forum.api.model.event.ArticleMsgEvent; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.dao.ColumnArticleDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.sitemap.constants.SitemapConstants; +import com.github.paicoding.forum.service.sitemap.model.SiteCntVo; +import com.github.paicoding.forum.service.sitemap.model.SiteMapVo; +import com.github.paicoding.forum.service.sitemap.model.SiteUrlVo; +import com.github.paicoding.forum.service.sitemap.service.SitemapService; +import com.github.paicoding.forum.service.statistics.service.CountService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2023/2/13 + */ +@Slf4j +@Service +public class SitemapServiceImpl implements SitemapService { + @Value("${view.site.host:https://paicoding.com}") + private String host; + private static final int SCAN_SIZE = 100; + + private static final String SITE_MAP_CACHE_KEY = "sitemap"; + + @Resource + private ArticleDao articleDao; + @Resource + private CountService countService; + @Resource + private ColumnArticleDao columnArticleDao; + + /** + * 查询站点地图 + * @return 返回站点地图 + */ + public SiteMapVo getSiteMap() { + // key = 文章id, value = 最后更新时间 + Map siteMap = RedisClient.hGetAll(SITE_MAP_CACHE_KEY, Long.class); + if (CollectionUtils.isEmpty(siteMap)) { + // 首次访问时,没有数据,全量初始化 + initSiteMap(); + } + siteMap = RedisClient.hGetAll(SITE_MAP_CACHE_KEY, Long.class); + SiteMapVo vo = initBasicSite(); + if (CollectionUtils.isEmpty(siteMap)) { + return vo; + } + + // 批量查询文章信息以获取slug + List articleIds = siteMap.keySet().stream() + .map(Long::valueOf) + .collect(Collectors.toList()); + + List articles = articleDao.listByIds(articleIds); + Map articleMap = articles.stream() + .collect(Collectors.toMap(ArticleDO::getId, article -> article, (a, b) -> a)); + + for (Map.Entry entry : siteMap.entrySet()) { + Long articleId = Long.valueOf(entry.getKey()); + ArticleDO article = articleMap.get(articleId); + if (article == null) { + continue; + } + + String url; + if (StringUtils.isNotBlank(article.getShortTitle())) { + ColumnArticleDO columnArticle = columnArticleDao.selectColumnArticleByArticleId(articleId); + if (columnArticle != null) { + url = host + "/column/" + columnArticle.getColumnId() + "/" + columnArticle.getSection(); + } else { + url = buildArticleUrl(article, articleId); + } + } else { + url = buildArticleUrl(article, articleId); + } + + vo.addUrl(new SiteUrlVo(url, DateUtil.time2utc(entry.getValue()))); + } + return vo; + } + + private String buildArticleUrl(ArticleDO article, Long articleId) { + if (StringUtils.isNotBlank(article.getUrlSlug())) { + return host + "/article/detail/" + articleId + "/" + article.getUrlSlug(); + } else { + return host + "/article/detail/" + articleId; + } + } + + /** + * fixme: 加锁初始化,更推荐的是采用分布式锁 + */ + private synchronized void initSiteMap() { + long lastId = 0L; + RedisClient.del(SITE_MAP_CACHE_KEY); + while (true) { + List list = articleDao.getBaseMapper().listArticlesOrderById(lastId, SCAN_SIZE); + // 刷新文章的统计信息 + list.forEach(s -> countService.refreshArticleStatisticInfo(s.getId())); + + // 刷新站点地图信息 + Map map = list.stream().collect(Collectors.toMap(s -> String.valueOf(s.getId()), s -> s.getCreateTime().getTime(), (a, b) -> a)); + RedisClient.hMSet(SITE_MAP_CACHE_KEY, map); + if (list.size() < SCAN_SIZE) { + break; + } + lastId = list.get(list.size() - 1).getId(); + } + } + + private SiteMapVo initBasicSite() { + SiteMapVo vo = new SiteMapVo(); + String time = DateUtil.time2utc(System.currentTimeMillis()); + vo.addUrl(new SiteUrlVo(host + "/", time)); + vo.addUrl(new SiteUrlVo(host + "/column", time)); + vo.addUrl(new SiteUrlVo(host + "/admin-view", time)); + return vo; + } + + /** + * 重新刷新站点地图 + */ + @Override + public void refreshSitemap() { + initSiteMap(); + } + + /** + * 基于文章的上下线,自动更新站点地图 + * + * @param event + */ + @EventListener(ArticleMsgEvent.class) + public void autoUpdateSiteMap(ArticleMsgEvent event) { + ArticleEventEnum type = event.getType(); + if (type == ArticleEventEnum.ONLINE) { + addArticle(event.getContent().getId()); + } else if (type == ArticleEventEnum.OFFLINE || type == ArticleEventEnum.DELETE) { + rmArticle(event.getContent().getId()); + } + } + + /** + * 新增文章并上线 + * + * @param articleId + */ + private void addArticle(Long articleId) { + RedisClient.hSet(SITE_MAP_CACHE_KEY, String.valueOf(articleId), System.currentTimeMillis()); + } + + /** + * 删除文章、or文章下线 + * + * @param articleId + */ + private void rmArticle(Long articleId) { + RedisClient.hDel(SITE_MAP_CACHE_KEY, String.valueOf(articleId)); + } + + + /** + * 采用定时器方案,每天5:15分刷新站点地图,确保数据的一致性 + */ + @Scheduled(cron = "0 15 5 * * ?") + public void autoRefreshCache() { + log.info("开始刷新sitemap.xml的url地址,避免出现数据不一致问题!"); + refreshSitemap(); + log.info("刷新完成!"); + } + + + /** + * 保存站点数据模型 + *

+ * 站点统计hash: + * - visit_info: + * ---- pv: 站点的总pv + * ---- uv: 站点的总uv + * ---- pv_path: 站点某个资源的总访问pv + * ---- uv_path: 站点某个资源的总访问uv + * - visit_info_ip: + * ---- pv: 用户访问的站点总次数 + * ---- path_pv: 用户访问的路径总次数 + * - visit_info_20230822每日记录, 一天一条记录 + * ---- pv: 12 # field = 月日_pv, pv的计数 + * ---- uv: 5 # field = 月日_uv, uv的计数 + * ---- pv_path: 2 # 资源的当前访问计数 + * ---- uv_path: # 资源的当天访问uv + * ---- pv_ip: # 用户当天的访问次数 + * ---- pv_path_ip: # 用户对资源的当天访问次数 + * + * @param visitIp 访问者ip + * @param path 访问的资源路径 + */ + @Override + public void saveVisitInfo(String visitIp, String path) { + String globalKey = SitemapConstants.SITE_VISIT_KEY; + String day = SitemapConstants.day(LocalDate.now()); + + String todayKey = globalKey + "_" + day; + + // 用户的全局访问计数+1 + Long globalUserVisitCnt = RedisClient.hIncr(globalKey + "_" + visitIp, "pv", 1); + // 用户的当日访问计数+1 + Long todayUserVisitCnt = RedisClient.hIncr(todayKey, "pv_" + visitIp, 1); + + RedisClient.PipelineAction pipelineAction = RedisClient.pipelineAction(); + if (globalUserVisitCnt == 1) { + // 站点新用户 + // 今日的uv + 1 + pipelineAction.add(todayKey, "uv" + , (connection, key, field) -> { + connection.hIncrBy(key, field, 1); + }); + pipelineAction.add(todayKey, "uv_" + path + , (connection, key, field) -> connection.hIncrBy(key, field, 1)); + + // 全局站点的uv + pipelineAction.add(globalKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1)); + pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1)); + } else if (todayUserVisitCnt == 1) { + // 判断是今天的首次访问,更新今天的uv+1 + pipelineAction.add(todayKey, "uv", (connection, key, field) -> connection.hIncrBy(key, field, 1)); + if (RedisClient.hIncr(todayKey, "pv_" + path + "_" + visitIp, 1) == 1) { + // 判断是否为今天首次访问这个资源,若是,则uv+1 + pipelineAction.add(todayKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1)); + } + + // 判断是否是用户的首次访问这个path,若是,则全局的path uv计数需要+1 + if (RedisClient.hIncr(globalKey + "_" + visitIp, "pv_" + path, 1) == 1) { + pipelineAction.add(globalKey, "uv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1)); + } + } + + + // 更新pv 以及 用户的path访问信息 + // 今天的相关信息 pv + pipelineAction.add(todayKey, "pv", (connection, key, field) -> connection.hIncrBy(key, field, 1)); + pipelineAction.add(todayKey, "pv_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1)); + if (todayUserVisitCnt > 1) { + // 非当天首次访问,则pv+1; 因为首次访问时,在前面更新uv时,已经计数+1了 + pipelineAction.add(todayKey, "pv_" + path + "_" + visitIp, (connection, key, field) -> connection.hIncrBy(key, field, 1)); + } + + + // 全局的 PV + pipelineAction.add(globalKey, "pv", (connection, key, field) -> connection.hIncrBy(key, field, 1)); + pipelineAction.add(globalKey, "pv" + "_" + path, (connection, key, field) -> connection.hIncrBy(key, field, 1)); + + // 保存访问信息 + pipelineAction.execute(); + if (log.isDebugEnabled()) { + log.info("用户访问信息更新完成! 当前用户总访问: {},今日访问: {}", globalUserVisitCnt, todayUserVisitCnt); + } + } + + /** + * 查询站点某一天or总的访问信息 + * + * @param date 日期,为空时,表示查询所有的站点信息 + * @param path 访问路径,为空时表示查站点信息 + * @return + */ + @Override + public SiteCntVo querySiteVisitInfo(LocalDate date, String path) { + String globalKey = SitemapConstants.SITE_VISIT_KEY; + String day = null, todayKey = globalKey; + if (date != null) { + day = SitemapConstants.day(date); + todayKey = globalKey + "_" + day; + } + + String pvField = "pv", uvField = "uv"; + if (path != null) { + // 表示查询对应路径的访问信息 + pvField += "_" + path; + uvField += "_" + path; + } + + Map map = RedisClient.hMGet(todayKey, Arrays.asList(pvField, uvField), Integer.class); + SiteCntVo siteInfo = new SiteCntVo(); + siteInfo.setDay(day); + siteInfo.setPv(map.getOrDefault(pvField, 0)); + siteInfo.setUv(map.getOrDefault(uvField, 0)); + return siteInfo; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/constants/CountConstants.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/constants/CountConstants.java new file mode 100644 index 000000000..2801db1e9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/constants/CountConstants.java @@ -0,0 +1,53 @@ +package com.github.paicoding.forum.service.statistics.constants; + +/** + * 用户相关的常量信息 + * + * @author YiHui + * @date 2023/8/25 + */ +public interface CountConstants { + + /** + * 用户相关统计信息 + */ + String USER_STATISTIC_INFO = "user_statistic_"; + /** + * 文章相关统计信息 + */ + String ARTICLE_STATISTIC_INFO = "article_statistic_"; + /** + * 关注数 + */ + String FOLLOW_COUNT = "followCount"; + + /** + * 粉丝数 + */ + String FANS_COUNT = "fansCount"; + + /** + * 已发布文章数 + */ + String ARTICLE_COUNT = "articleCount"; + + /** + * 文章点赞数 + */ + String PRAISE_COUNT = "praiseCount"; + + /** + * 文章被阅读数 + */ + String READ_COUNT = "readCount"; + + /** + * 文章被收藏数 + */ + String COLLECTION_COUNT = "collectionCount"; + + /** + * 评论数 + */ + String COMMENT_COUNT = "commentCount"; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/converter/StatisticsConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/converter/StatisticsConverter.java new file mode 100644 index 000000000..0fad10502 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/converter/StatisticsConverter.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.service.statistics.converter; + +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountExcelDO; +import com.github.paicoding.forum.service.statistics.repository.entity.StatisticsDayExcelDO; + +import java.util.List; +import java.util.stream.Collectors; + +public class StatisticsConverter { + public static StatisticsDayExcelDO convertToExcelDO(StatisticsDayDTO dto) { + StatisticsDayExcelDO excelDO = new StatisticsDayExcelDO(); + excelDO.setDate(dto.getDate()); + excelDO.setUvCount(dto.getUvCount()); + excelDO.setPvCount(dto.getPvCount()); + return excelDO; + } + + public static RequestCountExcelDO ConvertToRequestCountDO(RequestCountDO requestCountDO) { + RequestCountExcelDO excelDO = new RequestCountExcelDO(); + excelDO.setHost(requestCountDO.getHost()); + excelDO.setCnt(requestCountDO.getCnt()); + excelDO.setDate(requestCountDO.getDate()); + return excelDO; + } + + public static List convertToRequestCountExcelDOList(List requestCountDOList) { + return requestCountDOList.stream() + .map(StatisticsConverter::ConvertToRequestCountDO) + .collect(Collectors.toList()); + } + + public static List convertToExcelDOList(List dtoList) { + return dtoList.stream() + .map(StatisticsConverter::convertToExcelDO) + .collect(Collectors.toList()); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/listener/UserStatisticEventListener.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/listener/UserStatisticEventListener.java new file mode 100644 index 000000000..e777a00f5 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/listener/UserStatisticEventListener.java @@ -0,0 +1,102 @@ +package com.github.paicoding.forum.service.statistics.listener; + +import com.github.paicoding.forum.api.model.enums.ArticleEventEnum; +import com.github.paicoding.forum.api.model.event.ArticleMsgEvent; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import com.github.paicoding.forum.service.statistics.constants.CountConstants; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 用户活跃相关的消息监听器 + * + * @author YiHui + * @date 2023/8/19 + */ +@Component +public class UserStatisticEventListener { + @Resource + private ArticleDao articleDao; + + /** + * 用户操作行为,增加对应的积分 + * + * @param msgEvent + */ + @EventListener(classes = NotifyMsgEvent.class) + @Async + public void notifyMsgListener(NotifyMsgEvent msgEvent) { + switch (msgEvent.getNotifyType()) { + case COMMENT: + case REPLY: + CommentDO comment = (CommentDO) msgEvent.getContent(); + RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + comment.getArticleId(), CountConstants.COMMENT_COUNT, 1); + break; + case DELETE_COMMENT: + case DELETE_REPLY: + comment = (CommentDO) msgEvent.getContent(); + RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + comment.getArticleId(), CountConstants.COMMENT_COUNT, -1); + break; + case COLLECT: + UserFootDO foot = (UserFootDO) msgEvent.getContent(); + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.COLLECTION_COUNT, 1); + RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.COLLECTION_COUNT, 1); + break; + case CANCEL_COLLECT: + foot = (UserFootDO) msgEvent.getContent(); + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.COLLECTION_COUNT, -1); + RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.COLLECTION_COUNT, -1); + break; + case PRAISE: + foot = (UserFootDO) msgEvent.getContent(); + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.PRAISE_COUNT, 1); + RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.PRAISE_COUNT, 1); + break; + case CANCEL_PRAISE: + foot = (UserFootDO) msgEvent.getContent(); + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + foot.getDocumentUserId(), CountConstants.PRAISE_COUNT, -1); + RedisClient.hIncr(CountConstants.ARTICLE_STATISTIC_INFO + foot.getDocumentId(), CountConstants.PRAISE_COUNT, -1); + break; + case FOLLOW: + UserRelationDO relation = (UserRelationDO) msgEvent.getContent(); + // 主用户粉丝数 + 1 + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getUserId(), CountConstants.FANS_COUNT, 1); + // 粉丝的关注数 + 1 + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getFollowUserId(), CountConstants.FOLLOW_COUNT, 1); + break; + case CANCEL_FOLLOW: + relation = (UserRelationDO) msgEvent.getContent(); + // 主用户粉丝数 + 1 + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getUserId(), CountConstants.FANS_COUNT, -1); + // 粉丝的关注数 + 1 + RedisClient.hIncr(CountConstants.USER_STATISTIC_INFO + relation.getFollowUserId(), CountConstants.FOLLOW_COUNT, -1); + break; + default: + } + } + + /** + * 发布文章,更新对应的文章计数 + * + * @param event + */ + @Async + @EventListener(ArticleMsgEvent.class) + public void publishArticleListener(ArticleMsgEvent event) { + ArticleEventEnum type = event.getType(); + if (type == ArticleEventEnum.ONLINE || type == ArticleEventEnum.OFFLINE || type == ArticleEventEnum.DELETE) { + Long userId = event.getContent().getUserId(); + int count = articleDao.countArticleByUser(userId); + RedisClient.hSet(CountConstants.USER_STATISTIC_INFO + userId, CountConstants.ARTICLE_COUNT, count); + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/dao/RequestCountDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/dao/RequestCountDao.java new file mode 100644 index 000000000..f5f46047f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/dao/RequestCountDao.java @@ -0,0 +1,69 @@ +package com.github.paicoding.forum.service.statistics.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.entity.TagDO; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; +import com.github.paicoding.forum.service.statistics.repository.mapper.RequestCountMapper; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.util.List; + +/** + * 请求计数 + * + * @author louzai + * @date 2022-10-1 + */ +@Repository +public class RequestCountDao extends ServiceImpl { + + public Long getPvTotalCount() { + return baseMapper.getPvTotalCount(); + } + + /** + * 获取请求数据 + * + * @param host + * @param date + * @return + */ + public RequestCountDO getRequestCount(String host, Date date) { + return lambdaQuery() + .eq(RequestCountDO::getHost, host) + .eq(RequestCountDO::getDate, date) + .one(); + } + + public List listRequestCount(PageParam pageParam) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.orderByDesc(RequestCountDO::getId); + if (pageParam != null) { + query.last(PageParam.getLimitSql(pageParam)); + } + return baseMapper.selectList(query); + } + + /** + * 获取 PV UV 数据列表 + * @param day + * @return + */ + public List getPvUvDayList(Integer day) { + return baseMapper.getPvUvDayList(day); + } + + public void incrementCount(Long id) { + baseMapper.incrementCount(id); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/RequestCountDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/RequestCountDO.java new file mode 100644 index 000000000..6ce31d1d5 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/RequestCountDO.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.service.statistics.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 请求计数表 + * + * @author louzai + * @date 2022-10-1 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("request_count") +public class RequestCountDO extends BaseDO { + + private static final long serialVersionUID = 1L; + + /** + * 机器IP + */ + private String host; + + /** + * 访问计数 + */ + private Integer cnt; + + /** + * 当前日期 + */ + private Date date; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/RequestCountExcelDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/RequestCountExcelDO.java new file mode 100644 index 000000000..b73162c58 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/RequestCountExcelDO.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.service.statistics.repository.entity; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.util.Date; + +@Data +public class RequestCountExcelDO { + + @ExcelProperty("机器IP") + private String host; + + @ExcelProperty("访问计数") + private Integer cnt; + + @ExcelProperty("当前日期") + private Date date; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/StatisticsDayExcelDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/StatisticsDayExcelDO.java new file mode 100644 index 000000000..f4eb5ab1e --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/entity/StatisticsDayExcelDO.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.service.statistics.repository.entity; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +@Data +public class StatisticsDayExcelDO { + + /** + * 日期 + */ + @ExcelProperty("日期") + private String date; + + /** + * 数量 + */ + @ExcelProperty("PV") + private Long pvCount; + + /** + * UV数量 + */ + @ExcelProperty("UV") + private Long uvCount; +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/mapper/RequestCountMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/mapper/RequestCountMapper.java new file mode 100644 index 000000000..556c95893 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/repository/mapper/RequestCountMapper.java @@ -0,0 +1,42 @@ +package com.github.paicoding.forum.service.statistics.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +/** + * 请求计数mapper接口 + * + * @author louzai + * @date 2022-10-1 + */ +public interface RequestCountMapper extends BaseMapper { + + /** + * 获取 PV 总数 + * + * @return + */ + @Select("select sum(cnt) from request_count") + Long getPvTotalCount(); + + /** + * 获取 PV UV 数据列表 + * @param day + * @return + */ + List getPvUvDayList(@Param("day") Integer day); + + /** + * 增加计数 + * + * @param id + */ + @Update("update request_count set cnt = cnt + 1 where id = #{id}") + void incrementCount(Long id); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/CountService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/CountService.java new file mode 100644 index 000000000..90449f86d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/CountService.java @@ -0,0 +1,82 @@ +package com.github.paicoding.forum.service.statistics.service; + +import com.github.paicoding.forum.api.model.vo.user.dto.ArticleFootCountDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; + +/** + * 计数统计相关 + * + * @author YiHui + * @date 2022/9/2 + */ +public interface CountService { + /** + * 根据文章ID查询文章计数 + * 本方法直接基于db进行查询相关信息,改用下面的 queryArticleStatisticInfo() 方法进行替换 + * + * @param articleId + * @return + */ + @Deprecated + ArticleFootCountDTO queryArticleCountInfoByArticleId(Long articleId); + + + /** + * 查询用户总阅读相关计数(当前未返回评论数) + * 本方法直接基于db进行查询相关信息,改用下面的 queryUserStatisticInfo() 方法进行替换 + * + * @param userId + * @return + */ + @Deprecated + ArticleFootCountDTO queryArticleCountInfoByUserId(Long userId); + + /** + * 获取评论点赞数量 + * + * @param commentId + * @return + */ + Long queryCommentPraiseCount(Long commentId); + + + /** + * 查询用户的相关统计信息 + * + * @param userId + * @return 返回用户的 收藏、点赞、文章、粉丝、关注,总的文章阅读数 + */ + UserStatisticInfoDTO queryUserStatisticInfo(Long userId); + + /** + * 查询文章相关的统计信息 + * + * @param articleId + * @return 返回文章的 收藏、点赞、评论、阅读数 + */ + ArticleFootCountDTO queryArticleStatisticInfo(Long articleId); + + + /** + * 文章计数+1 + * + * @param authorUserId 作者 + * @param articleId 文章 + * @return 计数器 + */ + void incrArticleReadCount(Long authorUserId, Long articleId); + + /** + * 刷新用户的统计信息 + * + * @param userId + */ + void refreshUserStatisticInfo(Long userId); + + /** + * 刷新文章的统计信息 + * + * @param articleId + */ + void refreshArticleStatisticInfo(Long articleId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/RequestCountService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/RequestCountService.java new file mode 100644 index 000000000..1f2b54aed --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/RequestCountService.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.service.statistics.service; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; + +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/24/23 + */ +public interface RequestCountService { + RequestCountDO getRequestCount(String host); + + void insert(String host); + + void incrementCount(Long id); + + Long getPvTotalCount(); + + List getPvUvDayList(Integer day); + + long count(); + + // 分页返回 RequestCountDO + List listRequestCount(PageParam pageParam); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/StatisticsSettingService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/StatisticsSettingService.java new file mode 100644 index 000000000..5b29d3c7b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/StatisticsSettingService.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.service.statistics.service; + +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsCountDTO; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 数据统计后台接口 + * + * @author louzai + * @date 2022-09-19 + */ +public interface StatisticsSettingService { + + /** + * 保存计数 + * + * @param host + */ + void saveRequestCount(String host); + + /** + * 获取总数 + * + * @return + */ + StatisticsCountDTO getStatisticsCount(); + + /** + * 获取每天的PV UV统计数据 + * + * @param day + * @return + */ + List getPvUvDayList(Integer day); + + void download2Excel(Integer day, HttpServletResponse response); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/UserStatisticService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/UserStatisticService.java new file mode 100644 index 000000000..f09eb640c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/UserStatisticService.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.service.statistics.service; + +/** + * 用户统计服务 + * + * @author YiHui + * @date 2023/3/26 + */ +public interface UserStatisticService { + /** + * 添加在线人数 + * + * @param add 正数,表示添加在线人数;负数,表示减少在线人数 + * @return + */ + int incrOnlineUserCnt(int add); + + /** + * 查询在线用户人数 + * + * @return + */ + int getOnlineUserCnt(); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/CountServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/CountServiceImpl.java new file mode 100644 index 000000000..bdc5e7053 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/CountServiceImpl.java @@ -0,0 +1,184 @@ +package com.github.paicoding.forum.service.statistics.service.impl; + +import com.github.paicoding.forum.api.model.vo.user.dto.ArticleFootCountDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.util.MapUtils; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.statistics.constants.CountConstants; +import com.github.paicoding.forum.service.statistics.service.CountService; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.dao.UserFootDao; +import com.github.paicoding.forum.service.user.repository.dao.UserRelationDao; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 计数服务,后续计数相关的可以考虑基于redis来做 + * + * @author YiHui + * @date 2022/9/2 + */ +@Slf4j +@Service +public class CountServiceImpl implements CountService { + private final UserFootDao userFootDao; + public CountServiceImpl(UserFootDao userFootDao) { + this.userFootDao = userFootDao; + } + + @Resource + private UserRelationDao userRelationDao; + + @Resource + private ArticleDao articleDao; + + @Resource + private CommentReadService commentReadService; + + @Resource + private UserDao userDao; + + @Override + public ArticleFootCountDTO queryArticleCountInfoByArticleId(Long articleId) { + ArticleFootCountDTO res = userFootDao.countArticleByArticleId(articleId); + if (res == null) { + res = new ArticleFootCountDTO(); + } else { + res.setCommentCount(commentReadService.queryCommentCount(articleId)); + } + return res; + } + + + @Override + public ArticleFootCountDTO queryArticleCountInfoByUserId(Long userId) { + return userFootDao.countArticleByUserId(userId); + } + + /** + * 查询评论的点赞数 + * + * @param commentId + * @return + */ + @Override + public Long queryCommentPraiseCount(Long commentId) { + return userFootDao.countCommentPraise(commentId); + } + + @Override + public UserStatisticInfoDTO queryUserStatisticInfo(Long userId) { + Map ans = RedisClient.hGetAll(CountConstants.USER_STATISTIC_INFO + userId, Integer.class); + UserStatisticInfoDTO info = new UserStatisticInfoDTO(); + info.setFollowCount(ans.getOrDefault(CountConstants.FOLLOW_COUNT, 0)); + info.setArticleCount(ans.getOrDefault(CountConstants.ARTICLE_COUNT, 0)); + info.setPraiseCount(ans.getOrDefault(CountConstants.PRAISE_COUNT, 0)); + info.setCollectionCount(ans.getOrDefault(CountConstants.COLLECTION_COUNT, 0)); + info.setReadCount(ans.getOrDefault(CountConstants.READ_COUNT, 0)); + info.setFansCount(ans.getOrDefault(CountConstants.FANS_COUNT, 0)); + return info; + } + + @Override + public ArticleFootCountDTO queryArticleStatisticInfo(Long articleId) { + Map ans = RedisClient.hGetAll(CountConstants.ARTICLE_STATISTIC_INFO + articleId, Integer.class); + ArticleFootCountDTO info = new ArticleFootCountDTO(); + info.setPraiseCount(ans.getOrDefault(CountConstants.PRAISE_COUNT, 0)); + info.setCollectionCount(ans.getOrDefault(CountConstants.COLLECTION_COUNT, 0)); + info.setCommentCount(ans.getOrDefault(CountConstants.COMMENT_COUNT, 0)); + info.setReadCount(ans.getOrDefault(CountConstants.READ_COUNT, 0)); + return info; + } + + @Override + public void incrArticleReadCount(Long authorUserId, Long articleId) { + // db层的计数+1 + articleDao.incrReadCount(articleId); + // redis计数器 +1 + RedisClient.pipelineAction() + .add(CountConstants.ARTICLE_STATISTIC_INFO + articleId, CountConstants.READ_COUNT, + (connection, key, value) -> connection.hIncrBy(key, value, 1)) + .add(CountConstants.USER_STATISTIC_INFO + authorUserId, CountConstants.READ_COUNT, + (connection, key, value) -> connection.hIncrBy(key, value, 1)) + .execute(); + } + + /** + * 每天4:15分执行定时任务,全量刷新用户的统计信息 + */ + @Scheduled(cron = "0 15 4 * * ?") + public void autoRefreshAllUserStatisticInfo() { + Long now = System.currentTimeMillis(); + log.info("开始自动刷新用户统计信息"); + Long userId = 0L; + int batchSize = 20; + while (true) { + List userIds = userDao.scanUserId(userId, batchSize); + userIds.forEach(this::refreshUserStatisticInfo); + if (userIds.size() < batchSize) { + userId = userIds.get(userIds.size() - 1); + break; + } else { + userId = userIds.get(batchSize - 1); + } + } + log.info("结束自动刷新用户统计信息,共耗时: {}ms, maxUserId: {}", System.currentTimeMillis() - now, userId); + } + + + /** + * 更新用户的统计信息 + * + * @param userId + */ + @Override + public void refreshUserStatisticInfo(Long userId) { + // 用户的文章点赞数,收藏数,阅读计数 + ArticleFootCountDTO count = userFootDao.countArticleByUserId(userId); + if (count == null) { + count = new ArticleFootCountDTO(); + } + + // 获取关注数 + Long followCount = userRelationDao.queryUserFollowCount(userId); + // 粉丝数 + Long fansCount = userRelationDao.queryUserFansCount(userId); + + // 查询用户发布的文章数 + Integer articleNum = articleDao.countArticleByUser(userId); + + String key = CountConstants.USER_STATISTIC_INFO + userId; + RedisClient.hMSet(key, MapUtils.create(CountConstants.PRAISE_COUNT, count.getPraiseCount(), + CountConstants.COLLECTION_COUNT, count.getCollectionCount(), + CountConstants.READ_COUNT, count.getReadCount(), + CountConstants.FANS_COUNT, fansCount, + CountConstants.FOLLOW_COUNT, followCount, + CountConstants.ARTICLE_COUNT, articleNum)); + + } + + + public void refreshArticleStatisticInfo(Long articleId) { + ArticleFootCountDTO res = userFootDao.countArticleByArticleId(articleId); + if (res == null) { + res = new ArticleFootCountDTO(); + } else { + res.setCommentCount(commentReadService.queryCommentCount(articleId)); + } + + RedisClient.hMSet(CountConstants.ARTICLE_STATISTIC_INFO + articleId, + MapUtils.create(CountConstants.COLLECTION_COUNT, res.getCollectionCount(), + CountConstants.PRAISE_COUNT, res.getPraiseCount(), + CountConstants.READ_COUNT, res.getReadCount(), + CountConstants.COMMENT_COUNT, res.getCommentCount() + ) + ); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/RequestCountServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/RequestCountServiceImpl.java new file mode 100644 index 000000000..0ae5f92bc --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/RequestCountServiceImpl.java @@ -0,0 +1,75 @@ +package com.github.paicoding.forum.service.statistics.service.impl; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.service.statistics.repository.dao.RequestCountDao; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; +import com.github.paicoding.forum.service.statistics.service.RequestCountService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/24/23 + */ +@Slf4j +@Service +public class RequestCountServiceImpl implements RequestCountService { + @Autowired + private RequestCountDao requestCountDao; + + @Override + public RequestCountDO getRequestCount(String host) { + return requestCountDao.getRequestCount(host, Date.valueOf(LocalDate.now())); + } + + @Override + public void insert(String host) { + RequestCountDO requestCountDO = null; + try { + requestCountDO = new RequestCountDO(); + requestCountDO.setHost(host); + requestCountDO.setCnt(1); + requestCountDO.setDate(Date.valueOf(LocalDate.now())); + requestCountDao.save(requestCountDO); + } catch (Exception e) { + // fixme 非数据库原因得异常,则大概率是0点的并发访问,导致同一天写入多条数据的问题; 可以考虑使用分布式锁来避免 + // todo 后续考虑使用redis自增来实现pv计数统计 + log.error("save requestCount error: {}", requestCountDO, e); + } + } + + @Override + public void incrementCount(Long id) { + requestCountDao.incrementCount(id); + } + + @Override + public Long getPvTotalCount() { + return requestCountDao.getPvTotalCount(); + } + + @Override + public List getPvUvDayList(Integer day) { + return requestCountDao.getPvUvDayList(day); + } + + @Override + public long count() { + return requestCountDao.count(); + } + + @Override + public List listRequestCount(PageParam pageParam) { + return requestCountDao.listRequestCount(pageParam); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/StatisticsSettingServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/StatisticsSettingServiceImpl.java new file mode 100644 index 000000000..d5c61f919 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/StatisticsSettingServiceImpl.java @@ -0,0 +1,108 @@ +package com.github.paicoding.forum.service.statistics.service.impl; + +import cn.idev.excel.FastExcel; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsCountDTO; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserFootStatisticDTO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.ColumnService; +import com.github.paicoding.forum.service.statistics.converter.StatisticsConverter; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; +import com.github.paicoding.forum.service.statistics.repository.entity.StatisticsDayExcelDO; +import com.github.paicoding.forum.service.statistics.service.RequestCountService; +import com.github.paicoding.forum.service.statistics.service.StatisticsSettingService; +import com.github.paicoding.forum.service.user.service.UserFootService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.service.user.service.conf.AiConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +/** + * 数据统计后台接口 + * + * @author louzai + * @date 2022-09-19 + */ +@Slf4j +@Service +public class StatisticsSettingServiceImpl implements StatisticsSettingService { + + @Autowired + private RequestCountService requestCountService; + + @Autowired + private UserService userService; + + @Autowired + private ColumnService columnService; + + @Autowired + private UserFootService userFootService; + + @Autowired + private ArticleReadService articleReadService; + + @Resource + private AiConfig aiConfig; + + @Override + public void saveRequestCount(String host) { + RequestCountDO requestCountDO = requestCountService.getRequestCount(host); + if (requestCountDO == null) { + requestCountService.insert(host); + } else { + // 改为数据库直接更新 + requestCountService.incrementCount(requestCountDO.getId()); + } + } + + @Override + public StatisticsCountDTO getStatisticsCount() { + // 从 user_foot 表中查询点赞数、收藏数、留言数、阅读数 + UserFootStatisticDTO userFootStatisticDTO = userFootService.getFootCount(); + if (userFootStatisticDTO == null) { + userFootStatisticDTO = new UserFootStatisticDTO(); + } + return StatisticsCountDTO.builder() + .userCount(userService.getUserCount()) + .articleCount(articleReadService.getArticleCount()) + .pvCount(requestCountService.getPvTotalCount()) + .tutorialCount(columnService.getTutorialCount()) + .commentCount(userFootStatisticDTO.getCommentCount()) + .collectCount(userFootStatisticDTO.getCollectionCount()) + .likeCount(userFootStatisticDTO.getPraiseCount()) + .readCount(userFootStatisticDTO.getReadCount()) + .starPayCount(aiConfig.getMaxNum().getStarNumber()) + .build(); + } + + @Override + public List getPvUvDayList(Integer day) { + return requestCountService.getPvUvDayList(day); + } + + @Override + public void download2Excel(Integer day, HttpServletResponse response) { + List pvDayList = requestCountService.getPvUvDayList(day); + // StatisticsDayDTO 转 StatisticsDayExcelDTO + List excelDTOList = StatisticsConverter.convertToExcelDOList(pvDayList); + + // 使用 FastExcel 导出 Excel + // TODO 这里可以用一个大文件,比如说 10万条数据测试一下,看看 FastExcel 的性能 + try { + FastExcel.write(response.getOutputStream(), StatisticsDayExcelDO.class) + .sheet(day + "天统计") + .doWrite(excelDTOList); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/UserStatisticServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/UserStatisticServiceImpl.java new file mode 100644 index 000000000..731299d8f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/UserStatisticServiceImpl.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.service.statistics.service.impl; + +import com.github.paicoding.forum.service.statistics.service.UserStatisticService; +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 用户统计服务 + * + * @author YiHui + * @date 2023/3/26 + */ +@Service +public class UserStatisticServiceImpl implements UserStatisticService { + + /** + * 对于单机的场景,可以直接使用本地局部变量来实现计数 + * 对于集群的场景,可考虑借助 redis的zset 来实现集群的在线用户人数统计 + */ + private AtomicInteger onlineUserCnt = new AtomicInteger(0); + + /** + * 添加在线人数 + * + * @param add 正数,表示添加在线人数;负数,表示减少在线人数 + * @return + */ + @Override + public int incrOnlineUserCnt(int add) { + return onlineUserCnt.addAndGet(add); + } + + /** + * 查询在线用户人数 + * + * @return + */ + @Override + public int getOnlineUserCnt() { + return onlineUserCnt.get(); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserAiConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserAiConverter.java new file mode 100644 index 000000000..163a54551 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserAiConverter.java @@ -0,0 +1,41 @@ +package com.github.paicoding.forum.service.user.converter; + +import com.github.paicoding.forum.api.model.enums.user.StarSourceEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.service.help.UserRandomGenHelper; +import org.apache.commons.lang3.StringUtils; + +/** + * @author YiHui + * @date 2023/6/27 + */ +public class UserAiConverter { + + + public static UserAiDO initAi(Long userId) { + return initAi(userId, null); + } + + public static UserAiDO initAi(Long userId, String starNumber) { + UserAiDO userAiDO = new UserAiDO(); + userAiDO.setUserId(userId); + userAiDO.setStarType(0); + userAiDO.setInviterUserId(0L); + userAiDO.setStrategy(0); + userAiDO.setInviteNum(0); + userAiDO.setDeleted(0); + userAiDO.setInviteCode(UserRandomGenHelper.genInviteCode(userId)); + if (StringUtils.isBlank(starNumber)) { + userAiDO.setStarNumber(""); + userAiDO.setState(UserAIStatEnum.IGNORE.getCode()); + } else { + userAiDO.setStarNumber(starNumber); + userAiDO.setState(UserAIStatEnum.TRYING.getCode()); + // 先只支持Java进阶之路的星球绑定 + userAiDO.setStarType(StarSourceEnum.JAVA_GUIDE.getSource()); + } + return userAiDO; + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserConverter.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserConverter.java new file mode 100644 index 000000000..2317db228 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserConverter.java @@ -0,0 +1,108 @@ +package com.github.paicoding.forum.service.user.converter; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.FollowStateEnum; +import com.github.paicoding.forum.api.model.enums.RoleEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.vo.user.UserInfoSaveReq; +import com.github.paicoding.forum.api.model.vo.user.UserRelationReq; +import com.github.paicoding.forum.api.model.vo.user.UserSaveReq; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import org.springframework.beans.BeanUtils; +import org.springframework.util.CollectionUtils; + +/** + * 用户转换 + * + * @author louzai + * @date 2022-07-20 + */ +public class UserConverter { + + public static UserDO toDO(UserSaveReq req) { + if (req == null) { + return null; + } + UserDO userDO = new UserDO(); + userDO.setId(req.getUserId()); + userDO.setThirdAccountId(req.getThirdAccountId()); + userDO.setLoginType(req.getLoginType()); + return userDO; + } + + public static UserInfoDO toDO(UserInfoSaveReq req) { + if (req == null) { + return null; + } + UserInfoDO userInfoDO = new UserInfoDO(); + userInfoDO.setUserId(req.getUserId()); + userInfoDO.setUserName(req.getUserName()); + userInfoDO.setPhoto(req.getPhoto()); + userInfoDO.setPosition(req.getPosition()); + userInfoDO.setCompany(req.getCompany()); + userInfoDO.setProfile(req.getProfile()); + userInfoDO.setEmail(req.getEmail()); + if (!CollectionUtils.isEmpty(req.getPayCode())) { + userInfoDO.setPayCode(JsonUtil.toStr(req.getPayCode())); + } + return userInfoDO; + } + + public static BaseUserInfoDTO toDTO(UserInfoDO info, UserAiDO userAiDO) { + BaseUserInfoDTO user = toDTO(info); + if (userAiDO != null) { + // 获取星球账号 + user.setStarStatus(UserAIStatEnum.fromCode(userAiDO.getState())); + user.setStarNumber(userAiDO.getStarNumber()); + user.setExpireTime(userAiDO.getStarExpireTime()); + } + return user; + } + + public static BaseUserInfoDTO toDTO(UserInfoDO info) { + if (info == null) { + return null; + } + BaseUserInfoDTO user = new BaseUserInfoDTO(); + // todo 知识点,bean属性拷贝的几种方式, 直接get/set方式,使用BeanUtil工具类(spring, cglib, apache, objectMapper),序列化方式等 + BeanUtils.copyProperties(info, user); + // 设置用户最新登录地理位置 + user.setRegion(info.getIp().getLatestRegion()); + // 设置用户角色 + user.setRole(RoleEnum.role(info.getUserRole())); + return user; + } + + public static SimpleUserInfoDTO toSimpleInfo(UserInfoDO info) { + return new SimpleUserInfoDTO().setUserId(info.getUserId()) + .setName(info.getUserName()) + .setAvatar(info.getPhoto()) + .setProfile(info.getProfile()); + } + + public static UserRelationDO toDO(UserRelationReq req) { + if (req == null) { + return null; + } + UserRelationDO userRelationDO = new UserRelationDO(); + userRelationDO.setUserId(req.getUserId()); + userRelationDO.setFollowUserId(ReqInfoContext.getReqInfo().getUserId()); + userRelationDO.setFollowState(req.getFollowed() ? FollowStateEnum.FOLLOW.getCode() : FollowStateEnum.CANCEL_FOLLOW.getCode()); + return userRelationDO; + } + + public static UserStatisticInfoDTO toUserHomeDTO(UserStatisticInfoDTO userHomeDTO, BaseUserInfoDTO baseUserInfoDTO) { + if (baseUserInfoDTO == null) { + return null; + } + BeanUtils.copyProperties(baseUserInfoDTO, userHomeDTO); + return userHomeDTO; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserStructMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserStructMapper.java new file mode 100644 index 000000000..9b384e9dd --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/converter/UserStructMapper.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.service.user.converter; + +import com.github.paicoding.forum.api.model.vo.user.SearchZsxqUserReq; +import com.github.paicoding.forum.service.user.repository.params.SearchZsxqWhiteParams; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +@Mapper +public interface UserStructMapper { + UserStructMapper INSTANCE = Mappers.getMapper( UserStructMapper.class ); + // req to params + @Mapping(source = "pageNumber", target = "pageNum") + // state to status + @Mapping(source = "state", target = "status") + SearchZsxqWhiteParams toSearchParams(SearchZsxqUserReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/package-info.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/package-info.java new file mode 100644 index 000000000..516edd61d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/package-info.java @@ -0,0 +1,7 @@ +/** + * 用户相关包 + * + * @author YiHui + * @date 2022/7/6 + */ +package com.github.paicoding.forum.service.user; \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserAiDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserAiDao.java new file mode 100644 index 000000000..1786e0f19 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserAiDao.java @@ -0,0 +1,175 @@ +package com.github.paicoding.forum.service.user.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.enums.user.StarSourceEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAiStrategyEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.ZsxqUserInfoDTO; +import com.github.paicoding.forum.service.user.converter.UserAiConverter; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.mapper.UserAiMapper; +import com.github.paicoding.forum.service.user.repository.params.SearchZsxqWhiteParams; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Repository +public class UserAiDao extends ServiceImpl { + + @Resource + private UserAiMapper userAiMapper; + + @Resource + private UserDao userDao; + + /** + * 根据星球编号反查用户 + * + * @param starNumber + * @return + */ + public UserAiDO getByStarNumber(String starNumber) { + LambdaQueryWrapper queryUserAi = Wrappers.lambdaQuery(); + + queryUserAi.eq(UserAiDO::getStarNumber, starNumber) + .eq(UserAiDO::getDeleted, YesOrNoEnum.NO.getCode()) + .last("limit 1"); + return userAiMapper.selectOne(queryUserAi); + } + + public UserAiDO getByUserId(Long userId) { + LambdaQueryWrapper queryUserAi = Wrappers.lambdaQuery(); + + queryUserAi.eq(UserAiDO::getUserId, userId) + .eq(UserAiDO::getDeleted, YesOrNoEnum.NO.getCode()); + return userAiMapper.selectOne(queryUserAi); + } + + + /** + * 查询用户的ai信息,若不存在,则初始化一个,主要用于存量的账号已存在的场景 + * + * @param userId + */ + public UserAiDO getOrInitAiInfo(Long userId) { + UserAiDO ai = getByUserId(userId); + if (ai != null) { + return ai; + } + + // 当不存在时,初始化一个 + ai = UserAiConverter.initAi(userId); + saveOrUpdateAiBindInfo(ai); + return ai; + } + + /** + * 根据邀请码,查找对应的邀请人 + * + * @param inviteCode 邀请码 + * @return + */ + public UserAiDO getByInviteCode(String inviteCode) { + LambdaQueryWrapper queryUserAi = Wrappers.lambdaQuery(); + + queryUserAi.eq(UserAiDO::getInviteCode, inviteCode) + .eq(UserAiDO::getDeleted, YesOrNoEnum.NO.getCode()); + return userAiMapper.selectOne(queryUserAi); + } + + /** + * 更新用户的邀请人数 + * + * @param id + * @param incr + */ + private void updateInviteCnt(Long id, int incr) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(UserAiDO::getId, id).setSql("invite_num = invite_num + " + incr); + userAiMapper.update(null, updateWrapper); + } + + public void saveOrUpdateAiBindInfo(UserAiDO ai) { + saveOrUpdateAiBindInfo(ai, null); + } + + /** + * 更新userAi绑定信息 + * + * @param ai + * @param inviteCode + */ + public void saveOrUpdateAiBindInfo(UserAiDO ai, String inviteCode) { + int strategy = ai.getStrategy(); + if (StringUtils.isNotBlank(inviteCode)) { + // todo 待支持更新邀请绑定 + // 对于绑定邀请码的用户,需要将邀请他的用户找出来,计数 + 1 + UserAiDO inviteUser = getByInviteCode(inviteCode); + if (inviteUser != null) { + ai.setInviterUserId(inviteUser.getUserId()); + updateInviteCnt(inviteUser.getId(), 1); + strategy = UserAiStrategyEnum.INVITE_USER.updateCondition(strategy); + } + } + + // 这里有点问题 + // 用户名密码注册的时候,还没有审核通过,所以即使有星球编号,也无法绑定 AI 策略 + // 去掉用户审核通过的判断,如果用户绑定了星球,就直接更新策略,默认为进阶之路 + // 后面获取册数的时候会根据用户的审核状态,计算次数 + if (StringUtils.isNotBlank(ai.getStarNumber())) { + // 绑定了星球,且审核通过 + if (ai.getStarType() == StarSourceEnum.TECH_PAI.getSource()) { + strategy = UserAiStrategyEnum.STAR_TECH_PAI.updateCondition(strategy); + } else { + strategy = UserAiStrategyEnum.STAR_JAVA_GUIDE.updateCondition(strategy); + } + } else { + // 有星球编号就直接走上面的判断,不走这里公众号的判断了 + // 如果绑定了微信公众号 + UserDO user = userDao.getUserByUserId(ai.getUserId()); + if (StringUtils.isNotBlank(user.getThirdAccountId())) { + strategy = UserAiStrategyEnum.WECHAT.updateCondition(strategy); + } + } + + ai.setStrategy(strategy); + this.saveOrUpdate(ai); + } + + public List listZsxqUsersByParams(SearchZsxqWhiteParams params) { + return userAiMapper.listZsxqUsersByParams(params, + PageParam.newPageInstance(params.getPageNum(), params.getPageSize())); + } + + public Long countZsxqUserByParams(SearchZsxqWhiteParams params) { + return userAiMapper.countZsxqUsersByParams(params); + } + + public void batchUpdateState(List ids, Integer code) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.in(UserAiDO::getId, ids).set(UserAiDO::getState, code); + userAiMapper.update(null, updateWrapper); + } + + /** + * 更新用户的星球状态 + * @param userId 用户id + * @param code 状态码 + */ + public void updateUserStarState(Long userId, Integer code) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(UserAiDO::getUserId, userId).set(UserAiDO::getState, code); + userAiMapper.update(null, updateWrapper); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserAiHistoryDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserAiHistoryDao.java new file mode 100644 index 000000000..4d67421ef --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserAiHistoryDao.java @@ -0,0 +1,16 @@ +package com.github.paicoding.forum.service.user.repository.dao; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.service.user.repository.entity.UserAiHistoryDO; +import com.github.paicoding.forum.service.user.repository.mapper.UserAiHistoryMapper; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; + +@Repository +public class UserAiHistoryDao extends ServiceImpl { + + @Resource + private UserAiHistoryMapper userAiHistoryMapper; +} + diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserDao.java new file mode 100644 index 000000000..8bad97c7f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserDao.java @@ -0,0 +1,125 @@ +package com.github.paicoding.forum.service.user.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.YesOrNoEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.repository.mapper.UserInfoMapper; +import com.github.paicoding.forum.service.user.repository.mapper.UserMapper; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.List; + +/** + * UserDao + * + * @author YiHui + * @date 2022/9/2 + */ +@Repository +public class UserDao extends ServiceImpl { + + @Resource + private UserMapper userMapper; + + public List scanUserId(Long userId, Integer size) { + return userMapper.getUserIdsOrderByIdAsc(userId, size == null ? PageParam.DEFAULT_PAGE_SIZE : size); + } + + /** + * 三方账号登录方式 + * + * @param accountId + * @return + */ + public UserDO getByThirdAccountId(String accountId) { + return userMapper.getByThirdAccountId(accountId); + } + + /** + * 根据用户名来查询 + * + * @param userName + * @return + */ + public List getByUserNameLike(String userName) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.select(UserInfoDO::getUserId, UserInfoDO::getUserName, UserInfoDO::getPhoto, UserInfoDO::getProfile) + .and(!StringUtils.isEmpty(userName), + v -> v.like(UserInfoDO::getUserName, userName) + ) + .eq(UserInfoDO::getDeleted, YesOrNoEnum.NO.getCode()); + return baseMapper.selectList(query); + } + + public void saveUser(UserDO user) { + if (user.getId() == null) { + userMapper.insert(user); + } else { + userMapper.updateById(user); + } + } + + public UserInfoDO getByUserId(Long userId) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(UserInfoDO::getUserId, userId) + .eq(UserInfoDO::getDeleted, YesOrNoEnum.NO.getCode()); + return baseMapper.selectOne(query); + } + + public List getByUserIds(Collection userIds) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.in(UserInfoDO::getUserId, userIds) + .eq(UserInfoDO::getDeleted, YesOrNoEnum.NO.getCode()); + return baseMapper.selectList(query); + } + + public Long getUserCount() { + return lambdaQuery() + .eq(UserInfoDO::getDeleted, YesOrNoEnum.NO.getCode()) + .count(); + } + + public void updateUserInfo(UserInfoDO user) { + UserInfoDO record = getByUserId(user.getUserId()); + if (record.equals(user)) { + return; + } + if (StringUtils.isEmpty(user.getPhoto())) { + user.setPhoto(null); + } + if (StringUtils.isEmpty(user.getUserName())) { + user.setUserName(null); + } + if (StringUtils.isEmpty(user.getEmail())) { + user.setEmail(null); + } + if (StringUtils.isBlank(user.getPayCode())) { + user.setPayCode(null); + } + user.setId(record.getId()); + updateById(user); + } + + public UserDO getUserByUserName(String userName) { + LambdaQueryWrapper queryUser = Wrappers.lambdaQuery(); + queryUser.eq(UserDO::getUserName, userName) + .eq(UserDO::getDeleted, YesOrNoEnum.NO.getCode()) + .last("limit 1"); + return userMapper.selectOne(queryUser); + } + + public UserDO getUserByUserId(Long userId) { + return userMapper.selectById(userId); + } + + public void updateUser(UserDO userDO) { + userMapper.updateById(userDO); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserFootDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserFootDao.java new file mode 100644 index 000000000..f619e10a3 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserFootDao.java @@ -0,0 +1,101 @@ +package com.github.paicoding.forum.service.user.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.PraiseStatEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.ArticleFootCountDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserFootStatisticDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.repository.mapper.UserFootMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Repository +public class UserFootDao extends ServiceImpl { + public UserFootDO getByDocumentAndUserId(Long documentId, Integer type, Long userId) { + LambdaQueryWrapper query = Wrappers.lambdaQuery(); + query.eq(UserFootDO::getDocumentId, documentId) + .eq(UserFootDO::getDocumentType, type) + .eq(UserFootDO::getUserId, userId); + return baseMapper.selectOne(query); + } + + public List listDocumentPraisedUsers(Long documentId, Integer type, int size) { + return baseMapper.listSimpleUserInfosByArticleId(documentId, type, size); + } + + /** + * 查询用户收藏的文章列表 + * + * @param userId + * @param pageParam + * @return + */ + public List listCollectedArticlesByUserId(Long userId, PageParam pageParam) { + return baseMapper.listCollectedArticlesByUserId(userId, pageParam); + } + + + /** + * 查询用户阅读的文章列表 + * + * @param userId + * @param pageParam + * @return + */ + public List listReadArticleByUserId(Long userId, PageParam pageParam) { + return baseMapper.listReadArticleByUserId(userId, pageParam); + } + + /** + * 查询文章计数信息 + * + * @param articleId + * @return + */ + public ArticleFootCountDTO countArticleByArticleId(Long articleId) { + return baseMapper.countArticleByArticleId(articleId); + } + + /** + * 查询作者的文章统计 + * + * @param author + * @return + */ + public ArticleFootCountDTO countArticleByUserId(Long author) { + // 统计收藏、点赞数 + ArticleFootCountDTO count = baseMapper.countArticleByUserId(author); + Optional.ofNullable(count).ifPresent(s -> s.setReadCount(baseMapper.countArticleReadsByUserId(author))); + return count; + } + + /** + * 查询评论的点赞数 + * + * @param commentId + * @return + */ + public Long countCommentPraise(Long commentId) { + return lambdaQuery() + .eq(UserFootDO::getDocumentId, commentId) + .eq(UserFootDO::getDocumentType, DocumentTypeEnum.COMMENT.getCode()) + .eq(UserFootDO::getPraiseStat, PraiseStatEnum.PRAISE.getCode()) + .count(); + } + + public UserFootStatisticDTO getFootCount() { + return baseMapper.getFootCount(); + + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserRelationDao.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserRelationDao.java new file mode 100644 index 000000000..cbfb23bac --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/dao/UserRelationDao.java @@ -0,0 +1,104 @@ +package com.github.paicoding.forum.service.user.repository.dao; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.paicoding.forum.api.model.enums.FollowStateEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import com.github.paicoding.forum.service.user.repository.mapper.UserRelationMapper; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +/** + * 用户相关DB操作 + * + * @author louzai + * @date 2022-07-18 + */ +@Repository +public class UserRelationDao extends ServiceImpl { + + /** + * 查询用户的关注列表 + * + * @param followUserId + * @param pageParam + * @return + */ + public List listUserFollows(Long followUserId, PageParam pageParam) { + return baseMapper.queryUserFollowList(followUserId, pageParam); + } + + /** + * 查询用户的粉丝列表,即关注userId的用户 + * + * @param userId + * @param pageParam + * @return + */ + public List listUserFans(Long userId, PageParam pageParam) { + return baseMapper.queryUserFansList(userId, pageParam); + } + + /** + * 查询followUserId与给定的用户列表的关联关旭 + * + * @param followUserId 粉丝用户id + * @param targetUserId 关注者用户id列表 + * @return + */ + public List listUserRelations(Long followUserId, Collection targetUserId) { + return lambdaQuery().eq(UserRelationDO::getFollowUserId, followUserId) + .in(UserRelationDO::getUserId, targetUserId).list(); + } + + public Long queryUserFollowCount(Long userId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda() + .eq(UserRelationDO::getFollowUserId, userId) + .eq(UserRelationDO::getFollowState, FollowStateEnum.FOLLOW.getCode()); + return baseMapper.selectCount(queryWrapper); + } + + public Long queryUserFansCount(Long userId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda() + .eq(UserRelationDO::getUserId, userId) + .eq(UserRelationDO::getFollowState, FollowStateEnum.FOLLOW.getCode()); + return baseMapper.selectCount(queryWrapper); + } + + /** + * 获取关注信息 + * + * @param userId 登录用户 + * @param followUserId 关注的用户 + * @return + */ + public UserRelationDO getUserRelationByUserId(Long userId, Long followUserId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda() + .eq(UserRelationDO::getUserId, userId) + .eq(UserRelationDO::getFollowUserId, followUserId) + .eq(UserRelationDO::getFollowState, FollowStateEnum.FOLLOW.getCode()); + return baseMapper.selectOne(queryWrapper); + } + + /** + * 获取关注记录 + * + * @param userId 登录用户 + * @param followUserId 关注的用户 + * @return + */ + public UserRelationDO getUserRelationRecord(Long userId, Long followUserId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda() + .eq(UserRelationDO::getUserId, userId) + .eq(UserRelationDO::getFollowUserId, followUserId); + return baseMapper.selectOne(queryWrapper); + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/IpInfo.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/IpInfo.java new file mode 100644 index 000000000..f6360bb8d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/IpInfo.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.service.user.repository.entity; + +import lombok.Data; + +import java.io.Serializable; + +/** + * ip信息 + * + * @author YiHui + * @date 2022-12-29 + */ +@Data +public class IpInfo implements Serializable { + private static final long serialVersionUID = -4612222921661930429L; + + private String firstIp; + + private String firstRegion; + + private String latestIp; + + private String latestRegion; +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserAiDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserAiDO.java new file mode 100644 index 000000000..e5afe97cb --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserAiDO.java @@ -0,0 +1,81 @@ +package com.github.paicoding.forum.service.user.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.util.Date; + +/** + * ai用户表 + * + * @ClassName: UserAiDO + * @Author: ygl + * @Date: 2023/6/25 21:38 + * @Version: 1.0 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@TableName("user_ai") +public class UserAiDO extends BaseDO { + + /** + * 用户id + */ + private Long userId; + + /** + * 知识星球编号 + */ + private String starNumber; + + /** + * 星球来源 1=java进阶之路 2=技术派 + */ + private Integer starType; + + /** + * 当前用户绑定的邀请者 + */ + private Long inviterUserId; + + /** + * 邀请码 + */ + private String inviteCode; + + /** + * 当前用户邀请的人数 + */ + private Integer inviteNum; + + /** + * 二进制使用姿势
+ * 第0位: = 1 表示已绑定微信公众号
+ * 第1位: = 1 表示绑定了邀请用户
+ * 第2位: = 1 表示绑定了java星球
+ * 第3位: = 1 表示绑定了技术派星球 + */ + private Integer strategy; + + /** + * 0 审核中 1 试用中 2 审核通过 3 审核拒绝 4 已过期 + * + * @see UserAIStatEnum#getCode() + */ + private Integer state; + + /** + * 是否删除 + */ + private Integer deleted; + + /** + * 知识星球过期时间 + */ + private Date starExpireTime; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserAiHistoryDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserAiHistoryDO.java new file mode 100644 index 000000000..4423d4b1c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserAiHistoryDO.java @@ -0,0 +1,43 @@ +package com.github.paicoding.forum.service.user.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI 历史消息表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("user_ai_history") +public class UserAiHistoryDO extends BaseDO { + + /** + * 用户id + */ + private Long userId; + + /** + * 问题 + */ + private String question; + + /** + * 答案 + */ + private String answer; + + /** + * AI 类型 + * + * @see AISourceEnum#getCode() + */ + private Integer aiType; + + /** + * 会话id + */ + private String chatId; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserDO.java new file mode 100644 index 000000000..5222f63ef --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserDO.java @@ -0,0 +1,46 @@ +package com.github.paicoding.forum.service.user.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户登录表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("user") +public class UserDO extends BaseDO { + + private static final long serialVersionUID = 1L; + + /** + * 第三方用户ID + */ + private String thirdAccountId; + + /** + * 登录方式: 0-微信登录,1-账号密码登录 + */ + private Integer loginType; + + /** + * 删除标记 + */ + private Integer deleted; + + /** + * 登录用户名 + */ + private String userName; + + /** + * 登录密码,密文存储 + */ + private String password; + +} diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/user/repository/entity/UserFootDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserFootDO.java similarity index 89% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/user/repository/entity/UserFootDO.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserFootDO.java index 463f726f9..be9b8afd7 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/user/repository/entity/UserFootDO.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserFootDO.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.user.repository.entity; +package com.github.paicoding.forum.service.user.repository.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.github.liueyueyi.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.entity.BaseDO; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserInfoDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserInfoDO.java new file mode 100644 index 000000000..795fbf0ec --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserInfoDO.java @@ -0,0 +1,92 @@ +package com.github.paicoding.forum.service.user.repository.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.github.paicoding.forum.api.model.entity.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户个人信息表 + * + * @author louzai + * @date 2022-07-18 + */ +@Data +@EqualsAndHashCode(callSuper = true) +// autoResultMap 必须存在,否则ip对象无法正确获取 +@TableName(value = "user_info", autoResultMap = true) +public class UserInfoDO extends BaseDO { + + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String userName; + + /** + * 用户图像 + */ + private String photo; + + /** + * 职位 + */ + private String position; + + /** + * 公司 + */ + private String company; + + /** + * 个人简介 + */ + private String profile; + + /** + * 扩展字段 + */ + private String extend; + + /** + * 删除标记 + */ + private Integer deleted; + + /** + * 0 普通用户 + * 1 超级管理员 + */ + private Integer userRole; + + /** + * ip信息 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private IpInfo ip; + + /** + * 用户的邮箱 + */ + private String email; + + /** + * 收款码信息 + */ + private String payCode; + + public IpInfo getIp() { + if (ip == null) { + ip = new IpInfo(); + } + return ip; + } +} diff --git a/forum-service/src/main/java/com/github/liuyueyi/forum/service/user/repository/entity/UserRelationDO.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserRelationDO.java similarity index 76% rename from forum-service/src/main/java/com/github/liuyueyi/forum/service/user/repository/entity/UserRelationDO.java rename to paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserRelationDO.java index 8d7a8e2f2..2dfc52efb 100644 --- a/forum-service/src/main/java/com/github/liuyueyi/forum/service/user/repository/entity/UserRelationDO.java +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/entity/UserRelationDO.java @@ -1,7 +1,7 @@ -package com.github.liuyueyi.forum.service.user.repository.entity; +package com.github.paicoding.forum.service.user.repository.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.github.liueyueyi.forum.api.model.entity.BaseDO; +import com.github.paicoding.forum.api.model.entity.BaseDO; import lombok.Data; import lombok.EqualsAndHashCode; @@ -19,12 +19,12 @@ public class UserRelationDO extends BaseDO { private static final long serialVersionUID = 1L; /** - * 用户ID + * 主用户ID */ private Long userId; /** - * 关注用户ID + * 粉丝用户ID */ private Long followUserId; diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserAiHistoryMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserAiHistoryMapper.java new file mode 100644 index 000000000..e6dc63373 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserAiHistoryMapper.java @@ -0,0 +1,8 @@ +package com.github.paicoding.forum.service.user.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.user.repository.entity.UserAiHistoryDO; + +public interface UserAiHistoryMapper extends BaseMapper { + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserAiMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserAiMapper.java new file mode 100644 index 000000000..75b12a350 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserAiMapper.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.service.user.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.ZsxqUserInfoDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.params.SearchZsxqWhiteParams; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * ai用户登录mapper接口 + * + * @author ygl + * @date 2022-07-18 + */ +public interface UserAiMapper extends BaseMapper { + + Long countZsxqUsersByParams(@Param("searchParams") SearchZsxqWhiteParams params); + + List listZsxqUsersByParams(@Param("searchParams") SearchZsxqWhiteParams params, + @Param("pageParam") PageParam newPageInstance); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserFootMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserFootMapper.java new file mode 100644 index 000000000..b0f96f713 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserFootMapper.java @@ -0,0 +1,78 @@ +package com.github.paicoding.forum.service.user.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.ArticleFootCountDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserFootStatisticDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 用户足迹mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface UserFootMapper extends BaseMapper { + + /** + * 查询文章计数信息 + * + * @param articleId + * @return + */ + ArticleFootCountDTO countArticleByArticleId(@Param("articleId") Long articleId); + + /** + * 查询作者的文章统计 + * + * @param author + * @return + */ + ArticleFootCountDTO countArticleByUserId(@Param("userId") Long author); + + /** + * 查询作者的所有文章阅读计数 + * + * @param author + * @return + */ + Integer countArticleReadsByUserId(@Param("userId") Long author); + + /** + * 查询用户收藏的文章列表 + * + * @param userId + * @param pageParam + * @return + */ + List listCollectedArticlesByUserId(@Param("userId") Long userId, @Param("pageParam") PageParam pageParam); + + + /** + * 查询用户阅读的文章列表 + * + * @param userId + * @param pageParam + * @return + */ + List listReadArticleByUserId(@Param("userId") Long userId, @Param("pageParam") PageParam pageParam); + + /** + * 查询文章的点赞列表 + * + * @param documentId + * @param type + * @param size + * @return + */ + List listSimpleUserInfosByArticleId(@Param("documentId") Long documentId, + @Param("type") Integer type, + @Param("size") int size); + + + UserFootStatisticDTO getFootCount(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserInfoMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserInfoMapper.java new file mode 100644 index 000000000..e7684ed1b --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserInfoMapper.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.service.user.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; + +/** + * 用户个人信息mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface UserInfoMapper extends BaseMapper { +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserMapper.java new file mode 100644 index 000000000..f8642ef0f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserMapper.java @@ -0,0 +1,36 @@ +package com.github.paicoding.forum.service.user.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 用户登录mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface UserMapper extends BaseMapper { + /** + * 根据三方唯一id进行查询 + * + * @param accountId + * @return + */ + @Select("select * from user where third_account_id = #{account_id} limit 1") + UserDO getByThirdAccountId(@Param("account_id") String accountId); + + + /** + * 遍历用户id + * + * @param offsetUserId + * @param size + * @return + */ + @Select("select id from user where id > #{offsetUserId} order by id asc limit #{size}") + List getUserIdsOrderByIdAsc(@Param("offsetUserId") Long offsetUserId, @Param("size") Long size); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserRelationMapper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserRelationMapper.java new file mode 100644 index 000000000..578edcd60 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/mapper/UserRelationMapper.java @@ -0,0 +1,34 @@ +package com.github.paicoding.forum.service.user.repository.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 用户关系mapper接口 + * + * @author louzai + * @date 2022-07-18 + */ +public interface UserRelationMapper extends BaseMapper { + + /** + * 我关注的用户 + * @param followUserId + * @param pageParam + * @return + */ + List queryUserFollowList(@Param("followUserId") Long followUserId, @Param("pageParam") PageParam pageParam); + + /** + * 关注我的粉丝 + * @param userId + * @param pageParam + * @return + */ + List queryUserFansList(@Param("userId") Long userId, @Param("pageParam") PageParam pageParam); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/params/SearchZsxqWhiteParams.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/params/SearchZsxqWhiteParams.java new file mode 100644 index 000000000..e3a4819f9 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/repository/params/SearchZsxqWhiteParams.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.service.user.repository.params; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SearchZsxqWhiteParams extends PageParam { + + /** + * 审核状态 + */ + private Integer status; + + /** + * 星球编号 + */ + private String starNumber; + + /** + * 登录用户名 + */ + private String name; + + /** + * 用户编号 + */ + private String userCode; + +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/AuthorWhiteListService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/AuthorWhiteListService.java new file mode 100644 index 000000000..561882a87 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/AuthorWhiteListService.java @@ -0,0 +1,43 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; + +import java.util.List; + +/** + * @author YiHui + * @date 2023/4/9 + */ +public interface AuthorWhiteListService { + + /** + * 判断作者是否再文章发布的白名单中; + * 这个白名单主要是用于控制作者发文章之后是否需要进行审核 + * + * @param authorId + * @return + */ + boolean authorInArticleWhiteList(Long authorId); + + /** + * 获取所有的白名单用户 + * + * @return + */ + List queryAllArticleWhiteListAuthors(); + + /** + * 将用户添加到白名单中 + * + * @param userId + */ + void addAuthor2ArticleWhitList(Long userId); + + /** + * 从白名单中移除用户 + * + * @param userId + */ + void removeAuthorFromArticleWhiteList(Long userId); + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/LoginService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/LoginService.java new file mode 100644 index 000000000..255826221 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/LoginService.java @@ -0,0 +1,62 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.api.model.vo.user.UserZsxqLoginReq; + +/** + * @author YiHui + * @date 2022/8/15 + */ +public interface LoginService { + String SESSION_KEY = "f-session"; + String USER_DEVICE_KEY = "f-device"; + + + /** + * 适用于微信公众号登录场景下,自动注册一个用户 + * + * @param uuid 微信唯一标识 + * @return userId 用户主键 + */ + Long autoRegisterWxUserInfo(String uuid); + + /** + * 登出 + * + * @param session 用户会话 + */ + void logout(String session); + + /** + * 给微信公众号的用户生成一个用于登录的会话 + * + * @param userId 用户主键id + * @return + */ + String loginByWx(Long userId); + + /** + * 用户名密码方式登录 + * + * @param username 用户名 + * @param password 密码 + * @return + */ + String loginByUserPwd(String username, String password); + + /** + * 注册登录,并绑定对应的星球、邀请码 + * + * @param loginReq 登录信息 + * @return + */ + String registerByUserPwd(UserPwdLoginReq loginReq); + + + /** + * 知识星球登录or账号信息绑定 + * @param req + * @return + */ + String loginByZsxq(UserZsxqLoginReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/RegisterService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/RegisterService.java new file mode 100644 index 000000000..7382a015f --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/RegisterService.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; + +/** + * 用户注册服务 + * + * @author YiHui + * @date 2023/6/26 + */ +public interface RegisterService { + + /** + * 注册系统用户 + * + * @param loginUser + * @param nickUser + * @param avatar + * @return + */ + Long registerSystemUser(String loginUser, String nickUser, String avatar); + + /** + * 通过用户名/密码进行注册 + * + * @param loginReq + * @return + */ + Long registerByUserNameAndPassword(UserPwdLoginReq loginReq); + + /** + * 通过微信公众号进行注册 + * + * @param thirdAccount + * @return + */ + Long registerByWechat(String thirdAccount); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserAiService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserAiService.java new file mode 100644 index 000000000..4837d9676 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserAiService.java @@ -0,0 +1,32 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; + +public interface UserAiService { + /** + * 保存聊天历史记录 + * + * @param source + * @param user + * @param item + */ + void pushChatItem(AISourceEnum source, Long user, ChatItemVo item); + + /** + * 获取用户的最大聊天次数 + * + * @param userId + * @return + */ + int getMaxChatCnt(Long userId); + + /** + * 重建用户绑定的星球编号 + * + * @param loginReq + */ + + void initOrUpdateAiInfo(UserPwdLoginReq loginReq); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserFootService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserFootService.java new file mode 100644 index 000000000..8c5593809 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserFootService.java @@ -0,0 +1,103 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.OperateTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserFootStatisticDTO; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; + +import java.util.List; + +/** + * 用户足迹Service接口 + * + * @author louzai + * @date 2022-07-20 + */ +public interface UserFootService { + /** + * 保存或更新状态信息 + * + * @param documentType 文档类型:博文 + 评论 + * @param documentId 文档id + * @param authorId 作者 + * @param userId 操作人 + * @param operateTypeEnum 操作类型:点赞,评论,收藏等 + * @return + */ + UserFootDO saveOrUpdateUserFoot(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum); + + /** + * 文章/评论点赞、取消点赞、收藏、取消收藏 + * + * @param documentType 文档类型:博文 + 评论 + * @param documentId 文档id + * @param authorId 作者 + * @param userId 操作人 + * @param operateTypeEnum 操作类型:点赞,评论,收藏等 + */ + void favorArticleComment(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum); + + + /** + * 保存评论足迹 + * 1. 用户文章记录上,设置为已评论 + * 2. 若改评论为回复别人的评论,则针对父评论设置为已评论 + * + * @param comment 保存评论入参 + * @param articleAuthor 文章作者 + * @param parentCommentAuthor 父评论作者 + */ + void saveCommentFoot(CommentDO comment, Long articleAuthor, Long parentCommentAuthor); + + /** + * 删除评论足迹 + * + * @param comment 保存评论入参 + * @param articleAuthor 文章作者 + * @param parentCommentAuthor 父评论作者 + */ + void removeCommentFoot(CommentDO comment, Long articleAuthor, Long parentCommentAuthor); + + + /** + * 查询已读文章列表 + * + * @param userId + * @param pageParam + * @return + */ + List queryUserReadArticleList(Long userId, PageParam pageParam); + + /** + * 查询收藏文章列表 + * + * @param userId + * @param pageParam + * @return + */ + List queryUserCollectionArticleList(Long userId, PageParam pageParam); + + /** + * 查询文章的点赞用户信息 + * + * @param articleId + * @return + */ + List queryArticlePraisedUsers(Long articleId); + + + /** + * 查询用户记录,用于判断是否点过赞、是否评论、是否收藏过 + * + * @param documentId + * @param type + * @param userId + * @return + */ + UserFootDO queryUserFoot(Long documentId, Integer type, Long userId); + + UserFootStatisticDTO getFootCount(); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserRelationService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserRelationService.java new file mode 100644 index 000000000..fdf0e0f19 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserRelationService.java @@ -0,0 +1,62 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.user.UserRelationReq; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; + +import java.util.List; +import java.util.Set; + +/** + * 用户关系Service接口 + * + * @author louzai + * @date 2022-07-20 + */ +public interface UserRelationService { + + /** + * 我关注的用户 + * + * @param userId + * @param pageParam + * @return + */ + PageListVo getUserFollowList(Long userId, PageParam pageParam); + + + /** + * 关注我的粉丝 + * + * @param userId + * @param pageParam + * @return + */ + PageListVo getUserFansList(Long userId, PageParam pageParam); + + /** + * 更新当前登录用户与列表中的用户的关注关系 + * + * @param followList + * @param loginUserId + */ + void updateUserFollowRelationId(PageListVo followList, Long loginUserId); + + /** + * 根据登录用户从给定用户列表中,找出已关注的用户id + * + * @param userIds + * @param loginUserId + * @return + */ + Set getFollowedUserId(List userIds, Long loginUserId); + + /** + * 保存用户关系: 关注or取消关注 + * + * @param req + * @throws Exception + */ + void saveUserRelation(UserRelationReq req); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserService.java new file mode 100644 index 000000000..254132495 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserService.java @@ -0,0 +1,133 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.vo.user.UserInfoSaveReq; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.api.model.vo.user.UserZsxqLoginReq; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; + +import java.util.Collection; +import java.util.List; + +/** + * 用户Service接口 + * + * @author louzai + * @date 2022-07-20 + */ +public interface UserService { + /** + * 判断微信用户是否注册过 + * + * @param wxuuid + * @return + */ + UserDO getWxUser(String wxuuid); + + /** + * 根据用户名模糊搜索用户 + * + * @param userName 用户名 + * @return + */ + List searchUser(String userName); + + /** + * 保存用户详情 + * + * @param req + */ + void saveUserInfo(UserInfoSaveReq req); + + /** + * 获取登录的用户信息,并更行丢对应的ip信息 + * + * @param session 用户会话 + * @param clientIp 用户最新的登录ip + * @return 返回用户基本信息 + */ + BaseUserInfoDTO getAndUpdateUserIpInfoBySessionId(String session, String clientIp); + + /** + * 查询极简的用户信息 + * + * @param userId + * @return + */ + SimpleUserInfoDTO querySimpleUserInfo(Long userId); + + /** + * 查询用户基本信息 + * todo: 可以做缓存优化 + * + * @param userId + * @return + */ + BaseUserInfoDTO queryBasicUserInfo(Long userId); + + + /** + * 批量查询用户基本信息 + * + * @param userIds + * @return + */ + List batchQuerySimpleUserInfo(Collection userIds); + + /** + * 批量查询用户基本信息 + * + * @param userIds + * @return + */ + List batchQueryBasicUserInfo(Collection userIds); + + /** + * 查询用户主页信息 + * + * @param userId + * @return + * @throws Exception + */ + UserStatisticInfoDTO queryUserInfoWithStatistic(Long userId); + + /** + * 用户计数 + * + * @return + */ + Long getUserCount(); + + /** + * 走人工的录入星球号,绑定用户信息流程 + */ + void bindUserInfo(UserPwdLoginReq loginReq); + + + /** + * 根据登录用户名,查询用户信息 + * + * @param uname + * @return + */ + BaseUserInfoDTO queryUserByLoginName(String uname); + + + /** + * 知识星球API回调 -> 自动绑定用户的星球信息 + * + * @param loginReq + */ + void bindUserInfo(UserZsxqLoginReq loginReq); + + + UserDO getUserDO(Long userId); + + UserInfoDO getUserInfo(Long userId); + + UserAiDO getUserAiDO(Long userId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserTransferService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserTransferService.java new file mode 100644 index 000000000..c448cdb44 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/UserTransferService.java @@ -0,0 +1,12 @@ +package com.github.paicoding.forum.service.user.service; + +/** + * @author YiHui + * @date 2025/9/29 + */ +public interface UserTransferService { + + boolean transferUser(String uname, String pwd); + + boolean transferUser(String starNumber); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/ZsxqWhiteListService.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/ZsxqWhiteListService.java new file mode 100644 index 000000000..cd4c6e068 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/ZsxqWhiteListService.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.service.user.service; + +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.user.SearchZsxqUserReq; +import com.github.paicoding.forum.api.model.vo.user.ZsxqUserPostReq; +import com.github.paicoding.forum.api.model.vo.user.dto.ZsxqUserInfoDTO; + +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +public interface ZsxqWhiteListService { + PageVo getList(SearchZsxqUserReq req); + + void operate(Long id, UserAIStatEnum operate); + + void update(ZsxqUserPostReq req); + + void batchOperate(List ids, UserAIStatEnum operate); + + void reset(Integer authorId); +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/ai/UserAiServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/ai/UserAiServiceImpl.java new file mode 100644 index 000000000..cc1ea67bb --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/ai/UserAiServiceImpl.java @@ -0,0 +1,122 @@ +package com.github.paicoding.forum.service.user.service.ai; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAiStrategyEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatItemVo; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.service.chatai.bot.AiBots; +import com.github.paicoding.forum.service.user.converter.UserAiConverter; +import com.github.paicoding.forum.service.user.repository.dao.UserAiDao; +import com.github.paicoding.forum.service.user.repository.dao.UserAiHistoryDao; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserAiHistoryDO; +import com.github.paicoding.forum.service.user.service.UserAiService; +import com.github.paicoding.forum.service.user.service.conf.AiConfig; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.Objects; + +@Service +public class UserAiServiceImpl implements UserAiService { + @Resource + private UserAiHistoryDao userAiHistoryDao; + + @Resource + private UserAiDao userAiDao; + + @Resource + private AiConfig aiConfig; + + @Resource + private AiBots aiBots; + + @Override + public void pushChatItem(AISourceEnum source, Long user, ChatItemVo item) { + UserAiHistoryDO userAiHistoryDO = new UserAiHistoryDO(); + userAiHistoryDO.setAiType(source.getCode()); + userAiHistoryDO.setUserId(user); + userAiHistoryDO.setQuestion(item.getQuestion()); + userAiHistoryDO.setAnswer(item.getAnswer()); + userAiHistoryDO.setChatId(ReqInfoContext.getReqInfo().getChatId()); + userAiHistoryDao.save(userAiHistoryDO); + } + + /** + * 获取用户的最大使用次数 + * + * @param userId + * @return + */ + public int getMaxChatCnt(Long userId) { + // 对于系统AI机器人,不进行次数限制 + if (aiBots.aiBots(userId)) { + return Integer.MAX_VALUE; + } + + UserAiDO ai = userAiDao.getOrInitAiInfo(userId); + int strategy = ai.getStrategy(); + int cnt = 0; + + // 星球用户 +100 + if (UserAiStrategyEnum.STAR_JAVA_GUIDE.match(strategy) || UserAiStrategyEnum.STAR_TECH_PAI.match(strategy)) { + if (Objects.equals(ai.getState(), UserAIStatEnum.FORMAL.getCode())) { + // 审核通过 + cnt += aiConfig.getMaxNum().getStar(); + } else if (Objects.equals(ai.getState(), UserAIStatEnum.TRYING.getCode())) { + // 试用中 + cnt += aiConfig.getMaxNum().getStarTry(); + } + } else { + // 有星球走星球,无星球再走公众号 + // 微信公众号登录用户 +5次 + if (UserAiStrategyEnum.WECHAT.match(strategy)) { + cnt += aiConfig.getMaxNum().getWechat(); + } + } + + // 推荐机制,如果绑定了邀请码,则总次数 + 10% + if (UserAiStrategyEnum.INVITE_USER.match(strategy)) { + cnt = (int) (cnt + cnt * aiConfig.getMaxNum().getInvited()); + } + + // 根据推荐的人数,来进行增加 + if (ai.getInviteNum() > 0) { + cnt = cnt + ai.getInviteNum() * ((int) (cnt * aiConfig.getMaxNum().getInviteNum())); + } + + if (cnt == 0) { + // 对于登录用户,给五次使用机会 + cnt = aiConfig.getMaxNum().getBasic(); + } + return cnt; + } + + @Override + public void initOrUpdateAiInfo(UserPwdLoginReq loginReq) { + // 之前已经检查过编号是否已经被绑定过了,那我们直接进行绑定 + Long userId = loginReq.getUserId(); + UserAiDO userAiDO = userAiDao.getByUserId(userId); + if (userAiDO == null) { + // 初始化新的ai信息 + userAiDO = UserAiConverter.initAi(userId, loginReq.getStarNumber()); + } else if (StringUtils.isBlank(loginReq.getStarNumber()) && StringUtils.isBlank(loginReq.getInvitationCode())) { + // 没有传递星球和邀请码时,直接返回,不用更新ai信息 + return; + } else if (StringUtils.isNotBlank(loginReq.getStarNumber())) { + // 之前有绑定信息,检查到与之前的不一致,则执行更新星球编号流程 + if (!Objects.equals(loginReq.getStarNumber(), userAiDO.getStarNumber())) { + userAiDO.setStarNumber(loginReq.getStarNumber()); + } + // 并设置为试用 + userAiDO.setState(UserAIStatEnum.TRYING.getCode()); + if (ReqInfoContext.getReqInfo().getUser() != null) { + ReqInfoContext.getReqInfo().getUser().setStarStatus(UserAIStatEnum.TRYING); + } + } + userAiDao.saveOrUpdateAiBindInfo(userAiDO, loginReq.getInvitationCode()); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/conf/AiConfig.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/conf/AiConfig.java new file mode 100644 index 000000000..ece26702c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/conf/AiConfig.java @@ -0,0 +1,69 @@ +package com.github.paicoding.forum.service.user.service.conf; + +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * @author YiHui + * @date 2023/6/29 + */ +@Data +@Component +@ConfigurationProperties(prefix = "ai") +public class AiConfig { + @Data + public static class AiMaxChatNumStrategyConf { + /** + * 默认的策略 + */ + private Integer basic; + /** + * 公众号用户 AI交互次数 + */ + private Integer wechat; + /** + * 星球用户 AI交互次数 + */ + private Integer star; + + // 星球最大编号 + private Integer starNumber; + /** + * 星球试用用户 AI交互次数 + */ + private Integer starTry; + /** + * 绑定了邀请者,再当前次数基础上新增的策略, 默认增加 10% + */ + private Float invited; + + /** + * 根据邀请的人数,增加的聊天次数策略,默认增加 20% + */ + private Float inviteNum; + + /** + * 多轮对话上下文的条数,默认最多给10条 + */ + private Integer historyContextCnt; + + /** + * 过期天数 + */ + private Integer expireDays; + } + + /** + * 用户的最大使用次数配置项 + */ + private AiMaxChatNumStrategyConf maxNum; + + /** + * 当前支持的AI模型 + */ + private List source; +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/StarNumberHelper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/StarNumberHelper.java new file mode 100644 index 000000000..188b5440d --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/StarNumberHelper.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.service.user.service.help; + +import com.github.paicoding.forum.service.user.service.conf.AiConfig; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 密码加密器,后续接入SpringSecurity之后,可以使用 PasswordEncoder 进行替换 + * + * @author YiHui + * @date 2022/12/5 + */ +@Component +public class StarNumberHelper { + @Resource + private AiConfig aiConfig; + + public Boolean checkStarNumber(String starNumber) { + // 判断编号是否在 0 - maxStarNumber 之间 + return Integer.parseInt(starNumber) >= 0 && Integer.parseInt(starNumber) <= aiConfig.getMaxNum().getStarNumber(); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserPwdEncoder.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserPwdEncoder.java new file mode 100644 index 000000000..6c18a357a --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserPwdEncoder.java @@ -0,0 +1,46 @@ +package com.github.paicoding.forum.service.user.service.help; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.DigestUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * 密码加密器,后续接入SpringSecurity之后,可以使用 PasswordEncoder 进行替换 + * + * @author YiHui + * @date 2022/12/5 + */ +@Component +public class UserPwdEncoder { + /** + * 密码加盐,更推荐的做法是每个用户都使用独立的盐,提高安全性 + */ + @Value("${security.salt}") + private String salt; + + @Value("${security.salt-index}") + private Integer saltIndex; + + public boolean match(String plainPwd, String encPwd) { + return Objects.equals(encPwd(plainPwd), encPwd); + } + + /** + * 明文密码处理 + * + * @param plainPwd + * @return + */ + public String encPwd(String plainPwd) { + if (plainPwd.length() > saltIndex) { + plainPwd = plainPwd.substring(0, saltIndex) + salt + plainPwd.substring(saltIndex); + } else { + plainPwd = plainPwd + salt; + } + return DigestUtils.md5DigestAsHex(plainPwd.getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserRandomGenHelper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserRandomGenHelper.java new file mode 100644 index 000000000..0293d63f7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserRandomGenHelper.java @@ -0,0 +1,90 @@ +package com.github.paicoding.forum.service.user.service.help; + +import java.util.Random; + +/** + * 用户名生成器 + * + * @author YiHui + * @date 2022/9/27 + */ +public class UserRandomGenHelper { + public static final String[] name_decorate = new String[]{ + "迷你的", "鲜艳的", "飞快的", "真实的", "清新的", "幸福的", "可耐的", "快乐的", "冷静的", "醉熏的", "潇洒的", "糊涂的", "积极的", "冷酷的", "深情的", "粗暴的", + "温柔的", "可爱的", "愉快的", "义气的", "认真的", "威武的", "帅气的", "传统的", "潇洒的", "漂亮的", "自然的", "专一的", "听话的", "昏睡的", "狂野的", "等待的", "搞怪的", + "幽默的", "魁梧的", "活泼的", "开心的", "高兴的", "超帅的", "留胡子的", "坦率的", "直率的", "轻松的", "痴情的", "完美的", "精明的", "无聊的", "有魅力的", "丰富的", "繁荣的", + "饱满的", "炙热的", "暴躁的", "碧蓝的", "俊逸的", "英勇的", "健忘的", "故意的", "无心的", "土豪的", "朴实的", "兴奋的", "幸福的", "淡定的", "不安的", "阔达的", "孤独的", + "独特的", "疯狂的", "时尚的", "落后的", "风趣的", "忧伤的", "大胆的", "爱笑的", "矮小的", "健康的", "合适的", "玩命的", "沉默的", "斯文的", "香蕉", "苹果", "鲤鱼", "鳗鱼", + "任性的", "细心的", "粗心的", "大意的", "甜甜的", "酷酷的", "健壮的", "英俊的", "霸气的", "阳光的", "默默的", "大力的", "孝顺的", "忧虑的", "着急的", "紧张的", "善良的", + "凶狠的", "害怕的", "重要的", "危机的", "欢喜的", "欣慰的", "满意的", "跳跃的", "诚心的", "称心的", "如意的", "怡然的", "娇气的", "无奈的", "无语的", "激动的", "愤怒的", + "美好的", "感动的", "激情的", "激昂的", "震动的", "虚拟的", "超级的", "寒冷的", "精明的", "明理的", "犹豫的", "忧郁的", "寂寞的", "奋斗的", "勤奋的", "现代的", "过时的", + "稳重的", "热情的", "含蓄的", "开放的", "无辜的", "多情的", "纯真的", "拉长的", "热心的", "从容的", "体贴的", "风中的", "曾经的", "追寻的", "儒雅的", "优雅的", "开朗的", + "外向的", "内向的", "清爽的", "文艺的", "长情的", "平常的", "单身的", "伶俐的", "高大的", "懦弱的", "柔弱的", "爱笑的", "乐观的", "耍酷的", "酷炫的", "神勇的", "年轻的", + "唠叨的", "瘦瘦的", "无情的", "包容的", "顺心的", "畅快的", "舒适的", "靓丽的", "负责的", "背后的", "简单的", "谦让的", "彩色的", "缥缈的", "欢呼的", "生动的", "复杂的", + "慈祥的", "仁爱的", "魔幻的", "虚幻的", "淡然的", "受伤的", "雪白的", "高高的", "糟糕的", "顺利的", "闪闪的", "羞涩的", "缓慢的", "迅速的", "优秀的", "聪明的", "含糊的", + "俏皮的", "淡淡的", "坚强的", "平淡的", "欣喜的", "能干的", "灵巧的", "友好的", "机智的", "机灵的", "正直的", "谨慎的", "俭朴的", "殷勤的", "虚心的", "辛勤的", "自觉的", + "无私的", "无限的", "踏实的", "老实的", "现实的", "可靠的", "务实的", "拼搏的", "个性的", "粗犷的", "活力的", "成就的", "勤劳的", "单纯的", "落寞的", "朴素的", "悲凉的", + "忧心的", "洁净的", "清秀的", "自由的", "小巧的", "单薄的", "贪玩的", "刻苦的", "干净的", "壮观的", "和谐的", "文静的", "调皮的", "害羞的", "安详的", "自信的", "端庄的", + "坚定的", "美满的", "舒心的", "温暖的", "专注的", "勤恳的", "美丽的", "腼腆的", "优美的", "甜美的", "甜蜜的", "整齐的", "动人的", "典雅的", "尊敬的", "舒服的", "妩媚的", + "秀丽的", "喜悦的", "甜美的", "彪壮的", "强健的", "大方的", "俊秀的", "聪慧的", "迷人的", "陶醉的", "悦耳的", "动听的", "明亮的", "结实的", "魁梧的", "标致的", "清脆的", + "敏感的", "光亮的", "大气的", "老迟到的", "知性的", "冷傲的", "呆萌的", "野性的", "隐形的", "笑点低的", "微笑的", "笨笨的", "难过的", "沉静的", "火星上的", "失眠的", + "安静的", "纯情的", "要减肥的", "迷路的", "烂漫的", "哭泣的", "贤惠的", "苗条的", "温婉的", "发嗲的", "会撒娇的", "贪玩的", "执着的", "眯眯眼的", "花痴的", "想人陪的", + "眼睛大的", "高贵的", "傲娇的", "心灵美的", "爱撒娇的", "细腻的", "天真的", "怕黑的", "感性的", "飘逸的", "怕孤独的", "忐忑的", "高挑的", "傻傻的", "冷艳的", "爱听歌的", + "还单身的", "怕孤单的", "懵懂的" + }; + public static final String[] name_body = new String[]{ + "嚓茶", "皮皮虾", "皮卡丘", "马里奥", "小霸王", "凉面", "便当", "毛豆", "花生", "可乐", "灯泡", "哈密瓜", "野狼", "背包", "眼神", "缘分", "雪碧", "人生", "牛排", + "蚂蚁", "飞鸟", "灰狼", "斑马", "汉堡", "悟空", "巨人", "绿茶", "自行车", "保温杯", "大碗", "墨镜", "魔镜", "煎饼", "月饼", "月亮", "星星", "芝麻", "啤酒", "玫瑰", + "大叔", "小伙", "哈密瓜,数据线", "太阳", "树叶", "芹菜", "黄蜂", "蜜粉", "蜜蜂", "信封", "西装", "外套", "裙子", "大象", "猫咪", "母鸡", "路灯", "蓝天", "白云", + "星月", "彩虹", "微笑", "摩托", "板栗", "高山", "大地", "大树", "电灯胆", "砖头", "楼房", "水池", "鸡翅", "蜻蜓", "红牛", "咖啡", "机器猫", "枕头", "大船", "诺言", + "钢笔", "刺猬", "天空", "飞机", "大炮", "冬天", "洋葱", "春天", "夏天", "秋天", "冬日", "航空", "毛衣", "豌豆", "黑米", "玉米", "眼睛", "老鼠", "白羊", "帅哥", "美女", + "季节", "鲜花", "服饰", "裙子", "白开水", "秀发", "大山", "火车", "汽车", "歌曲", "舞蹈", "老师", "导师", "方盒", "大米", "麦片", "水杯", "水壶", "手套", "鞋子", "自行车", + "鼠标", "手机", "电脑", "书本", "奇迹", "身影", "香烟", "夕阳", "台灯", "宝贝", "未来", "皮带", "钥匙", "心锁", "故事", "花瓣", "滑板", "画笔", "画板", "学姐", "店员", + "电源", "饼干", "宝马", "过客", "大白", "时光", "石头", "钻石", "河马", "犀牛", "西牛", "绿草", "抽屉", "柜子", "往事", "寒风", "路人", "橘子", "耳机", "鸵鸟", "朋友", + "苗条", "铅笔", "钢笔", "硬币", "热狗", "大侠", "御姐", "萝莉", "毛巾", "期待", "盼望", "白昼", "黑夜", "大门", "黑裤", "钢铁侠", "哑铃", "板凳", "枫叶", "荷花", "乌龟", + "仙人掌", "衬衫", "大神", "草丛", "早晨", "心情", "茉莉", "流沙", "蜗牛", "战斗机", "冥王星", "猎豹", "棒球", "篮球", "乐曲", "电话", "网络", "世界", "中心", "鱼", "鸡", "狗", + "老虎", "鸭子", "雨", "羽毛", "翅膀", "外套", "火", "丝袜", "书包", "钢笔", "冷风", "八宝粥", "烤鸡", "大雁", "音响", "招牌", "胡萝卜", "冰棍", "帽子", "菠萝", "蛋挞", "香水", + "泥猴桃", "吐司", "溪流", "黄豆", "樱桃", "小鸽子", "小蝴蝶", "爆米花", "花卷", "小鸭子", "小海豚", "日记本", "小熊猫", "小懒猪", "小懒虫", "荔枝", "镜子", "曲奇", "金针菇", + "小松鼠", "小虾米", "酒窝", "紫菜", "金鱼", "柚子", "果汁", "百褶裙", "项链", "帆布鞋", "火龙果", "奇异果", "煎蛋", "唇彩", "小土豆", "高跟鞋", "戒指", "雪糕", "睫毛", "铃铛", + "手链", "香氛", "红酒", "月光", "酸奶", "银耳汤", "咖啡豆", "小蜜蜂", "小蚂蚁", "蜡烛", "棉花糖", "向日葵", "水蜜桃", "小蝴蝶", "小刺猬", "小丸子", "指甲油", "康乃馨", "糖豆", + "薯片", "口红", "超短裙", "乌冬面", "冰淇淋", "棒棒糖", "长颈鹿", "豆芽", "发箍", "发卡", "发夹", "发带", "铃铛", "小馒头", "小笼包", "小甜瓜", "冬瓜", "香菇", "小兔子", + "含羞草", "短靴", "睫毛膏", "小蘑菇", "跳跳糖", "小白菜", "草莓", "柠檬", "月饼", "百合", "纸鹤", "小天鹅", "云朵", "芒果", "面包", "海燕", "小猫咪", "龙猫", "唇膏", "鞋垫", + "羊", "黑猫", "白猫", "万宝路", "金毛", "山水", "音响", "纸飞机", "烧鹅" + }; + + private static final Random RANDOM = new Random(); + + private static final int AVATAR_NUM = 92; + + private static final String AVATAR_TEMPLATE = "https://cdn.tobebetterjavaer.com/paicoding/avatar/%04d.png"; + + /** + * 昵称自动生成器 + * + * @return + */ + public static String genNickName() { + int decorateIndex = RANDOM.nextInt(name_decorate.length); + int bodyIndex = RANDOM.nextInt(name_body.length); + return name_decorate[decorateIndex] + name_body[bodyIndex]; + } + + /** + * 头像自动选择 + * + * @return + */ + public static String genAvatar() { + return String.format(AVATAR_TEMPLATE, RANDOM.nextInt(AVATAR_NUM) + 1); + } + + /** + * 生成用户邀请码 + * 规则:前缀 + 年月日转十六进制 + * + * @return + */ + public static String genInviteCode(Long prefix) { + return String.format("%03x%04x", prefix, System.currentTimeMillis() / 1000 / 60 / 60 / 24).toUpperCase(); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserSessionHelper.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserSessionHelper.java new file mode 100644 index 000000000..da6a125e7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/help/UserSessionHelper.java @@ -0,0 +1,105 @@ +package com.github.paicoding.forum.service.user.service.help; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.core.mdc.SelfTraceIdGenerator; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.MapUtils; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.service.user.service.LoginService; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; + +import java.util.Date; +import java.util.HashMap; +import java.util.Objects; + +/** + * 使用jwt来存储用户token,则不需要后端来存储session了 + * + * @author YiHui + * @date 2022/12/5 + */ +@Slf4j +@Component +public class UserSessionHelper { + @Component + @Data + @ConfigurationProperties("paicoding.jwt") + public static class JwtProperties { + /** + * 签发人 + */ + private String issuer; + /** + * 密钥 + */ + private String secret; + /** + * 有效期,毫秒时间戳 + */ + private Long expire; + } + + private final JwtProperties jwtProperties; + + private Algorithm algorithm; + private JWTVerifier verifier; + + public UserSessionHelper(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + algorithm = Algorithm.HMAC256(jwtProperties.getSecret()); + verifier = JWT.require(algorithm).withIssuer(jwtProperties.getIssuer()).build(); + } + + public String genSession(Long userId) { + // 1.生成jwt格式的会话,内部持有有效期,用户信息 + String session = JsonUtil.toStr(MapUtils.create("s", SelfTraceIdGenerator.generate(), "u", userId)); + String token = JWT.create().withIssuer(jwtProperties.getIssuer()).withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getExpire())) + .withPayload(session) + .sign(algorithm); + + // 2.使用jwt生成的token时,后端可以不存储这个session信息, 完全依赖jwt的信息 + // 但是需要考虑到用户登出,需要主动失效这个token,而jwt本身无状态,所以再这里的redis做一个简单的token -> userId的缓存,用于双重判定 + RedisClient.setStrWithExpire(token, String.valueOf(userId), jwtProperties.getExpire() / 1000); + return token; + } + + public void removeSession(String session) { + RedisClient.del(session); + } + + /** + * 根据会话获取用户信息 + * + * @param session + * @return + */ + public Long getUserIdBySession(String session) { + // jwt的校验方式,如果token非法或者过期,则直接验签失败 + try { + DecodedJWT decodedJWT = verifier.verify(session); + String pay = new String(Base64Utils.decodeFromString(decodedJWT.getPayload())); + // jwt验证通过,获取对应的userId + String userId = String.valueOf(JsonUtil.toObj(pay, HashMap.class).get("u")); + + // 从redis中获取userId,解决用户登出,后台失效jwt token的问题 + String user = RedisClient.getStr(session); + if (user == null || !Objects.equals(userId, user)) { + return null; + } + return Long.valueOf(user); + } catch (Exception e) { + log.debug("jwt token校验失败! token: {}, msg: {}", session, e.getMessage()); + // 如果jwt过期,自动删除用户的cookie;主要是为了解决jwt的有效期与cookie有效期不一致的场景 + SessionUtil.delCookies(LoginService.SESSION_KEY); + return null; + } + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/relation/UserRelationServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/relation/UserRelationServiceImpl.java new file mode 100644 index 000000000..76c37b587 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/relation/UserRelationServiceImpl.java @@ -0,0 +1,124 @@ +package com.github.paicoding.forum.service.user.service.relation; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.FollowStateEnum; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.api.model.vo.user.UserRelationReq; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; +import com.github.paicoding.forum.core.util.MapUtils; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.user.converter.UserConverter; +import com.github.paicoding.forum.service.user.repository.dao.UserRelationDao; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import com.github.paicoding.forum.service.user.service.UserRelationService; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import javax.annotation.Resource; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户关系Service + * + * @author louzai + * @date 2022-07-20 + */ +@Service +public class UserRelationServiceImpl implements UserRelationService { + @Resource + private UserRelationDao userRelationDao; + + + /** + * 查询用户的关注列表 + * + * @param userId + * @param pageParam + * @return + */ + @Override + public PageListVo getUserFollowList(Long userId, PageParam pageParam) { + List userRelationList = userRelationDao.listUserFollows(userId, pageParam); + return PageListVo.newVo(userRelationList, pageParam.getPageSize()); + } + + @Override + public PageListVo getUserFansList(Long userId, PageParam pageParam) { + List userRelationList = userRelationDao.listUserFans(userId, pageParam); + return PageListVo.newVo(userRelationList, pageParam.getPageSize()); + } + + @Override + public void updateUserFollowRelationId(PageListVo followList, Long loginUserId) { + if (loginUserId == null) { + followList.getList().forEach(r -> { + r.setRelationId(null); + r.setFollowed(false); + }); + return; + } + + // 判断登录用户与给定的用户列表的关注关系 + Set userIds = followList.getList().stream().map(FollowUserInfoDTO::getUserId).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(userIds)) { + return; + } + + List relationList = userRelationDao.listUserRelations(loginUserId, userIds); + Map relationMap = MapUtils.toMap(relationList, UserRelationDO::getUserId, r -> r); + followList.getList().forEach(follow -> { + UserRelationDO relation = relationMap.get(follow.getUserId()); + if (relation == null) { + follow.setRelationId(null); + follow.setFollowed(false); + } else if (Objects.equals(relation.getFollowState(), FollowStateEnum.FOLLOW.getCode())) { + follow.setRelationId(relation.getId()); + follow.setFollowed(true); + } else { + follow.setRelationId(relation.getId()); + follow.setFollowed(false); + } + }); + } + + /** + * 根据登录用户从给定用户列表中,找出已关注的用户id + * + * @param userIds 主用户列表 + * @param fansUserId 粉丝用户id + * @return 返回fansUserId已经关注过的用户id列表 + */ + @Override + public Set getFollowedUserId(List userIds, Long fansUserId) { + if (CollectionUtils.isEmpty(userIds)) { + return Collections.emptySet(); + } + + List relationList = userRelationDao.listUserRelations(fansUserId, userIds); + Map relationMap = MapUtils.toMap(relationList, UserRelationDO::getUserId, r -> r); + return relationMap.values().stream().filter(s -> s.getFollowState().equals(FollowStateEnum.FOLLOW.getCode())).map(UserRelationDO::getUserId).collect(Collectors.toSet()); + } + + @Override + public void saveUserRelation(UserRelationReq req) { + // 查询是否存在 + UserRelationDO userRelationDO = userRelationDao.getUserRelationRecord(req.getUserId(), ReqInfoContext.getReqInfo().getUserId()); + if (userRelationDO == null) { + userRelationDO = UserConverter.toDO(req); + userRelationDao.save(userRelationDO); + // 发布关注事件 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, NotifyTypeEnum.FOLLOW, userRelationDO)); + return; + } + + // 将是否关注状态重置 + userRelationDO.setFollowState(req.getFollowed() ? FollowStateEnum.FOLLOW.getCode() : FollowStateEnum.CANCEL_FOLLOW.getCode()); + userRelationDao.updateById(userRelationDO); + // 发布关注、取消关注事件 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, req.getFollowed() ? NotifyTypeEnum.FOLLOW : NotifyTypeEnum.CANCEL_FOLLOW, userRelationDO)); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/LoginServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/LoginServiceImpl.java new file mode 100644 index 000000000..d3f384fd8 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/LoginServiceImpl.java @@ -0,0 +1,281 @@ +package com.github.paicoding.forum.service.user.service.user; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.user.LoginTypeEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.api.model.vo.user.UserSaveReq; +import com.github.paicoding.forum.api.model.vo.user.UserZsxqLoginReq; +import com.github.paicoding.forum.core.util.StarNumberUtil; +import com.github.paicoding.forum.service.image.service.ImageService; +import com.github.paicoding.forum.service.user.repository.dao.UserAiDao; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.service.user.service.RegisterService; +import com.github.paicoding.forum.service.user.service.UserAiService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.service.user.service.help.StarNumberHelper; +import com.github.paicoding.forum.service.user.service.help.UserPwdEncoder; +import com.github.paicoding.forum.service.user.service.help.UserSessionHelper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.Objects; + +/** + * 基于验证码、用户名密码的登录方式 + * + * @author YiHui + * @date 2022/8/15 + */ +@Service +@Slf4j +public class LoginServiceImpl implements LoginService { + @Autowired + private UserDao userDao; + + @Autowired + private UserAiDao userAiDao; + + @Autowired + private UserSessionHelper userSessionHelper; + @Autowired + private StarNumberHelper starNumberHelper; + + @Autowired + private RegisterService registerService; + + @Autowired + private UserPwdEncoder userPwdEncoder; + + @Autowired + private UserService userService; + + @Autowired + private UserAiService userAiService; + @Autowired + private ImageService imageService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long autoRegisterWxUserInfo(String uuid) { + UserSaveReq req = new UserSaveReq().setLoginType(0).setThirdAccountId(uuid); + Long userId = registerOrGetUserInfo(req); + ReqInfoContext.getReqInfo().setUserId(userId); + return userId; + } + + /** + * 没有注册时,先注册一个用户;若已经有,则登录 + * + * @param req + */ + private Long registerOrGetUserInfo(UserSaveReq req) { + UserDO user = userDao.getByThirdAccountId(req.getThirdAccountId()); + if (user == null) { + return registerService.registerByWechat(req.getThirdAccountId()); + } + return user.getId(); + } + + @Override + public void logout(String session) { + userSessionHelper.removeSession(session); + } + + /** + * 给微信公众号的用户生成一个用于登录的会话 + * + * @param userId 用户id + * @return + */ + @Override + public String loginByWx(Long userId) { + return userSessionHelper.genSession(userId); + } + + /** + * 用户名密码方式登录 + * + * @param username 用户名 + * @param password 密码 + * @return + */ + @Override + public String loginByUserPwd(String username, String password) { + UserDO user = userDao.getUserByUserName(username); + if (user == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, "userName=" + username); + } + + if (!userPwdEncoder.match(password, user.getPassword())) { + throw ExceptionUtil.of(StatusEnum.USER_PWD_ERROR); + } + + Long userId = user.getId(); + // 1. 为了兼容历史数据,对于首次登录成功的用户,初始化ai信息 + userAiService.initOrUpdateAiInfo(new UserPwdLoginReq().setUserId(userId).setUsername(username).setPassword(password)); + + // 登录成功,返回对应的session + ReqInfoContext.getReqInfo().setUserId(userId); + return userSessionHelper.genSession(userId); + } + + + /** + * 用户名密码方式登录,若用户不存在,则进行注册 + * + * @param loginReq 登录信息 + * @return + */ + @Override + public String registerByUserPwd(UserPwdLoginReq loginReq) { + // 1. 前置校验 + registerPreCheck(loginReq); + + // 2. 判断当前用户是否登录,若已经登录,则直接走绑定流程 + Long userId = ReqInfoContext.getReqInfo().getUserId(); + loginReq.setUserId(userId); + if (userId != null) { + // 如果星球编号已经绑定,且已经登录,应该跳转到个人中心页面 + // 2.1 如果用户已经登录,则走绑定用户信息流程 + userService.bindUserInfo(loginReq); + return ReqInfoContext.getReqInfo().getSession(); + } + + + // 3. 尝试使用用户名进行登录,若成功,则依然走绑定流程 + UserDO user = userDao.getUserByUserName(loginReq.getUsername()); + if (user != null) { + if (!userPwdEncoder.match(loginReq.getPassword(), user.getPassword())) { + // 3.1 用户名已经存在 + throw ExceptionUtil.of(StatusEnum.USER_LOGIN_NAME_REPEAT, loginReq.getUsername()); + } + + // 3.2 用户存在,尝试走绑定流程 + userId = user.getId(); + loginReq.setUserId(userId); + userAiService.initOrUpdateAiInfo(loginReq); + } else { + //4. 走用户注册流程 + userId = registerService.registerByUserNameAndPassword(loginReq); + } + ReqInfoContext.getReqInfo().setUserId(userId); + return userSessionHelper.genSession(userId); + } + + + /** + * 注册前置校验 + * + * @param loginReq + */ + private void registerPreCheck(UserPwdLoginReq loginReq) { + if (StringUtils.isBlank(loginReq.getUsername()) || StringUtils.isBlank(loginReq.getPassword())) { + throw ExceptionUtil.of(StatusEnum.USER_PWD_ERROR); + } + + String starNumber = loginReq.getStarNumber(); + // 若传了星球信息,首先进行校验 + if (StringUtils.isNotBlank(starNumber)) { + // 格式化星球编号 + starNumber = StarNumberUtil.formatStarNumber(starNumber); + loginReq.setStarNumber(starNumber); + + if (Boolean.FALSE.equals(starNumberHelper.checkStarNumber(starNumber))) { + // 星球编号校验不通过,直接抛异常 + throw ExceptionUtil.of(StatusEnum.USER_STAR_NOT_EXISTS, "星球编号=" + starNumber); + } + } else { + throw ExceptionUtil.of(StatusEnum.USER_STAR_EMPTY); + } + + UserAiDO userAi = userAiDao.getByStarNumber(loginReq.getStarNumber()); + if (userAi != null) { + Long currentUserId = ReqInfoContext.getReqInfo().getUserId(); + + if (currentUserId != null && userAi.getUserId().equals(currentUserId)) { + return; + } + + throw ExceptionUtil.of(StatusEnum.USER_STAR_REPEAT, loginReq.getStarNumber()); + } + + String invitationCode = loginReq.getInvitationCode(); + if (StringUtils.isNotBlank(invitationCode) && userAiDao.getByInviteCode(invitationCode) == null) { + // 填写的邀请码不对, 找不到对应的用户 + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "非法的邀请码【" + starNumber + "】"); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String loginByZsxq(UserZsxqLoginReq req) { + Long userId; + // 1 若是全新的用户,则自动进行注册 + UserAiDO aiDO = userAiDao.getByStarNumber(req.getStarNumber()); + if (aiDO == null) { + UserPwdLoginReq loginReq = new UserPwdLoginReq() + // 星球编号 + .setStarNumber(req.getStarNumber()) + // 使用知识星球的starNumber作为登录用户名,前缀为zsq_ + .setUsername("zsxq_" + req.getStarNumber()) + // 系统随机生成密码 + .setPassword("zsxqp_" + req.getStarNumber()) + // 使用知识星球的用户作为当前用户 + .setDisplayName(StringUtils.isBlank(req.getDisplayName()) ? req.getUsername() : req.getDisplayName()) + // 用户头像 + .setAvatar(imageService.saveImg(req.getAvatar())) + // 过期时间 + .setStarExpireTime(req.getExpireTime()) + // 设置登录类型为知识星球登录 + .setLoginType(LoginTypeEnum.ZSXQ.getType()) + // 设置thirdAccountId为星球用户ID + .setThirdAccountId(String.valueOf(req.getStarUserId())); + userId = registerService.registerByUserNameAndPassword(loginReq); + + if (System.currentTimeMillis() < req.getExpireTime()) { + // 对于知识星球授权登录的情况,无需审核,直接成功 + userAiDao.updateUserStarState(userId, UserAIStatEnum.FORMAL.getCode()); + } + } else { + userId = aiDO.getUserId(); + // 2 若是已经存在的用户,则尝试更新对应的星球账号信息 + boolean needToUpdate = false; + + // 1. 更新过期时间(如果有变化) + if (aiDO.getStarExpireTime() == null || + Math.abs(req.getExpireTime() - aiDO.getStarExpireTime().getTime()) > 1000) { // 允许1秒误差 + aiDO.setStarExpireTime(new Date(req.getExpireTime())); + needToUpdate = true; + } + + // 2. 根据当前时间判断应该设置的状态 + long currentTime = System.currentTimeMillis(); + int expectedState = currentTime < req.getExpireTime() ? + UserAIStatEnum.FORMAL.getCode() : + UserAIStatEnum.EXPIRED.getCode(); // 假设有过期状态 + + if (!Objects.equals(aiDO.getState(), expectedState)) { + aiDO.setState(expectedState); + needToUpdate = true; + } + + if (needToUpdate) { + aiDO.setUpdateTime(new Date()); + userAiDao.updateById(aiDO); + } + } + + ReqInfoContext.getReqInfo().setUserId(userId); + return userSessionHelper.genSession(userId); + } +} \ No newline at end of file diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/RegisterServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/RegisterServiceImpl.java new file mode 100644 index 000000000..74bca08ac --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/RegisterServiceImpl.java @@ -0,0 +1,145 @@ +package com.github.paicoding.forum.service.user.service.user; + +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.enums.user.LoginTypeEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.notify.NotifyMsgEvent; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.core.util.RandUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.util.StarNumberUtil; +import com.github.paicoding.forum.core.util.TransactionUtil; +import com.github.paicoding.forum.service.user.converter.UserAiConverter; +import com.github.paicoding.forum.service.user.repository.dao.UserAiDao; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.service.RegisterService; +import com.github.paicoding.forum.service.user.service.help.UserPwdEncoder; +import com.github.paicoding.forum.service.user.service.help.UserRandomGenHelper; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +/** + * 用户注册服务 + * + * @author YiHui + * @date 2023/6/26 + */ +@Service +public class RegisterServiceImpl implements RegisterService { + @Autowired + private UserPwdEncoder userPwdEncoder; + @Autowired + private UserDao userDao; + + @Autowired + private UserAiDao userAiDao; + + + @Override + @Transactional(rollbackFor = Exception.class) + public Long registerSystemUser(String loginUser, String nickUser, String avatar) { + UserDO dbUser = userDao.getUserByUserName(loginUser); + if (dbUser != null) { + return dbUser.getId(); + } + + // 注册系统账号 + UserDO user = new UserDO(); + user.setUserName(loginUser); + user.setThirdAccountId("system_" + RandUtil.random(16)); + user.setLoginType(LoginTypeEnum.WECHAT.getType()); + userDao.saveUser(user); + + UserInfoDO userInfo = new UserInfoDO(); + userInfo.setUserId(user.getId()); + userInfo.setUserName(nickUser); + userInfo.setPhoto(avatar); + userDao.save(userInfo); + return user.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long registerByUserNameAndPassword(UserPwdLoginReq loginReq) { + // 1. 判断用户名是否准确 + UserDO user = userDao.getUserByUserName(loginReq.getUsername()); + if (user != null) { + throw ExceptionUtil.of(StatusEnum.USER_LOGIN_NAME_REPEAT, loginReq.getUsername()); + } + + // 2. 保存用户登录信息 + user = new UserDO(); + user.setUserName(loginReq.getUsername()); + user.setPassword(userPwdEncoder.encPwd(loginReq.getPassword())); + // 使用传入的thirdAccountId,如果没有则设为空字符串 + user.setThirdAccountId(loginReq.getThirdAccountId() != null ? loginReq.getThirdAccountId() : ""); + // 根据传入的loginType设置,如果没有则默认为用户名密码登录 + user.setLoginType(loginReq.getLoginType() != null ? loginReq.getLoginType() : LoginTypeEnum.USER_PWD.getType()); + userDao.saveUser(user); + + // 3. 保存用户信息 + UserInfoDO userInfo = new UserInfoDO(); + userInfo.setUserId(user.getId()); + userInfo.setUserName(StringUtils.isNoneBlank(loginReq.getDisplayName()) ? loginReq.getDisplayName() : loginReq.getUsername()); + userInfo.setPhoto(StringUtils.isNotBlank(loginReq.getAvatar()) ? loginReq.getAvatar() : UserRandomGenHelper.genAvatar()); + userDao.save(userInfo); + + // 4. 保存ai相互信息 + UserAiDO userAiDO = UserAiConverter.initAi(user.getId(), loginReq.getStarNumber()); + if (loginReq.getStarExpireTime() != null) { + userAiDO.setStarExpireTime(new Date(loginReq.getStarExpireTime())); + } + userAiDao.saveOrUpdateAiBindInfo(userAiDO, loginReq.getInvitationCode()); + processAfterUserRegister(user.getId()); + return user.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long registerByWechat(String thirdAccount) { + // 用户不存在,则需要注册 + // 1. 保存用户登录信息 + UserDO user = new UserDO(); + user.setThirdAccountId(thirdAccount); + user.setLoginType(LoginTypeEnum.WECHAT.getType()); + userDao.saveUser(user); + + + // 2. 初始化用户信息,随机生成用户昵称 + 头像 + UserInfoDO userInfo = new UserInfoDO(); + userInfo.setUserId(user.getId()); + userInfo.setUserName(UserRandomGenHelper.genNickName()); + userInfo.setPhoto(UserRandomGenHelper.genAvatar()); + userDao.save(userInfo); + + // 3. 保存ai相互信息 + UserAiDO userAiDO = UserAiConverter.initAi(user.getId()); + userAiDao.saveOrUpdateAiBindInfo(userAiDO); + processAfterUserRegister(user.getId()); + return user.getId(); + } + + + /** + * 用户注册完毕之后触发的动作 + * + * @param userId + */ + private void processAfterUserRegister(Long userId) { + TransactionUtil.registryAfterCommitOrImmediatelyRun(new Runnable() { + @Override + public void run() { + // 用户注册事件 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, NotifyTypeEnum.REGISTER, userId)); + } + }); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/UserServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/UserServiceImpl.java new file mode 100644 index 000000000..38e8a8092 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/UserServiceImpl.java @@ -0,0 +1,408 @@ +package com.github.paicoding.forum.service.user.service.user; + +import com.beust.ah.A; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.article.dto.YearArticleDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.UserInfoSaveReq; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.api.model.vo.user.UserZsxqLoginReq; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.core.util.IpUtil; +import com.github.paicoding.forum.service.article.repository.dao.ArticleDao; +import com.github.paicoding.forum.service.image.service.ImageService; +import com.github.paicoding.forum.service.statistics.service.CountService; +import com.github.paicoding.forum.service.user.converter.UserAiConverter; +import com.github.paicoding.forum.service.user.converter.UserConverter; +import com.github.paicoding.forum.service.user.repository.dao.UserAiDao; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.dao.UserRelationDao; +import com.github.paicoding.forum.service.user.repository.entity.IpInfo; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.repository.entity.UserRelationDO; +import com.github.paicoding.forum.service.user.service.UserAiService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.service.user.service.conf.AiConfig; +import com.github.paicoding.forum.service.user.service.help.UserPwdEncoder; +import com.github.paicoding.forum.service.user.service.help.UserSessionHelper; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 用户Service + * + * @author louzai + * @date 2022-07-20 + */ +@Service +public class UserServiceImpl implements UserService { + + @Resource + private UserDao userDao; + + @Resource + private UserAiDao userAiDao; + + @Resource + private UserRelationDao userRelationDao; + + @Autowired + private CountService countService; + + @Autowired + private ArticleDao articleDao; + + @Autowired + private UserSessionHelper userSessionHelper; + + @Autowired + private UserPwdEncoder userPwdEncoder; + + @Autowired + private UserAiService userAiService; + + @Autowired + private ImageService imageService; + + @Resource + private AiConfig aiConfig; + + @Override + public UserDO getWxUser(String wxuuid) { + return userDao.getByThirdAccountId(wxuuid); + } + + @Override + public List searchUser(String userName) { + List users = userDao.getByUserNameLike(userName); + if (CollectionUtils.isEmpty(users)) { + return Collections.emptyList(); + } + return users.stream().map(s -> new SimpleUserInfoDTO() + .setUserId(s.getUserId()) + .setName(s.getUserName()) + .setAvatar(s.getPhoto()) + .setProfile(s.getProfile()) + ) + .collect(Collectors.toList()); + } + + @Override + public void saveUserInfo(UserInfoSaveReq req) { + UserInfoDO userInfoDO = UserConverter.toDO(req); + userDao.updateUserInfo(userInfoDO); + } + + @Override + public BaseUserInfoDTO getAndUpdateUserIpInfoBySessionId(String session, String clientIp) { + if (StringUtils.isBlank(session)) { + return null; + } + + Long userId = userSessionHelper.getUserIdBySession(session); + if (userId == null) { + return null; + } + + // 查询用户信息,并更新最后一次使用的ip + UserInfoDO user = userDao.getByUserId(userId); + if (user == null) { + // 常见于:session中记录的用户被删除了,直接移除缓存中的session,走重新登录流程 + userSessionHelper.removeSession(session); + return null; + } + + IpInfo ip = user.getIp(); + if (clientIp != null && !Objects.equals(ip.getLatestIp(), clientIp)) { + // ip不同,需要更新 + ip.setLatestIp(clientIp); + ip.setLatestRegion(IpUtil.getLocationByIp(clientIp).toRegionStr()); + + if (ip.getFirstIp() == null) { + ip.setFirstIp(clientIp); + ip.setFirstRegion(ip.getLatestRegion()); + } + userDao.updateById(user); + } + + // 查询 user_ai信息,标注用户是否为星球专属用户 + UserAiDO userAiDO = userAiDao.getByUserId(userId); + this.autoUpdateUserStarState(userAiDO); + return UserConverter.toDTO(user, userAiDO); + } + + private void autoUpdateUserStarState(UserAiDO userAiDO) { + if (userAiDO == null) { + return; + } + if (userAiDO.getStarExpireTime() == null) { + // 更新用户星球过期时间 + if (userAiDO.getState().equals(UserAIStatEnum.FORMAL.getCode())) { + // 没有失效时间的星球用户,默认设置为当前时间往后 + 360天(一年) + userAiDO.setStarExpireTime(new Date(System.currentTimeMillis() + aiConfig.getMaxNum().getExpireDays() * 24 * 60 * 60 * 1000L)); + userAiDO.setUpdateTime(new Date()); + userAiDao.updateById(userAiDO); + } + } else if (System.currentTimeMillis() >= userAiDO.getStarExpireTime().getTime()) { + // 账号已过期 + if (!userAiDO.getState().equals(UserAIStatEnum.EXPIRED.getCode())) { + userAiDO.setState(UserAIStatEnum.EXPIRED.getCode()); + userAiDO.setUpdateTime(new Date()); + userAiDao.updateById(userAiDO); + } + } + } + + @Override + public SimpleUserInfoDTO querySimpleUserInfo(Long userId) { + UserInfoDO user = userDao.getByUserId(userId); + if (user == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, "userId=" + userId); + } + return UserConverter.toSimpleInfo(user); + } + + @Override + public BaseUserInfoDTO queryBasicUserInfo(Long userId) { + UserInfoDO user = userDao.getByUserId(userId); + if (user == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, "userId=" + userId); + } + return UserConverter.toDTO(user); + } + + @Override + public List batchQuerySimpleUserInfo(Collection userIds) { + List users = userDao.getByUserIds(userIds); + if (CollectionUtils.isEmpty(users)) { + return Collections.emptyList(); + } + return users.stream().map(UserConverter::toSimpleInfo).collect(Collectors.toList()); + } + + @Override + public List batchQueryBasicUserInfo(Collection userIds) { + List users = userDao.getByUserIds(userIds); + if (CollectionUtils.isEmpty(users)) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, "userId=" + userIds); + } + return users.stream().map(UserConverter::toDTO).collect(Collectors.toList()); + } + + @Override + public UserStatisticInfoDTO queryUserInfoWithStatistic(Long userId) { + BaseUserInfoDTO userInfoDTO = queryBasicUserInfo(userId); + UserAiDO aiDO = userAiDao.getByUserId(userId); + if (aiDO != null) { + userInfoDTO.setStarNumber(aiDO.getStarNumber()); + userInfoDTO.setExpireTime(aiDO.getStarExpireTime()); + userInfoDTO.setStarStatus(UserAIStatEnum.fromCode(aiDO.getState())); + } + + UserStatisticInfoDTO userHomeDTO = countService.queryUserStatisticInfo(userId); + userHomeDTO = UserConverter.toUserHomeDTO(userHomeDTO, userInfoDTO); + + // 用户资料完整度 + int cnt = 0; + if (StringUtils.isNotBlank(userHomeDTO.getCompany())) { + ++cnt; + } + if (StringUtils.isNotBlank(userHomeDTO.getPosition())) { + ++cnt; + } + if (StringUtils.isNotBlank(userHomeDTO.getProfile())) { + ++cnt; + } + userHomeDTO.setInfoPercent(cnt * 100 / 3); + + // 是否关注 + Long followUserId = ReqInfoContext.getReqInfo().getUserId(); + if (followUserId != null) { + UserRelationDO userRelationDO = userRelationDao.getUserRelationByUserId(userId, followUserId); + userHomeDTO.setFollowed((userRelationDO == null) ? Boolean.FALSE : Boolean.TRUE); + } else { + userHomeDTO.setFollowed(Boolean.FALSE); + } + + // 加入天数 + int joinDayCount = (int) ((System.currentTimeMillis() - userHomeDTO.getCreateTime() + .getTime()) / (1000 * 3600 * 24)); + userHomeDTO.setJoinDayCount(Math.max(1, joinDayCount)); + + // 创作历程 + List yearArticleDTOS = articleDao.listYearArticleByUserId(userId); + userHomeDTO.setYearArticleList(yearArticleDTOS); + return userHomeDTO; + } + + @Override + public Long getUserCount() { + return this.userDao.getUserCount(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void bindUserInfo(UserPwdLoginReq loginReq) { + // 0. 绑定用户名 & 密码 前置校验 + UserDO user = userDao.getUserByUserName(loginReq.getUsername()); + if (user == null) { + // 用户名不存在,则标识当前登录用户可以使用这个用户名 + user = new UserDO(); + user.setId(loginReq.getUserId()); + } else if (!Objects.equals(loginReq.getUserId(), user.getId())) { + // 登录用户名已经存在了 + throw ExceptionUtil.of(StatusEnum.USER_LOGIN_NAME_REPEAT, loginReq.getUsername()); + } + + // 1. 更新用户名密码 + user.setUserName(loginReq.getUsername()); + user.setPassword(userPwdEncoder.encPwd(loginReq.getPassword())); + userDao.saveUser(user); + + // 2. 更新ai相关信息 + userAiService.initOrUpdateAiInfo(loginReq); + } + + @Override + public BaseUserInfoDTO queryUserByLoginName(String uname) { + UserDO user = userDao.getUserByUserName(uname); + if (user == null) { + return null; + } + + return queryBasicUserInfo(user.getId()); + } + + + @Override + @Transactional(rollbackFor = Exception.class) + public void bindUserInfo(UserZsxqLoginReq loginReq) { + long userId = ReqInfoContext.getReqInfo().getUserId(); + + // 总是尝试更新用户信息(昵称和头像),因为知识星球的信息应该是最新的 + boolean shouldUpdateUserInfo = loginReq.getUpdateUserInfo() != null ? loginReq.getUpdateUserInfo() : true; + + if (shouldUpdateUserInfo) { + UserInfoDO user = new UserInfoDO(); + user.setUserId(userId); + boolean hasUpdates = false; + + // 更新用户昵称:优先使用displayName,如果没有则使用username + if (StringUtils.isNotBlank(loginReq.getDisplayName())) { + user.setUserName(loginReq.getDisplayName()); + hasUpdates = true; + } else if (StringUtils.isNotBlank(loginReq.getUsername())) { + user.setUserName(loginReq.getUsername()); + hasUpdates = true; + } + + // 更新头像(如果有的话) + if (StringUtils.isNotBlank(loginReq.getAvatar())) { + user.setPhoto(imageService.saveImg(loginReq.getAvatar())); + hasUpdates = true; + } + + // 只有当有实际更新内容时才调用更新方法 + if (hasUpdates) { + userDao.updateUserInfo(user); + } + } + + // 更新用户绑定的星球账号信息 + UserAiDO aiDO = userAiDao.getByUserId(userId); + if (aiDO == null) { + // 插入当前用户的星球信息 + checkAndIllegalOtherStarNumber(userId, loginReq.getStarNumber()); + aiDO = UserAiConverter.initAi(userId); + aiDO.setStarNumber(loginReq.getStarNumber()); + this.autoUpdateStarInfo(aiDO, loginReq); + } else if (!aiDO.getStarNumber().equals(loginReq.getStarNumber())) { + // 存在,且星球号不同,以接口为准 + checkAndIllegalOtherStarNumber(userId, loginReq.getStarNumber()); + this.autoUpdateStarInfo(aiDO, loginReq); + } else { + // 星球号相同,但需要检查过期时间和状态是否需要更新 + Date currentExpireTime = aiDO.getStarExpireTime(); + Date newExpireTime = new Date(loginReq.getExpireTime()); + + // 如果过期时间不同,或者当前状态是过期但新的过期时间未过期,则需要更新 + boolean needUpdate = false; + if (currentExpireTime == null || !currentExpireTime.equals(newExpireTime)) { + needUpdate = true; + } + // 检查状态:如果用户当前是过期状态,但新的过期时间未过期,则需要更新状态为正常 + if (aiDO.getState().equals(UserAIStatEnum.EXPIRED.getCode()) && + System.currentTimeMillis() < loginReq.getExpireTime()) { + needUpdate = true; + } + + if (needUpdate) { + this.autoUpdateStarInfo(aiDO, loginReq); + } + } + } + + + /** + * 如果星球号被别的账号占用了,则下单之前的账号 + * + * @param userId 当前用户 + * @param starNumber 星球号 + */ + private void checkAndIllegalOtherStarNumber(Long userId, String starNumber) { + UserAiDO conflict = userAiDao.getByStarNumber(starNumber); + if (conflict != null && !conflict.getUserId().equals(userId)) { + // 知识星球回调的用户星球号,被其他账号绑定了的场景:去掉其他账号的星球号信息,以接口绑定的为准 + conflict.setStarNumber(""); + conflict.setState(UserAIStatEnum.NOT_PASS.getCode()); + userAiDao.updateById(conflict); + } + } + + /** + * 更新用户星球号信息 + * + * @param aiDO + * @param loginReq + */ + private void autoUpdateStarInfo(UserAiDO aiDO, UserZsxqLoginReq loginReq) { + aiDO.setStarNumber(loginReq.getStarNumber()); + aiDO.setStarExpireTime(new Date(loginReq.getExpireTime())); + if (System.currentTimeMillis() < loginReq.getExpireTime()) { + aiDO.setState(UserAIStatEnum.FORMAL.getCode()); + } else { + aiDO.setState(UserAIStatEnum.EXPIRED.getCode()); + } + userAiDao.saveOrUpdateAiBindInfo(aiDO); + } + + + public UserDO getUserDO(Long userId) { + return userDao.getUserByUserId(userId); + } + + public UserInfoDO getUserInfo(Long userId) { + return userDao.getByUserId(userId); + } + + public UserAiDO getUserAiDO(Long userId) { + return userAiDao.getByUserId(userId); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/UserTransferServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/UserTransferServiceImpl.java new file mode 100644 index 000000000..3b43c14fb --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/UserTransferServiceImpl.java @@ -0,0 +1,113 @@ +package com.github.paicoding.forum.service.user.service.user; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.service.user.repository.dao.UserAiDao; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.service.user.service.UserTransferService; +import com.github.paicoding.forum.service.user.service.help.UserPwdEncoder; +import com.github.paicoding.forum.service.user.service.help.UserSessionHelper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletResponse; +import java.util.Objects; + +/** + * 基于验证码、用户名密码的登录方式 + * + * @author YiHui + * @date 2022/8/15 + */ +@Service +@Slf4j +public class UserTransferServiceImpl implements UserTransferService { + @Autowired + private UserDao userDao; + + @Autowired + private UserAiDao userAiDao; + + @Autowired + private UserSessionHelper userSessionHelper; + + @Autowired + private UserPwdEncoder userPwdEncoder; + + + @Override + public boolean transferUser(String uname, String pwd) { + UserDO user = userDao.getUserByUserName(uname); + if (user == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, "userName=" + uname); + } + + if (!userPwdEncoder.match(pwd, user.getPassword())) { + throw ExceptionUtil.of(StatusEnum.USER_PWD_ERROR); + } + + // 将当前登录用户,与目标用户进行置换 + return transferUser(user); + } + + @Override + public boolean transferUser(String starNumber) { + // 根据星球号找到原始用户 + UserAiDO userAiDO = userAiDao.getByStarNumber(starNumber); + if (userAiDO == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, "starNumber=" + starNumber); + } + + // 找到需要迁移到的目标用户 + UserDO targetUser = userDao.getUserByUserId(userAiDO.getUserId()); + return transferUser(targetUser); + } + + /** + * 账号迁移 + * + * @param targetUser + */ + private boolean transferUser(UserDO targetUser) { + Long currentUserId = ReqInfoContext.getReqInfo().getUserId(); + if (Objects.equals(currentUserId, targetUser.getId())) { + // 同一个用户,无需迁移 + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "当前用户与目标用户相同,无需迁移"); + } + + UserDO loginUser = userDao.getUserByUserId(currentUserId); + if (StringUtils.isBlank(loginUser.getThirdAccountId())) { + throw ExceptionUtil.of(StatusEnum.UNEXPECT_ERROR, "非企业号登录,无需账号迁移"); + } + + String oldId = targetUser.getThirdAccountId(); + String transId = loginUser.getThirdAccountId(); + + // 将当前登录用户的微信身份信息,转移到目标用户 + targetUser.setThirdAccountId(transId); + userDao.updateUser(targetUser); + + // 将目标用户的微信身份信息,转移到当前登录用户 + loginUser.setThirdAccountId(oldId); + userDao.updateUser(loginUser); + + // 迁移成功,重置上下文信息 + ReqInfoContext.getReqInfo().setUserId(targetUser.getId()); + String session = userSessionHelper.genSession(targetUser.getId()); + HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse(); + if (response != null) { + response.addCookie(SessionUtil.newCookie(LoginService.SESSION_KEY, session)); + } + log.info("用户迁移成功,从 {} -> {}", loginUser.getId(), targetUser.getId()); + return true; + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/userfoot/UserFootServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/userfoot/UserFootServiceImpl.java new file mode 100644 index 000000000..0d2b6f02c --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/userfoot/UserFootServiceImpl.java @@ -0,0 +1,223 @@ +package com.github.paicoding.forum.service.user.service.userfoot; + +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.enums.OperateTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserFootStatisticDTO; +import com.github.paicoding.forum.core.common.CommonConstants; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.notify.help.MsgNotifyHelper; +import com.github.paicoding.forum.service.notify.service.RabbitmqService; +import com.github.paicoding.forum.service.user.repository.dao.UserFootDao; +import com.github.paicoding.forum.service.user.repository.entity.UserFootDO; +import com.github.paicoding.forum.service.user.service.UserFootService; +import com.rabbitmq.client.BuiltinExchangeType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * 用户足迹Service + * + * @author louzai + * @date 2022-07-20 + */ +@Service +public class UserFootServiceImpl implements UserFootService { + private final UserFootDao userFootDao; + + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private CommentReadService commentReadService; + + @Autowired + private RabbitmqService rabbitmqService; + + public UserFootServiceImpl(UserFootDao userFootDao) { + this.userFootDao = userFootDao; + } + + /** + * 保存或更新状态信息 + * + * @param documentType 文档类型:博文 + 评论 + * @param documentId 文档id + * @param authorId 作者 + * @param userId 操作人 + * @param operateTypeEnum 操作类型:点赞,评论,收藏等 + */ + @Override + public UserFootDO saveOrUpdateUserFoot(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum) { + // 查询是否有该足迹;有则更新,没有则插入 + UserFootDO readUserFootDO = userFootDao.getByDocumentAndUserId(documentId, documentType.getCode(), userId); + if (readUserFootDO == null) { + readUserFootDO = new UserFootDO(); + readUserFootDO.setUserId(userId); + readUserFootDO.setDocumentId(documentId); + readUserFootDO.setDocumentType(documentType.getCode()); + readUserFootDO.setDocumentUserId(authorId); + setUserFootStat(readUserFootDO, operateTypeEnum); + userFootDao.save(readUserFootDO); + } else if (setUserFootStat(readUserFootDO, operateTypeEnum)) { + readUserFootDO.setUpdateTime(new Date()); + userFootDao.updateById(readUserFootDO); + } + return readUserFootDO; + } + + /** + * 文章/评论点赞、取消点赞、收藏、取消收藏 + * + * @param documentType 文档类型:博文 + 评论 + * @param documentId 文档id + * @param authorId 作者 + * @param userId 操作人 + * @param operateTypeEnum 操作类型:点赞,评论,收藏等 + */ + @Override + public void favorArticleComment(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum) { + // fixme 这里没有做并发控制,在大并发场景下,可能出现查询出来的数据,与db中数据不一致的场景 + // fixme 解决方案:自旋等待的分布式锁 or 事务 + 悲观锁 + // fixme 考虑到这个足迹的准确性影响并不大,留待有缘人进行修正 + + // 查询是否有该足迹;有则更新,没有则插入 + UserFootDO readUserFootDO = userFootDao.getByDocumentAndUserId(documentId, documentType.getCode(), userId); + boolean dbChanged = false; + if (readUserFootDO == null) { + readUserFootDO = new UserFootDO(); + readUserFootDO.setUserId(userId); + readUserFootDO.setDocumentId(documentId); + readUserFootDO.setDocumentType(documentType.getCode()); + readUserFootDO.setDocumentUserId(authorId); + setUserFootStat(readUserFootDO, operateTypeEnum); + userFootDao.save(readUserFootDO); + dbChanged = true; + } else if (setUserFootStat(readUserFootDO, operateTypeEnum)) { + readUserFootDO.setUpdateTime(new Date()); + userFootDao.updateById(readUserFootDO); + dbChanged = true; + } + + if (!dbChanged) { + // 幂等,直接返回 + return; + } + + + // 点赞、收藏两种操作时,需要发送异步消息,用于生成消息通知、更新文章/评论的相关计数统计、更新用户的活跃积分 + NotifyTypeEnum notifyType = OperateTypeEnum.getNotifyType(operateTypeEnum); + if (notifyType == null) { + // 不需要发送通知的场景,直接返回 + return; + } + + // 点赞消息走 RabbitMQ,其它走 Java 内置消息机制 + if (notifyType.equals(NotifyTypeEnum.PRAISE) && rabbitmqService.enabled()) { + rabbitmqService.publishMsg( + CommonConstants.EXCHANGE_NAME_DIRECT, + BuiltinExchangeType.DIRECT, + CommonConstants.QUERE_KEY_PRAISE, + JsonUtil.toStr(readUserFootDO)); + } else { + MsgNotifyHelper.publish(notifyType, readUserFootDO); + } + } + + @Override + public void saveCommentFoot(CommentDO comment, Long articleAuthor, Long parentCommentAuthor) { + // 保存文章对应的评论足迹 + saveOrUpdateUserFoot(DocumentTypeEnum.ARTICLE, comment.getArticleId(), articleAuthor, comment.getUserId(), OperateTypeEnum.COMMENT); + // 如果是子评论,则找到父评论的记录,然后设置为已评 + if (comment.getParentCommentId() != null && comment.getParentCommentId() != 0) { + // 如果需要展示父评论的子评论数量,authorId 需要传父评论的 userId + saveOrUpdateUserFoot(DocumentTypeEnum.COMMENT, comment.getParentCommentId(), parentCommentAuthor, comment.getUserId(), OperateTypeEnum.COMMENT); + } + } + + @Override + public void removeCommentFoot(CommentDO comment, Long articleAuthor, Long parentCommentAuthor) { + saveOrUpdateUserFoot(DocumentTypeEnum.ARTICLE, comment.getArticleId(), articleAuthor, comment.getUserId(), OperateTypeEnum.DELETE_COMMENT); + if (comment.getParentCommentId() != null) { + // 如果需要展示父评论的子评论数量,authorId 需要传父评论的 userId + saveOrUpdateUserFoot(DocumentTypeEnum.COMMENT, comment.getParentCommentId(), parentCommentAuthor, comment.getUserId(), OperateTypeEnum.DELETE_COMMENT); + } + } + + + private boolean setUserFootStat(UserFootDO userFootDO, OperateTypeEnum operate) { + switch (operate) { + case READ: + // 设置为已读 + userFootDO.setReadStat(1); + // 需要更新时间,用于浏览记录 + return true; + case PRAISE: + case CANCEL_PRAISE: + return compareAndUpdate(userFootDO::getPraiseStat, userFootDO::setPraiseStat, operate.getDbStatCode()); + case COLLECTION: + case CANCEL_COLLECTION: + return compareAndUpdate(userFootDO::getCollectionStat, userFootDO::setCollectionStat, operate.getDbStatCode()); + case COMMENT: + case DELETE_COMMENT: + return compareAndUpdate(userFootDO::getCommentStat, userFootDO::setCommentStat, operate.getDbStatCode()); + default: + return false; + } + } + + /** + * 相同则直接返回false不用更新;不同则更新,返回true + * + * @param supplier + * @param consumer + * @param input + * @param + * @return + */ + private boolean compareAndUpdate(Supplier supplier, Consumer consumer, T input) { + if (Objects.equals(supplier.get(), input)) { + return false; + } + consumer.accept(input); + return true; + } + + @Override + public List queryUserReadArticleList(Long userId, PageParam pageParam) { + return userFootDao.listReadArticleByUserId(userId, pageParam); + } + + @Override + public List queryUserCollectionArticleList(Long userId, PageParam pageParam) { + return userFootDao.listCollectedArticlesByUserId(userId, pageParam); + } + + @Override + public List queryArticlePraisedUsers(Long articleId) { + return userFootDao.listDocumentPraisedUsers(articleId, DocumentTypeEnum.ARTICLE.getCode(), 10); + } + + @Override + public UserFootDO queryUserFoot(Long documentId, Integer type, Long userId) { + return userFootDao.getByDocumentAndUserId(documentId, type, userId); + } + + @Override + public UserFootStatisticDTO getFootCount() { + return userFootDao.getFootCount(); + } + +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/whitelist/AuthorWhiteListServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/whitelist/AuthorWhiteListServiceImpl.java new file mode 100644 index 000000000..fa1706630 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/whitelist/AuthorWhiteListServiceImpl.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.service.user.service.whitelist; + +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.cache.RedisClient; +import com.github.paicoding.forum.service.user.service.AuthorWhiteListService; +import com.github.paicoding.forum.service.user.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * @author YiHui + * @date 2023/4/9 + */ +@Service +public class AuthorWhiteListServiceImpl implements AuthorWhiteListService { + /** + * 实用 redis - set 来存储允许直接发文章的白名单 + */ + private static final String ARTICLE_WHITE_LIST = "auth_article_white_list"; + + @Autowired + private UserService userService; + + @Override + public boolean authorInArticleWhiteList(Long authorId) { + return RedisClient.sIsMember(ARTICLE_WHITE_LIST, authorId); + } + + /** + * 获取所有的白名单用户 + * + * @return + */ + @Override + public List queryAllArticleWhiteListAuthors() { + Set users = RedisClient.sGetAll(ARTICLE_WHITE_LIST, Long.class); + if (CollectionUtils.isEmpty(users)) { + return Collections.emptyList(); + } + List userInfos = userService.batchQueryBasicUserInfo(users); + return userInfos; + } + + @Override + public void addAuthor2ArticleWhitList(Long userId) { + RedisClient.sPut(ARTICLE_WHITE_LIST, userId); + } + + @Override + public void removeAuthorFromArticleWhiteList(Long userId) { + RedisClient.sDel(ARTICLE_WHITE_LIST, userId); + } +} diff --git a/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/whitelist/ZsxqWhiteListServiceImpl.java b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/whitelist/ZsxqWhiteListServiceImpl.java new file mode 100644 index 000000000..14cf4b2f7 --- /dev/null +++ b/paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/whitelist/ZsxqWhiteListServiceImpl.java @@ -0,0 +1,178 @@ +package com.github.paicoding.forum.service.user.service.whitelist; + +import cn.hutool.core.date.DateUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.github.paicoding.forum.api.model.enums.user.LoginTypeEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.SearchZsxqUserReq; +import com.github.paicoding.forum.api.model.vo.user.ZsxqUserPostReq; +import com.github.paicoding.forum.api.model.vo.user.dto.ZsxqUserInfoDTO; +import com.github.paicoding.forum.service.user.converter.UserAiConverter; +import com.github.paicoding.forum.service.user.converter.UserStructMapper; +import com.github.paicoding.forum.service.user.repository.dao.UserAiDao; +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.entity.UserAiDO; +import com.github.paicoding.forum.service.user.repository.entity.UserDO; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import com.github.paicoding.forum.service.user.repository.params.SearchZsxqWhiteParams; +import com.github.paicoding.forum.service.user.service.ZsxqWhiteListService; +import com.github.paicoding.forum.service.user.service.conf.AiConfig; +import com.github.paicoding.forum.service.user.service.help.UserPwdEncoder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 6/29/23 + */ +@Service +public class ZsxqWhiteListServiceImpl implements ZsxqWhiteListService { + @Autowired + private UserAiDao userAiDao; + @Autowired + private UserDao userDao; + + @Autowired + private UserPwdEncoder userPwdEncoder; + @Resource + private AiConfig aiConfig; + + @Override + public PageVo getList(SearchZsxqUserReq req) { + SearchZsxqWhiteParams params = UserStructMapper.INSTANCE.toSearchParams(req); + // 查询知识星球用户 + List zsxqUserInfoDTOs = userAiDao.listZsxqUsersByParams(params); + Long totalCount = userAiDao.countZsxqUserByParams(params); + return PageVo.build(zsxqUserInfoDTOs, req.getPageSize(), req.getPageNumber(), totalCount); + } + + @Override + public void operate(Long id, UserAIStatEnum operate) { + // 根据id获取用户信息 + UserAiDO userAiDO = userAiDao.getById(id); + // 为空则抛出异常 + if (userAiDO == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, id, "用户不存在"); + } + + // 更新用户状态 + userAiDO.setState(operate.getCode()); + + // 如果设置为正式用户状态,则将过期时间延长360天 + if (UserAIStatEnum.FORMAL.equals(operate) && userAiDO.getStarNumber() != null) { + userAiDO.setStarExpireTime(new Date(System.currentTimeMillis() + aiConfig.getMaxNum().getExpireDays() * 24 * 60 * 60 * 1000L)); + } + + // 审核通过的时候调整用户的策略 + userAiDao.updateById(userAiDO); + } + + @Override + // 加事务 + @Transactional(rollbackFor = Exception.class) + public void update(ZsxqUserPostReq req) { + // 根据id获取用户信息 + UserAiDO userAiDO = userAiDao.getById(req.getId()); + // 为空则抛出异常 + if (userAiDO == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, req.getId(), "用户不存在"); + } + + // 星球编号不能重复 + UserAiDO userAiDOByStarNumber = userAiDao.getByStarNumber(req.getStarNumber()); + if (userAiDOByStarNumber != null && !userAiDOByStarNumber.getId().equals(req.getId())) { + throw ExceptionUtil.of(StatusEnum.USER_STAR_REPEAT, req.getStarNumber(), "星球编号已存在"); + } + + // 用户登录名不能重复 + UserDO userDO = userDao.getUserByUserName(req.getUserCode()); + if (userDO != null && !userDO.getId().equals(userAiDO.getUserId())) { + throw ExceptionUtil.of(StatusEnum.USER_LOGIN_NAME_REPEAT, req.getUserCode(), "用户登录名已存在"); + } + + // 更新用户登录名 + userDO = new UserDO(); + userDO.setId(userAiDO.getUserId()); + userDO.setUserName(req.getUserCode()); + userDao.updateUser(userDO); + + // 更新用户昵称 + UserInfoDO userInfoDO = new UserInfoDO(); + userInfoDO.setId(userAiDO.getUserId()); + userInfoDO.setUserName(req.getName()); + userDao.updateById(userInfoDO); + + // 更新星球编号 + userAiDO.setStarNumber(req.getStarNumber()); + + // 更新星球过期时间 + if (req.getExpireTime() != null) { + userAiDO.setStarExpireTime(DateUtil.parseDate(req.getExpireTime())); + } + + if (userAiDO.getStarExpireTime() != null && userAiDO.getStarExpireTime().after(new Date())) { + userAiDO.setState(UserAIStatEnum.FORMAL.getCode()); + } + + userAiDao.updateById(userAiDO); + } + + @Override + public void batchOperate(List ids, UserAIStatEnum operate) { + // 如果设置为正式用户状态,则将过期时间延长360天 + if (UserAIStatEnum.FORMAL.equals(operate)) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.in(UserAiDO::getId, ids) + .set(UserAiDO::getState, operate.getCode()) + .setSql("star_expire_time = date_add(now(), interval " + aiConfig.getMaxNum().getExpireDays() + " day)"); + userAiDao.update(null, updateWrapper); + } else { + userAiDao.batchUpdateState(ids, operate.getCode()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void reset(Integer authorId) { + // 根据id获取用户信息 + UserAiDO userAiDO = userAiDao.getById(authorId); + // 为空则抛出异常 + if (userAiDO == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, authorId, "该星球用户不存在"); + } + + // 获取用户,看是微信还是用户名密码注册用户 + UserDO userDO = userDao.getUserByUserId(userAiDO.getUserId()); + if (userDO == null) { + throw ExceptionUtil.of(StatusEnum.USER_NOT_EXISTS, userAiDO.getUserId(), "该用户不存在"); + } + + // 不能直接删除,要初始化用户的 AI 信息 + UserAiDO initUserAiDO = UserAiConverter.initAi(userAiDO.getUserId()); + initUserAiDO.setId(userAiDO.getId()); + userAiDao.updateById(initUserAiDO); + + UserDO user = new UserDO(); + user.setId(userAiDO.getUserId()); + // 如果是微信注册用户 + if (LoginTypeEnum.WECHAT.getType() == userDO.getLoginType()) { + // 用户登录名也重置 + user.setUserName(""); + } + + // 密码重置为 + user.setPassword(userPwdEncoder.encPwd("paicoding")); + userDao.saveUser(user); + } +} diff --git a/paicoding-service/src/main/java/com/zhipu/oapi/service/v4/deserialize/MessageDeserializeFactory.java b/paicoding-service/src/main/java/com/zhipu/oapi/service/v4/deserialize/MessageDeserializeFactory.java new file mode 100644 index 000000000..8a4c4dadf --- /dev/null +++ b/paicoding-service/src/main/java/com/zhipu/oapi/service/v4/deserialize/MessageDeserializeFactory.java @@ -0,0 +1,98 @@ +package com.zhipu.oapi.service.v4.deserialize; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.zhipu.oapi.service.v4.assistant.AssistantChoice; +import com.zhipu.oapi.service.v4.assistant.AssistantCompletion; +import com.zhipu.oapi.service.v4.assistant.CompletionUsage; +import com.zhipu.oapi.service.v4.assistant.ErrorInfo; +import com.zhipu.oapi.service.v4.deserialize.assistant.AssistantChoiceDeserializer; +import com.zhipu.oapi.service.v4.deserialize.assistant.AssistantCompletionDeserializer; +import com.zhipu.oapi.service.v4.deserialize.assistant.CompletionUsageDeserializer; +import com.zhipu.oapi.service.v4.deserialize.assistant.ErrorInfoDeserializer; +import com.zhipu.oapi.service.v4.deserialize.embedding.EmbeddingDeserializer; +import com.zhipu.oapi.service.v4.deserialize.embedding.EmbeddingResultDeserializer; +import com.zhipu.oapi.service.v4.deserialize.image.ImageDeserializer; +import com.zhipu.oapi.service.v4.deserialize.image.ImageResultDeserializer; +import com.zhipu.oapi.service.v4.deserialize.knowledge.KnowledgeInfoDeserializer; +import com.zhipu.oapi.service.v4.deserialize.knowledge.KnowledgePageDeserializer; +import com.zhipu.oapi.service.v4.deserialize.knowledge.KnowledgeStatisticsDeserializer; +import com.zhipu.oapi.service.v4.deserialize.knowledge.KnowledgeUsedDeserializer; +import com.zhipu.oapi.service.v4.deserialize.knowledge.document.*; +import com.zhipu.oapi.service.v4.deserialize.tools.*; +import com.zhipu.oapi.service.v4.deserialize.videos.VideoObjectDeserializer; +import com.zhipu.oapi.service.v4.deserialize.videos.VideoResultDeserializer; +import com.zhipu.oapi.service.v4.embedding.Embedding; +import com.zhipu.oapi.service.v4.embedding.EmbeddingResult; +import com.zhipu.oapi.service.v4.image.Image; +import com.zhipu.oapi.service.v4.image.ImageResult; +import com.zhipu.oapi.service.v4.knowledge.KnowledgeInfo; +import com.zhipu.oapi.service.v4.knowledge.KnowledgePage; +import com.zhipu.oapi.service.v4.knowledge.KnowledgeStatistics; +import com.zhipu.oapi.service.v4.knowledge.KnowledgeUsed; +import com.zhipu.oapi.service.v4.knowledge.document.*; +import com.zhipu.oapi.service.v4.model.*; +import com.zhipu.oapi.service.v4.model.params.CodeGeexContext; +import com.zhipu.oapi.service.v4.model.params.CodeGeexExtra; +import com.zhipu.oapi.service.v4.model.params.CodeGeexTarget; +import com.zhipu.oapi.service.v4.tools.*; +import com.zhipu.oapi.service.v4.videos.VideoObject; +import com.zhipu.oapi.service.v4.videos.VideoResult; + +public class MessageDeserializeFactory { + + public static ObjectMapper defaultObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); + SimpleModule module = new SimpleModule(); + + module.addDeserializer(ModelData.class, new ModelDataDeserializer()); + module.addDeserializer(Choice.class, new ChoiceDeserializer()); + module.addDeserializer(ChatMessage.class, new ChatMessageDeserializer()); + module.addDeserializer(Delta.class, new DeltaDeserializer()); + module.addDeserializer(ToolCalls.class, new ToolCallsDeserializer()); + module.addDeserializer(ChatFunctionCall.class, new ChatFunctionCallDeserializer()); + module.addDeserializer(CodeGeexContext.class, new CodeGeexContextDeserializer()); + module.addDeserializer(ChoiceDelta.class, new ChoiceDeltaDeserializer()); + module.addDeserializer(ChoiceDeltaToolCall.class, new ChoiceDeltaToolCallDeserializer()); + module.addDeserializer(SearchChatMessage.class, new SearchChatMessageDeserializer()); + module.addDeserializer(SearchIntent.class, new SearchIntentDeserializer()); + module.addDeserializer(SearchRecommend.class, new SearchRecommendDeserializer()); + module.addDeserializer(SearchResult.class, new SearchResultDeserializer()); + module.addDeserializer(WebSearchChoice.class, new WebSearchChoiceDeserializer()); + module.addDeserializer(WebSearchMessage.class, new WebSearchMessageDeserializer()); + module.addDeserializer(WebSearchMessageToolCall.class, new WebSearchMessageToolCallDeserializer()); + module.addDeserializer(WebSearchPro.class, new WebSearchProDeserializer()); + module.addDeserializer(VideoResult.class, new VideoResultDeserializer()); + module.addDeserializer(VideoObject.class, new VideoObjectDeserializer()); + module.addDeserializer(Image.class, new ImageDeserializer()); + module.addDeserializer(ImageResult.class, new ImageResultDeserializer()); + module.addDeserializer(KnowledgeInfo.class, new KnowledgeInfoDeserializer()); + module.addDeserializer(KnowledgeUsed.class, new KnowledgeUsedDeserializer()); + module.addDeserializer(KnowledgeStatistics.class, new KnowledgeStatisticsDeserializer()); + module.addDeserializer(KnowledgePage.class, new KnowledgePageDeserializer()); + module.addDeserializer(DocumentFailedInfo.class, new DocumentFailedInfoDeserializer()); + module.addDeserializer(DocumentObject.class, new DocumentObjectDeserializer()); + module.addDeserializer(DocumentSuccessInfo.class, new DocumentSuccessInfoDeserializer()); + module.addDeserializer(DocumentData.class, new DocumentDataDeserializer()); + module.addDeserializer(DocumentDataFailInfo.class, new DocumentDataFailInfoDeserializer()); + module.addDeserializer(DocumentPage.class, new DocumentPageDeserializer()); + module.addDeserializer(EmbeddingResult.class, new EmbeddingResultDeserializer()); + module.addDeserializer(Embedding.class, new EmbeddingDeserializer()); + module.addDeserializer(KnowledgeInfo.class, new KnowledgeInfoDeserializer()); + module.addDeserializer(AssistantChoice.class, new AssistantChoiceDeserializer()); + module.addDeserializer(AssistantCompletion.class, new AssistantCompletionDeserializer()); + module.addDeserializer(CompletionUsage.class, new CompletionUsageDeserializer()); + module.addDeserializer(ErrorInfo.class, new ErrorInfoDeserializer()); + mapper.registerModule(module); + // 官方SDK的反序列化逻辑无法应对模型数据中包含未知字段的场景,这里进行自定义处理 + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + return mapper; + } + +} diff --git a/paicoding-service/src/main/resources/META-INF/spring.factories b/paicoding-service/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..2160cae5a --- /dev/null +++ b/paicoding-service/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.github.paicoding.forum.service.ServiceAutoConfig \ No newline at end of file diff --git a/paicoding-service/src/main/resources/mapper/ArticleMapper.xml b/paicoding-service/src/main/resources/mapper/ArticleMapper.xml new file mode 100644 index 000000000..816d6aa97 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/ArticleMapper.xml @@ -0,0 +1,111 @@ + + + + + + + limit #{pageParam.offset}, #{pageParam.limit} + + + + + + + + + + + + + + + where a.deleted = ${@com.github.paicoding.forum.api.model.enums.YesOrNoEnum@NO.code} + + and a.title like concat('%', #{searchParams.title}, '%') + + + and u.user_name like concat('%', #{searchParams.userName}, '%') + + + and a.offical_stat = #{searchParams.officalStat} + + + and a.topping_stat = #{searchParams.toppingStat} + + + and a.status = #{searchParams.status} + + + and a.url_slug = #{searchParams.urlSlug} + + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/ArticleTagMapper.xml b/paicoding-service/src/main/resources/mapper/ArticleTagMapper.xml new file mode 100644 index 000000000..a801abb06 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/ArticleTagMapper.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/ColumnArticleMapper.xml b/paicoding-service/src/main/resources/mapper/ColumnArticleMapper.xml new file mode 100644 index 000000000..324e85703 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/ColumnArticleMapper.xml @@ -0,0 +1,70 @@ + + + + + + + limit #{pageParam.offset}, #{pageParam.limit} + + + + + + + + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/CommentMapper.xml b/paicoding-service/src/main/resources/mapper/CommentMapper.xml new file mode 100644 index 000000000..5f56acfd1 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/CommentMapper.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/NotifyMsgMapper.xml b/paicoding-service/src/main/resources/mapper/NotifyMsgMapper.xml new file mode 100644 index 000000000..331fb2704 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/NotifyMsgMapper.xml @@ -0,0 +1,57 @@ + + + + + + limit #{pageParam.offset}, #{pageParam.limit} + + + + + + + + + + update notify_msg set `state` = 1 where `id` in + + #{id} + + + + + diff --git a/paicoding-service/src/main/resources/mapper/QueryCountMapper.xml b/paicoding-service/src/main/resources/mapper/QueryCountMapper.xml new file mode 100644 index 000000000..4e0a154b3 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/QueryCountMapper.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/UserAiMapper.xml b/paicoding-service/src/main/resources/mapper/UserAiMapper.xml new file mode 100644 index 000000000..73a731066 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/UserAiMapper.xml @@ -0,0 +1,47 @@ + + + + + + + limit #{pageParam.offset}, #{pageParam.limit} + + + + + where a.deleted = ${@com.github.paicoding.forum.api.model.enums.YesOrNoEnum@NO.code} + + and u.user_name like concat('%', #{searchParams.userCode}, '%') + + + and ui.user_name like concat('%', #{searchParams.name}, '%') + + + and a.star_number like concat('%', #{searchParams.starNumber}, '%') + + + and a.state = #{searchParams.status} + + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/UserFootMapper.xml b/paicoding-service/src/main/resources/mapper/UserFootMapper.xml new file mode 100644 index 000000000..326d21fc6 --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/UserFootMapper.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/paicoding-service/src/main/resources/mapper/UserRelationMapper.xml b/paicoding-service/src/main/resources/mapper/UserRelationMapper.xml new file mode 100644 index 000000000..1dd8097eb --- /dev/null +++ b/paicoding-service/src/main/resources/mapper/UserRelationMapper.xml @@ -0,0 +1,47 @@ + + + + + + limit #{pageParam.offset}, #{pageParam.limit} + + + + + + + + + + diff --git a/paicoding-ui/README.md b/paicoding-ui/README.md new file mode 100644 index 000000000..e1675abf1 --- /dev/null +++ b/paicoding-ui/README.md @@ -0,0 +1,4 @@ +forum-ui +=== + +存储前端资源文件 \ No newline at end of file diff --git a/paicoding-ui/pom.xml b/paicoding-ui/pom.xml new file mode 100644 index 000000000..80e98298a --- /dev/null +++ b/paicoding-ui/pom.xml @@ -0,0 +1,21 @@ + + + + paicoding-forum + com.github.paicoding.forum + 0.0.1-SNAPSHOT + + 4.0.0 + + paicoding-ui + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/admonition/admonition.css b/paicoding-ui/src/main/resources/static/admonition/admonition.css new file mode 100644 index 000000000..9ec268acf --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/admonition.css @@ -0,0 +1,265 @@ +.adm-block { + display: block; + width: 99%; + border-radius: 6px; + padding-left: 10px; + margin-bottom: 1em; + border: 1px solid; + border-left-width: 4px; + box-shadow: 2px 2px 6px #cdcdcd; +} + +.adm-heading { + display: block; + font-weight: bold; + font-size: 0.9em; + height: 1.8em; + padding-top: 0.3em; + padding-bottom: 2em; + border-bottom: solid 1px; + padding-left: 10px; + margin-left: -10px; +} + +.adm-body { + display: block; + padding-bottom: 0.5em; + padding-top: 0.5em; + margin-left: 1.5em; + margin-right: 1.5em; +} + +.adm-heading > span { + color: initial; +} + +.adm-icon { + height: 1.6em; + width: 1.6em; + display: inline-block; + vertical-align: middle; + margin-right: 0.25em; + margin-left: -0.25em; +} + +.adm-hidden { + display: none !important; +} + +.adm-block.adm-collapsed > .adm-heading, .adm-block.adm-open > .adm-heading { + position: relative; + cursor: pointer; +} + +.adm-block.adm-collapsed > .adm-heading { + margin-bottom: 0; +} + +.adm-block.adm-collapsed .adm-body { + display: none !important; +} + +.adm-block.adm-open > .adm-heading:after, +.adm-block.adm-collapsed > .adm-heading:after { + display: inline-block; + position: absolute; + top:calc(50% - .65em); + right: 0.5em; + font-size: 1.3em; + content: '▼'; +} + +.adm-block.adm-collapsed > .adm-heading:after { + right: 0.50em; + top:calc(50% - .75em); + transform: rotate(90deg); +} + +/* default scheme */ + +.adm-block { + border-color: #ebebeb; + border-bottom-color: #bfbfbf; +} + +.adm-block.adm-abstract { + border-left-color: #48C4FF; +} + +.adm-block.adm-abstract .adm-heading { + background: #E8F7FF; + color: #48C4FF; + border-bottom-color: #dbf3ff; +} + +.adm-block.adm-abstract.adm-open > .adm-heading:after, +.adm-block.adm-abstract.adm-collapsed > .adm-heading:after { + color: #80d9ff; +} + + +.adm-block.adm-bug { + border-left-color: #F50057; +} + +.adm-block.adm-bug .adm-heading { + background: #FEE7EE; + color: #F50057; + border-bottom-color: #fcd9e4; +} + +.adm-block.adm-bug.adm-open > .adm-heading:after, +.adm-block.adm-bug.adm-collapsed > .adm-heading:after { + color: #f57aab; +} + +.adm-block.adm-danger { + border-left-color: #FE1744; +} + +.adm-block.adm-danger .adm-heading { + background: #FFE9ED; + color: #FE1744; + border-bottom-color: #ffd9e0; +} + +.adm-block.adm-danger.adm-open > .adm-heading:after, +.adm-block.adm-danger.adm-collapsed > .adm-heading:after { + color: #fc7e97; +} + +.adm-block.adm-example { + border-left-color: #7940ff; +} + +.adm-block.adm-example .adm-heading { + background: #EFEBFF; + color: #7940ff; + border-bottom-color: #e0d9ff; +} + +.adm-block.adm-example.adm-open > .adm-heading:after, +.adm-block.adm-example.adm-collapsed > .adm-heading:after { + color: #b199ff; +} + +.adm-block.adm-fail { + border-left-color: #FE5E5E; +} + +.adm-block.adm-fail .adm-heading { + background: #FFEEEE; + color: #Fe5e5e; + border-bottom-color: #ffe3e3; +} + +.adm-block.adm-fail.adm-open > .adm-heading:after, +.adm-block.adm-fail.adm-collapsed > .adm-heading:after { + color: #fcb1b1; +} + +.adm-block.adm-faq { + border-left-color: #5ED116; +} + +.adm-block.adm-faq .adm-heading { + background: #EEFAE8; + color: #5ED116; + border-bottom-color: #e6fadc; +} + +.adm-block.adm-faq.adm-open > .adm-heading:after, +.adm-block.adm-faq.adm-collapsed > .adm-heading:after { + color: #98cf72; +} + +.adm-block.adm-info { + border-left-color: #00B8D4; +} + +.adm-block.adm-info .adm-heading { + background: #E8F7FA; + color: #00B8D4; + border-bottom-color: #dcf5fa; +} + +.adm-block.adm-info.adm-open > .adm-heading:after, +.adm-block.adm-info.adm-collapsed > .adm-heading:after { + color: #83ced6; +} + +.adm-block.adm-note { + border-left-color: #448AFF; +} + +.adm-block.adm-note .adm-heading { + background: #EDF4FF; + color: #448AFF; + border-bottom-color: #e0edff; +} + +.adm-block.adm-note.adm-open > .adm-heading:after, +.adm-block.adm-note.adm-collapsed > .adm-heading:after { + color: #8cb8ff; +} + +.adm-block.adm-quote { + border-left-color: #9E9E9E; +} + +.adm-block.adm-quote .adm-heading { + background: #F4F4F4; + color: #9E9E9E; + border-bottom-color: #e8e8e8; +} + +.adm-block.adm-quote.adm-open > .adm-heading:after, +.adm-block.adm-quote.adm-collapsed > .adm-heading:after { + color: #b3b3b3; +} + +.adm-block.adm-success { + border-left-color: #1DCD63; +} + +.adm-block.adm-success .adm-heading { + background: #E9F8EE; + color: #1DCD63; + border-bottom-color: #dcf7e5; +} + +.adm-block.adm-success.adm-open > .adm-heading:after, +.adm-block.adm-success.adm-collapsed > .adm-heading:after { + color: #7acc98; +} + +.adm-block.adm-tip { + border-left-color: #01BFA5; +} + +.adm-block.adm-tip .adm-heading { + background: #E9F9F6; + color: #01BFA5; + border-bottom-color: #dcf7f2; +} + +.adm-block.adm-tip.adm-open > .adm-heading:after, +.adm-block.adm-tip.adm-collapsed > .adm-heading:after { + color: #7dd1c0; +} + +.adm-block.adm-warning { + border-left-color: #FF9001; +} + +.adm-block.adm-warning .adm-heading { + background: #FEF3E8; + color: #FF9001; + border-bottom-color: #Fef3e8; +} + +.adm-block.adm-warning.adm-open > .adm-heading:after, +.adm-block.adm-warning.adm-collapsed > .adm-heading:after { + color: #fcbb6a; +} + diff --git a/paicoding-ui/src/main/resources/static/admonition/admonition.js b/paicoding-ui/src/main/resources/static/admonition/admonition.js new file mode 100644 index 000000000..31478adef --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/admonition.js @@ -0,0 +1,25 @@ +(() => { + let divs = document.getElementsByClassName("adm-block"); + for (let i = 0; i < divs.length; i++) { + let div = divs[i]; + if (div.classList.contains("adm-collapsed") || div.classList.contains("adm-open")) { + let headings = div.getElementsByClassName("adm-heading"); + if (headings.length > 0) { + headings[0].addEventListener("click", event => { + let el = div; + event.preventDefault(); + event.stopImmediatePropagation(); + if (el.classList.contains("adm-collapsed")) { + console.debug("Admonition Open", event.srcElement); + el.classList.remove("adm-collapsed"); + el.classList.add("adm-open"); + } else { + console.debug("Admonition Collapse", event.srcElement); + el.classList.add("adm-collapsed"); + el.classList.remove("adm-open"); + } + }); + } + } + } +})(); diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-abstract.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-abstract.svg new file mode 100644 index 000000000..2757f1bfd --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-abstract.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-bug.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-bug.svg new file mode 100644 index 000000000..d6a8fb7ad --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-bug.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-danger.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-danger.svg new file mode 100644 index 000000000..056d60e4d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-danger.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-example.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-example.svg new file mode 100644 index 000000000..13f1834d1 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-example.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-fail.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-fail.svg new file mode 100644 index 000000000..0ecf9f7c4 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-fail.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-faq.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-faq.svg new file mode 100644 index 000000000..e3a836c2e --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-faq.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-info.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-info.svg new file mode 100644 index 000000000..27677adde --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-note.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-note.svg new file mode 100644 index 000000000..5f8f0fcf3 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-note.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-quote.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-quote.svg new file mode 100644 index 000000000..e41cf5ce9 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-quote.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-success.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-success.svg new file mode 100644 index 000000000..583a8e9b0 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-success.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-tip.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-tip.svg new file mode 100644 index 000000000..0b8ae7f04 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-tip.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/admonition/images/adm-warning.svg b/paicoding-ui/src/main/resources/static/admonition/images/adm-warning.svg new file mode 100644 index 000000000..5348eec73 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/admonition/images/adm-warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/paicoding-ui/src/main/resources/static/css/common.css b/paicoding-ui/src/main/resources/static/css/common.css new file mode 100644 index 000000000..9f16f3aa3 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/common.css @@ -0,0 +1,16 @@ +/* 页面公共组件样式 */ + +/* 导航栏 */ +@import "./components/navbar.css"; + +/* 底部 */ +@import "./components/footer.css"; + +/* 文章样式 */ +@import "./components/article-item.css"; + +/* 侧边栏 */ +@import "./components/side-column.css"; + +/* 文章底部 */ +@import "./components/article-footer.css"; diff --git a/paicoding-ui/src/main/resources/static/css/components/article-footer.css b/paicoding-ui/src/main/resources/static/css/components/article-footer.css new file mode 100644 index 000000000..82031a533 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/components/article-footer.css @@ -0,0 +1,555 @@ +/*文章底部的点赞*/ +.article-heart { + width: 100%; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + margin-top: 30px; + border-top: 1px solid var(--pai-hr-color-1); + padding-top: 30px; +} + +.article-heart .praise-box.active { + color: var(--pai-brand-3-click); + border: 1px solid var(--pai-brand-3-click); +} + +.article-heart .praise-box { + font-size: 27px; + width: 3rem; + height: 3rem; + text-align: center; + border-radius: 30px; + cursor: pointer; + border: 1px solid var(--pai-color-3-gray); + color: var(--pai-color-3-gray); + margin-bottom: 7.5px; +} + +.article-heart .approval-tips-line { + position: relative; + margin-bottom: 15px; + color: var(--pai-color-999-gray); + font-size: 14px; +} + +.article-heart .approval-tips-line:before { + content: ""; + position: absolute; + top: 10px; + left: -2rem; + height: 2px; + width: 25%; + background: linear-gradient( + 270deg, + var(--pai-brand-1-normal), + var(--pai-color-fff-normal) + ); +} + +.article-heart .approval-tips-line:after { + content: ""; + position: absolute; + top: 10px; + right: -3rem; + height: 2px; + width: 25%; + background: linear-gradient( + 90deg, + var(--pai-brand-1-normal), + var(--pai-color-fff-normal) + ); +} + +.article-heart .approval-img { + display: inline-block; + width: 25px; + height: 25px; + margin-right: 7.5px; + border-radius: 50%; + cursor: pointer; + text-align: center; +} + +.praise-photos { + text-align: center; +} + +.article-heart .approval-img:last-child { + margin-right: 0; +} + +.article-heart .approval-img img { + width: 100%; + height: 100%; + border-radius: 50%; +} + +/* 评论列表 */ +.common-item-content { + margin-left: 10px; + flex: 1; + padding-top: 8px; +} + +.common-item-content-head { + flex: 1; + display: flex; + justify-content: space-between; + font-size: 12px; + line-height: 14px; + color: var(--pai-color-999-gray); +} + +.common-item-content-value { + font-size: 14px; + line-height: 24px; + color: #333; + margin: 10px 0 0; + word-break: break-all; + white-space: pre-wrap; +} + +.comment-write-wrap { + display: flex; + justify-content: flex-start; + padding: 20px; +} + +.comment-write-img { + font-size: 40px; + width: 40px; + height: 40px; + margin-right: 15px; + border-radius: 50%; +} + +.common-write-content { + display: flex; + flex-direction: column; + align-items: flex-end; + flex: 1; +} + +/* 新增的评论输入框容器样式 */ +.comment-input-container { + width: 100%; + border: none; + border-radius: 4px; + box-shadow: 0 0 0 1px var(--pai-color-999-gray); + transition: box-shadow 0.2s ease; + position: relative; + z-index: 1; +} + +.comment-input-container:focus-within { + box-shadow: 0 0 0 2px var(--pai-brand-3-click); +} + +/* 使用更高特异性的选择器来确保样式不会被全局样式覆盖 */ +.comment-input-container .comment-write-textarea { + width: 100%; + border: none !important; + outline: none !important; + border-radius: 0; + padding: 12px 16px; + margin-bottom: 0; + resize: none; + min-height: 100px; + box-sizing: border-box; + box-shadow: none !important; + background: transparent; +} + +.comment-input-container .comment-write-textarea:focus { + outline: none !important; + border: none !important; + box-shadow: none !important; +} + +.comment-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: #f8f9fa; + border-radius: 0 0 4px 4px; +} + +.toolbar-left { + flex: 1; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +/* AI机器人选择器样式 */ +.ai-bot-selector { + position: relative; + display: inline-block; + /* 确保下拉菜单有足够的z-index */ + z-index: 1001; +} + +.ai-bot-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--pai-color-999-gray); +} + +.ai-bot-btn:hover { + background-color: #e9ecef; + color: var(--pai-brand-2-hover); +} + +.ai-bot-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + min-width: 120px; + padding: 4px 0; + margin: 2px 0 0; + font-size: 14px; + text-align: left; + list-style: none; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + /* 添加额外的z-index确保不会被遮挡 */ + z-index: 9999; +} + +.ai-bot-dropdown.show { + display: block; +} + +.ai-bot-option { + display: block; + padding: 6px 12px; + clear: both; + font-weight: normal; + line-height: 1.42857143; + color: #333; + white-space: nowrap; + cursor: pointer; +} + +.ai-bot-option:hover { + background-color: #f5f5f5; + color: var(--pai-brand-2-hover); +} + +.comment-count { + font-size: 12px; + color: var(--pai-color-999-gray); +} + +.comment-write-btn { + border-radius: 2px; +} + +.c-btn-disabled, +.c-btn-disabled:hover { + background-color: var(--pai-color-5-gray); + border-color: var(--pai-color-5-gray); + color: var(--pai-color-999-gray); + cursor: pointer; +} + +.comment-item-wrap, +.comment-item-wrap-second { + /* display: flex; */ + margin-bottom: 10px; + padding: 12px 0; + border-bottom: 1px solid var(--pai-hr-color-1); +} + +.comment-item-top { + display: flex; +} + +.comment-item-wrap-second { + margin-left: 30px; + background-color: #f7f8fa; + border-radius: 4px; + padding: 12px; +} + +.comment-item-img { + width: 24px; + height: 24px; + border-radius: 50%; + box-sizing: border-box; + border: 1px solid var(--pai-color-5-gray); +} + +/* 评论 */ +.hf-con { + display: flex; + flex-direction: column; + margin-top: 8px; + width: 100%; +} + +.hf-pl { + border: 0; + flex: 0 0 auto; + margin-left: auto; + width: 92px; + text-align: center; + border-radius: 2px; + font-size: 14px; + line-height: 36px; + background: var(--pai-brand-1-normal); + color: #fff; + padding: 0; + cursor: pointer; + margin-top: 4px; +} + +.hf-pl--disabled { + background-color: #999; + cursor: not-allowed; +} + +.hf-input { + padding: 8px 12px; + border-radius: 2px; +} + +.reply-comment-text-none { + display: none; +} + +.ui-message { + box-shadow: inset 0 0 0 1px #a9d5de, 0 0 0 0 transparent; + background: #f8f8f9; + border-radius: 0.28571429rem; + color: rgba(0, 0, 0, 0.87); + line-height: 1.4285em; + margin: 1em 0; + min-height: 1em; + padding: 1em 1.5em; + position: relative; + transition: opacity 0.1s ease, color 0.1s ease, background 0.1s ease, + box-shadow 0.1s ease; + color: #276f86; + font-size: 0.8em; +} + +/*全部评论*/ +.all-comment, +.hot-comment { + padding: 20px; +} + +.all-comment-title, +.hot-comment-title { + font-size: 18px; + border-bottom: 1px solid var(--pai-hr-color-1); + padding-bottom: 12px; +} + +.all-comment-title em { + font-weight: 500; + color: var(--pai-brand-1-normal); +} + +/* 评论的点赞回复 */ +.action-box { + margin-top: 8px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.action-box .item, +.action-box { + display: flex; + align-items: center; +} + +.action-box .item { + margin-right: 16px; + line-height: 22px; + font-size: 12px; + cursor: pointer; + color: var(--pai-color-999-gray); +} + +.action-box .item svg { + fill: #8a919f; + margin-right: 4px; +} + +.action-box .item:hover { + color: var(--pai-brand-2-hover); +} + +.action-box .item:hover svg { + fill: var(--pai-brand-2-hover); +} + +.action-box .item.active { + color: var(--pai-brand-2-hover); +} + +.action-box .item.active svg { + fill: var(--pai-brand-2-hover); +} + +/*文章评论*/ +.correlation-article { + padding: 20px; + margin-top: 20px; +} + +.correlation-article-title { + font-size: 24px; + font-weight: 400; +} + +.panel-btn { + position: relative; + margin-bottom: 1.667rem; + width: 3rem; + height: 3rem; + background-color: #fff; + background-position: 50%; + background-repeat: no-repeat; + border-radius: 50%; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04); + cursor: pointer; + text-align: center; + font-size: 1.67rem; +} + +.panel-btn .sprite-icon { + color: var(--pai-color-3-gray); + height: 100%; +} + +.panel-btn:hover .sprite-icon { + color: var(--pai-color-4-gray); +} + +.panel-btn:not(.share-btn).active .sprite-icon.icon-collect { + color: var(--pai-brand-3-click); +} + +.panel-btn:not(.share-btn).active .sprite-icon { + color: var(--pai-brand-3-click); +} + +.panel-btn:not(.share-btn).active.with-badge:after { + background-color: var(--pai-brand-6-mq); +} + +.panel-btn.with-badge:after { + content: attr(badge); + position: absolute; + top: 0; + left: 75%; + height: 17px; + line-height: 17px; + padding: 0 5px; + border-radius: 9px; + font-size: 11px; + text-align: center; + white-space: nowrap; + background-color: #c2c8d1; + color: #fff; +} + +.panel-btn.share-btn:after { + display: block; + content: " "; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 50%; +} + +.panel-btn.share-btn:hover .share-popup { + display: flex; +} + +.panel-btn.share-btn .share-popup { + display: none; + position: absolute; + top: 0; + flex-direction: column; + left: calc(100% + 14px); + z-index: 30; + background: #fff; + border-radius: 4px; + padding: 9px 0; + width: -webkit-max-content; + width: -moz-max-content; + width: max-content; + box-shadow: 0 8px 24px rgba(81, 87, 103, 0.16); +} + +.panel-btn.share-btn .share-popup:after { + position: absolute; + width: 0; + height: 0; + content: " "; + right: 100%; + top: 14px; + border: 12px solid transparent; + border-right-color: #fff; +} + +.panel-btn.share-btn .share-popup .share-item { + display: flex; + align-items: center; + height: 44px; + padding: 0 15px; +} + +.panel-btn.share-btn .share-popup .share-item:hover { + background-color: #f2f3f5; +} + +.panel-btn.share-btn .share-popup .share-item:hover.wechat .wechat-qrcode { + display: flex; +} + +.panel-btn.share-btn .share-popup .share-item:hover .share-icon { + color: #515767; +} + +.panel-btn.share-btn .share-popup .share-item .share-item-title { + margin-left: 8px; + font-size: 14px; + color: #515767; +} + +.panel-btn.share-btn .share-popup .share-item .share-icon { + color: #8a919f; + width: 20px; + height: 20px; + font-size: 1.67rem; +} + +.sprite-icon { + width: 20px; + height: 20px; + fill: currentColor; + vertical-align: middle; + transition: all 0.15s linear; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/components/article-item.css b/paicoding-ui/src/main/resources/static/css/components/article-item.css new file mode 100644 index 000000000..6e02d2f7f --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/components/article-item.css @@ -0,0 +1,327 @@ +/*首页文章列表*/ +.cdc-article-panel__list .cdc-article-panel { + margin-bottom: 4px; +} + +/*首页文章*/ +.cdc-article-panel { + box-sizing: border-box; + padding: 16px 0; + border-bottom: 1px solid #d6dbe3; + position: relative; + cursor: pointer; + max-width: 872px; + padding-right: 20px; +} + +/*首页文章跳转详情链接*/ +.cdc-article-panel__link { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 1; +} + +/*首页文章(除去 a 链接的部分)*/ +.cdc-article-panel__inner { + position: relative; + display: flex; + align-items: flex-start; + flex-flow: row nowrap; +} +/*再套一层*/ +.cdc-article-panel__main { + min-width: 0; + flex: 1 1 auto; +} + +/*文章标题+推荐图标*/ +.user-article-item-title-wrap { + display: flex; + align-items: center; +} + +/*文章上的推荐图标*/ +.article-card-top-img { + margin-bottom: 12px; + margin-right: 12px; +} + +/*文章标题*/ +.user-article-item-title { + word-break: break-word; + font-weight: 500; + font-size: 18px; + line-height: 26px; + color: var(--pai-color-3-black); + margin-bottom: 12px; + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +/*文章状态tag*/ +.user-article-item-tag { + word-break: break-word; + font-weight: 500; + font-size: 14px; + line-height: 26px; + color: var(--pai-brand-6-mq); + margin-bottom: 12px; + margin-left: 1em; + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.cdc-tag-links__item:not(:first-child):not(:last-child) { + margin: 0; +} + +/*文章的简介描述*/ +.cdc-article-panel__media { + display: flex; + align-items: center; +} + +/*下一层*/ +.cdc-article-panel__desc { + font-size: 14px; + line-height: 22px; + color: var(--pai-color-4-gray); + min-height: 44px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-word; +} + +/*文章列表底部的数据(作者、日期、阅读、留言、点赞、文章标签)*/ +.cdc-article-panel__infos { + margin-top: 10px; + display: flex; + align-items: center; + font-size: 12px; + line-height: 20px; +} + +/*作者*/ +.cdc-article-panel__user { + position: relative; + display: flex; + align-items: center; + z-index: 2; +} + +/*作者头像 圆角*/ +.cdc-avatar.circle, +.cdc-avatar.circle .cdc-avatar__inner { + border-radius: 50%; +} + +/*作者头像大小*/ +.cdc-avatar.large { + width: 32px; + height: 32px; +} + +/*作者头像边距*/ +.cdc-article-panel__user-avatar { + flex-shrink: 0; + margin-right: 8px; +} + +/*作者头像*/ +.cdc-avatar { + display: inline-block; + vertical-align: middle; + position: relative; + width: 28px; + height: 28px; + border-radius: 50%; + border-bottom-left-radius: 0; + background-color: #d8d8d8; +} + +/*头像的位置*/ +.cdc-avatar__inner, +.cdc-avatar__level { + box-sizing: border-box; + position: absolute; + bottom: 0; +} + +/*头像*/ +.cdc-avatar.large .cdc-avatar__inner { + font-size: 16px; + line-height: 30px; +} + +/*头像*/ +.cdc-avatar__inner { + display: block; + width: 100%; + height: 100%; + background-position: bottom; + background-size: auto 100%; + background-repeat: no-repeat; + border-radius: 50%; + border-bottom-left-radius: 0; + text-align: center; + font-size: 14px; + line-height: 26px; + left: 50%; + transform: translateX(-50%); +} + +/*作者名*/ +.cdc-article-panel__user-name { + font-weight: 500; + color: var(--pai-color-3-black); + line-height: 20px; +} +/*文章发布时间*/ +.cdc-article-panel__user + .cdc-article-panel__date { + margin-left: 12px; +} +/*文章发布时间的颜色位置*/ +.cdc-article-panel__date { + color: #97a3b7; + position: relative; +} + +/*发布时间前的显示方式*/ +.cdc-article-panel__user + .cdc-article-panel__date:before { + display: block; +} + +/*发布时间前的小圆点*/ +.cdc-article-panel__date:before { + content: ""; + position: absolute; + top: 9px; + left: -7px; + width: 2px; + height: 2px; + border-radius: 50%; + background-color: rgba(151, 163, 183, 0.9); + display: none; +} + +/*时间留言点赞*/ +.cdc-icon__list { + margin-right: -24px; +} + +/*时间留言点赞*/ +.cdc-article-panel__operate { + margin-left: 24px; +} +/*时间留言点赞大小*/ +.read-comment-praise { + height: 16px; + width: 16px; +} + +/*数量的大小颜色*/ +.cdc-icon__number { + vertical-align: middle; + font-size: 12px; + line-height: 20px; + color: #97a3b7; + margin-left: 6px; +} + +/*时间留言点赞*/ +.article-show-wrap { + display: flex; + align-items: center; + justify-content: flex-start; + color: var(--pai-color-999-gray); + cursor: pointer; + position: relative; + margin-right: 24px; +} + +/*文章标签*/ +.cdc-article-panel__tags { + position: absolute; + right: 0; + bottom: 5px; + z-index: 2; +} + +/*标签的对齐方式*/ +.cdc-tag-links { + display: flex; + align-items: center; +} + +/*标签的图标*/ +.cdc-tag-links__icon { + display: block; + margin-right: 8px; +} + +/*标签的位置*/ +.cdc-tag-links__items { + display: flex; + align-items: center; + margin: 0 -12px; +} + +/*标签的大小*/ +.cdc-tag-links__item { + display: block; + margin: 0 12px; + font-size: 12px; + line-height: 20px; + color: #97a3b7; +} + +/*文章封面图*/ +.cdc-article-panel__object { + flex-shrink: 0; + margin-left: 20px; + width: 180px; + position: relative; +} + +.cdc-article-panel__object-thumbnail { + display: block; + background-size: cover; + background-repeat: no-repeat; + background-position: 50%; + width: 100%; + height: auto; + padding-top: 54.6667%; +} + +.cdc-article-panel:hover .user-article-item-title { + color: var(--pai-brand-2-hover); +} + +.cdc-article-panel__user-name:hover { + color: var(--pai-color-3-black); +} + +/* home 适配 */ +@media screen and (max-width: 768px) { + .article-title-wrap, + .cdc-article-panel__date { + display: none; + } + + .cdc-article-panel__title { + + } + +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/components/footer.css b/paicoding-ui/src/main/resources/static/css/components/footer.css new file mode 100644 index 000000000..9db116435 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/components/footer.css @@ -0,0 +1,61 @@ +.foot { + height: 70px; + display: flex; + align-items: center; + justify-content: center; + background-color: #000; + color: #fff; + flex-direction: column; + font-size: 14px; +} + +.body-404 footer { + position: absolute; + bottom: 0; + width: 100%; + height: 70px; +} + +.foot-link { + list-style: none; + padding: 25px 0; + width: 80%; + margin: 0 auto; + text-align: left; + font-size: 0; +} + +.foot li { + font-size: 14px; + padding: 0 10px; + display: inline-block; + vertical-align: middle; + line-height: 1em; +} + +.foot li:last-child { + border-left: none; + float: right; + padding-right: 0; +} + +.foot .visit_cnt { + color: #e96900; +} + +/* 宽屏布局 */ +.stats-container { + display: flex; + justify-content: space-between; /* 使两个 .stats-row 分布在容器两端 */ +} + +/* 媒体查询,针对小屏幕设备 */ +@media screen and (max-width: 768px) { + .stats-row { + white-space: nowrap; /* 防止内容换行 */ + text-align: center; /* 使内容居中 */ + } + .stats-container { + display: block; /* 使 .stats-row 堆叠显示 */ + } +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/components/navbar.css b/paicoding-ui/src/main/resources/static/css/components/navbar.css new file mode 100644 index 000000000..35316fa08 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/components/navbar.css @@ -0,0 +1,277 @@ +/* 覆盖框架默认样式 */ +.navbar { + padding: 0.5rem 4rem; +} + +.navbar-count-msg-box { + position: relative; +} + +.navbar-count-msg { + position: absolute; + top: 4px; + right: 2px; + width: 15px; + height: 15px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--pai-brand-6-mq); + color: #f1f1f1; + transform: scale(0.8); + font-size: 12px; + font-weight: 500; + background: #f03535; +} + +.nav-item { + clear: both; + margin: 0; + padding: 5px 10px; + color: rgba(0, 0, 0, 0.65); + font-weight: 600; + font-size: 1.1em; + line-height: 22px; + white-space: nowrap; + cursor: pointer; + transition: all 0.3s; +} + +.navbar-light .navbar-nav .nav-link { + color: rgba(0, 0, 0, 0.85); +} + +/*navbar*/ +.navbar { + height: 60px; + /* border-bottom: 1px solid #dee2e6; */ + position: sticky; + --tw-bg-opacity: 1; + background-color: rgba(36, 41, 47, var(--tw-bg-opacity)); +} +.nav-right { + display: flex; + align-items: center; +} + +.nav-body { + display: flex; + width: 100%; + justify-content: space-between; + max-width: 1200px; + margin: 0 auto; +} + +.nav-link { + font-size: 18px; + font-weight: 500; +} + +.nav-link:hover { + color: var(--pai-brand-2-hover); +} + +.nav-article { + font-weight: 500; + height: 32px; + font-size: 14px; + display: flex; + align-items: center; + border-radius: 2px; +} + +.nav-notice { + display: flex; + align-items: center; +} + +.dropdown-toggle::after { + display: none !important; +} + +.nav-login-img { + height: 36px; + width: 36px; + border-radius: 50%; + cursor: pointer; +} + +.nav-login-menu { + left: -20px; + box-shadow: 0 0 24px rgb(81 87 103 / 16%); + width: 224px; + border-radius: 4px; + padding: 20px; + left: -200px; +} + +.nav-login-head { + display: flex; + align-items: center; + padding: 0; +} + +.nav-link { + color: #fff; +} +.navbar-logo-wrap { + display: flex; + align-items: center; +} + +.logo-lg { + height: 30px; + width: 30px; + display: none; +} + +.nav-right-user { + display: flex; +} + +.nav-menu-lg-btn { + color: #fff; + font-weight: 500; + font-size: 18px; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 8px 12px; + border-radius: 4px; + transition: background-color 0.3s ease; +} + +.nav-menu-lg-btn:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.nav-menu-arrow { + transition: transform 0.3s ease; + margin-left: 2px; +} + +.nav-menu-lg-btn[aria-expanded="true"] .nav-menu-arrow { + transform: rotate(180deg); +} +/*categories*/ + +.home-nav-classify { + background-color: #fff; +} + +.nav-menu-lg { + display: none; +} + +.nav-logo-wrap-lg { + display: flex; + padding-right: 36px; +} + +/* navbar right user */ +.nav-right-user { + position: relative; +} + +.nav-user-avatar { + display: flex; + align-items: center; + position: relative; +} + +.nav-login-img { + width: 36px; + height: 36px; + cursor: pointer; +} + +.nav-user-dropdown { + position: absolute; + top: 50px; + right: 0; + z-index: 9999; + display: none; + background-color: #fff; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + border-radius: 4px; +} + +/* 头像侧边箭头 */ +.nav-user-arrow { + cursor: pointer; + position: absolute; + top: calc(50% - 3px); + left: 42px; + width: 0; + height: 0; + border-style: solid; + border-width: 6px 6px 0 6px; + border-color: #fff transparent transparent transparent; +} + +.nav-user-dropdown-inner { + padding: 10px; +} + +/* 下落框向上箭头 */ +.nav-user-dropdown::before { + content: ""; + position: absolute; + top: -6px; + left: 87%; + margin-left: -6px; + border-width: 0 7px 7px; + border-style: solid; + border-color: #fff transparent; +} + + +/* home 适配 */ +@media screen and (max-width: 768px) { + .nav-article { + display: none; + } + .logo { + display: none; + } + .logo-lg { + display: block; + } + .navbar { + padding-left: 18px; + padding-right: 32px; + } + .collapse:not(.show) { + display: none; + } + .nav-menu-lg { + display: flex; + margin-left: 20px; + align-items: center; + } +} + +.vip span { + color: var(--pai-brand-1-normal); + padding: 0; + height: 48px; + line-height: 48px; + font-size: 14px; +} + +.vip img { + margin-left: 10px; + position: relative; + vertical-align: middle; + width: 14px; + height: 24px; + top: 11px; + left: 0; + display: inline-block; +} + +.vip { + display: flex; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/components/side-column.css b/paicoding-ui/src/main/resources/static/css/components/side-column.css new file mode 100644 index 000000000..07e9099b9 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/components/side-column.css @@ -0,0 +1,941 @@ +/* 右侧列表 */ +.home-right-item-wrap { + background-color: var(--pai-bg-white-fff); + overflow: hidden; + padding: 20px; + margin-bottom: 20px; + } + .home-right-item-icon { + height: 18px; + width: 18px; + position: relative; + } + .home-right-item-first { + padding-top: 16px; + } + .home-right-item-title { + margin-bottom: 24px; + left: 20px; + top: 16px; + } + + .home-right-item-article-item, + .home-right-item-post-item { + display: flex; + align-items: center; + margin-bottom: 16px; + } + + .home-right-item-post-title { + flex: 1 1; + } + + .oneline { + display: flex; + align-items: flex-start; + flex-wrap: nowrap; + } + + .home-right-item-icon-wrap { + flex-shrink: 0; + display: inline-block; + margin-right: 4px; + line-height: initial; + } + + .home-right-item-post-title-txt { + flex: 1 1; + min-width: 0; + word-wrap: break-word; + word-break: break-word; + } + + .com-2-side-activity-desc { + margin-top: 4px; + } + .home-right-item-post-time { + color: #adb5bd; + font-size: 14px; + } + .home-right-item-article-title-txt { + flex: 1 1; + font-size: 14px; + font-weight: 400; + line-height: 22px; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + -webkit-line-clamp: 2; + } + + .home-right-item-article-item i { + flex-shrink: 0; + font-size: 16px; + color: rgba(0, 0, 0, 0.06); + margin-right: 8px; + } + .home-right-item-about-content { + text-align: justify; + font-size: 14px; + line-height: 180%; + color: #333; + } + .home-QR-title { + display: flex; + border-bottom: 1px var(--pai-hr-color-1) solid; + margin-bottom: 20px; + padding-bottom: 20px; + } + .home-QR-title-img { + height: 24px; + width: 24px; + float: left; + margin-right: 6px; + } + .home-QR-title-code { + width: 57px; + height: 57px; + } + .home-QR-title-wrap { + flex: 1; + } + .home-QR-title-content { + font-size: 20px; + height: 24px; + line-height: 120%; + color: #333; + font-weight: 600; + margin-bottom: 12px; + } + .home-QR-title-content-other { + font-size: 14px; + color: #333; + } + .home-QR-content { + display: flex; + flex-direction: column; + } + .home-QR-content span { + font-size: 14px; + line-height: 25px; + color: #333; + } + + /*侧边栏的背景图*/ + .home-right-item-wrap.hot-article, + .home-right-item-wrap.notice, + .home-right-item-wrap.column, + .home-right-item-wrap.pdf { + padding: 0; + --tw-bg-opacity: 1; + background-color: rgba(255, 255, 255, var(--tw-bg-opacity)); + } + + .hot-article-bg, + .notice-bg, + .column-bg, + .pdf-bg { + height: 74px; + margin-top: -14px; + } + + .hot-article + .home-right-item-article-list + .home-right-item-article-item:nth-child(1) + i { + background: var(--pai-brand-6-mq); + } + + .hot-article + .home-right-item-article-list + .home-right-item-article-item:nth-child(2) + i { + background: var(--pai-brand-5-bak); + } + + .hot-article + .home-right-item-article-list + .home-right-item-article-item:nth-child(3) + i { + background: var(--pai-brand-3-click); + } + + .hot-article .home-right-item-article-list .home-right-item-article-item i { + height: 18px; + width: 18px; + line-height: 18px; + background: #ccd0d7; + color: #fff; + display: inline-block; + text-align: center; + } + + .hot-article-content, + .notice-content, + .column-content { + padding: 20px; + margin-top: -20px; + } + + .home-right-item-post-list a:hover, + .home-right-item-article-list a:hover { + color: var(--pai-brand-2-hover); + } + + .com-2-side-activity-desc { + margin-top: 4px; + font-size: 14px; + line-height: 24px; + color: var(--pai-color-999-gray); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + max-height: 48px; + } + + .c-btn-small, + .c-btn.s, + .c-btn.small { + height: 28px; + line-height: 26px; + min-width: 66px; + padding: 0 5px; + font-size: 12px; + } + + .com-2-side-activity-title { + font-size: 14px; + line-height: 24px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + + /*星球海报*/ + .com-2-panel-subhd { + position: relative; + margin-top: 20px; + margin-bottom: 12px; + line-height: 20px; + color: var(--pai-color-999-gray); + } + + .com-2-panel-subhd:before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 1px; + background-color: #e5e5e5; + } + .com-2-panel-subtitle { + font-size: 12px; + position: relative; + display: inline-block; + box-sizing: border-box; + max-width: 100%; + font-weight: 400; + padding-right: 8px; + background-color: #fff; + } + + .com-event-panel.without-margin { + margin-bottom: 0; + } + .com-event-panel-inner { + box-sizing: border-box; + display: table; + width: 100%; + table-layout: fixed; + } + .com-event-panel-l .com-event-panel-object { + display: block; + width: auto; + } + .com-event-panel-l.theme2 .com-event-panel-img { + width: 100%; + height: auto; + } + + /*精选教程*/ + .com-media { + margin-bottom: 20px; + display: table; + table-layout: fixed; + width: 100%; + box-sizing: border-box; + } + + .com-2-side-topics.without-margin > li:last-child .com-side-topic { + margin-bottom: 0; + } + + .com-2-side-activity .com-media-object { + width: 60px; + vertical-align: middle; + } + + .column-content .com-side-topic-title { + font-weight: 500; + font-size: 14px; + line-height: 22px; + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .com-media-object { + display: table-cell; + vertical-align: middle; + box-sizing: content-box; + width: 40px; + } + + .com-side-topic .com-media-object { + width: 60px; + vertical-align: middle; + padding-right: 20px; + } + + .com-media-body { + display: table-cell; + vertical-align: top; + box-sizing: border-box; + } + + .com-thumbnail { + display: block; + width: 236px; + height: 177px; + background-size: cover; + background-repeat: no-repeat; + background-position: 50%; + } + + .com-side-topic .com-thumbnail { + width: 71px; + height: 100px; + border-radius: 2px; + } + + .com-side-topic-title { + font-size: 14px; + line-height: 24px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + + .com-side-topic-desc { + margin-top: 4px; + font-size: 14px; + line-height: 24px; + color: var(--pai-color-999-gray); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + max-height: 48px; + } + + .column .vip-free-tag { + width: 30px; + height: 20px; + line-height: 20px; + display: inline-block; + vertical-align: middle; + margin-right: 3px; + transform: translateY(-1px); + background: linear-gradient(90deg, #fde8c3, #edd3a7); + border-radius: 2px; + font-size: 12px; + text-align: center; + font-weight: 500; + color: #7e5d25; + white-space: nowrap; + } + + .column .column-title { + margin-right: 30px; + } + + /*侧边栏的 title*/ + .home-right-item-wrap .com-2-panel-title { + font-size: 18px; + } + + /*侧边栏*/ + .col-body { + box-sizing: border-box; + margin: 30px auto 60px; + padding: 0 10px; + max-width: 1200px; + } + .pg-2-article { + margin-top: 20px; + } + + .com-3-layout { + display: table; + table-layout: fixed; + margin-bottom: 60px; + box-sizing: border-box; + width: 100%; + } + + .com-3-layout > .layout-main { + display: table-cell; + vertical-align: top; + padding-left: 20px; + } + + .com-3-layout > .layout-main:first-child { + padding-right: 20px; + padding-left: 0; + } + + .pg-2-article .com-3-layout > .layout-main, + .pg-2-article .com-crumb { + padding-left: 60px; + } + + .com-3-layout > .layout-side { + display: table-cell; + vertical-align: top; + width: 300px; + } + .com-2-panel { + margin-bottom: 20px; + box-sizing: border-box; + background-color: var(--pai-color-fff-normal); + padding: 32px; + box-shadow: 0 2px 4px 0 rgb(3 27 78 / 6%); + } + + + + /*作者介绍*/ + .com-2-panel.side { + padding: 20px; + } + + .com-2-panel.side .com-2-panel-hd { + margin-bottom: 20px; + } + .com-2-panel.side .com-2-panel-title { + font-size: 16px; + line-height: 26px; + font-weight: 500; + } + + .com-author-intro { + text-align: center; + } + + .com-author-intro-object { + margin-bottom: 20px; + } + + .com-author-intro-name { + margin-bottom: 8px; + font-size: 16px; + line-height: 26px; + } + .com-author-intro-btns { + margin-top: 20px; + font-size: 0; + } + + .com-author-intro-infos { + margin-top: 28px; + padding-top: 15px; + border-top: 1px solid #e5e5e5; + font-size: 0; + } + /*头像*/ + .com-author-intro-avatar:only-child { + margin-bottom: 0; + } + .com-author-intro-avatar { + display: block; + margin: 0 auto -11px; + width: 80px; + height: 80px; + border-radius: 50%; + background-position: 50%; + background-repeat: repeat; + background-size: cover; + background-color: #d1d4db; + box-sizing: border-box; + border: 1px solid #d1d4db; + } + + .join-days { + font-size: 12px; + color: var(--pai-color-999-gray); + } + + a:hover .join-days { + color: inherit; + } + + /*作者介绍区域的一个小背景*/ + .com-2-panel.internal:before { + content: ""; + position: absolute; + width: 111px; + height: 138px; + background: url(../../img/icon-decorate_edc.svg) 50% no-repeat; + top: -2px; + right: -20px; + } + + .com-2-panel.internal { + position: relative; + overflow: hidden; + } + + /*加入天数*/ + .com-author-intro-desc { + position: relative; + display: inline-block; + vertical-align: middle; + box-sizing: border-box; + max-width: 100%; + padding-right: 20px; + font-size: 12px; + line-height: 18px; + color: var(--pai-color-999-gray); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .com-verification { + position: relative; + top: -1px; + display: inline-block; + width: 12px; + height: 12px; + font-size: 0; + vertical-align: middle; + margin: -1px 3px 0; + line-height: 12px; + } + + .com-author-intro-desc .com-verification { + position: absolute; + right: 0; + top: 3px; + } + + .com-verification .verified { + display: inline-block; + width: 100%; + height: 100%; + background-image: url(../../img/icon-verified_804.svg); + } + + /*取消关注*/ + .com-author-intro-btns .c-btn { + min-width: 88px; + margin: 0 5px; + } + + .c-btn { + height: 32px; + min-width: 88px; + padding: 0 16px; + background-color: var(--pai-brand-1-normal); + border: 1px solid transparent; + color: #fff; + font-size: 14px; + line-height: 30px; + text-align: center; + display: inline-block; + cursor: pointer; + outline: 0 none; + box-sizing: border-box; + border-radius: 0; + } + + .c-btn:hover { + text-decoration: none; + background-color: var(--pai-brand-2-hover); + } + + /*教程*/ + .c-btn-hole, + .c-btn-hole:hover { + border: 1px solid var(--pai-brand-2-hover); + color: var(--pai-brand-2-hover); + } + + .c-btn-hole { + background-color: transparent; + line-height: 30px; + } + + .c-btn-hole:hover { + background-color: var(--pai-brand-7-light); + } + + /*阅读和点赞*/ + .com-author-intro-info { + display: inline-block; + vertical-align: top; + width: 24%; + text-align: center; + font-size: 12px; + line-height: 20px; + color: var(--pai-color-999-gray); + padding-top: 5px; + padding-bottom: 5px; + } + + .com-author-intro-info-link { + display: block; + color: inherit; + margin: -5px 0; + padding: 5px 0; + } + + .com-author-intro-info-num { + margin-top: 4px; + font-size: 16px; + line-height: 28px; + height: 28px; + font-weight: 500; + } + + .com-author-intro-info-link:hover { + background-color: #f3f5f9; + } + + .col-2-article { + position: relative; + word-wrap: break-word; + } + + /* 文章部分 */ + .detail-head-img { + width: 100%; + max-height: 432px; + object-fit: cover; + margin-bottom: 20px; + } + + /*文章标题*/ + .article-info-title { + font-size: 26px; + line-height: 31px; + vertical-align: bottom; + margin-bottom: 22px; + } + /*原创标签*/ + .com-2-mark-triangle { + position: relative; + width: 48px; + height: 48px; + line-height: 18px; + font-weight: 500; + color: #ff7800; + font-size: 12px; + } + + .com-2-mark-triangle:before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; + border-color: #faece0 #faece0 transparent transparent; + border-style: solid; + border-width: 24px; + } + + .col-2-article .article-mark { + position: absolute; + right: 0; + top: 0; + } + + .com-2-mark-triangle .mark-cnt { + position: absolute; + right: 0; + top: 50%; + z-index: 2; + margin-top: -16px; + margin-right: -8px; + width: 100%; + text-align: center; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + } + + /*电子书的星标*/ + .ebook-home-stars { + align-items: center; + } + + .ebook-def-star { + height: 16px; + width: 92px; + background: url(../../img/star.png) no-repeat 0 / auto 100%; + position: relative; + } + + .ebook-home-stars .ebook-star-count { + font-size: 16px; + line-height: 22px; + margin-left: 6px; + } + + .ebook-home-stars .ebook-def-star .ebook-cur-star { + height: 16px; + background: url(../../img/star-lighten.png) no-repeat 0 / auto 100%; + position: absolute; + left: 0; + top: 0; + } + + .ebook-home-info .ebook-home-view { + font-size: 13px; + color: var(--pai-color-999-gray); + line-height: 18px; + padding-left: 20px; + background: url(../../img/eye.png) no-repeat 0/16px auto; + } + + .ebook-home-info .ebook-home-download { + font-size: 13px; + color: var(--pai-color-999-gray); + line-height: 18px; + padding-left: 16px; + margin-left: 16px; + background: url(../../img/download.png) no-repeat 0/12.5px auto; + } + + .rank-box-item-right { + flex: 1; + overflow: hidden; + height: 100px; + display: flex; + flex-direction: column; + justify-content: space-between; + } + + .pdf .com-thumbnail:after { + content: "立即下载"; + width: 60px; + height: 20px; + background-image: linear-gradient(270deg, #ffa400, #ff791a); + font-size: 12px; + color: var(--pai-color-fff-normal); + font-weight: 500; + text-align: center; + line-height: 20px; + position: absolute; + } + +.cdc-card { + background: linear-gradient(1turn,var(--pai-bg-white-fff),var(--pai-color-6-gray)); + border: 2px solid var(--pai-bg-white-fff); + box-shadow: 8px 8px 20px rgb(55 99 170 / 10%); +} + +.cdc-card__inner { + box-sizing: border-box; + padding: 20px; +} + +.cdc-card__hd { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + padding: 2px 0 12px; + border-bottom: 1px solid var(--pai-border-color-1); +} + +.mod-subscribe .cdc-card__hd { + border-bottom: none; + padding-bottom: 16px; +} + +.cdc-card__title { + flex: 1 1; + position: relative; + font-weight: 500; + font-size: 18px; + line-height: 26px; + color: var(--pai-color-3-black); +} + +.cdc-card__title:before { + content: ""; + position: absolute; + display: block; + top: 4px; + left: -12px; + width: 3px; + height: 18px; + background: var(--pai-brand-1-normal); +} + +.mod-subscribe__title { + font-weight: 500; + font-size: 16px; + line-height: 28px; + color: var(--pai-brand-1-normal); + margin-bottom: 16px; +} + +.mod-subscribe__content { + position: relative; + background: var(--pai-color-7-gray); + border-radius: 2px; + box-sizing: border-box; + padding: 14px 16px; +} + +.mod-subscribe__title em { + font-weight: 500; + font-style: normal; + padding: 0 2px; + color: var(--pai-brand-5-bak); +} + +.mod-subscribe__content-tag { + position: absolute; + left: -4px; + top: 8px; + width: 77px; + height: 26px; + line-height: 26px; + background: url(../../img/you-will-get.png) 50% no-repeat; + background-size: cover; + box-sizing: border-box; + padding-left: 8px; + font-size: 14px; + color: var(--pai-color-fff-normal); +} + +.mod-subscribe__content-inner { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mod-subscribe__content-detail { + margin-top: 14px; +} + +.mod-subscribe__content-qr { + width: 100px; + height: 100px; + flex-shrink: 0; + margin-left: 12px; + background: linear-gradient(180deg,var(--pai-color-5-gray),var(--pai-color-fff-normal)); + border: 2px solid var(--pai-color-fff-normal); + box-shadow: 8px 8px 20px rgb(55 99 170 / 10%), -8px -8px 20px hsl(0deg 0% 100% / 40%); + border-radius: 4px; + box-sizing: border-box; + padding: 2px; +} + +.mod-subscribe__content-qr>img { + width: 100%; + height: 100%; +} + +.mod-subscribe__content-prize { + font-weight: 500; + font-size: 14px; + line-height: 22px; + color: var(--pai-color-3-gray); + margin-bottom: 8px; + margin-top: 16px; +} + +.mod-subscribe__image { + margin-top: 16px; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(55, 99, 170, 0.1); +} + +.mod-subscribe__image a { + display: block; +} + +.mod-subscribe__image img { + width: 100%; + height: auto; + display: block; + transition: transform 0.3s ease; +} + +.mod-subscribe__image:hover img { + transform: scale(1.02); +} + +/* PDF侧边栏浮动样式 */ +.pdf.home-right-item-wrap.floating { + position: fixed; + top: 10px; + width: 300px; + z-index: 11; + margin-bottom: 20px; + /* left值通过JavaScript动态设置,与目录保持一致 */ +} + +/* PDF侧边栏隐藏状态 */ +.pdf.home-right-item-wrap.floating.hidden { + display: none !important; +} + +/* PDF侧边栏关闭按钮 */ +.pdf-close-btn { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 12; + transition: all 0.3s ease; +} + +.pdf-close-btn:hover { + background-color: rgba(0, 0, 0, 0.7); + transform: scale(1.1); +} + +.pdf-close-btn::before, +.pdf-close-btn::after { + content: ''; + position: absolute; + width: 12px; + height: 2px; + background-color: #fff; +} + +.pdf-close-btn::before { + transform: rotate(45deg); +} + +.pdf-close-btn::after { + transform: rotate(-45deg); +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/global.css b/paicoding-ui/src/main/resources/static/css/global.css new file mode 100644 index 000000000..625185597 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/global.css @@ -0,0 +1,1700 @@ +/*技术派的整体色系*/ +body { + /*正常色*/ + --pai-brand-1-normal: #ff6900; + /*鼠标放上去后的颜色*/ + --pai-brand-2-hover: #ff8721; + /*点击后激活后的颜色*/ + --pai-brand-3-click: #f59e2f; + /*不可用的颜色*/ + --pai-brand-4-disable: #d1a278; + /*备用色 非常深的颜色*/ + --pai-brand-5-bak: #fe6617; + /*消息提醒点*/ + --pai-brand-6-mq: #f85959; + --pai-brand-7-light: rgba(255, 105, 0, 0.15); + /*浅色时由上一个过度到下一个*/ + --pai-brand-8-light: rgba(255, 105, 0, 0.35); + /*颜色白色*/ + --pai-color-fff-normal: #fff; + /*浅灰色*/ + --pai-color-999-gray: #999; + /*另外一种灰*/ + --pai-color-3-gray: #8a919f; + /*另外一种灰 深一点*/ + --pai-color-4-gray: #515767; + /*disable 的灰*/ + --pai-color-5-gray: #ddd; + /*非常浅的灰*/ + --pai-color-6-gray: #f3f5f8; + /*比上面更灰一点*/ + --pai-color-7-gray: #eff3f9; + /*偏黑色*/ + --pai-color-3-black: #212529; + /*背景色*/ + --pai-bg-white-fff: #fff; + /*淡黄色*/ + --pai-bg-normal-1: #fff3db; + --pai-bg-light-1: #eeeeee; + /*浅一点的颜色*/ + --pai-bg-light-2: #f7f8fa; + /*背景色浅一点*/ + --pai-bg-dark-1: #373d41; + /*主题色深一点*/ + --pai-bg-dark-2: #2c3134; + /*主题色更深一点*/ + --pai-bg-dark-3: #000000; + /*讯飞星火的左侧聊天背景色*/ + --pai-bg-smart-chat: #f5f5f5; + /*横线的颜色*/ + --pai-hr-color-1: #f0f0f0; + /*边框颜色*/ + --pai-border-color-1: #d2d9e7; + /*字体*/ + --pai-font-2: #515767; + + /*边框*/ + --color-canvas-default: transparent; + /*消息的宽度*/ + --message-max-width: 80%; + /*左边宽度*/ + --pai-sidebar-width: 360px; + /*卡片的阴影*/ + --card-shadow: 0px 2px 4px 0px rgba(0,0,0,.05); + --window-height: calc(100vh - 170px); + --window-width: 90vw; + --window-content-width: calc(100% - var(--pai-sidebar-width)); + --second: #e7f8ff; + /*外边距*/ + margin: 0; +} + +/*全局的色调往前面放*/ +ul { + margin: 0; + padding: 0; +} + +li { + list-style: none; +} + +a { + color: var(--pai-color-3-black); +} + +a.underline { + text-decoration: underline; + color: var(--pai-brand-1-normal); +} + +a:hover.underline { + text-decoration: underline; + color: var(--pai-brand-2-hover); +} + +a:hover { + text-decoration: none; + color: var(--pai-brand-2-hover); +} + +input:focus,.form-control:focus { + border: 1px solid var(--pai-brand-3-click); + outline: none; + box-shadow: none; +} + +textarea { + resize: none; + font-size: 14px; +} + +input::placeholder, +textarea::placeholder { + font-size: 14px; +} + +textarea:focus { + border: 1px solid var(--pai-brand-3-click); + outline: none; +} + +button, +button:focus { + outline: none; + box-shadow: none !important; +} + +/*接下来是 ID 的*/ +#scrollUp { + bottom: 82px; + right: 20px; + width: 46px; + height: 44px; + position: absolute; + border-color: transparent; + background-color: var(--pai-color-fff-normal); + transition: all 0.2s ease-in-out; + outline: none; + padding-top: 8px; + padding-left: 12px; +} + +#scrollUp:before { + content: ""; + display: inline-block; + vertical-align: middle; + width: 22px; + height: 12px; + background-image: url(../img/widget-top_2e4.svg); +} + +#scrollUp:hover { + background-color: var(--pai-brand-7-light); +} + +#scrollUp-active { + display: none; +} + +/* 登录、注册弹窗 */ + +.modal .modal-dialog { + max-width: 600px; +} + +.modal .title { + font-size: 0.8rem; + font-weight: 500; + margin: 0 0 1rem; +} + +.login-main, .register-main { + width: 32rem; +} + +.modal .mdnice-user-dialog-footer { + display: flex; + justify-content: space-between; + padding: 10px 20px; + font-size: 0.8rem; +} + +.modal .modal-footer { + justify-content: center; +} + +.modal a:not([href]) { + color: var(--pai-brand-1-normal); + cursor: pointer; +} + +.mdnice-user-dialog-footer a { + color: var(--pai-brand-1-normal); +} + +.mdnice-user-dialog-footer a:hover { + color: var(--pai-brand-2-hover); +} + +.modal a:not([href]):hover { + color: var(--pai-brand-2-hover); +} + +.modal .mdnice-user-dialog-footer p { + margin: 0; +} + +.modal .close { + z-index: 10; + padding: 0; + color: rgba(0, 0, 0, 0.45); + font-weight: 700; + line-height: 1; + text-decoration: none; + background: transparent; + border: 0; + outline: 0; + cursor: pointer; + transition: color 0.3s; +} + +.modal .close span { + display: block; + width: 54px; + height: 54px; + line-height: 54px; + text-align: center; + text-transform: none; + text-rendering: auto; +} + +.modal .modal-content { + border-radius: 8px; +} + +.modal .modal-content h2 { + text-align: center; + margin-bottom: 10px; +} + +.other-login-box { + display: flex; + margin-top: 1rem; + color: var(--pai-font-2); + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.auth-body { + display: flex; +} + +.oauth-box, .oauth { + display: flex; + align-items: center; + flex-direction: row; + font-size: 0.8rem; +} + +.oauth-bg { + border-radius: 50%; + background-color: var(--pai-bg-light-1); + justify-content: center; + margin-right: 8px; +} + +.clickable { + cursor: pointer; + font-size: 0.8rem; +} + + +.modal .modal-content .modal-body .tabpane-container { + border-left: 1px solid var(--pai-border-color-1); + margin-left: 2rem; + display: flex; + flex-direction: column; + justify-content: center; + width: 80%; +} + +.modal input, .modal input::placeholder { + font-size: 0.8rem; +} + +.modal .first { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.modal .first .signin-qrcode { + width: 140px; +} + +.modal .explain { + text-align: center; + font-size: 12px; +} + +.modal #code { + color: var(--pai-brand-6-mq); + font-size: 20px; +} + +/*接下来是 class 的*/ + +/*需要背景色主动添加,一种浅灰色,腾讯云社区的背景色*/ +.bg-color { + background-color: var(--pai-bg-light-2); +} + +/*纯白色*/ +.bg-color-white { + background-color: var(--pai-bg-white-fff); +} + +.dropdown-item { + color: var(--pai-color-3-black); +} + +/*下拉菜单的颜色覆盖*/ +.dropdown-item.active, +.dropdown-item:active { + color: var(--pai-color-3-black); + background-color: var(--pai-brand-3-click); +} + +.dropdown-item:focus, +.dropdown-item:hover { + background-color: var(--pai-bg-normal-1); +} + +/*覆盖 bootstrap*/ +.card { + border: none; + margin-bottom: 20px; +} + +/*右侧贴片*/ +.home-right, +.custom-home-right { + width: 24%; + border-radius: 20px; + position: relative; +} + +.custom-home-right { + width: 28%; +} + +/*评论*/ +.custom-empty { + text-align: center; + margin-top: 20px; + width: 100%; + font-size: 1rem; +} + +/*页面整体的背景色*/ +.posts-comment-input-box, +.posts-author-box, +.posts-box, +.page-box, +.user-info-box { + background-color: #fff; +} + +.btn-outline-primary:hover, +.page-item.active .page-link, +.current-page { + color: #fff !important; +} + +.bottom-line, +.list-group, +.editor-title { + border-bottom: 1px solid rgba(0, 0, 0, 0.125); +} + +.faq-solution-box, +.posts-comment-input-box { + background-color: #fafbfc; +} + +.posts-comment-input-box { + margin-top: -20px; +} + +.posts-comment-box, +.posts-author-box, +.posts-box, +.editor-form-box, +.editor-title, +.card-body, +.card-header { + padding: 20px; +} + +.type-box { + padding: 10px; +} + +.tag-box, +.no-comment-box, +.posts-author-box, +.posts-box, +.user-info-box, +.page-box, +.carousel { + margin-bottom: 0px; +} + +/*按钮*/ +.custom-theme-bg-color, +.btn-outline-primary:hover, +.page-item.active .page-link, +.current-page, +.btn-primary, +.btn-primary:active, +.btn-primary:focus { + border-color: var(--pai-brand-1-normal); + background-color: var(--pai-brand-1-normal); +} + +.btn-primary:hover { + border-color: var(--pai-brand-2-hover); + background-color: var(--pai-brand-2-hover); +} + +.btn-primary:focus { + border-color: var(--pai-brand-2-hover); + box-shadow: none; +} + +.btn-primary:not(:disabled):not(.disabled).active, +.btn-primary:not(:disabled):not(.disabled):active, +.show > .btn-primary.dropdown-toggle { + background-color: var(--pai-brand-2-hover); + border-color: var(--pai-brand-2-hover); +} + +.page-link, +.page-link:hover, +.btn-outline-primary, +.posts-admin-tag-official, +.custom-font-color { + color: var(--pai-bg-normal-1); +} + +.dropdown-menu { + border: 0; +} + +.input-group-text, +.navbar-toggler, +.modal-content { + margin-bottom: 10px; +} + +.form-control, +.btn, +.dropdown-menu, +.list-group-item:first-child, +.list-group-item:last-child, +.pagination { + border-radius: 0; +} + +.posts-list-desc { + color: rgba(0, 0, 0, 0.87); +} + +.custom-by-both { + padding-left: 10px; + padding-right: 10px; +} + +.carousel-inner img { + width: 100%; + height: 100%; +} + +.posts-list-desc { + display: inline; + max-height: 48px; + text-overflow: -o-ellipsis-lastline; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} + +.posts-list-title { + font-size: 18px; + font-weight: 700; + line-height: 1.5; + margin-bottom: 5px; + color: rgba(0, 0, 0, 0.85); +} + +.posts-list-payload-item, +.posts-list-payload-item a, +.posts-list-payload-box-author { + color: #6c757d !important; +} + +.posts-list-payload-item { + padding: 3px 8px; +} + +.page-box { + padding: 10px; +} + +.faq-solution-box { + margin-top: 10px; + padding: 15px; +} + +.faq-solution-box, +.posts-list-desc { + font-size: 13px; + line-height: 24px; +} + +.posts-admin-tag { + margin-top: 4px; + height: 16px; + padding: 2px; + border-radius: 2px; + line-height: 1; + font-size: 12px; + margin-right: 6px; + vertical-align: middle; + -webkit-transform: translateY(1px); + -ms-transform: translateY(1px); + transform: translateY(1px); +} + +.posts-admin-tag-official { + background: rgba(101, 212, 117, 0.1); +} + +.posts-admin-tag-top { + color: #f85959; + background: rgba(248, 89, 89, 0.1); +} + +.posts-admin-tag-marrow { + color: #3c8cff; + background: rgba(60, 140, 255, 0.1); +} + +.selected-domain { + border-bottom: 1px solid var(--pai-brand-1-normal); +} + +.selected-domain a { + /*color: var(--pai-brand-1-normal);*/ +} + +.user-info-box { + height: 200px; + width: 100%; + margin-left: 0; + margin-right: 0; +} + +.user-info-date-box { + height: 80px; + padding-top: 40px; + padding-left: 40px; +} + +.user-info-date-box > p { + display: inline-block; +} + +.user-info-desc-box { + margin-top: 15px; + padding-left: 40px; + padding-right: 40px; +} + +.input-icon { + position: relative; +} + +.input-icon input { + border-radius: 26px; +} + +.input-icon-addon { + position: absolute; + top: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + color: rgb(179 174 174 / 60%); + pointer-events: none; + font-size: 1.2em; +} + +.input-icon .form-control:not(:first-child), +.input-icon .form-select:not(:last-child) { + padding-left: 2.5rem; +} + +.list-group-item { + border: none; + padding: 0.75rem 1.25rem; +} + +.btn { + padding-left: 25px; + padding-right: 25px; +} + +.btn-sm { + padding-left: 15px; + padding-right: 15px; +} + +.page-item:first-child .page-link, +.page-item:last-child .page-link { + border-radius: 0; +} + +.comment-avatar-box { + width: 40px; + float: left; +} + +.posts-comment-input-box-btn { + width: 100%; + display: none; +} + +.posts-comment-input-box-textarea { + padding: 4px 10px; + font-size: 13px; + line-height: 1.7; +} + +.posts-comment-input-box-textarea, +.comment-content-box { + width: calc(100% - 40px); + float: right; +} + +.best-answer { + margin-left: 20px; +} + +.best-answer:hover, +.reply-comment:hover { + cursor: pointer; +} + +.comment-content-box-title { + font-size: 16px; + color: #3d464d; + font-weight: 300; +} + +.comment-content-box-content { + color: #505050; + font-size: 14px; + margin: 12px 0; +} + +.comment-content-box-foot { + color: #b2b2b2; + font-size: 14px; +} + +.message-block { + margin-right: 10px; +} + +.third-oauth-login-box::before { + content: "三方账号登录"; + position: absolute; + left: 50%; + bottom: 55px; + font-size: 10px; + transform: translateX(-50%); + -webkit-transform: translate(-50%, -50%); + padding: 0 10px; + background-color: #fff; +} + +.third-oauth-login-box { + display: block; + text-align: center; +} + +.hidden { + display: none; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.block { + display: block; +} + +.z-10 { + z-index: 10; +} + +.left-0 { + left: 0; +} + +.opacity-100 { + opacity: 1; +} + +.opacity-0 { + opacity: 0; +} + +.z-50 { + z-index: 50; +} + +.w-full { + width: 100%; +} + +.w-0 { + width: 0; +} + +.-ml-2 { + margin-left: -0.5rem; +} + +.justify-center { + justify-content: center; +} + +.lg\:max-w-lg { + max-width: 32rem; +} + +.lg\:text-primary-900 { + --tw-text-opacity: 1; + color: #007fff; +} + +.lg\:py-1 { + padding-bottom: 0.25rem; + padding-top: 0.25rem; +} + +.lg\:bg-transparent { + background-color: transparent; +} + +.text-sm { + font-size: 1rem; + line-height: 2rem; +} + +.px-2\.5 { + padding-left: 0.625rem; + padding-right: 0.625rem; +} + +.border-search { + border-width: 1px; +} + +.svg-inline--fa.fa-w-10 { + width: 0.625em; +} + +.border-gray-400 { + --tw-border-opacity: 1; + border-color: var(--pai-bg-dark-1); +} + +.border-gray-500 { + --tw-border-opacity: 1; + border-color: var(--pai-bg-dark-2); +} + +.text-gray-100 { + --tw-text-opacity: 1; + color: var(--pai-bg-dark-1); +} + +.text-primary-300 { + color: var(--pai-brand-1-normal); +} + +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: var(--pai-bg-dark-1); +} + +.border-b { + border-bottom-width: 1px; +} + +.border-w-1 { + border-width: 1px; +} + +.font-bold { + font-weight: 700; +} + +.search-no-result p { + line-height: 2.6rem; +} + +.inline-block { + display: inline-block; +} + +/*文章详情*/ +.article-content h1, +.article-content h2, +.article-content h3, +.article-content h4, +.article-content h5 { + color: var(--pai-brand-1-normal); + margin-bottom: 10px; + padding-bottom: 7px; +} + +.article-detail .home-right-item-wrap .com-2-panel-title { + font-size: 16px; +} + +.article-content img { + max-width: 100%; + box-sizing: content-box; + background-color: #fff; + margin: 0 auto; + display: block; +} + +.article-content h1 { + border-bottom: 1px solid #eaecef; + font-size: 1.7em; +} + +.article-content h2 { + font-size: 1.5em; +} + +.article-content h3 { + font-size: 1.3em; +} + +.article-content h4 { + font-size: 1.1em; +} + +.article-content h5 { + font-size: 1em; +} + +.article-content strong { + color: var(--pai-brand-1-normal); +} + +.article-content p, +.article-content ol, +.article-content ul, +.article-content table, +.article-content pre, +.article-content blockquote { + /* font-weight: 400; */ + line-height: 1.8; + margin-bottom: 15px; +} + +.article-content blockquote { + padding: 0 1em; + color: #6a737d; + border-left: 0.25em solid #dfe2e5; +} + +.article-content ol, +.article-content ul { + padding-left: 20px; +} + +.article-content table { + display: table; + border-collapse: separate; + border-spacing: 2px; + border-color: grey; + border-spacing: 0; + border-collapse: collapse; + font-size: 14px; +} + +.article-content table th, +.article-content table tr, +.article-content table td { + padding: 6px 13px; + border: 1px solid #dfe2e5; +} + +.article-content pre { + padding: 5px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #fafafa; + border-radius: 3px; + word-wrap: normal; +} + +.article-content pre div { + background-color: #fafafa; +} + +.article-content li { + list-style: disc; + line-height: 1.4; + font-size: 15px; + margin-bottom: 5px; +} + +.article-content p a,.article-content li a { + color: var(--pai-brand-1-normal); + text-decoration: underline; +} + +.article-content p a:hover,.article-content li a:hover { + color: var(--pai-brand-2-hover); +} + +.article-content .hljs-center { + text-align: center; +} + +.article-content .hljs-left { + text-align: left; +} + +.article-content .hljs-right { + text-align: right; +} + +.article-suspended-panel { + position: fixed; + margin-left: -80px; + top: 140px; + z-index: 2; +} + +.article-suspended-panel-md { + position: fixed; + width: 100%; + bottom: 0; + z-index: 2; + display: flex; + justify-content: space-around; + background: #fff; + padding: 12px 0; + visibility: hidden; +} + +.article-suspended-panel-md .panel-btn { + margin-bottom: 0; +} + + +.article-content pre, .article-content pre>code.hljs { + color: #333; + background: #f8f8f8; +} + +.article-content pre>code { + font-size: 12px; + padding: 15px 12px; + margin: 0; + word-break: normal; + display: block; + overflow-x: auto; + color: #333; + background: #f8f8f8; +} + +.article-content code, .article-content pre { + font-family: Menlo,Monaco,Consolas,Courier New,monospace; +} + +.article-content pre>code.copyable.hljs[lang]:before { + right: 70px; +} + +.article-content pre>code.hljs[lang]:before { + content: attr(lang); + position: absolute; + right: 15px; + top: 3px; + color: hsla(0,0%,54.9%,.8); +} + +.article-content pre .copy-code-btn { + position: absolute; + top: 6px; + right: 15px; + font-size: 12px; + line-height: 1; + cursor: pointer; + color: hsla(0,0%,54.9%,.8); + transition: color .1s; +} + +.article-content pre .copy-code-btn:hover { + color: #8c8c8c; +} + +.article-content pre { + position: relative; +} + +/*文章目录*/ +.com-nav-bar { + max-height: none; + overflow-y: auto; + margin-bottom: 20px; +} + +.com-nav-bar-menu { + border-left: 2px solid #e5e5e5; + padding-left: 15px; +} + +.toc { + height: 100%; + word-wrap: break-word; +} + +.com-nav-bar-title { + font-size: 16px; + line-height: 26px; + font-weight: 500; + margin-bottom: 20px; +} + +.com-nav-bar-menu .h2 a, +.com-nav-bar-menu .h3 a { + position: relative; + display: block; + font-size: 14px; + line-height: 24px; + font-weight: 400; +} + +.com-nav-bar-menu .h3 a { + padding-left: 17px; + color: var(--pai-color-4-gray); +} + +.com-nav-bar-menu .h3 a:hover { + padding-left: 17px; + color: var(--pai-brand-2-hover); +} + +.com-nav-bar-menu .h3.active > a, +.com-nav-bar-menu .h3.active > a:hover { + color: var(--pai-brand-2-hover); +} + +.com-nav-bar-menu .h3.active > a:before { + content: ""; + position: absolute; + left: -17px; + top: 0; + width: 2px; + height: 24px; + background-color: var(--pai-brand-2-hover); +} + +.com-nav-bar-menu .h3 > a:before { + left: -34px; +} + +.arCatalog { + position: relative; + margin: 20px 0 0 0; +} + +.arCatalog .arCatalog-line { + position: absolute; + left: 7px; + top: -5px; + width: 0; + border: 1px solid #eaeaea; + border-top: 0; + border-bottom: 0; + background-color: #eaeaea; +} + +.arCatalog .arCatalog-body { + margin-left: -2px; + padding-left: 27px; + overflow-y: auto; + font-size: 14px; +} + +.arCatalog .arCatalog-body dl { + margin: 0; + transition: 0.3s all; +} + +.arCatalog-body .arCatalog-tack1 .arCatalog-index { + position: absolute; + left: 2px; + top: 0; + color: #bbb; +} + +.arCatalog-body dd.on a { + color: var(--pai-brand-1-normal); +} + +.arCatalog-body .arCatalog-tack1 a { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 260px; +} + +.arCatalog-body .arCatalog-tack1 .arCatalog-dot { + position: absolute; + left: -21px; + top: 11px; + width: 8px; + height: 8px; + background-color: #eaeaea; + border: 1px solid #fff; + border-radius: 2em; +} + +.arCatalog-body dd.on .arCatalog-dot { + position: absolute; + left: -23px; + top: 8px; + width: 12px; + height: 12px; + background-color: #fd7013; + box-shadow: 0px 0px 2px 2px rgb(253 112 19 / 50%); + border-radius: 2em; + border-width: 0; +} + +.arCatalog .arCatalog-body dd { + position: relative; + margin: 0; + font-size: 15px; + line-height: 28px; +} + +.arCatalog-body .arCatalog-tack2 { + padding-left: 17px; +} + +.arCatalog-body .arCatalog-tack2 .arCatalog-index { + position: absolute; + left: 27px; + top: 0; + color: #bbb; +} + +.arCatalog-body .arCatalog-tack2 a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pai-color-3-gray); + max-width: 240px; +} + +.arCatalog .arCatalog-line:before { + content: ""; + position: absolute; + top: -10px; + left: -5px; + display: block; + width: 10px; + height: 10px; + border: 1px solid #d2d2d2; + border-radius: 10px; +} + +.arCatalog .arCatalog-line:after { + content: ""; + position: absolute; + bottom: -10px; + left: -5px; + display: block; + width: 10px; + height: 10px; + border: 1px solid #d2d2d2; + border-radius: 10px; +} +.toc-container { + /* position: sticky; + z-index: 10; + top: 30px; */ + position: fixed; + z-index: 10; + width: 300px; + top: 90px; + right: 0; +} + +.widget { + margin-bottom: 30px; +} + +/* 目录滚动条样式 */ +.widget::-webkit-scrollbar { + width: 6px; +} + +.widget::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.widget::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} + +.widget::-webkit-scrollbar-thumb:hover { + background: #999; +} + +.comment-list-wrap { + padding: 20px; +} + +.short-url-container .btn { + color: white; + background-color: var(--pai-brand-1-normal); border-color: var(--pai-brand-1-normal); +} + +.short-url-container .btn:hover { + background-color: var(--pai-brand-2-hover); +} + +.short-url-container input { + border-color: var(--pai-border-color-1); font-size: 1rem; +} + +.join-star img { + cursor: zoom-in;height: 200px;display: block;margin: 0 auto;object-fit: contain;width: auto; +} + +@media (max-width: 768px) { + #registerModal .modal-content .modal-body .tabpane-container, + #loginModal .modal-content .modal-body .login-main, #loginModal .modal-footer { + display: none; + } + + .join-star img { + width: 100%; + } + + /* 移动端登录框宽度优化 */ + #loginModal .modal-dialog { + max-width: 320px; + margin: 1.75rem auto; + } + + #loginModal .modal-content .modal-body { + padding: 20px 15px; + } + + #loginModal .modal-content .modal-body .tabpane-container { + border-left: none; + margin-left: 0; + width: 100%; + } + + /* 移动端隐藏tabpane-container内的title */ + #loginModal .modal-content .modal-body .tabpane-container .title { + display: none; + } + + .modal input, .modal input::placeholder,.modal .title { + font-size: 1rem; + } + + #loginModal .first .signin-qrcode { + width: 180px; + } +} + +/* 图片说明样式 */ +.article-content figure { + margin: 20px 0; + text-align: center; +} + +/* 所有图片的通用样式 */ +.article-content img { + border: 1px solid var(--pai-color-5-gray); + padding: 5px; + border-radius: 4px; + width: auto !important; + height: auto !important; + display: block; + margin: 0 auto; +} + +.article-content figcaption { + margin-top: 10px; + font-size: 14px; + color: var(--pai-color-3-gray); + font-style: italic; + line-height: 1.6; + text-align: center; +} + +.selected-text-highlight { + cursor: pointer; +} + +/* 引用评论侧边栏样式 */ +.quote-content { + margin-bottom: 3rem; +} +.quote-comment-sidebar { + position: sticky; + top: 20px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: 16px; + max-height: 80vh; + margin-top: 20px; + overflow-y: auto; +} + +.quote-comment-sidebar .widget { + padding: 0; + margin-bottom: 0; +} + +.quote-comment-sidebar .com-nav-bar-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #eee; +} + +.quote-comment-form .comment-list-wrap { + padding: 0; +} + +.quote-text { + background: #f8f9fa; + color: var(--pai-color-999-gray); + border-left: 4px solid var(--pai-brand-2-hover); + padding: 12px; + margin-top: 10px; + margin-bottom: 6px; + font-size: 14px; + line-height: 1.5; + border-radius: 4px; + max-height: 200px; + overflow-y: auto; +} + +.quote-comment-form #quoteCommentInput { + width: 100%; + min-height: 100px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + resize: vertical; + font-size: 14px; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.quote-comment-form #quoteCommentInput:focus { + outline: none; + outline: none !important; + border: none !important; + box-shadow: none !important; +} + +.quote-comment-form .c-btn { + font-size: 14px; +} + +.quote-comment-form .c-btn:disabled { + background-color: var(--pai-brand-4-disable); + cursor: not-allowed; +} + +.quote-comment-form .c-btn:disabled:hover { + background-color: var(--pai-color-5-gray); +} + +/* 引用评论弹窗样式 (移动端) */ +#quoteCommentModal .widget { + border-radius: 8px; + background: white; + margin: 15vh 10vw; + max-height: 70vh; + overflow-y: auto; + padding: 20px; + width: 100vw; +} + +#quoteCommentModal .modal-content { + border-radius: 8px; + background: white; + margin: 15vh 10vw; + max-height: 70vh; + overflow-y: auto; + padding: 0px; +} + +#quoteCommentModal .modal-header { + border-bottom: 1px solid #eee; + padding: 15px 20px; +} + +#quoteCommentModal .modal-title { + font-size: 16px; + font-weight: 600; +} + +#quoteCommentModal .quote-text { + background: #f8f9fa; + color: var(--pai-color-999-gray); + border-left: 4px solid var(--pai-brand-2-hover); + padding: 12px; + margin-top: 10px; + margin-bottom: 16px; + font-size: 14px; + line-height: 1.5; + border-radius: 4px; + max-height: 150px; + overflow-y: auto; +} + +#quoteCommentModal .quote-comment-form textarea { + width: 100%; + min-height: 100px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + resize: vertical; + font-size: 14px; + margin-bottom: 10px; +} + +#quoteCommentModal .quote-comment-form textarea:focus { + outline: none; + border-color: var(--pai-brand-2-hover); + box-shadow: 0 0 0 2px var(--pai-brand-3-click); +} + +#quoteCommentModal .quote-comment-form .c-btn { + width: 100%; + font-size: 14px; +} + +#quoteCommentModal .quote-comment-form .c-btn:disabled { + background-color: var(--pai-brand-4-disable); + cursor: not-allowed; +} + +#quoteCommentModal .quote-comment-form .c-btn:disabled:hover { + background-color: var(--pai-color-5-gray); +} + +/* 评论图标样式 */ +#comment-icon { + position: absolute; + cursor: pointer; + z-index: 1000; + background: var(--pai-brand-8-light); + color: white; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + transition: all 0.2s ease; +} + +#comment-icon:hover { + background: var(--pai-brand-2-hover); + transform: scale(1.1); +} + +/* 评论 Markdown 渲染样式 */ +.comment-content-markdown.markdown-rendered { + word-wrap: break-word; + word-break: break-word; + line-height: 0.5; +} + +.comment-content-markdown.markdown-rendered p { + margin: 0.2em 0; + line-height: 1.5; +} + +.comment-content-markdown.markdown-rendered p:first-child { + margin-top: 0; +} + +.comment-content-markdown.markdown-rendered p:last-child { + margin-bottom: 0; +} + +.comment-content-markdown.markdown-rendered p + p { + margin-top: 0.3em; +} + +.comment-content-markdown.markdown-rendered code { + background-color: #f5f5f5; + color: #e83e8c; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + font-size: 0.9em; +} + +.comment-content-markdown.markdown-rendered pre { + background-color: #f6f8fa; + border: 1px solid #e1e4e8; + border-radius: 4px; + padding: 6px 10px; + overflow-x: auto; + margin: 0.4em 0; + font-size: 0.9em; +} + +.comment-content-markdown.markdown-rendered pre code { + background: none; + color: inherit; + padding: 0; + border-radius: 0; + font-size: 0.85em; +} + +.comment-content-markdown.markdown-rendered strong { + font-weight: 600; +} + +.comment-content-markdown.markdown-rendered em { + font-style: italic; +} + +.comment-content-markdown.markdown-rendered del { + text-decoration: line-through; + opacity: 0.7; +} + +.comment-content-markdown.markdown-rendered a { + color: var(--pai-brand-2-hover); + text-decoration: none; + transition: color 0.2s; +} + +.comment-content-markdown.markdown-rendered a:hover { + color: var(--pai-brand-3-click); + text-decoration: underline; +} + +.comment-content-markdown.markdown-rendered img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 0.4em 0; + display: block; +} + +.comment-content-markdown.markdown-rendered ul, +.comment-content-markdown.markdown-rendered ol { + margin: 0.2em 0; + padding-left: 1.5em; + display: block; +} + +.comment-content-markdown.markdown-rendered ul { + list-style-type: disc !important; + list-style-position: outside !important; +} + +.comment-content-markdown.markdown-rendered ol { + list-style-type: decimal !important; + list-style-position: outside !important; +} + +.comment-content-markdown.markdown-rendered li { + margin: 0 0; + display: list-item !important; + list-style: inherit !important; + line-height: 1.5; + padding: 0.1em 0; +} + +.comment-content-markdown.markdown-rendered p + ul, +.comment-content-markdown.markdown-rendered p + ol { + margin-top: 0; +} + +.comment-content-markdown.markdown-rendered ul + p, +.comment-content-markdown.markdown-rendered ol + p { + margin-top: 0.2em; +} + +.comment-content-markdown.markdown-rendered blockquote { + border-left: 3px solid #dfe2e5; + color: #6a737d; + padding: 0.2em 0.8em; + margin: 0.3em 0; + font-size: 0.95em; +} + +.comment-content-markdown.markdown-rendered h1, +.comment-content-markdown.markdown-rendered h2, +.comment-content-markdown.markdown-rendered h3, +.comment-content-markdown.markdown-rendered h4, +.comment-content-markdown.markdown-rendered h5, +.comment-content-markdown.markdown-rendered h6 { + margin: 0.5em 0 0.2em; + font-weight: 600; + line-height: 1.3; +} + +.comment-content-markdown.markdown-rendered h1:first-child, +.comment-content-markdown.markdown-rendered h2:first-child, +.comment-content-markdown.markdown-rendered h3:first-child { + margin-top: 0; +} + +.comment-content-markdown.markdown-rendered h1 { font-size: 1.4em; } +.comment-content-markdown.markdown-rendered h2 { font-size: 1.2em; } +.comment-content-markdown.markdown-rendered h3 { font-size: 1.05em; } + +.comment-content-markdown.markdown-rendered hr { + border: none; + border-top: 1px solid #e1e4e8; + margin: 0.6em 0; +} + +.comment-content-markdown.markdown-rendered table { + border-collapse: collapse; + width: 100%; + margin: 0.4em 0; + font-size: 0.9em; +} + +.comment-content-markdown.markdown-rendered table th, +.comment-content-markdown.markdown-rendered table td { + border: 1px solid #dfe2e5; + padding: 6px 13px; +} + +.comment-content-markdown.markdown-rendered table th { + background-color: #f6f8fa; + font-weight: 600; +} + +.comment-content-markdown.markdown-rendered table tr:nth-child(even) { + background-color: #f6f8fa; +} \ No newline at end of file diff --git a/forum-ui/src/main/resources/static/css/bootstrap.min.css b/paicoding-ui/src/main/resources/static/css/three/bootstrap.min.css similarity index 100% rename from forum-ui/src/main/resources/static/css/bootstrap.min.css rename to paicoding-ui/src/main/resources/static/css/three/bootstrap.min.css diff --git a/paicoding-ui/src/main/resources/static/css/three/fancybox.css b/paicoding-ui/src/main/resources/static/css/three/fancybox.css new file mode 100644 index 000000000..3d99f68ce --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/fancybox.css @@ -0,0 +1 @@ +:root{--f-button-width: 40px;--f-button-height: 40px;--f-button-border: 0;--f-button-border-radius: 0;--f-button-color: #374151;--f-button-bg: #f8f8f8;--f-button-shadow: none;--f-button-transition: all .15s ease;--f-button-transform: none;--f-button-outline-width: 1px;--f-button-outline-color: rgba(0, 0, 0, .7);--f-button-svg-width: 20px;--f-button-svg-height: 20px;--f-button-svg-stroke-width: 1.5;--f-button-svg-fill: none;--f-button-svg-filter: none;--f-button-svg-opacity: 1;--f-button-svg-disabled-opacity: .5;--f-button-svg-transition: opacity .15s ease;--f-button-svg-transform: none}.f-button{width:var(--f-button-width);height:var(--f-button-height);border:var(--f-button-border);border-radius:var(--f-button-border-radius);color:var(--f-button-color);background:var(--f-button-bg);box-shadow:var(--f-button-shadow);transform:var(--f-button-transform);transition:var(--f-button-transition);backdrop-filter:var(--f-button-backdrop-filter);display:flex;justify-content:center;align-items:center;box-sizing:content-box;position:relative;margin:0;padding:0;pointer-events:all;cursor:pointer;overflow:hidden}@media (hover: hover){.f-button:hover:not([aria-disabled]){color:var(--f-button-hover-color, var(--f-button-color));background-color:var(--f-button-hover-bg, var(--f-button-bg))}}.f-button:active:not([aria-disabled]){color:var(--f-button-active-color, var(--f-button-hover-color, var(--f-button-color)));background-color:var(--f-button-active-bg, var(--f-button-hover-bg, var(--f-button-bg)))}.f-button:focus{outline:none}.f-button:focus-visible{outline:var(--f-button-outline-width) solid var(--f-button-outline-color);outline-offset:var(--f-button-outline-offset);position:relative;z-index:1}.f-button svg{width:var(--f-button-svg-width);height:var(--f-button-svg-height);transform:var(--f-button-svg-transform);fill:var(--f-button-svg-fill);filter:var(--f-button-svg-filter);opacity:var(--f-button-svg-opacity, 1);transition:var(--f-button-svg-transition);stroke:currentColor;stroke-width:var(--f-button-svg-stroke-width);stroke-linecap:round;stroke-linejoin:round;pointer-events:none}.f-button[aria-disabled]{cursor:default}.f-button[aria-disabled] svg{opacity:var(--f-button-svg-disabled-opacity)}[data-panzoom-action=toggleFS] g:first-child{display:flex}[data-panzoom-action=toggleFS] g:last-child{display:none}.in-fullscreen [data-panzoom-action=toggleFS] g:first-child{display:none}.in-fullscreen [data-panzoom-action=toggleFS] g:last-child{display:flex}[data-autoplay-action=toggle] svg g:first-child{display:flex}[data-autoplay-action=toggle] svg g:last-child{display:none}.has-autoplay [data-autoplay-action=toggle] svg g:first-child{display:none}.has-autoplay [data-autoplay-action=toggle] svg g:last-child{display:flex}:fullscreen [data-fullscreen-action=toggle] svg [data-fullscreen-action=toggle] svg g:first-child{display:none}:fullscreen [data-fullscreen-action=toggle] svg [data-fullscreen-action=toggle] svg g:last-child{display:flex}:root{--f-spinner-color-1: rgba(0, 0, 0, .1);--f-spinner-color-2: rgba(17, 24, 28, .8);--f-spinner-width: 50px;--f-spinner-height: 50px;--f-spinner-border-radius: 50%;--f-spinner-border-width: 4px}.f-spinner{position:absolute;top:50%;left:50%;margin:calc(var(--f-spinner-width) * -.5) 0 0 calc(var(--f-spinner-height) * -.5);padding:0;width:var(--f-spinner-width);height:var(--f-spinner-height);border-radius:var(--f-spinner-border-radius);border:var(--f-spinner-border-width) solid var(--f-spinner-color-1);border-top-color:var(--f-spinner-color-2);animation:f-spinner .75s linear infinite,f-fadeIn .2s ease .2s both}@keyframes f-spinner{to{transform:rotate(360deg)}}.f-panzoom,.f-zoomable{position:relative;overflow:hidden;display:flex;align-items:center;flex-direction:column}.f-panzoom:before,.f-panzoom:after,.f-zoomable:before,.f-zoomable:after{display:block;content:""}.f-panzoom:not(.has-controls):before,.f-zoomable:not(.has-controls):before{margin-bottom:auto}.f-panzoom:after,.f-zoomable:after{margin-top:auto}.f-panzoom.in-fullscreen,.f-zoomable.in-fullscreen{position:fixed;top:0;left:0;margin:0!important;width:100%!important;height:100%!important;max-width:none!important;max-height:none!important;aspect-ratio:unset!important;z-index:9999}.f-panzoom__wrapper{position:relative;min-width:0;min-height:0;max-width:100%;max-height:100%}.f-panzoom__wrapper.will-zoom-out{cursor:zoom-out}.f-panzoom__wrapper.can-drag{cursor:move;cursor:grab}.f-panzoom__wrapper.will-zoom-in{cursor:zoom-in}.f-panzoom__wrapper.is-dragging{cursor:move;cursor:grabbing}.f-panzoom__wrapper.has-error{display:none}.f-panzoom__content{display:block;min-width:0;min-height:0;max-width:100%;max-height:100%}.f-panzoom__content.is-lazyloading,.f-panzoom__content.has-lazyerror{visibility:hidden}img.f-panzoom__content{width:auto;height:auto;vertical-align:top;object-fit:contain;transition:none;user-select:none}.f-panzoom__wrapper>.f-panzoom__content{visibility:hidden}.f-panzoom__viewport{display:block;position:absolute;top:0;left:0;width:100%;height:100%;z-index:1}.f-panzoom__viewport>.f-panzoom__content{width:100%;height:100%;object-fit:fill}picture.f-panzoom__content img{vertical-align:top;width:100%;height:auto;max-height:100%;object-fit:contain;transition:none;user-select:none}.f-panzoom__protected{position:absolute;inset:0;z-index:1;user-select:none}html.with-panzoom-in-fullscreen{overflow:hidden}.f-fadeIn{animation:var(--f-transition-duration, .2s) var(--f-transition-easing, ease) var(--f-transition-delay, 0s) both f-fadeIn;z-index:2}.f-fadeOut{animation:var(--f-transition-duration, .2s) var(--f-transition-easing, ease) var(--f-transition-delay, 0s) both f-fadeOut;z-index:1}@keyframes f-fadeIn{0%{opacity:0}to{opacity:1}}@keyframes f-fadeOut{to{opacity:0}}.f-crossfadeIn{animation:var(--f-transition-duration, .2s) ease both f-crossfadeIn;z-index:2}.f-crossfadeOut{animation:calc(var(--f-transition-duration, .2s) * .2) ease calc(var(--f-transition-duration, .2s) * .8) both f-crossfadeOut;z-index:1}@keyframes f-crossfadeIn{0%{opacity:0}to{opacity:1}}@keyframes f-crossfadeOut{to{opacity:0}}.is-horizontal .f-slideIn.from-next{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideInNextX}.is-horizontal .f-slideIn.from-prev{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideInPrevX}.is-horizontal .f-slideOut.to-next{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideOutNextX}.is-horizontal .f-slideOut.to-prev{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideOutPrevX}@keyframes f-slideInPrevX{0%{transform:translate(calc(100% + var(--f-carousel-gap, 0)))}to{transform:translateZ(0)}}@keyframes f-slideInNextX{0%{transform:translate(calc(-100% - var(--f-carousel-gap, 0)))}to{transform:translateZ(0)}}@keyframes f-slideOutNextX{to{transform:translate(calc(-100% - var(--f-carousel-gap, 0)))}}@keyframes f-slideOutPrevX{to{transform:translate(calc(100% + var(--f-carousel-gap, 0)))}}.is-vertical .f-slideIn.from-next{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideInNextY}.is-vertical .f-slideIn.from-prev{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideInPrevY}.is-vertical .f-slideOut.to-next{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideOutNextY}.is-vertical .f-slideOut.to-prev{animation:var(--f-transition-duration, .85s) cubic-bezier(.16,1,.3,1) f-slideOutPrevY}@keyframes f-slideInPrevY{0%{transform:translateY(calc(100% + var(--f-carousel-gap, 0)))}to{transform:translateZ(0)}}@keyframes f-slideInNextY{0%{transform:translateY(calc(-100% - var(--f-carousel-gap, 0)))}to{transform:translateZ(0)}}@keyframes f-slideOutNextY{to{transform:translateY(calc(-100% - var(--f-carousel-gap, 0)))}}@keyframes f-slideOutPrevY{to{transform:translateY(calc(100% + var(--f-carousel-gap, 0)))}}.f-zoomInUp{animation:var(--f-transition-duration, .3s) ease both f-zoomInUp}.f-zoomOutDown{animation:var(--f-transition-duration, .3s) ease both f-zoomOutDown}@keyframes f-zoomInUp{0%{transform:scale(var(--f-zoomInUp-scale, .975)) translate3d(var(--f-zoomInUp-x, 0),var(--f-zoomInUp-y, 16px),0);opacity:var(--f-zoomInUp-opacity, 0)}to{transform:scale(1) translateZ(0);opacity:1}}@keyframes f-zoomOutDown{to{transform:scale(var(--f-zoomOutDown-scale, .975)) translate3d(var(--f-zoomOutDown-x, 0),var(--f-zoomOutDown-y, 16px),0);opacity:0}}.f-throwOutUp{animation:var(--f-throwOutUp-duration, .2s) ease-out both f-throwOutUp}.f-throwOutDown{animation:var(--f-throwOutDown-duration, .2s) ease-out both f-throwOutDown}@keyframes f-throwOutUp{to{transform:translate3d(0,calc(var(--f-throwOutUp-y, 150px) * -1),0);opacity:0}}@keyframes f-throwOutDown{to{transform:translate3d(0,var(--f-throwOutDown-y, 150px),0);opacity:0}}.has-iframe .f-html,.has-pdf .f-html,.has-gmap .f-html{width:100%;height:100%;min-height:1px;overflow:visible}.has-pdf .f-html,.has-gmap .f-html{padding:0}.f-html{position:relative;box-sizing:border-box;margin:var(--f-html-margin, 0);padding:var(--f-html-padding, 2rem);color:var(--f-html-color, currentColor);background:var(--f-html-bg)}.f-html.is-error{text-align:center}.f-iframe{display:block;margin:0;border:0;height:100%;width:100%}.f-caption{align-self:center;flex-shrink:0;margin:var(--f-caption-margin);padding:var(--f-caption-padding, 16px 8px);max-width:100%;max-height:calc(80vh - 100px);overflow:auto;overflow-wrap:anywhere;line-height:var(--f-caption-line-height);color:var(--f-caption-color);background:var(--f-caption-bg);font:var(--f-caption-font)}.has-html5video .f-html,.has-youtube .f-html,.has-vimeo .f-html{padding:0;width:100%;height:100%;min-height:1px;overflow:visible;max-width:var(--f-video-width, 960px);max-height:var(--f-video-height, 540px);aspect-ratio:var(--f-video-aspect-ratio);background:var(--f-video-bg, rgba(0, 0, 0, .9))}.f-html5video{border:0;display:block;height:100%;width:100%;background:transparent}.f-button.is-arrow{--f-button-width: var(--f-arrow-width, 46px);--f-button-height: var(--f-arrow-height, 46px);--f-button-svg-width: var(--f-arrow-svg-width, 24px);--f-button-svg-height: var(--f-arrow-svg-height, 24px);--f-button-svg-stroke-width: var(--f-arrow-svg-stroke-width, 1.75);--f-button-border-radius: var(--f-arrow-border-radius, unset);--f-button-bg: var(--f-arrow-bg, transparent);--f-button-hover-bg: var(--f-arrow-hover-bg, var(--f-arrow-bg));--f-button-active-bg: var(--f-arrow-active-bg, var(--f-arrow-hover-bg));--f-button-shadow: var(--f-arrow-shadow);--f-button-color: var(--f-arrow-color);--f-button-hover-color: var(--f-arrow-hover-color, var(--f-arrow-color));--f-button-active-color: var( --f-arrow-active-color, var(--f-arrow-hover-color) );overflow:visible}.f-button.is-arrow.is-prev,.f-button.is-arrow.is-next{position:absolute;transform:translate(0);z-index:20}.is-horizontal .f-button.is-arrow.is-prev,.is-horizontal .f-button.is-arrow.is-next{inset:50% auto auto;transform:translateY(-50%)}.is-horizontal.is-ltr .f-button.is-arrow.is-prev{left:var(--f-arrow-pos, 0)}.is-horizontal.is-ltr .f-button.is-arrow.is-next{right:var(--f-arrow-pos, 0)}.is-horizontal.is-rtl .f-button.is-arrow.is-prev{right:var(--f-arrow-pos, 0);transform:translateY(-50%) rotateY(180deg)}.is-horizontal.is-rtl .f-button.is-arrow.is-next{left:var(--f-arrow-pos, 0);transform:translateY(-50%) rotateY(180deg)}.is-vertical.is-ltr .f-button.is-arrow.is-prev,.is-vertical.is-rtl .f-button.is-arrow.is-prev{top:var(--f-arrow-pos, 0);right:auto;bottom:auto;left:50%;transform:translate(-50%)}.is-vertical.is-ltr .f-button.is-arrow.is-next,.is-vertical.is-rtl .f-button.is-arrow.is-next{top:auto;right:auto;bottom:var(--f-arrow-pos, 0);left:50%;transform:translate(-50%)}.is-vertical .f-button.is-arrow.is-prev svg,.is-vertical .f-button.is-arrow.is-next svg{transform:rotate(90deg)}.f-carousel__toolbar{display:grid;grid-template-columns:1fr auto 1fr;margin:var(--f-toolbar-margin, 0);padding:var(--f-toolbar-padding, 8px);line-height:var(--f-toolbar-line-height);background:var(--f-toolbar-bg, none);box-shadow:var(--f-toolbar-shadow, none);backdrop-filter:var(--f-toolbar-backdrop-filter);position:relative;z-index:20;color:var(--f-toolbar-color, currentColor);font-size:var(--f-toolbar-font-size, 17px);font-weight:var(--f-toolbar-font-weight, inherit);font-family:var(--f-toolbar-font, -apple-system, BlinkMacSystemFont, "Segoe UI Adjusted", "Segoe UI", "Liberation Sans", sans-serif);text-shadow:var(--f-toolbar-text-shadow);text-align:center;font-variant-numeric:tabular-nums;-webkit-font-smoothing:subpixel-antialiased;white-space:nowrap;pointer-events:none}.f-carousel__toolbar.is-absolute{position:absolute;top:0;left:0;right:0}.f-carousel__toolbar__column{display:flex;flex-direction:row;flex-wrap:wrap;align-content:flex-start;gap:var(--f-toolbar-gap, 0)}.f-carousel__toolbar__column.is-left{display:flex;justify-self:flex-start;justify-content:flex-start}.f-carousel__toolbar__column.is-middle{display:flex;justify-content:center}.f-carousel__toolbar__column.is-right{display:flex;justify-self:flex-end;justify-content:flex-end;flex-flow:nowrap}.f-carousel__toolbar__column{pointer-events:none}.f-carousel__toolbar__column>*{pointer-events:all}.f-counter{position:relative;display:flex;flex-direction:row;cursor:default;user-select:none;margin:var(--f-counter-margin, 0);padding:var(--f-counter-padding, 4px);line-height:var(--f-counter-line-height);background:var(--f-counter-bg);border-radius:var(--f-counter-border-radius)}.f-counter span{padding:0 var(--f-counter-gap, 4px)}:root{--f-thumbs-gap: 8px;--f-thumbs-margin: 0;--f-thumbs-padding-x: 8px;--f-thumbs-padding-y: 8px;--f-thumbs-z-index: 1;--f-thumb-width: 96px;--f-thumb-height: 72px;--f-thumb-clip-width: 46px;--f-thumb-extra-gap: 16px;--f-thumb-fit: cover;--f-thumb-opacity: 1;--f-thumb-transition: opacity .3s ease, transform .15s ease;--f-thumb-border: none;--f-thumb-border-radius: 4px;--f-thumb-transfors: none;--f-thumb-shadow: none;--f-thumb-bg: linear-gradient(rgba(0, 0, 0, .1), rgba(0, 0, 0, .05));--f-thumb-focus-shadow: inset 0 0 0 .8px #222, inset 0 0 0 2.25px #fff;--f-thumb-selected-shadow: inset 0 0 0 .8px #222, inset 0 0 0 2.25px #fff}.f-thumbs{flex-shrink:0;margin:var(--f-thumbs-margin);padding:0;background:var(--f-thumbs-bg);-webkit-tap-highlight-color:transparent;user-select:none;transition:max-height .3s ease,max-width .3s ease;position:relative;overflow:hidden;z-index:var(--f-thumbs-z-index)}.f-thumbs.is-horizontal{max-height:calc(var(--f-carousel-slide-height) + var(--f-thumbs-padding-y) * 2 + var(--f-thumbs-gap) * 2)}.f-thumbs.is-vertical{max-width:calc(var(--f-carousel-slide-width) + var(--f-thumbs-padding-x) * 2 + var(--f-thumbs-gap) * 2)}.f-thumbs.is-ltr{direction:ltr}.f-thumbs.is-rtl{direction:rtl}.f-thumbs__viewport{margin:var(--f-thumbs-padding-y) var(--f-thumbs-padding-x);overflow:visible;display:grid}.f-thumbs.is-vertical .f-thumbs__viewport{height:calc(100% - var(--f-thumbs-padding-y) * 2)}.f-thumbs__slide{position:relative;box-sizing:border-box;grid-area:1/1;width:var(--f-carousel-slide-width);height:var(--f-carousel-slide-height);margin:0;padding:0;display:flex;align-items:center;flex-direction:column;cursor:pointer;overflow:visible}.f-thumbs__slide:hover button{opacity:var(--f-thumb-hover-opacity, 1);transform:var(--f-thumb-hover-transform, none)}.f-thumbs__slide:hover button:after{border:var(--f-thumb-hover-border, none);box-shadow:var(--f-thumb-hover-shadow, var(--f-thumb-shadow))}.f-thumbs__slide button{all:unset;margin:auto;padding:0;position:relative;overflow:visible;width:100%;height:100%;outline:none;transition:var(--f-thumb-transition);border-radius:var(--f-thumb-border-radius);opacity:var(--f-thumb-opacity);transform:var(--f-thumb-transform);background:var(--f-thumb-bg)}.f-thumbs__slide button:after{content:"";position:absolute;inset:0;z-index:1;transition:none;border-radius:inherit;border:var(--f-thumb-border);box-shadow:var(--f-thumb-shadow)}.f-thumbs__slide button:focus-within{opacity:var(--f-thumb-focus-opacity, 1);transform:var(--f-thumb-focus-transform, none)}.f-thumbs__slide button:focus-within:after{border:var(--f-thumb-focus-border, none);box-shadow:var(--f-thumb-focus-shadow, var(--f-thumb-shadow))}.f-thumbs__slide:active{opacity:var(--f-thumb-active-opacity, 1);transform:var(--f-thumb-active-transform, none)}.f-thumbs__slide:active:after{border:var(--f-thumb-active-border, none);box-shadow:var(--f-thumb-active-shadow, var(--f-thumb-shadow))}.f-thumbs__slide.is-selected{z-index:2}.f-thumbs__slide.is-selected button{opacity:var(--f-thumb-selected-opacity, 1);transform:var(--f-thumb-selected-transform, none)}.f-thumbs__slide.is-selected button:after{border:var(--f-thumb-selected-border, none);box-shadow:var(--f-thumb-selected-shadow, var(--f-thumb-shadow))}.f-thumbs__slide img{display:block;width:100%;height:100%;object-fit:var(--f-thumb-fit);border-radius:inherit;pointer-events:none}.f-thumbs__slide img.has-lazyerror{display:none}.f-thumbs.is-classic{--f-carousel-slide-width: var(--f-thumb-width);--f-carousel-slide-height: var(--f-thumb-height);--f-carousel-gap: var(--f-thumbs-gap)}.f-thumbs.is-modern{--f-carousel-slide-width: calc( var(--f-thumb-clip-width) + var(--f-thumbs-gap) );--f-carousel-slide-height: var(--f-thumb-height);--f-carousel-gap: 0;--width-diff: calc((var(--f-thumb-width) - var(--f-thumb-clip-width)))}.f-thumbs.is-modern .f-thumbs__viewport{width:calc(100% + var(--f-carousel-slide-width) * 2);margin-inline:calc(var(--f-carousel-slide-width) * -1)}.f-thumbs.is-modern .f-thumbs__slide{--clip-shift: calc((var(--width-diff) * .5) * var(--progress));--clip-path: inset( 0 var(--clip-shift) round var(--f-thumb-border-radius, 0) );padding:0;overflow:visible;left:var(--shift, 0);will-change:left;transition:left var(--f-transition-duration) var(--f-transition-easing)}.f-thumbs.is-modern .f-thumbs__slide button{display:block;margin-inline:50%;width:var(--f-thumb-width);clip-path:var(--clip-path);border:none;box-shadow:none;transition:clip-path var(--f-transition-duration) var(--f-transition-easing),opacity var(--f-thumb-transition-duration, .2s) var(--f-thumb-transition-easing, ease)}.f-thumbs.is-modern .f-thumbs__slide button:after{display:none}.f-thumbs.is-modern .f-thumbs__slide:focus:not(:focus-visible){outline:none}.f-thumbs.is-modern .f-thumbs__slide:focus-within:not(.is-selected) button:before{content:"";position:absolute;z-index:1;top:0;left:var(--clip-shift);bottom:0;right:var(--clip-shift);transition:border var(--f-transition-duration) var(--f-transition-easing),box-shadow var(--f-transition-duration) var(--f-transition-easing);border-radius:inherit;border:var(--f-thumb-focus-border, none);box-shadow:var(--f-thumb-focus-shadow, none)}.f-thumbs.is-modern{--f-transition-duration: .25s;--f-transition-easing: ease-out}.f-thumbs.is-modern.is-syncing{--f-transition-duration: 0s}:root{--f-progressbar-height: 3px;--f-progressbar-color: var(--f-carousel-theme-color, #575ad6);--f-progressbar-opacity: 1;--f-progressbar-z-index: 30}.f-progressbar{position:absolute;top:0;left:0;right:0;z-index:var(--f-progressbar-z-index);height:var(--f-progressbar-height);transform:scaleX(0);transform-origin:0;opacity:var(--f-progressbar-opacity);background:var(--f-progressbar-color);user-select:none;pointer-events:none;animation-name:f-progressbar;animation-play-state:running;animation-timing-function:linear}.f-progressbar:empty{display:block}button>.f-progressbar{--f-progressbar-height: 100%;--f-progressbar-opacity: .2}@keyframes f-progressbar{0%{transform:scaleX(0)}to{transform:scaleX(1)}}[data-fullscreen-action=toggle] svg g:first-child{display:flex}[data-fullscreen-action=toggle] svg g:last-child{display:none}:fullscreen [data-fullscreen-action=toggle] svg g:first-child{display:none}:fullscreen [data-fullscreen-action=toggle] svg g:last-child{display:flex}.in-fullscreen-mode>.f-carousel{flex:1;min-width:0!important;min-height:0!important}html.with-fancybox{width:auto;overflow:visible;scroll-behavior:auto}html.with-fancybox body.hide-scrollbar{width:auto;margin-right:calc(var(--f-body-margin, 0px) + var(--f-scrollbar-compensate, 0px));overflow:hidden!important;overscroll-behavior-y:none}.fancybox__dialog{width:100%;height:100vh;max-height:unset;max-width:unset;padding:0;margin:0;border:0;overflow:hidden;background:transparent;touch-action:none}.fancybox__dialog:focus{outline:none}.fancybox__dialog::backdrop{opacity:0}@supports (height: 100dvh){.fancybox__dialog{height:100dvh}}.fancybox__dialog *:empty{display:block}div.fancybox__dialog{position:fixed;inset:0;z-index:1050}.fancybox__container{--fancybox-color: #dbdbdb;--fancybox-backdrop-bg: rgba(24, 24, 27, .95);--f-toolbar-margin: 0;--f-toolbar-padding: 8px;--f-toolbar-gap: 0;--f-toolbar-color: #ddd;--f-toolbar-font-size: 16px;--f-toolbar-font-weight: 500;--f-toolbar-font: -apple-system, BlinkMacSystemFont, "Segoe UI Adjusted", "Segoe UI", "Liberation Sans", sans-serif;--f-toolbar-line-height: var(--f-button-height);--f-toolbar-text-shadow: 1px 1px 1px rgba(0, 0, 0, .75);--f-toolbar-shadow: none;--f-toolbar-bg: none;--f-counter-margin: 0;--f-counter-padding: 0px 10px;--f-counter-gap: 4px;--f-counter-line-height: var(--f-button-height);--f-carousel-gap: 17px;--f-carousel-slide-width: 100%;--f-carousel-slide-height: 100%;--f-carousel-slide-padding: 0;--f-carousel-slide-bg: unset;--f-html-color: #222;--f-html-bg: #fff;--f-error-color: #fff;--f-error-bg: #333;--f-caption-margin: 0;--f-caption-padding: 16px 8px;--f-caption-color: var(--fancybox-color, #dbdbdb);--f-caption-bg: transparent;--f-caption-font: inherit;--f-caption-line-height: 1.375;--f-spinner-color-1: rgba(255, 255, 255, .2);--f-spinner-color-2: rgba(255, 255, 255, .8);--f-spinner-width: 50px;--f-spinner-height: 50px;--f-spinner-border-radius: 50%;--f-spinner-border-width: 4px;--f-progressbar-color: rgba(255, 255, 255);--f-button-width: 46px;--f-button-height: 46px;--f-button-color: #ddd;--f-button-hover-color: #fff;--f-button-outline-width: 1px;--f-button-outline-color: rgba(255, 255, 255, .75);--f-button-outline-offset: 0px;--f-button-bg: rgba(54, 54, 54, .75);--f-button-border: 0;--f-button-border-radius: 0;--f-button-shadow: none;--f-button-transition: all .2s ease;--f-button-transform: none;--f-button-svg-width: 24px;--f-button-svg-height: 24px;--f-button-svg-stroke-width: 1.75;--f-button-svg-filter: drop-shadow(1px 1px 1px rgba(24, 24, 27, .01)), drop-shadow(1px 2px 1px rgba(24, 24, 27, .05));--f-button-svg-fill: none;--f-button-svg-disabled-opacity: .5;--f-arrow-pos: 32px;--f-arrow-width: 50px;--f-arrow-height: 50px;--f-arrow-svg-width: 24px;--f-arrow-svg-height: 24px;--f-arrow-svg-stroke-width: 2;--f-arrow-border-radius: 50%;--f-arrow-bg: rgba(54, 54, 54, .65);--f-arrow-color: #ddd;--f-arrow-hover-color: #fff;--f-close-button-width: 34px;--f-close-button-height: 34px;--f-close-border-radius: 4px;--f-close-button-color: #fff;--f-close-button-hover-color: #fff;--f-close-button-bg: transparent;--f-close-button-hover-bg: transparent;--f-close-button-active-bg: transparent;--f-close-button-svg-width: 22px;--f-close-button-svg-height: 22px;--f-thumbs-margin: 0px;--f-thumbs-padding-x: 8px;--f-thumbs-padding-y: 8px;--f-thumbs-bg: none;--f-thumb-transition: all .2s ease;--f-thumb-width: 94px;--f-thumb-height: 76px;--f-thumb-opacity: 1;--f-thumb-border: none;--f-thumb-shadow: none;--f-thumb-transform: none;--f-thumb-focus-opacity: 1;--f-thumb-focus-border: none;--f-thumb-focus-shadow: inset 0 0 0 2px rgba(255, 255, 255, .65);--f-thumb-focus-transform: none;--f-thumb-hover-opacity: 1;--f-thumb-hover-border: none;--f-thumb-hover-transform: none;--f-thumb-active-opacity: var(--f-thumb-hover-opacity);--f-thumb-active-border: var(--f-thumb-hover-border);--f-thumb-active-transform: var(--f-thumb-hover-transform);--f-thumb-selected-opacity: 1;--f-thumb-selected-border: none;--f-thumb-selected-shadow: inset 0 0 0 2px #fff;--f-thumb-selected-transform: none}.fancybox__container[theme=light]{--fancybox-color: #222;--fancybox-backdrop-bg: rgba(255, 255, 255, .97);--f-toolbar-color: var(--fancybox-color, #222);--f-toolbar-text-shadow: none;--f-toolbar-font-weight: 400;--f-html-color: var(--fancybox-color, #222);--f-html-bg: #fff;--f-error-color: #555;--f-error-bg: #fff;--f-video-bg: #fff;--f-caption-color: #333;--f-spinner-color-1: rgba(0, 0, 0, .2);--f-spinner-color-2: rgba(0, 0, 0, .8);--f-spinner-border-width: 3.5px;--f-progressbar-color: rgba(111, 111, 116);--f-button-color: #333;--f-button-hover-color: #000;--f-button-outline-color: rgba(0, 0, 0, .85);--f-button-bg: rgba(255, 255, 255, .85);--f-button-svg-stroke-width: 1.3;--f-button-svg-filter: none;--f-arrow-bg: rgba(255, 255, 255, .85);--f-arrow-color: #333;--f-arrow-hover-color: #000;--f-arrow-svg-stroke-width: 1.3;--f-close-button-color: #555;--f-close-button-hover-color: #000;--f-thumb-bg: linear-gradient(#ebeff2, #e2e8f0);--f-thumb-focus-shadow: 0 0 0 1.8px #fff, 0px 0px 0px 2.25px #888;--f-thumb-selected-shadow: 0 0 0 1.8px #fff, 0px 0px 0px 2.25px #000}.fancybox__container{position:absolute;inset:0;overflow:hidden;display:flex;flex-direction:column}.fancybox__container:focus{outline:none}.fancybox__container.has-vertical-thumbs{flex-direction:row-reverse}.fancybox__container.has-vertical-thumbs:not(.is-closing) .fancybox__viewport{overflow-x:clip;overflow-y:visible}.fancybox__container>*:not(.fancybox__carousel),.fancybox__container .fancybox__carousel>*:not(.fancybox__viewport),.fancybox__container .fancybox__carousel>.fancybox__viewport>.fancybox__slide:not(.is-selected),.fancybox__container .fancybox__carousel>.fancybox__viewport>.fancybox__slide.is-selected>*:not(.f-html,.f-panzoom__wrapper,.f-spinner){opacity:var(--f-drag-opacity, 1)}.fancybox__container:not(.is-ready,.is-hiding){visibility:hidden}.fancybox__container.is-revealing>*:not(.fancybox__carousel),.fancybox__container.is-revealing .fancybox__carousel>*:not(.fancybox__viewport),.fancybox__container.is-revealing .fancybox__carousel>.fancybox__viewport>.fancybox__slide:not(.is-selected),.fancybox__container.is-revealing .fancybox__carousel>.fancybox__viewport>.fancybox__slide.is-selected>*:not(.f-html,.f-panzoom__wrapper,.f-spinner){animation:var(--f-interface-enter-duration, .35s) ease none f-fadeIn}.fancybox__container.is-hiding>*:not(.fancybox__carousel),.fancybox__container.is-hiding .fancybox__carousel>*:not(.fancybox__viewport),.fancybox__container.is-hiding .fancybox__carousel>.fancybox__viewport>.fancybox__slide:not(.is-selected),.fancybox__container.is-hiding .fancybox__carousel>.fancybox__viewport>.fancybox__slide.is-selected>*:not(.f-html,.f-panzoom__wrapper){animation:var(--f-interface-exit-duration, .35s) ease forwards f-fadeOut}.fancybox__container.is-idle .f-carousel__toolbar{pointer-events:none;opacity:0}.fancybox__container.is-idle .f-button.is-arrow{opacity:0}.fancybox__container.is-idle.is-ready .f-carousel__toolbar{pointer-events:none;animation:.15s ease-out both f-fadeOut}.fancybox__container.is-idle.is-ready .f-button.is-arrow{animation:.15s ease-out both f-fadeOut}.fancybox__backdrop{position:fixed;inset:0;z-index:-1;background:var(--fancybox-backdrop-bg)}.fancybox__carousel{flex:1;display:flex;flex-direction:column;min-height:0;min-width:0;position:relative;z-index:10;overflow-y:visible;overflow-x:clip}.fancybox__carousel.is-vertical{--f-carousel-slide-height: 100%}.fancybox__carousel.is-ltr{direction:ltr}.fancybox__carousel.is-rtl{direction:rtl}.fancybox__carousel>.f-button.is-arrow:before{position:absolute;content:"";inset:-30px;z-index:1}.fancybox__viewport{display:grid;flex:1;min-height:0;min-width:0;position:relative;overflow:visible;transform:translate3d(0,var(--f-drag-offset, 0),0)}.fancybox__viewport.is-draggable{cursor:move;cursor:grab}.fancybox__viewport.is-dragging{cursor:move;cursor:grabbing}.fancybox__viewport [data-selectable],.fancybox__viewport [contenteditable]{cursor:auto}.fancybox__slide{box-sizing:border-box;position:relative;grid-area:1/1;display:flex;align-items:center;flex-direction:column;width:var(--f-carousel-slide-width);height:var(--f-carousel-slide-height);min-width:0;min-height:0;max-width:100%;margin:0;padding:var(--f-carousel-slide-padding);background:var(--f-carousel-slide-bg);backface-visibility:hidden;transform:translateZ(0);will-change:transform}.fancybox__slide:before,.fancybox__slide:after{display:block;content:""}.fancybox__slide:before{margin-bottom:auto}.fancybox__slide:after{margin-top:auto}.fancybox__slide.is-selected{z-index:1}.fancybox__slide.f-zoomable{overflow:visible}.fancybox__slide.has-error{--f-html-color: var(--f-error-color, --f-html-color);--f-html-bg: var(--f-error-bg, --f-html-bg)}.fancybox__slide.has-html{overflow:auto;padding:8px}.fancybox__slide.has-close-btn{padding-top:34px}.fancybox__slide.has-controls:before{margin:0}.fancybox__slide .f-spinner{cursor:pointer}.fancybox__container.is-closing .f-caption,.fancybox__slide.is-loading .f-caption{visibility:hidden}.fancybox__container.is-closing .fancybox__carousel{overflow:visible}.f-button.is-close-button{--f-button-width: var(--f-close-button-width);--f-button-height: var(--f-close-button-height);--f-button-border-radius: var(--f-close-border-radius);--f-button-color: var(--f-close-button-color);--f-button-hover-color: var(--f-close-button-hover-color);--f-button-bg: var(--f-close-button-bg);--f-button-hover-bg: var(--f-close-button-hover-bg);--f-button-active-bg: var(--f-close-button-active-bg);--f-button-svg-width: var(--f-close-button-svg-width);--f-button-svg-height: var(--f-close-button-svg-height);position:absolute;top:calc(var(--f-button-height) * -1);right:0;z-index:40} diff --git a/paicoding-ui/src/main/resources/static/css/three/highlightjs.github.min.css b/paicoding-ui/src/main/resources/static/css/three/highlightjs.github.min.css new file mode 100644 index 000000000..275239a7a --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/highlightjs.github.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/three/index.css b/paicoding-ui/src/main/resources/static/css/three/index.css new file mode 100644 index 000000000..9f2880953 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/index.css @@ -0,0 +1,8 @@ +/* 第三方样式 */ + +@import "./bootstrap.min.css"; + +@import "./toastr.min.css"; + +@import "./select2.min.css"; + diff --git a/paicoding-ui/src/main/resources/static/css/three/nprogress.css b/paicoding-ui/src/main/resources/static/css/three/nprogress.css new file mode 100644 index 000000000..6752d7f4b --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/nprogress.css @@ -0,0 +1,74 @@ +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + background: #29d; + + position: fixed; + z-index: 1031; + top: 0; + left: 0; + + width: 100%; + height: 2px; +} + +/* Fancy blur effect */ +#nprogress .peg { + display: block; + position: absolute; + right: 0px; + width: 100px; + height: 100%; + box-shadow: 0 0 10px #29d, 0 0 5px #29d; + opacity: 1.0; + + -webkit-transform: rotate(3deg) translate(0px, -4px); + -ms-transform: rotate(3deg) translate(0px, -4px); + transform: rotate(3deg) translate(0px, -4px); +} + +/* Remove these to get rid of the spinner */ +#nprogress .spinner { + display: block; + position: fixed; + z-index: 1031; + top: 15px; + right: 15px; +} + +#nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + + border: solid 2px transparent; + border-top-color: #29d; + border-left-color: #29d; + border-radius: 50%; + + -webkit-animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; +} + +.nprogress-custom-parent { + overflow: hidden; + position: relative; +} + +.nprogress-custom-parent #nprogress .spinner, +.nprogress-custom-parent #nprogress .bar { + position: absolute; +} + +@-webkit-keyframes nprogress-spinner { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} +@keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + diff --git a/paicoding-ui/src/main/resources/static/css/three/select2.min.css b/paicoding-ui/src/main/resources/static/css/three/select2.min.css new file mode 100644 index 000000000..d493fe0a0 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/select2.min.css @@ -0,0 +1,540 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } +.select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } +.select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +.select2-container .select2-selection--single .select2-selection__clear { + background-color: transparent; + border: none; + font-size: 1em; } +.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } +.select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } +.select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline; + list-style: none; + padding: 0; } +.select2-container .select2-selection--multiple .select2-selection__clear { + background-color: transparent; + border: none; + font-size: 1em; } +.select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + margin-left: 5px; + padding: 0; + max-width: 100%; + resize: none; + height: 18px; + vertical-align: bottom; + font-family: sans-serif; + overflow: hidden; + word-break: keep-all; } +.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid var(--pai-border-color-1); + border-radius: 2px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + font-size: 14px; + padding: 6px; + user-select: none; + -webkit-user-select: none; } + +.select2-results__option--selectable { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } +.select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } +.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } +.select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } +.select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } +.select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + height: 26px; + margin-right: 20px; + padding-right: 0px; } +.select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } +.select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } +.select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } +.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid var(--pai-border-color-1); + border-radius: 2px; + cursor: text; + padding-bottom: 5px; + padding-right: 5px; + position: relative; } +.select2-container--default .select2-selection--multiple.select2-selection--clearable { + padding-right: 25px; } +.select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + font-weight: bold; + height: 20px; + margin-right: 10px; + margin-top: 5px; + position: absolute; + right: 0; + padding: 1px; } +.select2-container--default .select2-selection--multiple .select2-selection__clear:hover { + color: var(--pai-brand-2-hover); +} +.select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: var(--pai-brand-7-light); + border: 1px solid var(--pai-brand-7-light); + border-radius: 4px; + box-sizing: border-box; + display: inline-block; + margin-left: 5px; + margin-top: 5px; + padding: 0; + padding-left: 20px; + position: relative; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; } +.select2-container--default .select2-selection--multiple .select2-selection__choice__display { + color: var(--pai-brand-1-normal); + cursor: default; + padding-left: 2px; + padding-right: 5px; } +.select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + background-color: transparent; + border: none; + border-right: 1px solid var(--pai-brand-7-light); + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + color: var(--pai-brand-1-normal); + cursor: pointer; + font-size: 1em; + font-weight: bold; + padding: 0 4px; + position: absolute; + left: 0; + top: 0; } +.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover, .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:focus { + background-color: var(--pai-brand-8-light); + outline: none; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display { + padding-left: 5px; + padding-right: 2px; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + border-left: 1px solid #aaa; + border-right: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__clear { + float: left; + margin-left: 10px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid var(--pai-brand-3-click) 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } +.select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } +.select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } +.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } +.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } +.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } +.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--group { + padding: 0; } + +.select2-container--default .select2-results__option--disabled { + color: #999; } + +.select2-container--default .select2-results__option--selected { + color: var(--pai-brand-1-normal); } + +.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { + background-color: var(--pai-brand-7-light);} + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } +.select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } +.select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } +.select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + height: 26px; + margin-right: 20px; } +.select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } +.select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } +.select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } +.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } +.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; + padding-bottom: 5px; + padding-right: 5px; } +.select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } +.select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } +.select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + display: inline-block; + margin-left: 5px; + margin-top: 5px; + padding: 0; } +.select2-container--classic .select2-selection--multiple .select2-selection__choice__display { + cursor: default; + padding-left: 2px; + padding-right: 5px; } +.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + background-color: transparent; + border: none; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + color: #888; + cursor: pointer; + font-size: 1em; + font-weight: bold; + padding: 0 4px; } +.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; + outline: none; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display { + padding-left: 5px; + padding-right: 2px; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option--group { + padding: 0; } + +.select2-container--classic .select2-results__option--disabled { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted.select2-results__option--selectable { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/paicoding-ui/src/main/resources/static/css/three/simplemde.min.css b/paicoding-ui/src/main/resources/static/css/three/simplemde.min.css new file mode 100644 index 000000000..6f8a120ce --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/simplemde.min.css @@ -0,0 +1,750 @@ +/** + * simplemde v1.11.2 + * Copyright Next Step Webs, Inc. + * @link https://github.com/NextStepWebs/simplemde-markdown-editor + * @license MIT + */ +.CodeMirror { + color: #000 +} + +.CodeMirror-lines { + padding: 4px 0 +} + +.CodeMirror pre { + padding: 0 4px +} + +.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler { + background-color: #fff +} + +.CodeMirror-gutters { + border-right: 1px solid #ddd; + background-color: #f7f7f7; + white-space: nowrap +} + +.CodeMirror-linenumber { + padding: 0 3px 0 5px; + min-width: 20px; + text-align: right; + color: #999; + white-space: nowrap +} + +.CodeMirror-guttermarker { + color: #000 +} + +.CodeMirror-guttermarker-subtle { + color: #999 +} + +.CodeMirror-cursor { + border-left: 1px solid #000; + border-right: none; + width: 0 +} + +.CodeMirror div.CodeMirror-secondarycursor { + border-left: 1px solid silver +} + +.cm-fat-cursor .CodeMirror-cursor { + width: auto; + border: 0!important; + background: #7e7 +} + +.cm-fat-cursor div.CodeMirror-cursors { + z-index: 1 +} + +.cm-animate-fat-cursor { + width: auto; + border: 0; + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; + background-color: #7e7 +} + +@-moz-keyframes blink { + 50% { + background-color: transparent + } +} + +@-webkit-keyframes blink { + 50% { + background-color: transparent + } +} + +@keyframes blink { + 50% { + background-color: transparent + } +} + +.cm-tab { + display: inline-block; + text-decoration: inherit +} + +.CodeMirror-ruler { + border-left: 1px solid #ccc; + position: absolute +} + +.cm-s-default .cm-header { + color: #00f +} + +.cm-s-default .cm-quote { + color: #090 +} + +.cm-negative { + color: #d44 +} + +.cm-positive { + color: #292 +} + +.cm-header,.cm-strong { + font-weight: 700 +} + +.cm-em { + font-style: italic +} + +.cm-link { + text-decoration: underline +} + +.cm-strikethrough { + text-decoration: line-through +} + +.cm-s-default .cm-keyword { + color: #708 +} + +.cm-s-default .cm-atom { + color: #219 +} + +.cm-s-default .cm-number { + color: #164 +} + +.cm-s-default .cm-def { + color: #00f +} + +.cm-s-default .cm-variable-2 { + color: #05a +} + +.cm-s-default .cm-variable-3 { + color: #085 +} + +.cm-s-default .cm-comment { + color: #a50 +} + +.cm-s-default .cm-string { + color: #a11 +} + +.cm-s-default .cm-string-2 { + color: #f50 +} + +.cm-s-default .cm-meta,.cm-s-default .cm-qualifier { + color: #555 +} + +.cm-s-default .cm-builtin { + color: #30a +} + +.cm-s-default .cm-bracket { + color: #997 +} + +.cm-s-default .cm-tag { + color: #170 +} + +.cm-s-default .cm-attribute { + color: #00c +} + +.cm-s-default .cm-hr { + color: #999 +} + +.cm-s-default .cm-link { + color: #00c +} + +.cm-invalidchar,.cm-s-default .cm-error { + color: red +} + +.CodeMirror-composing { + border-bottom: 2px solid +} + +div.CodeMirror span.CodeMirror-matchingbracket { + color: #0f0 +} + +div.CodeMirror span.CodeMirror-nonmatchingbracket { + color: #f22 +} + +.CodeMirror-matchingtag { + background: rgba(255,150,0,.3) +} + +.CodeMirror-activeline-background { + background: #e8f2ff +} + +.CodeMirror { + position: relative; + overflow: hidden; + background: #fff +} + +.CodeMirror-scroll { + overflow: scroll!important; + margin-bottom: -30px; + margin-right: -30px; + padding-bottom: 30px; + height: 100%; + outline: 0; + position: relative +} + +.CodeMirror-sizer { + position: relative; + border-right: 30px solid transparent +} + +.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar { + position: absolute; + z-index: 6; + display: none +} + +.CodeMirror-vscrollbar { + right: 0; + top: 0; + overflow-x: hidden; + overflow-y: scroll +} + +.CodeMirror-hscrollbar { + bottom: 0; + left: 0; + overflow-y: hidden; + overflow-x: scroll +} + +.CodeMirror-scrollbar-filler { + right: 0; + bottom: 0 +} + +.CodeMirror-gutter-filler { + left: 0; + bottom: 0 +} + +.CodeMirror-gutters { + position: absolute; + left: 0; + top: 0; + min-height: 100%; + z-index: 3 +} + +.CodeMirror-gutter { + white-space: normal; + height: 100%; + display: inline-block; + vertical-align: top; + margin-bottom: -30px +} + +.CodeMirror-gutter-wrapper { + position: absolute; + z-index: 4; + background: 0 0!important; + border: none!important; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none +} + +.CodeMirror-gutter-background { + position: absolute; + top: 0; + bottom: 0; + z-index: 4 +} + +.CodeMirror-gutter-elt { + position: absolute; + cursor: default; + z-index: 4 +} + +.CodeMirror-lines { + cursor: text; + min-height: 1px +} + +.CodeMirror pre { + -moz-border-radius: 0; + -webkit-border-radius: 0; + border-radius: 0; + border-width: 0; + background: 0 0; + font-family: inherit; + font-size: inherit; + margin: 0; + white-space: pre; + word-wrap: normal; + line-height: inherit; + color: inherit; + z-index: 2; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none +} + +.CodeMirror-wrap pre { + word-wrap: break-word; + white-space: pre-wrap; + word-break: normal +} + +.CodeMirror-linebackground { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 0 +} + +.CodeMirror-linewidget { + position: relative; + z-index: 2; + overflow: auto +} + +.CodeMirror-code { + outline: 0 +} + +.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer { + -moz-box-sizing: content-box; + box-sizing: content-box +} + +.CodeMirror-measure { + position: absolute; + width: 100%; + height: 0; + overflow: hidden; + visibility: hidden +} + +.CodeMirror-cursor { + position: absolute +} + +.CodeMirror-measure pre { + position: static +} + +div.CodeMirror-cursors { + visibility: hidden; + position: relative; + z-index: 3 +} + +.CodeMirror-focused div.CodeMirror-cursors,div.CodeMirror-dragcursors { + visibility: visible +} + +.CodeMirror-selected { + background: #d9d9d9 +} + +.CodeMirror-focused .CodeMirror-selected,.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection { + background: #d7d4f0 +} + +.CodeMirror-crosshair { + cursor: crosshair +} + +.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection { + background: #d7d4f0 +} + +.cm-searching { + background: #ffa; + background: rgba(255,255,0,.4) +} + +.cm-force-border { + padding-right: .1px +} + +@media print { + .CodeMirror div.CodeMirror-cursors { + visibility: hidden + } +} + +.cm-tab-wrap-hack:after { + content: '' +} + +span.CodeMirror-selectedtext { + background: 0 0 +} + +.CodeMirror { + height: auto; + min-height: 300px; + border: 1px solid #ddd; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + padding: 10px; + font: inherit; + z-index: 1 +} + +.CodeMirror-scroll { + min-height: 300px +} + +.CodeMirror-sided { + width: 50%!important +} + +.editor-toolbar { + position: relative; + opacity: .6; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + padding: 0 10px; + border-top: 1px solid #bbb; + border-left: 1px solid #bbb; + border-right: 1px solid #bbb; + border-top-left-radius: 4px; + border-top-right-radius: 4px +} + +.editor-toolbar:after,.editor-toolbar:before { + display: block; + content: ' '; + height: 1px +} + +.editor-toolbar:before { + margin-bottom: 8px +} + +.editor-toolbar:after { + margin-top: 8px +} + +.editor-toolbar:hover,.editor-wrapper input.title:focus,.editor-wrapper input.title:hover { + opacity: .8 +} + +.editor-toolbar.fullscreen { + width: 100%; + height: 50px; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + padding-top: 10px; + padding-bottom: 10px; + box-sizing: border-box; + background: #fff; + border: 0; + position: fixed; + top: 0; + left: 0; + opacity: 1; + z-index: 9 +} + +.editor-toolbar.fullscreen::before { + width: 20px; + height: 50px; + background: -moz-linear-gradient(left,rgba(255,255,255,1) 0,rgba(255,255,255,0) 100%); + background: -webkit-gradient(linear,left top,right top,color-stop(0,rgba(255,255,255,1)),color-stop(100%,rgba(255,255,255,0))); + background: -webkit-linear-gradient(left,rgba(255,255,255,1) 0,rgba(255,255,255,0) 100%); + background: -o-linear-gradient(left,rgba(255,255,255,1) 0,rgba(255,255,255,0) 100%); + background: -ms-linear-gradient(left,rgba(255,255,255,1) 0,rgba(255,255,255,0) 100%); + background: linear-gradient(to right,rgba(255,255,255,1) 0,rgba(255,255,255,0) 100%); + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0 +} + +.editor-toolbar.fullscreen::after { + width: 20px; + height: 50px; + background: -moz-linear-gradient(left,rgba(255,255,255,0) 0,rgba(255,255,255,1) 100%); + background: -webkit-gradient(linear,left top,right top,color-stop(0,rgba(255,255,255,0)),color-stop(100%,rgba(255,255,255,1))); + background: -webkit-linear-gradient(left,rgba(255,255,255,0) 0,rgba(255,255,255,1) 100%); + background: -o-linear-gradient(left,rgba(255,255,255,0) 0,rgba(255,255,255,1) 100%); + background: -ms-linear-gradient(left,rgba(255,255,255,0) 0,rgba(255,255,255,1) 100%); + background: linear-gradient(to right,rgba(255,255,255,0) 0,rgba(255,255,255,1) 100%); + position: fixed; + top: 0; + right: 0; + margin: 0; + padding: 0 +} + +.editor-toolbar a { + display: inline-block; + text-align: center; + text-decoration: none!important; + color: #2c3e50!important; + width: 30px; + height: 30px; + margin: 0; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer +} + +.editor-toolbar a.active,.editor-toolbar a:hover { + background: #fcfcfc; + border-color: #95a5a6 +} + +.editor-toolbar a:before { + line-height: 30px +} + +.editor-toolbar i.separator { + display: inline-block; + width: 0; + border-left: 1px solid #d9d9d9; + border-right: 1px solid #fff; + color: transparent; + text-indent: -10px; + margin: 0 6px +} + +.editor-toolbar a.fa-header-x:after { + font-family: Arial,"Helvetica Neue",Helvetica,sans-serif; + font-size: 65%; + vertical-align: text-bottom; + position: relative; + top: 2px +} + +.editor-toolbar a.fa-header-1:after { + content: "1" +} + +.editor-toolbar a.fa-header-2:after { + content: "2" +} + +.editor-toolbar a.fa-header-3:after { + content: "3" +} + +.editor-toolbar a.fa-header-bigger:after { + content: "▲" +} + +.editor-toolbar a.fa-header-smaller:after { + content: "▼" +} + +.editor-toolbar.disabled-for-preview a:not(.no-disable) { + pointer-events: none; + background: #fff; + border-color: transparent; + text-shadow: inherit +} + +@media only screen and (max-width: 700px) { + .editor-toolbar a.no-mobile { + display:none + } +} + +.editor-statusbar { + padding: 8px 10px; + font-size: 12px; + color: #959694; + text-align: right; + position: fixed; + bottom: 0; +} + +.editor-statusbar span { + display: inline-block; + min-width: 4em; + margin-left: 1em +} + +.editor-preview,.editor-preview-side { + padding: 10px; + background: #fafafa; + overflow: auto; + display: none; + box-sizing: border-box +} + +.editor-statusbar .lines:before { + content: 'lines: ' +} + +.editor-statusbar .words:before { + content: 'words: ' +} + +.editor-statusbar .characters:before { + content: 'characters: ' +} + +.editor-preview { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 7 +} + +.editor-preview-side { + position: fixed; + bottom: 34px; + width: 50%; + top: 84px; + right: 0; + z-index: 9; + border: 1px solid #ddd +} + +.editor-preview-active,.editor-preview-active-side { + display: block +} + +.editor-preview-side>p,.editor-preview>p { + margin-top: 0 +} + +.editor-preview pre,.editor-preview-side pre { + background: #eee; + margin-bottom: 10px +} + +.editor-preview table td,.editor-preview table th,.editor-preview-side table td,.editor-preview-side table th { + border: 1px solid #ddd; + padding: 5px +} + +.CodeMirror .CodeMirror-code .cm-tag { + color: #63a35c +} + +.CodeMirror .CodeMirror-code .cm-attribute { + color: #795da3 +} + +.CodeMirror .CodeMirror-code .cm-string { + color: #183691 +} + +.CodeMirror .CodeMirror-selected { + background: #d9d9d9 +} + +.CodeMirror .CodeMirror-code .cm-header-1 { + font-size: 200%; + line-height: 200% +} + +.CodeMirror .CodeMirror-code .cm-header-2 { + font-size: 160%; + line-height: 160% +} + +.CodeMirror .CodeMirror-code .cm-header-3 { + font-size: 125%; + line-height: 125% +} + +.CodeMirror .CodeMirror-code .cm-header-4 { + font-size: 110%; + line-height: 110% +} + +.CodeMirror .CodeMirror-code .cm-comment { + background: rgba(0,0,0,.05); + border-radius: 2px +} + +.CodeMirror .CodeMirror-code .cm-link { + color: #7f8c8d +} + +.CodeMirror .CodeMirror-code .cm-url { + color: #aab2b3 +} + +.CodeMirror .CodeMirror-code .cm-strikethrough { + text-decoration: line-through +} + +.CodeMirror .CodeMirror-placeholder { + opacity: .5 +} + +.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) { + background: rgba(255,0,0,.15) +} + +.CodeMirror-fullscreen { + position: fixed!important; + top: 84px; + left: 0; + right: 0; + bottom: 34px; + height: auto; + z-index: 9; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/three/sweetalert2.min.css b/paicoding-ui/src/main/resources/static/css/three/sweetalert2.min.css new file mode 100644 index 000000000..d760784b2 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/three/sweetalert2.min.css @@ -0,0 +1 @@ +:root{--swal2-outline: 0 0 0 3px rgba(100, 150, 200, 0.5);--swal2-container-padding: 0.625em;--swal2-backdrop: rgba(0, 0, 0, 0.4);--swal2-backdrop-transition: background-color 0.15s;--swal2-width: 32em;--swal2-padding: 0 0 1.25em;--swal2-border: none;--swal2-border-radius: 0.3125rem;--swal2-background: white;--swal2-color: #545454;--swal2-show-animation: swal2-show 0.3s;--swal2-hide-animation: swal2-hide 0.15s forwards;--swal2-icon-zoom: 1;--swal2-icon-animations: true;--swal2-title-padding: 0.8em 1em 0;--swal2-html-container-padding: 1em 1.6em 0.3em;--swal2-input-border: 1px solid #d9d9d9;--swal2-input-border-radius: 0.1875em;--swal2-input-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px transparent;--swal2-input-background: transparent;--swal2-input-transition: border-color 0.2s, box-shadow 0.2s;--swal2-input-hover-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px transparent;--swal2-input-focus-border: 1px solid #b4dbed;--swal2-input-focus-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px rgba(100, 150, 200, 0.5);--swal2-progress-step-background: #add8e6;--swal2-validation-message-background: #f0f0f0;--swal2-validation-message-color: #666;--swal2-footer-border-color: #eee;--swal2-footer-background: transparent;--swal2-footer-color: inherit;--swal2-timer-progress-bar-background: rgba(0, 0, 0, 0.3);--swal2-close-button-position: initial;--swal2-close-button-inset: auto;--swal2-close-button-font-size: 2.5em;--swal2-close-button-color: #ccc;--swal2-close-button-transition: color 0.2s, box-shadow 0.2s;--swal2-close-button-outline: initial;--swal2-close-button-box-shadow: inset 0 0 0 3px transparent;--swal2-close-button-focus-box-shadow: inset var(--swal2-outline);--swal2-close-button-hover-transform: none;--swal2-actions-justify-content: center;--swal2-actions-width: auto;--swal2-actions-margin: 1.25em auto 0;--swal2-actions-padding: 0;--swal2-actions-border-radius: 0;--swal2-actions-background: transparent;--swal2-action-button-transition: background-color 0.2s, box-shadow 0.2s;--swal2-action-button-hover: black 10%;--swal2-action-button-active: black 10%;--swal2-confirm-button-box-shadow: none;--swal2-confirm-button-border-radius: 0.25em;--swal2-confirm-button-background-color: #7066e0;--swal2-confirm-button-color: #fff;--swal2-deny-button-box-shadow: none;--swal2-deny-button-border-radius: 0.25em;--swal2-deny-button-background-color: #dc3741;--swal2-deny-button-color: #fff;--swal2-cancel-button-box-shadow: none;--swal2-cancel-button-border-radius: 0.25em;--swal2-cancel-button-background-color: #6e7881;--swal2-cancel-button-color: #fff;--swal2-toast-show-animation: swal2-toast-show 0.5s;--swal2-toast-hide-animation: swal2-toast-hide 0.1s forwards;--swal2-toast-border: none;--swal2-toast-box-shadow: 0 0 1px hsl(0deg 0% 0% / 0.075), 0 1px 2px hsl(0deg 0% 0% / 0.075), 1px 2px 4px hsl(0deg 0% 0% / 0.075), 1px 3px 8px hsl(0deg 0% 0% / 0.075), 2px 4px 16px hsl(0deg 0% 0% / 0.075)}[data-swal2-theme=dark]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white);--swal2-timer-progress-bar-background: rgba(255, 255, 255, 0.7)}@media(prefers-color-scheme: dark){[data-swal2-theme=auto]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white);--swal2-timer-progress-bar-background: rgba(255, 255, 255, 0.7)}}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow:hidden}body.swal2-height-auto{height:auto !important}body.swal2-no-backdrop .swal2-container{background-color:rgba(0,0,0,0) !important;pointer-events:none}body.swal2-no-backdrop .swal2-container .swal2-popup{pointer-events:all}body.swal2-no-backdrop .swal2-container .swal2-modal{box-shadow:0 0 10px var(--swal2-backdrop)}body.swal2-toast-shown .swal2-container{box-sizing:border-box;width:360px;max-width:100%;background-color:rgba(0,0,0,0);pointer-events:none}body.swal2-toast-shown .swal2-container.swal2-top{inset:0 auto auto 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-top-end,body.swal2-toast-shown .swal2-container.swal2-top-right{inset:0 0 auto auto}body.swal2-toast-shown .swal2-container.swal2-top-start,body.swal2-toast-shown .swal2-container.swal2-top-left{inset:0 auto auto 0}body.swal2-toast-shown .swal2-container.swal2-center-start,body.swal2-toast-shown .swal2-container.swal2-center-left{inset:50% auto auto 0;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-center{inset:50% auto auto 50%;transform:translate(-50%, -50%)}body.swal2-toast-shown .swal2-container.swal2-center-end,body.swal2-toast-shown .swal2-container.swal2-center-right{inset:50% 0 auto auto;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-start,body.swal2-toast-shown .swal2-container.swal2-bottom-left{inset:auto auto 0 0}body.swal2-toast-shown .swal2-container.swal2-bottom{inset:auto auto 0 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-end,body.swal2-toast-shown .swal2-container.swal2-bottom-right{inset:auto 0 0 auto}@media print{body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow-y:scroll !important}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown)>[aria-hidden=true]{display:none}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown) .swal2-container{position:static !important}}div:where(.swal2-container){display:grid;position:fixed;z-index:1060;inset:0;box-sizing:border-box;grid-template-areas:"top-start top top-end" "center-start center center-end" "bottom-start bottom-center bottom-end";grid-template-rows:minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto);height:100%;padding:var(--swal2-container-padding);overflow-x:hidden;transition:var(--swal2-backdrop-transition);-webkit-overflow-scrolling:touch}div:where(.swal2-container).swal2-backdrop-show,div:where(.swal2-container).swal2-noanimation{background:var(--swal2-backdrop)}div:where(.swal2-container).swal2-backdrop-hide{background:rgba(0,0,0,0) !important}div:where(.swal2-container).swal2-top-start,div:where(.swal2-container).swal2-center-start,div:where(.swal2-container).swal2-bottom-start{grid-template-columns:minmax(0, 1fr) auto auto}div:where(.swal2-container).swal2-top,div:where(.swal2-container).swal2-center,div:where(.swal2-container).swal2-bottom{grid-template-columns:auto minmax(0, 1fr) auto}div:where(.swal2-container).swal2-top-end,div:where(.swal2-container).swal2-center-end,div:where(.swal2-container).swal2-bottom-end{grid-template-columns:auto auto minmax(0, 1fr)}div:where(.swal2-container).swal2-top-start>.swal2-popup{align-self:start}div:where(.swal2-container).swal2-top>.swal2-popup{grid-column:2;place-self:start center}div:where(.swal2-container).swal2-top-end>.swal2-popup,div:where(.swal2-container).swal2-top-right>.swal2-popup{grid-column:3;place-self:start end}div:where(.swal2-container).swal2-center-start>.swal2-popup,div:where(.swal2-container).swal2-center-left>.swal2-popup{grid-row:2;align-self:center}div:where(.swal2-container).swal2-center>.swal2-popup{grid-column:2;grid-row:2;place-self:center center}div:where(.swal2-container).swal2-center-end>.swal2-popup,div:where(.swal2-container).swal2-center-right>.swal2-popup{grid-column:3;grid-row:2;place-self:center end}div:where(.swal2-container).swal2-bottom-start>.swal2-popup,div:where(.swal2-container).swal2-bottom-left>.swal2-popup{grid-column:1;grid-row:3;align-self:end}div:where(.swal2-container).swal2-bottom>.swal2-popup{grid-column:2;grid-row:3;place-self:end center}div:where(.swal2-container).swal2-bottom-end>.swal2-popup,div:where(.swal2-container).swal2-bottom-right>.swal2-popup{grid-column:3;grid-row:3;place-self:end end}div:where(.swal2-container).swal2-grow-row>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-column:1/4;width:100%}div:where(.swal2-container).swal2-grow-column>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-row:1/4;align-self:stretch}div:where(.swal2-container).swal2-no-transition{transition:none !important}div:where(.swal2-container)[popover]{width:auto;border:0}div:where(.swal2-container) div:where(.swal2-popup){display:none;position:relative;box-sizing:border-box;grid-template-columns:minmax(0, 100%);width:var(--swal2-width);max-width:100%;padding:var(--swal2-padding);border:var(--swal2-border);border-radius:var(--swal2-border-radius);background:var(--swal2-background);color:var(--swal2-color);font-family:inherit;font-size:1rem;container-name:swal2-popup}div:where(.swal2-container) div:where(.swal2-popup):focus{outline:none}div:where(.swal2-container) div:where(.swal2-popup).swal2-loading{overflow-y:hidden}div:where(.swal2-container) div:where(.swal2-popup).swal2-draggable{cursor:grab}div:where(.swal2-container) div:where(.swal2-popup).swal2-draggable div:where(.swal2-icon){cursor:grab}div:where(.swal2-container) div:where(.swal2-popup).swal2-dragging{cursor:grabbing}div:where(.swal2-container) div:where(.swal2-popup).swal2-dragging div:where(.swal2-icon){cursor:grabbing}div:where(.swal2-container) h2:where(.swal2-title){position:relative;max-width:100%;margin:0;padding:var(--swal2-title-padding);color:inherit;font-size:1.875em;font-weight:600;text-align:center;text-transform:none;overflow-wrap:break-word;cursor:initial}div:where(.swal2-container) div:where(.swal2-actions){display:flex;z-index:1;box-sizing:border-box;flex-wrap:wrap;align-items:center;justify-content:var(--swal2-actions-justify-content);width:var(--swal2-actions-width);margin:var(--swal2-actions-margin);padding:var(--swal2-actions-padding);border-radius:var(--swal2-actions-border-radius);background:var(--swal2-actions-background)}div:where(.swal2-container) div:where(.swal2-loader){display:none;align-items:center;justify-content:center;width:2.2em;height:2.2em;margin:0 1.875em;animation:swal2-rotate-loading 1.5s linear 0s infinite normal;border-width:.25em;border-style:solid;border-radius:100%;border-color:#2778c4 rgba(0,0,0,0) #2778c4 rgba(0,0,0,0)}div:where(.swal2-container) button:where(.swal2-styled){margin:.3125em;padding:.625em 1.1em;transition:var(--swal2-action-button-transition);border:none;box-shadow:0 0 0 3px rgba(0,0,0,0);font-weight:500}div:where(.swal2-container) button:where(.swal2-styled):not([disabled]){cursor:pointer}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm){border-radius:var(--swal2-confirm-button-border-radius);background:initial;background-color:var(--swal2-confirm-button-background-color);box-shadow:var(--swal2-confirm-button-box-shadow);color:var(--swal2-confirm-button-color);font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm):hover{background-color:color-mix(in srgb, var(--swal2-confirm-button-background-color), var(--swal2-action-button-hover))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm):active{background-color:color-mix(in srgb, var(--swal2-confirm-button-background-color), var(--swal2-action-button-active))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny){border-radius:var(--swal2-deny-button-border-radius);background:initial;background-color:var(--swal2-deny-button-background-color);box-shadow:var(--swal2-deny-button-box-shadow);color:var(--swal2-deny-button-color);font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny):hover{background-color:color-mix(in srgb, var(--swal2-deny-button-background-color), var(--swal2-action-button-hover))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny):active{background-color:color-mix(in srgb, var(--swal2-deny-button-background-color), var(--swal2-action-button-active))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel){border-radius:var(--swal2-cancel-button-border-radius);background:initial;background-color:var(--swal2-cancel-button-background-color);box-shadow:var(--swal2-cancel-button-box-shadow);color:var(--swal2-cancel-button-color);font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel):hover{background-color:color-mix(in srgb, var(--swal2-cancel-button-background-color), var(--swal2-action-button-hover))}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel):active{background-color:color-mix(in srgb, var(--swal2-cancel-button-background-color), var(--swal2-action-button-active))}div:where(.swal2-container) button:where(.swal2-styled):focus-visible{outline:none;box-shadow:var(--swal2-action-button-focus-box-shadow)}div:where(.swal2-container) button:where(.swal2-styled)[disabled]:not(.swal2-loading){opacity:.4}div:where(.swal2-container) button:where(.swal2-styled)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-footer){margin:1em 0 0;padding:1em 1em 0;border-top:1px solid var(--swal2-footer-border-color);background:var(--swal2-footer-background);color:var(--swal2-footer-color);font-size:1em;text-align:center;cursor:initial}div:where(.swal2-container) .swal2-timer-progress-bar-container{position:absolute;right:0;bottom:0;left:0;grid-column:auto !important;overflow:hidden;border-bottom-right-radius:var(--swal2-border-radius);border-bottom-left-radius:var(--swal2-border-radius)}div:where(.swal2-container) div:where(.swal2-timer-progress-bar){width:100%;height:.25em;background:var(--swal2-timer-progress-bar-background)}div:where(.swal2-container) img:where(.swal2-image){max-width:100%;margin:2em auto 1em;cursor:initial}div:where(.swal2-container) button:where(.swal2-close){position:var(--swal2-close-button-position);inset:var(--swal2-close-button-inset);z-index:2;align-items:center;justify-content:center;width:1.2em;height:1.2em;margin-top:0;margin-right:0;margin-bottom:-1.2em;padding:0;overflow:hidden;transition:var(--swal2-close-button-transition);border:none;border-radius:var(--swal2-border-radius);outline:var(--swal2-close-button-outline);background:rgba(0,0,0,0);color:var(--swal2-close-button-color);font-family:monospace;font-size:var(--swal2-close-button-font-size);cursor:pointer;justify-self:end}div:where(.swal2-container) button:where(.swal2-close):hover{transform:var(--swal2-close-button-hover-transform);background:rgba(0,0,0,0);color:#f27474}div:where(.swal2-container) button:where(.swal2-close):focus-visible{outline:none;box-shadow:var(--swal2-close-button-focus-box-shadow)}div:where(.swal2-container) button:where(.swal2-close)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-html-container){z-index:1;justify-content:center;margin:0;padding:var(--swal2-html-container-padding);overflow:auto;color:inherit;font-size:1.125em;font-weight:normal;line-height:normal;text-align:center;overflow-wrap:break-word;word-break:break-word;cursor:initial}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea),div:where(.swal2-container) select:where(.swal2-select),div:where(.swal2-container) div:where(.swal2-radio),div:where(.swal2-container) label:where(.swal2-checkbox){margin:1em 2em 3px}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea){box-sizing:border-box;width:auto;transition:var(--swal2-input-transition);border:var(--swal2-input-border);border-radius:var(--swal2-input-border-radius);background:var(--swal2-input-background);box-shadow:var(--swal2-input-box-shadow);color:inherit;font-size:1.125em}div:where(.swal2-container) input:where(.swal2-input).swal2-inputerror,div:where(.swal2-container) input:where(.swal2-file).swal2-inputerror,div:where(.swal2-container) textarea:where(.swal2-textarea).swal2-inputerror{border-color:#f27474 !important;box-shadow:0 0 2px #f27474 !important}div:where(.swal2-container) input:where(.swal2-input):hover,div:where(.swal2-container) input:where(.swal2-file):hover,div:where(.swal2-container) textarea:where(.swal2-textarea):hover{box-shadow:var(--swal2-input-hover-box-shadow)}div:where(.swal2-container) input:where(.swal2-input):focus,div:where(.swal2-container) input:where(.swal2-file):focus,div:where(.swal2-container) textarea:where(.swal2-textarea):focus{border:var(--swal2-input-focus-border);outline:none;box-shadow:var(--swal2-input-focus-box-shadow)}div:where(.swal2-container) input:where(.swal2-input)::placeholder,div:where(.swal2-container) input:where(.swal2-file)::placeholder,div:where(.swal2-container) textarea:where(.swal2-textarea)::placeholder{color:#ccc}div:where(.swal2-container) .swal2-range{margin:1em 2em 3px;background:var(--swal2-background)}div:where(.swal2-container) .swal2-range input{width:80%}div:where(.swal2-container) .swal2-range output{width:20%;color:inherit;font-weight:600;text-align:center}div:where(.swal2-container) .swal2-range input,div:where(.swal2-container) .swal2-range output{height:2.625em;padding:0;font-size:1.125em;line-height:2.625em}div:where(.swal2-container) .swal2-input{height:2.625em;padding:0 .75em}div:where(.swal2-container) .swal2-file{width:75%;margin-right:auto;margin-left:auto;background:var(--swal2-input-background);font-size:1.125em}div:where(.swal2-container) .swal2-textarea{height:6.75em;padding:.75em}div:where(.swal2-container) .swal2-select{min-width:50%;max-width:100%;padding:.375em .625em;background:var(--swal2-input-background);color:inherit;font-size:1.125em}div:where(.swal2-container) .swal2-radio,div:where(.swal2-container) .swal2-checkbox{align-items:center;justify-content:center;background:var(--swal2-background);color:inherit}div:where(.swal2-container) .swal2-radio label,div:where(.swal2-container) .swal2-checkbox label{margin:0 .6em;font-size:1.125em}div:where(.swal2-container) .swal2-radio input,div:where(.swal2-container) .swal2-checkbox input{flex-shrink:0;margin:0 .4em}div:where(.swal2-container) label:where(.swal2-input-label){display:flex;justify-content:center;margin:1em auto 0}div:where(.swal2-container) div:where(.swal2-validation-message){align-items:center;justify-content:center;margin:1em 0 0;padding:.625em;overflow:hidden;background:var(--swal2-validation-message-background);color:var(--swal2-validation-message-color);font-size:1em;font-weight:300}div:where(.swal2-container) div:where(.swal2-validation-message)::before{content:"!";display:inline-block;width:1.5em;min-width:1.5em;height:1.5em;margin:0 .625em;border-radius:50%;background-color:#f27474;color:#fff;font-weight:600;line-height:1.5em;text-align:center}div:where(.swal2-container) .swal2-progress-steps{flex-wrap:wrap;align-items:center;max-width:100%;margin:1.25em auto;padding:0;background:rgba(0,0,0,0);font-weight:600}div:where(.swal2-container) .swal2-progress-steps li{display:inline-block;position:relative}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step{z-index:20;flex-shrink:0;width:2em;height:2em;border-radius:2em;background:#2778c4;color:#fff;line-height:2em;text-align:center}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step{background:#2778c4}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step{background:var(--swal2-progress-step-background);color:#fff}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step-line{background:var(--swal2-progress-step-background)}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step-line{z-index:10;flex-shrink:0;width:2.5em;height:.4em;margin:0 -1px;background:#2778c4}div:where(.swal2-icon){position:relative;box-sizing:content-box;justify-content:center;width:5em;height:5em;margin:2.5em auto .6em;zoom:var(--swal2-icon-zoom);border:.25em solid rgba(0,0,0,0);border-radius:50%;border-color:#000;font-family:inherit;line-height:5em;cursor:default;user-select:none}div:where(.swal2-icon) .swal2-icon-content{display:flex;align-items:center;font-size:3.75em}div:where(.swal2-icon).swal2-error{border-color:#f27474;color:#f27474}div:where(.swal2-icon).swal2-error .swal2-x-mark{position:relative;flex-grow:1}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line]{display:block;position:absolute;top:2.3125em;width:2.9375em;height:.3125em;border-radius:.125em;background-color:#f27474}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=left]{left:1.0625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=right]{right:1em;transform:rotate(-45deg)}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-error.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-error.swal2-icon-show .swal2-x-mark{animation:swal2-animate-error-x-mark .5s}}div:where(.swal2-icon).swal2-warning{border-color:#f8bb86;color:#f8bb86}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-warning.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-warning.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .5s}}div:where(.swal2-icon).swal2-info{border-color:#3fc3ee;color:#3fc3ee}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-info.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-info.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .8s}}div:where(.swal2-icon).swal2-question{border-color:#87adbd;color:#87adbd}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-question.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-question.swal2-icon-show .swal2-icon-content{animation:swal2-animate-question-mark .8s}}div:where(.swal2-icon).swal2-success{border-color:#a5dc86;color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line]{position:absolute;width:3.75em;height:7.5em;border-radius:50%}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.4375em;left:-2.0635em;transform:rotate(-45deg);transform-origin:3.75em 3.75em;border-radius:7.5em 0 0 7.5em}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.6875em;left:1.875em;transform:rotate(-45deg);transform-origin:0 3.75em;border-radius:0 7.5em 7.5em 0}div:where(.swal2-icon).swal2-success .swal2-success-ring{position:absolute;z-index:2;top:-0.25em;left:-0.25em;box-sizing:content-box;width:100%;height:100%;border:.25em solid rgba(165,220,134,.3);border-radius:50%}div:where(.swal2-icon).swal2-success .swal2-success-fix{position:absolute;z-index:1;top:.5em;left:1.625em;width:.4375em;height:5.625em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line]{display:block;position:absolute;z-index:2;height:.3125em;border-radius:.125em;background-color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=tip]{top:2.875em;left:.8125em;width:1.5625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=long]{top:2.375em;right:.5em;width:2.9375em;transform:rotate(-45deg)}@container swal2-popup style(--swal2-icon-animations:true){div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-animate-success-line-tip .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-animate-success-line-long .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-circular-line-right{animation:swal2-rotate-success-circular-line 4.25s ease-in}}[class^=swal2]{-webkit-tap-highlight-color:rgba(0,0,0,0)}.swal2-show{animation:var(--swal2-show-animation)}.swal2-hide{animation:var(--swal2-hide-animation)}.swal2-noanimation{transition:none}.swal2-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}.swal2-rtl .swal2-close{margin-right:initial;margin-left:0}.swal2-rtl .swal2-timer-progress-bar{right:0;left:auto}.swal2-toast{box-sizing:border-box;grid-column:1/4 !important;grid-row:1/4 !important;grid-template-columns:min-content auto min-content;padding:1em;overflow-y:hidden;border:var(--swal2-toast-border);background:var(--swal2-background);box-shadow:var(--swal2-toast-box-shadow);pointer-events:all}.swal2-toast>*{grid-column:2}.swal2-toast h2:where(.swal2-title){margin:.5em 1em;padding:0;font-size:1em;text-align:initial}.swal2-toast .swal2-loading{justify-content:center}.swal2-toast input:where(.swal2-input){height:2em;margin:.5em;font-size:1em}.swal2-toast .swal2-validation-message{font-size:1em}.swal2-toast div:where(.swal2-footer){margin:.5em 0 0;padding:.5em 0 0;font-size:.8em}.swal2-toast button:where(.swal2-close){grid-column:3/3;grid-row:1/99;align-self:center;width:.8em;height:.8em;margin:0;font-size:2em}.swal2-toast div:where(.swal2-html-container){margin:.5em 1em;padding:0;overflow:initial;font-size:1em;text-align:initial}.swal2-toast div:where(.swal2-html-container):empty{padding:0}.swal2-toast .swal2-loader{grid-column:1;grid-row:1/99;align-self:center;width:2em;height:2em;margin:.25em}.swal2-toast .swal2-icon{grid-column:1;grid-row:1/99;align-self:center;width:2em;min-width:2em;height:2em;margin:0 .5em 0 0}.swal2-toast .swal2-icon .swal2-icon-content{display:flex;align-items:center;font-size:1.8em;font-weight:bold}.swal2-toast .swal2-icon.swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line]{top:.875em;width:1.375em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left]{left:.3125em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right]{right:.3125em}.swal2-toast div:where(.swal2-actions){justify-content:flex-start;height:auto;margin:0;margin-top:.5em;padding:0 .5em}.swal2-toast button:where(.swal2-styled){margin:.25em .5em;padding:.4em .6em;font-size:1em}.swal2-toast .swal2-success{border-color:#a5dc86}.swal2-toast .swal2-success [class^=swal2-success-circular-line]{position:absolute;width:1.6em;height:3em;border-radius:50%}.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.8em;left:-0.5em;transform:rotate(-45deg);transform-origin:2em 2em;border-radius:4em 0 0 4em}.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.25em;left:.9375em;transform-origin:0 1.5em;border-radius:0 4em 4em 0}.swal2-toast .swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-toast .swal2-success .swal2-success-fix{top:0;left:.4375em;width:.4375em;height:2.6875em}.swal2-toast .swal2-success [class^=swal2-success-line]{height:.3125em}.swal2-toast .swal2-success [class^=swal2-success-line][class$=tip]{top:1.125em;left:.1875em;width:.75em}.swal2-toast .swal2-success [class^=swal2-success-line][class$=long]{top:.9375em;right:.1875em;width:1.375em}@container swal2-popup style(--swal2-icon-animations:true){.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-toast-animate-success-line-tip .75s}.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-toast-animate-success-line-long .75s}}.swal2-toast.swal2-show{animation:var(--swal2-toast-show-animation)}.swal2-toast.swal2-hide{animation:var(--swal2-toast-hide-animation)}@keyframes swal2-show{0%{transform:translate3d(0, -50px, 0) scale(0.9);opacity:0}100%{transform:translate3d(0, 0, 0) scale(1);opacity:1}}@keyframes swal2-hide{0%{transform:translate3d(0, 0, 0) scale(1);opacity:1}100%{transform:translate3d(0, -50px, 0) scale(0.9);opacity:0}}@keyframes swal2-animate-success-line-tip{0%{top:1.1875em;left:.0625em;width:0}54%{top:1.0625em;left:.125em;width:0}70%{top:2.1875em;left:-0.375em;width:3.125em}84%{top:3em;left:1.3125em;width:1.0625em}100%{top:2.8125em;left:.8125em;width:1.5625em}}@keyframes swal2-animate-success-line-long{0%{top:3.375em;right:2.875em;width:0}65%{top:3.375em;right:2.875em;width:0}84%{top:2.1875em;right:0;width:3.4375em}100%{top:2.375em;right:.5em;width:2.9375em}}@keyframes swal2-rotate-success-circular-line{0%{transform:rotate(-45deg)}5%{transform:rotate(-45deg)}12%{transform:rotate(-405deg)}100%{transform:rotate(-405deg)}}@keyframes swal2-animate-error-x-mark{0%{margin-top:1.625em;transform:scale(0.4);opacity:0}50%{margin-top:1.625em;transform:scale(0.4);opacity:0}80%{margin-top:-0.375em;transform:scale(1.15)}100%{margin-top:0;transform:scale(1);opacity:1}}@keyframes swal2-animate-error-icon{0%{transform:rotateX(100deg);opacity:0}100%{transform:rotateX(0deg);opacity:1}}@keyframes swal2-rotate-loading{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}@keyframes swal2-animate-question-mark{0%{transform:rotateY(-360deg)}100%{transform:rotateY(0)}}@keyframes swal2-animate-i-mark{0%{transform:rotateZ(45deg);opacity:0}25%{transform:rotateZ(-25deg);opacity:.4}50%{transform:rotateZ(15deg);opacity:.8}75%{transform:rotateZ(-5deg);opacity:1}100%{transform:rotateX(0);opacity:1}}@keyframes swal2-toast-show{0%{transform:translateY(-0.625em) rotateZ(2deg)}33%{transform:translateY(0) rotateZ(-2deg)}66%{transform:translateY(0.3125em) rotateZ(2deg)}100%{transform:translateY(0) rotateZ(0deg)}}@keyframes swal2-toast-hide{100%{transform:rotateZ(1deg);opacity:0}}@keyframes swal2-toast-animate-success-line-tip{0%{top:.5625em;left:.0625em;width:0}54%{top:.125em;left:.125em;width:0}70%{top:.625em;left:-0.25em;width:1.625em}84%{top:1.0625em;left:.75em;width:.5em}100%{top:1.125em;left:.1875em;width:.75em}}@keyframes swal2-toast-animate-success-line-long{0%{top:1.625em;right:1.375em;width:0}65%{top:1.25em;right:.9375em;width:0}84%{top:.9375em;right:0;width:1.125em}100%{top:.9375em;right:.1875em;width:1.375em}} diff --git a/forum-ui/src/main/resources/static/css/toastr.min.css b/paicoding-ui/src/main/resources/static/css/three/toastr.min.css similarity index 100% rename from forum-ui/src/main/resources/static/css/toastr.min.css rename to paicoding-ui/src/main/resources/static/css/three/toastr.min.css diff --git a/paicoding-ui/src/main/resources/static/css/views/article-detail.css b/paicoding-ui/src/main/resources/static/css/views/article-detail.css new file mode 100644 index 000000000..4f9b5db92 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/article-detail.css @@ -0,0 +1,184 @@ +/* 确保文章内容区域允许文本选择 */ +#articleContent { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +/* 确保评论图标在移动端可点击 */ +#comment-icon { + -webkit-touch-callout: default; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.detail-content-title { + color: rgba(0, 0, 0, 0.85); + font-size: 26px; + line-height: 31px; + vertical-align: bottom; + margin-bottom: 12px; +} + +.detail-content-title-other-wrap { + font-size: 14px; + display: flex; + align-items: center; + color: var(--pai-color-999-gray); + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--pai-hr-color-1); +} + +.detail-content-title-other-img { + height: 22px; + width: 22px; + border-radius: 50%; +} +.detail-content-title-other-name { + color: #62749f; + margin-left: 8px; + margin-right: 16px; +} +.detail-content-title-other-time { + margin-right: 16px; +} + +.detail-content-title-other-look { + display: flex; + align-items: center; + justify-content: center; + margin-left: 28px; + padding: 0 11px; + height: 20px; + border-radius: 20px; + font-size: 12px; + color: #3973ff; + border: 1px solid #3973ff; + cursor: pointer; +} + +.com-opt-link, +.com-opt-text { + display: inline-block; + vertical-align: middle; + color: var(--pai-color-999-gray); +} + +[class*="com-i-"], +[class^="com-i-"] { + display: inline-block; + vertical-align: middle; + width: 20px; + height: 20px; + background-size: 100% auto; +} + +.com-opt-link:hover .com-i-edit, +.com-opt-link:hover .com-i-delete { + fill: var(--pai-brand-2-hover); +} + +.com-opt-link .com-i-edit, +.com-opt-link .com-i-delete { + fill: var(--pai-color-999-gray); +} + +.detail-content-title-edit [class*="com-i-"], +.detail-content-title-edit [class^="com-i-"] { + position: relative; +} + +.detail-content-title-edit { + position: absolute; + right: 0; + top: 0; + line-height: 20px; +} + +/* home 适配 */ +@media screen and (max-width: 768px) { + .layout-side, + .article-suspended-panel { + display: none !important; + } + .layout-main { + padding: 0 !important; + } + .article-suspended-panel-md { + visibility: visible; + } + .foot { + margin-bottom: 72px; + } +} + +/* 视频容器样式 */ +.video-container { + position: relative; + padding-bottom: 56.25%; /* 16:9 宽高比 */ + height: 0; + overflow: hidden; + margin: 1.5em 0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + background: #000; +} + +.video-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 8px; +} + +/* 移动端视频容器优化 */ +@media screen and (max-width: 768px) { + .video-container { + margin: 1em 0; + border-radius: 4px; + } + + .video-container iframe { + border-radius: 4px; + } +} + +/* 文章详情页目录样式优化 - 解决双滚动条问题 */ +.article-detail { + position: relative; +} + +.article-detail .toc-container { + background-color: transparent; /* 背景透明,让下层滚动条可见 */ + padding: 0; + position: fixed; + top: 90px; /* 初始值,会被JS动态调整 */ + right: 0; + width: 300px; + z-index: 5; + /* 不设置max-height和overflow,让内层.widget处理滚动 */ +} + +.article-detail .toc-container .widget { + background-color: var(--pai-color-fff-normal); /* 背景色设置在内部容器 */ + padding: 20px; + padding-right: 8px; /* 给内容留一点右侧空间 */ + box-sizing: border-box; + max-height: calc(100vh - 120px); /* 设置最大高度,会被JS动态调整 */ + overflow-y: auto; /* 滚动在widget层 */ +} + +/* 移除arCatalog-body的滚动,让它和arCatalog-line作为一个整体 */ +.article-detail .toc-container .arCatalog-body { + overflow-y: visible !important; /* 覆盖global.css和JS设置的overflow */ + max-height: none !important; /* 移除高度限制 */ + height: auto !important; /* 让高度自适应内容 */ +} + + diff --git a/paicoding-ui/src/main/resources/static/css/views/article-edit.css b/paicoding-ui/src/main/resources/static/css/views/article-edit.css new file mode 100644 index 000000000..06d659ceb --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/article-edit.css @@ -0,0 +1,440 @@ +.edit-nav { + background-color: #fff; + height: 50px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 3px 6px rgb(0 0 0 / 5%); + z-index: 3; + padding: 0 10px; +} + +.edit-title-input::placeholder{ + font-size: 24px; +} + +.form-control:focus { + border-color: var(--pai-brand-3-click); + box-shadow: none; +} + +.edit-save { + background: var(--pai-brand-4-disable); + border-color: #d9d9d9; + text-shadow: none; + box-shadow: none; + width: 66px; + height: 28px; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: #ccc; + cursor: not-allowed; + margin-right: 20px; +} + +.edit-save--active { + background-color: var(--pai-brand-1-normal); + color: var(--pai-color-fff-normal); + cursor: pointer; +} + +.edit-save--active:hover { + background-color: var(--pai-brand-2-hover); +} + +.edit-title-input { + border: none; + font-size: 24px; + height: 100%; + width: 100%; +} + +.edit-title-input:focus-visible { + outline: none !important; +} + +.edit-title-form { + height: 80%; + flex: 1; + overflow-x: auto; + margin-right: 20px; +} + +.edit-title-input:active { + border: none; +} + +/* 编辑器样式 */ +.editor-toolbar::before, +.editor-toolbar::after { + margin: 0 !important; +} + +.editor-toolbar { + border-bottom: 1px solid #e1e4e8 !important; + border-top: 1px solid #e1e4e8 !important; + background-color: #fafbfc !important; +} + +.editor-preview-active { + background-color: #fff !important; +} + +.editor-preview-side .editor-preview-active-side .editor-statusbar { + text-align: left !important; + border-top: 1px solid #fff !important; + font-size: 12px !important; + background-color: #fff !important; +} + +.editor-preview-active-side { + background-color: #fff !important; +} + +blockquote { + color: #666; + padding: 1px 23px; + margin: 22px 0; + border-left: 4px solid #cbcbcb; + background-color: #f8f8f8; +} + +.modal-content { + width: 630px; + font-size: 14px; +} + +.modal-title { + font-size: 18px; +} + +.required .form-label:before { + content: "*"; + color: var(--pai-brand-6-mq); + vertical-align: -2px; + padding-right: 2px; +} + +.category .form-label { + padding-top: 5px; +} + +.input-group { + display: flex; + flex-wrap: nowrap; + align-items: center; + position: relative; + margin-bottom: 10px; +} + +.form-group { + margin-bottom: 10px; +} + +.cover { + margin: 16px 0 20px 0; +} + +.input-group input, +.input-group textarea { + border-radius: 2px !important; +} + +.edit-sort-wrap { + display: flex; +} + +.edit-sort-wrap label { + flex: none; +} + +.edit-tag-wrap { + align-items: baseline; +} + +.form-selectgroup-item { + width: 100px; + height: 32px; + line-height: 32px; + text-align: center; + margin-right: 10px; + background-color: var(--pai-bg-light-2); + color: var(--pai-color-4-gray); + cursor: pointer; +} +.r-form-selectgroup-item { + width: 100px; + height: 32px; + line-height: 32px; + text-align: center; + margin-right: 10px; + background-color: var(--pai-bg-light-2); + color: var(--pai-color-4-gray); + cursor: pointer; +} + +.form-check { + display: flex; + align-items: center; + justify-content: center; + min-width: 70px; + height: 30px; + border-radius: 4px; + /* margin-right: 10px; */ + background-color: #f8f9fa; + color: #919aa6; + cursor: pointer; + position: relative; + padding-left: 0; +} + +.form-check-input { + margin: 0; + left: 0; + top: 0; + height: 30px; + min-width: 70px; + opacity: 0; + cursor: pointer; +} +.form-check-label { + padding: 10px; +} +.form-selectgroup-item--active, +.form-check--active { + background-color: var(--pai-brand-7-light); + color: var(--pai-brand-1-normal); +} + +.r-form-selectgroup-item:hover, +.form-check:hover { + background-color: var(--pai-brand-7-light); +} + +.form-selectgroup-input { + position: absolute; + visibility: hidden; +} + +.form-textarea { + height: 100px !important; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + resize: none; + line-height: 22px; +} + +.form-textarea-limit { + position: absolute; + bottom: 5px; + right: 90px; + color: var(--pai-color-999-gray); + font-size: 12px; + z-index: 10; +} + +.btn-getdistill { + position: absolute; + bottom: 5px; + right: 12px; + font-size: 12px; + z-index: 10; + display: inline-block; + white-space: nowrap; + background-color: var(--pai-color-6-gray); + border: 1px solid var(--pai-border-color-1); + color: var(--pai-color-4-gray); + border-radius: 20px; + padding: 0 10px; +} + +.form-label { + width: 80px; + text-align: right; + margin-right: 8px; + padding: 0; + align-items: flex-start; + flex-shrink: 0; +} + +.input-textarea { + align-items: flex-start !important; +} + +.person-img-wrap { + display: flex; + flex-direction: column; + align-items: center; + width: 112px; + margin-left: 74px; +} +.person-img-inter-wrap { + width: 150px; + height: 80px; + position: relative; + border: 1px #e9ecef solid; +} +.person-img { + width: 100%; + height: 100%; +} +.person-upload-text { + color: #1d2129; + font-weight: 500; + font-size: 14px; + margin-top: 10px; + margin-bottom: 8px; +} +.person-upload-limit { + color: #86909c; + font-size: 12px; + line-height: 17px; + font-weight: 400; +} +.person-img-inter-wrap-img { + position: relative; +} +.upload-icon-up { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} +.cancel-title { + color: var(--pai-brand-1-normal); + cursor: pointer; +} +.click-cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: #fff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: rgba(29, 33, 41, 0.5); + z-index: 2; + visibility: hidden; + cursor: pointer; +} +.click-text { + font-size: 12px; + margin-top: 7px; + line-height: 17px; + font-weight: 400; +} +.click-input { + display: none; +} + +.custom-file { + position: relative; + height: 80px; +} + +.article-tag-wrap { + display: flex; + flex-wrap: wrap; + min-height: 40px; + align-items: center; + gap: 8px; +} + +.editor-preview-side a { + color: var(--pai-brand-1-normal); + text-decoration: none; + border-bottom: 1px solid var(--pai-brand-1-normal); +} + +.editor-preview-side p>img { + max-width: 100%; + margin: 0; +} + +.editor-preview-side ol, .editor-preview-side ul { + margin: 0 0 24px; + padding: 0; + font-size: 16px; + overflow: hidden; + overflow-x: auto; +} + +.editor-preview-side li { + margin: 10px 0; +} + +.editor-preview-side strong { + color: var(--pai-brand-1-normal); +} + +.editor-preview-side h2 { + margin-left: -10px; + display: inline-block; + width: auto; + height: 40px; + background-color: var(--pai-brand-1-normal); + border-bottom-right-radius: 100px; + color: rgb(255, 255, 255); + padding-right: 30px; + padding-left: 30px; + line-height: 40px; + font-size: 16px; +} + +.summary { + font-size: 14px; + padding: 8px 8px 0; +} + +.btn-getdistill:hover { + background-color: var(--pai-brand-7-light); + color: var(--pai-brand-2-hover); +} + +.person-img-inter-wrap .close_icon { + z-index: 9; + position: absolute; + background: var(--pai-color-999-gray); + color: var(--pai-color-fff-normal); + line-height: 20px; + right: -8px; + top: -8px; + display: none; + width: 20px; + height: 20px; + font-size: 14px; + text-align: center; + background-size: contain; + border-radius: 50%; + cursor: pointer; +} + +.edit-mask { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #fff; + z-index: 100; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + font-size: 20px; + font-weight: 500; + display: none; +} + +.edit-mask-img { + width: 70px; + height: 70px; + margin-bottom: 12px; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/views/article-tag.css b/paicoding-ui/src/main/resources/static/css/views/article-tag.css new file mode 100644 index 000000000..7e5cdf30d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/article-tag.css @@ -0,0 +1,16 @@ +.tag-wrap-out { + height: calc(100vh - 60px); + overflow-y: auto; +} + +.tag-wrap { + width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.tag-list { + padding: 20px; + background-color: #fff; + border-radius: 8px; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/views/chat-home.css b/paicoding-ui/src/main/resources/static/css/views/chat-home.css new file mode 100644 index 000000000..51cae7d5c --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/chat-home.css @@ -0,0 +1,675 @@ +.chat-wrap { + display: flex; + margin: 20px; + border-radius: 5px; + min-height: 480px; + height: var(--window-height); +} + +.chat-sidebar { + overflow-y: auto; + top: 0; + width: var(--pai-sidebar-width); + box-sizing: border-box; + display: flex; + flex-direction: column; + position: relative; + transition: width .05s ease; +} + +.name .annotation { + font-size: small; + padding-left: 6px; + color: var(--pai-color-3-gray); +} + +.window-header-title .name { + display: flex; +} + +.window-header-title .name .com-verification { + top: 2px; +} + +.chat-sidebar .uno-buy-card-foot { + margin-right: 20px; +} + +.chat-sidebar .uno-buy-card-wrap { + display: flex; + background-color: #fff; + padding: 20px; + align-items: center; +} + +.chat-sidebar .uno-buy-card-wrap .qrcode { + width: 200px; +} + +.chat-sidebar .uno-buy-card-foot-prices { +} + +.chat-sidebar .uno-buy-card-foot-tag-list { + height: 22px; + font-size: 14px; + margin-bottom: 8px; + white-space: nowrap; +} + +.chat-sidebar .uno-buy-card-foot-tag-type1 { + padding: 4px; + background: var(--pai-brand-1-normal); + color: var(--pai-color-fff-normal); + border: none; +} + +.chat-sidebar .uno-buy-card-foot-tag-type3 { + padding: 4px; + line-height: 20px; + background: transparent; + color: #6f7a94; + border: 1px solid rgba(111,122,148,.5); +} + +.chat-sidebar .uno-buy-card-foot-price-num { + color: var(--pai-brand-1-normal); + font-size: 22px; + line-height: 36px; + font-weight: 500; + display: inline-block; +} + +.chat-sidebar .uno-buy-card-foot-tag-item { + font-size: 12px; +} + +.chat-sidebar .uno-buy-card-foot-price-unit { + color: var(--pai-brand-5-bak); + font-size: 14px; + line-height: 22px; + font-weight: 500; + margin-left: 4px; +} + +.chat-sidebar .uno-buy-card-foot-price-average { + font-size: 12px; + line-height: 18px; + color: var(--pai-color-3-gray); + margin-right: 8px; +} + +.chat-sidebar .uno-buy-card-foot-price-original { + font-size: 12px; + line-height: 18px; + color: var(--pai-color-3-gray); + text-decoration: line-through; +} + +.chat-sidebar .uno-buy-card-foot-price-detail { + margin-bottom: 10px; +} + +.chat-sidebar .login-guide-wrap { + margin-bottom: 20px; + padding: 20px; + background-color: #fff; +} + + +.chat-main { + width: var(--window-content-width); + padding: 0 20px; + display: flex; + flex-direction: column; + position: relative; + height: 100%; + background-color: #fff; + margin-left: 20px; +} + +.chat-main a { + color: var(--pai-brand-1-normal); +} + +.chat-main a:hover { + color: var(--pai-brand-2-hover); +} + +.server-msg .markdown-body pre { + padding: 0; +} + +.server-msg .home_chat-message-item__hDEOq { + margin-top: 0; +} + +.bg-black { + --tw-bg-opacity: 1; + background-color: rgba(0,0,0,var(--tw-bg-opacity)); +} + +#chatCnt { + color: var(--pai-brand-6-mq); +} + +#chat-content { + overflow: auto; + flex: 1 1; + padding: 20px 20px 40px; + position: relative; + overscroll-behavior: none; +} + +.chat-input { + display: flex; + position: relative; +} + +#input-field { + height: 60px; + resize: none; /* 禁止手动改变大小 */ + padding: 10px 90px 10px 10px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; +} + +#input-field:focus { + border: 1px solid var(--pai-brand-3-click); + outline: none; + box-shadow: none; +} + +#send-btn { + background-color: var(--pai-brand-1-normal); + color: #fff; + position: absolute; + right: 16px; + bottom: 12px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + cursor: pointer; + transition: all .3s ease; + overflow: hidden; + user-select: none; + outline: none; + border: none; +} + +#send-btn:hover { + background-color: var(--pai-brand-2-hover); +} + +/*send-btn disabled*/ +#send-btn[disabled] { + background-color: var(--pai-brand-4-disable); + cursor: not-allowed; +} + +.button_icon-button-icon__qlUH3 { + width: 16px; + height: 16px; + display: flex; + justify-content: center; + align-items: center; +} + +.button_icon-button-text__k3vob { + margin-left: 5px; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home_chat-message__rdH_g { + display: flex; + flex-direction: row; +} + +.home_chat-message-item__hDEOq { + box-sizing: border-box; + max-width: 100%; + margin-top: 10px; + border-radius: 10px; + background-color: rgba(0,0,0,.05); + padding: 10px; + font-size: 14px; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; + word-break: break-word; + border: var(--pai-border-color-1); + position: relative; +} + +.home_chat-message-item__hDEOq img { + width: 100%; +} + +.home_chat-message-actions__nrHd1, .home_chat-message-actions__loading { + display: flex; + flex-direction: row-reverse; + width: 100%; + padding-top: 5px; + box-sizing: border-box; + font-size: 12px; +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: #24292f; + background-color: var(--color-canvas-default); + font-size: 14px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body p { + white-space: pre-wrap; +} + +.markdown-body>:last-child { + margin-bottom: 0!important; +} + +.markdown-body>:first-child { + margin-top: 0!important; +} + +.home_chat-message-action-date__6ToUp,.home_chat-message-action-loading__6ToUp { + color: var(--pai-color-3-gray); +} + +.home_chat-message-user__WsuiB { + display: flex; + flex-direction: row-reverse; +} +.home_chat-message-container__plj_e { + max-width: var(--message-max-width); + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.home_chat-message-user__WsuiB>.home_chat-message-container__plj_e { + align-items: flex-end; +} + +.home_chat-message-avatar__611lI { + margin-top: 20px; +} + +.home_chat-message-user__WsuiB>.home_chat-message-container__plj_e>.home_chat-message-item__hDEOq { + background-color: var(--pai-brand-7-light); +} + +.user-avatar { + height: 35px; + min-height: 35px; + width: 35px; + min-width: 35px; + display: flex; + align-items: center; + justify-content: center; + border: var(--pai-border-color-1); + box-shadow: var(--card-shadow); + border-radius: 10px; +} + +.user-avatar img { + height: 100%; + width: 100%; + border-radius: 10px; +} + +.lds-ellipsis { + display: flex; + justify-content: space-between; + align-items: center; + height: 20px; +} + +.lds-ellipsis div { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + animation: lds-ellipsis 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; +} + +.lds-ellipsis div:nth-child(2) { + animation-delay: 0.2s; +} + +.lds-ellipsis div:nth-child(3) { + animation-delay: 0.4s; +} + +.lds-ellipsis div:nth-child(4) { + animation-delay: 0.6s; +} + +@keyframes lds-ellipsis { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } + 80%, 90% { + transform: scale(0); + } +} + +.home_chat-message-top-actions__PfOzb { + min-width: 120px; + font-size: 12px; + position: absolute; + right: 20px; + top: -26px; + left: 30px; + transition: all .3s ease; + opacity: 0; + pointer-events: none; + display: flex; + flex-direction: row-reverse; +} + +.home_chat-message-container__plj_e:hover .home_chat-message-top-actions__PfOzb { + opacity: 1; + transform: translateX(10px); + pointer-events: all; +} + +.home_chat-message-top-actions__PfOzb .home_chat-message-top-action__wXKmA { + opacity: .5; + color: var(--pai-color-3-gray); + white-space: nowrap; + cursor: pointer; +} + +.home_chat-message-top-actions__PfOzb .home_chat-message-top-action__wXKmA:not(:first-child) { + margin-right: 10px; +} + +.home_chat-message-top-actions__PfOzb .home_chat-message-top-action__wXKmA:hover { + opacity: 1; +} + +.window-header { + padding: 14px 20px; + border-bottom: 1px solid rgba(0,0,0,.1); + position: relative; + display: flex; + justify-content: space-between; + align-items: center; +} + +.window-header-title { + max-width: calc(100% - 100px); + overflow: hidden; +} + +.window-header-title .window-header-main-title { + font-size: 20px; + font-weight: bolder; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + max-width: 50vw; +} + +.window-header-title .window-header-sub-title { + font-size: 14px; + margin-top: 5px; +} + +.home_chat-body-title__5S8w4:hover { + text-decoration: underline; + color: var(--pai-brand-2-hover); +} + +.home_chat-body-title__5S8w4 { + cursor: pointer; + color: var(--pai-brand-1-normal); +} + +.home_sidebar-header__b5asC { + position: relative; + background-color: #fff; + padding: 20px; + margin-bottom: 20px; +} + +.home_sidebar-title__d8_c_ { + font-size: 20px; + font-weight: 700; + margin-bottom: 12px; + animation: home_slide-in__gYZA0 .3s ease; +} + +.home_sidebar-sub-title__IS2Or { + font-size: 12px; + font-weight: 400; + animation: home_slide-in__gYZA0 .3s ease; + color: var(--pai-color-4-gray); +} + +.home_sidebar-logo__FFdBS { + position: absolute; + right: 0; + bottom: 18px; +} + +.login-guide-list { + display: flex; + flex-direction: row; + flex: 1 0 auto; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + color: #515767; + font-size: 12px; + line-height: 22px; +} + +.login-guide-list-item { + width: 100%; + margin-bottom: 8px; + display: flex; + flex-direction: row; + align-items: center; +} + +.login-guide-icon-wrap { + width: 28px; + height: 28px; + border-radius: 14px; + background-color: var(--pai-brand-7-light); + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.login-guide-icon { + color: var(--pai-brand-1-normal); + width: 16px; + height: 16px; +} + +.login-guide-text { + width: 80%; + margin-left: 8px; + color: var(--pai-color-4-gray); +} + +.chat-sidebar img { + width: 100%; + object-fit: cover; +} + +.chat_split_hr { + height:0; + border-top:1px solid #888686; + text-align:center; + margin-top: 1em; + margin-bottom: 1em; +} + +.chat_split_txt { + position:relative; + top:-14px; + color: #888686; + font-size: 0.8em; + background-color:#fff; +} + +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em +} + +code.hljs { + padding: 3px 5px +} + +.hljs { + background: #1e1e1e; + color: #dcdcdc +} + +.hljs-keyword,.hljs-literal,.hljs-name,.hljs-symbol { + color: #569cd6 +} + +.hljs-link { + color: #569cd6; + text-decoration: underline +} + +.hljs-built_in,.hljs-type { + color: #4ec9b0 +} + +.hljs-class,.hljs-number { + color: #b8d7a3 +} + +.hljs-meta .hljs-string,.hljs-string { + color: #d69d85 +} + +.hljs-regexp,.hljs-template-tag { + color: #9a5334 +} + +.hljs-formula,.hljs-function,.hljs-params,.hljs-subst,.hljs-title { + color: #dcdcdc +} + +.hljs-comment,.hljs-quote { + color: #57a64a; + font-style: italic +} + +.hljs-doctag { + color: #608b4e +} + +.hljs-meta,.hljs-meta .hljs-keyword,.hljs-tag { + color: #9b9b9b +} + +.hljs-template-variable,.hljs-variable { + color: #bd63c5 +} + +.hljs-attr,.hljs-attribute { + color: #9cdcfe +} + +.hljs-section { + color: gold +} + +.hljs-emphasis { + font-style: italic +} + +.hljs-strong { + font-weight: 700 +} + +.hljs-bullet,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-selector-pseudo,.hljs-selector-tag { + color: #d7ba7d +} + +.hljs-addition { + background-color: #144212; + display: inline-block; + width: 100% +} + +.hljs-deletion { + background-color: #600; + display: inline-block; + width: 100% +} + +select.styled-dropdown { + width: 120px; + font-size: 16px; + border: 1px solid #aaa; + padding: 6px; + border-radius: 4px; + appearance: none; /* removes the default arrow in some browsers */ + background: #fff url('data:image/svg+xml;utf8,') no-repeat 90% center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out; +} + +select.styled-dropdown:focus { + border-color: #ff6900; + box-shadow: 0 0 5px rgba(255, 105, 0, 0.5); + outline: none; +} + +select.styled-dropdown option { + font-size: 16px; + padding: 8px 10px; +} + +/*手机端适配*/ +@media screen and (max-width: 768px) { + .chat-sidebar, .chat-annotation, .window-header-sub-title .info { + display: none; + } + + .chat-main { + width: 100%; + padding: 0; + margin-left: 0; + } + + .window-header-title .window-header-main-title { + font-size: 15px; + } +} + +.chat-main .markdown-body ol li { + list-style-type: decimal; +} + +.chat-main .markdown-body h3 { + font-size: 1.17em; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/views/column-detail.css b/paicoding-ui/src/main/resources/static/css/views/column-detail.css new file mode 100644 index 000000000..4d936d8a4 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/column-detail.css @@ -0,0 +1,780 @@ +.article-wrap { + height: calc(100vh - 60px); + display: flex; +} + +.article-content-wrap { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 20px 0; + position: relative; /* 创建定位上下文 */ +} + +.article-content-inter-wrap { + margin-right: 300px; + margin-left: 20px; +} + +.article-content { + word-break: break-word; +} + +.book-directory-comp { + color: #000; + padding: 18px 0; +} + +.column.toc-container { + background-color: transparent; /* 背景透明,让下层滚动条可见 */ + padding: 0; + width: 280px; + position: fixed; + top: 80px; /* 从导航栏下方开始 */ + right: 0; /* 紧贴右边缘 */ + max-height: calc(100vh - 80px); /* 设置最大高度 */ + overflow-y: auto; /* 添加垂直滚动条 */ + z-index: 5; /* 在页面内容之上 */ +} + +.column.toc-container.underline { + /*background-color: var(--pai-color-fff-normal);*/ + padding: 20px; + width: 290px; + right: 20px; + top: 80px; + max-height: calc(100vh - 60px); /* 设置最大高度 */ +} + +.column.toc-container .widget { + background-color: var(--pai-color-fff-normal); /* 背景色设置在内部容器 */ + padding: 20px; + margin-right: 12px; /* 右侧留出滚动条空间 */ +} + +/* 分组标题样式 */ +.group-header { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid #f0f0f0; +} + +.group-header:hover { + background-color: #f5f5f5; +} + +.group-toggle { + margin-right: 8px; + transition: transform 0.2s ease; + transform: rotate(0deg); /* 默认向右 */ +} + +.group-toggle.expanded { + transform: rotate(90deg); /* 展开时向下 */ +} + +.group-name { + font-weight: 600; + font-size: 14px; + color: #333; +} + +/* 分组文章容器 */ +.group-articles { + /* 可以添加额外样式 */ +} + +.book-directory-comp .section-list .section { + position: relative; + display: flex; + justify-content: flex-start; + transition: all 0.2s; + padding: 9px 10px 9px 10px; + cursor: pointer; +} + +.book-directory-comp .section-list .section:hover { + background-color: hsla(0, 0%, 84.7%, 0.2); +} + +.book-directory-comp .section-list .section.unfinished { + cursor: not-allowed; +} + +.book-directory-comp + .section-list + .section.route-active + .center + .main-line + .title + .icon-camera + path, +.book-directory-comp + .section-list + .section.route-active + .left + .index + .icon-camera + path { + fill: currentColor; +} + +.book-summary .group-name { + margin-left: 0.2rem;border-bottom: 1px solid #aba2a240; +} + +.book-directory-comp .section-list .section .left .index { + font-weight: 600; + font-size: 16px; + line-height: 24px; + color: var(--pai-color-999-gray); + padding: 0 6px; + min-width: 26px; + text-align: center; +} + +.book-directory-comp .section-list .section .center { + flex-grow: 1; +} + +.book-directory-comp .section-list .section .center .main-line { + font-size: 15px; + line-height: 24px; + display: flex; +} + +.book-directory-comp .section-list .section .center .main-line .title { + font-size: 0; + flex: 1; + color: #252933; +} + +.book-directory-comp .section-list .section .center .main-line .title .icon-camera { + vertical-align: middle; + display: inline-block; + margin-right: 6px; +} + +.book-directory-comp .section-list .section .center .main-line .title .icon-camera path { + fill: #8a919f; +} + +.book-directory-comp .section-list .section .center .main-line .title .title-text { + vertical-align: bottom; + font-size: 16px; + line-height: 24px; +} + +.book-directory-comp .section-list .section .center .main-line .right { + margin-left: 15px; +} + +.book-directory-comp .section-list .section .center .main-line .right .lock { + width: 40px; + text-align: center; +} + +.book-directory-comp .section-list .section .center .main-line .right .label { + height: 24px; + background: #fff3db; + line-height: 24px; + border-radius: 12px; + padding: 0 8px; + color: #ff8412; + font-size: 12px; + white-space: nowrap; +} + +.book-directory-comp .section-list .section .center .sub-line { + display: flex; + align-items: center; + margin-top: 6px; + font-size: 13px; + color: var(--pai-color-999-gray); + line-height: 24px; +} + +.book-directory-comp .section-list .section .center .sub-line .label { + background: #eaf2ff; + border-radius: 2px; + line-height: 20px; + padding: 0 6px; + color: #1e80ff; + margin-right: 12px; + font-size: 12px; + min-width: 40px; + white-space: nowrap; + flex-shrink: 0; +} + +.book-summary { + width: 280px; + height: 100%; + cursor: default; + flex-shrink: 0; + z-index: 11; + border-right: 1px solid #ddd; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: left; + background-color: #fff; +} + +.book-summary .book-summary-masker { + display: none; + position: fixed; + left: 0; + top: 0; + z-index: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.3); +} + +.book-summary .book-summary-inner { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + z-index: 1; + height: 100%; +} + +.book-summary .book-summary-inner .book-summary__header { + height: 60px; + display: flex; + padding-left: 16px; + align-items: center; + background-color: #fff; + border-bottom: 1px solid #ddd; +} + +.book-summary .book-summary-inner .book-summary__header .logo { + height: 24px; +} + +.book-summary .book-summary-inner .book-summary__header .logo img { + height: 100%; +} + +.book-summary .book-summary-inner .book-summary__header .label { + margin-left: 13px; + margin-right: 25px; + padding-left: 10px; + padding-right: 10px; + height: 24px; + line-height: 24px; + font-size: 15px; + font-weight: 500; + color: #007fff; + position: relative; + background-color: rgba(0, 127, 255, 0.1); +} + +.book-summary .book-summary-inner .book-summary__header .label:after { + content: ""; + position: absolute; + bottom: 0; + right: 0; + width: 0; + height: 0; + border-color: rgba(0, 127, 255, 0.2) #fff #fff rgba(0, 127, 255, 0.2); + border-style: solid; + border-width: 5px; +} + +.book-summary .book-summary-inner .book-summary__header .audit { + color: #71777c; + font-size: 15px; + opacity: 0.6; +} + +.book-summary .book-summary-inner .buy-sticky { + display: flex; + justify-content: center; + align-items: center; + height: 60px; + background: #fff; + padding: 0 10px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); +} + +.book-summary .book-summary-inner .buy-sticky .section-buy { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + height: 40px; + flex-grow: 1; + cursor: pointer; + border-radius: 4px; + color: #fff; + font-size: 14px; + text-align: center; +} + +.book-summary .book-summary-inner .book-directory { + box-sizing: border-box; + -webkit-overflow-scrolling: touch; + height: calc(100% - 60px); +} + +.book-summary .book-summary-inner .book-directory.bought { + height: calc(100% - 120px); +} + +.book-summary__footer { + position: absolute; + left: 0; + bottom: 0; + right: 0; + height: 60px; + padding-top: 20px; + padding-left: 20px; + box-sizing: border-box; + z-index: 1; +} + +.book-summary__footer .ion-close { + position: absolute; + right: 15px; + top: 15px; + cursor: pointer; + color: #bec3c7; + line-height: 1; +} + +.book-summary__footer .qr-icon { + width: 20px; + position: relative; +} + +.book-summary__footer .qr-icon img { + cursor: pointer; + width: 100%; +} + +.book-summary__footer .qr-tips { + z-index: -1; + opacity: 0; + position: absolute; + left: 16px; + bottom: 50px; + width: 180px; + height: 235px; + box-sizing: border-box; + background-color: #fff; + padding: 20px 30px 0; + border-radius: 2px; + transition: all 0.3s ease; + visibility: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.book-summary__footer .qr-tips.show { + z-index: 1; + visibility: visible; + opacity: 1; +} + +.book-summary__footer .qr-tips .title { + margin-top: 10px; + text-align: center; +} + +.book-summary__footer .qr-tips .title span { + display: block; + font-size: 16px; +} + +.book-summary__footer .qr-tips .qr-img { + margin-top: 5px; +} + +.book-summary__footer .qr-tips .qr-img img { + width: 100%; +} + +.book-summary__footer .qr-tips:after { + content: ""; + position: absolute; + transform: rotate(45deg); + box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.15); + left: 9px; + bottom: 0; + width: 0; + height: 0; + border-color: transparent #fff #fff transparent; + border-style: solid; + border-width: 5px; +} + +/* 底部左右切换 */ +.direction { + margin: 0 auto; +} + +.article-change { + z-index: 100; + position: fixed; + bottom: 70px; + right: 20px; + display: flex; + justify-content: center; + align-items: center; + transition: all 0.3s; +} + +.article-change-item { + cursor: pointer; + position: absolute; + bottom: 0; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + font-size: 14px; + user-select: none; + transition: all 0.3s ease; +} + +.article-change-item:hover { + transform: scale(1.1); +} + +.article-change-item:active { + transform: scale(1.05); +} + +.article-change-item svg { + transition: all 0.3s ease; + width: 50px; + height: 50px; + padding: 13px; + border-radius: 50%; + background-color: white; + box-shadow: 0 4px 10px rgb(0 0 0 / 15%); +} + +.article-change-item:hover svg { + box-shadow: 0 6px 20px rgb(0 0 0 / 25%); +} + +/* 评论数徽章样式 */ +.article-change-item.with-badge::after { + content: attr(badge); + position: absolute; + top: -5px; + right: -5px; + min-width: 20px; + height: 20px; + padding: 0 5px; + background-color: white; + color: #333; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + border: 1px solid #e0e0e0; + line-height: 1; +} + +/* 隐藏评论数为0的徽章 */ +.article-change-item.with-badge[badge="0"]::after { + display: none; +} + +.step-btn--prev .article-change-item { + left: -10%; +} + +.step-btn--next .article-change-item { + right: 8%; +} + +.right { + font-size: 15px; + line-height: 24px; +} + +.right .label { + height: 24px; + background: var(--pai-bg-normal-1); + line-height: 24px; + border-radius: 12px; + padding: 0 8px; + color: var(--pai-brand-1-normal); + font-size: 12px; + white-space: nowrap; +} + +.right .label-star { + color: #17bb98; + background: #e9ffdb +} + +.right .label-free { + color: #5ab4fe; + background: #f2f3f5; +} + + +.book-directory-comp .section-list .section.active:after { + content: ""; + position: absolute; + width: 4px; + height: 24px; + left: 0; + top: 9px; + background: var(--pai-brand-2-hover); + border-radius: 0 8px 8px 0; +} + +.book-directory-comp .section-list .section.active .center .main-line .title, +.book-directory-comp .section-list .section.active .left .index { + color: var(--pai-brand-2-hover); +} + +.needlock { + width: 100%; + text-align: center; + font-size: xx-large; + font-weight: 400; + background-image: -webkit-gradient(linear,left top, left bottom,from(rgba(255,255,255,0)),color-stop(70%, #fff)); + background-image: linear-gradient(-180deg,rgba(255,255,255,0) 0%,#fff 70%); + padding-bottom: 24px; position: relative; + padding-top: 160px; + margin-top: -220px; + z-index: 996; + bottom: -1px; +} + +.needlock h2 { + font-size: 1.5rem; +} + +.join-star { + font-size: small; +} + +.join-star p { + margin-bottom: 8px; +} +.join-star .category { + color: var(--pai-brand-1-normal); + font-weight: bold; +} + +.bd-search { + position: relative; + padding: 1rem 15px; +} + +/* 为两个导航按钮添加不同的视觉样式 */ +.bd-search-docs-toggle { + color: #007bff; /* 蓝色,表示左侧专栏导航 */ +} + +.bd-toc-toggle { + color: #28a745; /* 绿色,表示右侧内容目录 */ +} + +@media (max-width: 700px) { + .book-directory-comp { + overflow-y: auto; + overflow-x: hidden; + } + + .book-directory-comp .section:hover { + background-color: transparent; + } + + .article-change { + width: 100%; + } + + .article-content-wrap{ + padding: 0; + background-color: var(--pai-bg-white-fff); + flex: none; + display: block; + } + + .for-menu { + border-bottom: 1px solid rgba(0,0,0,0.1); + } + + .book-summary { + height: auto; + width: 100%; + } + + .article-wrap { + display: block; + } + + .step-btn--prev .article-change-item { + left: -6%; + } + + .book-summary__footer { + display: none; + } + + .article-change-item { + width: 40px; + height: 40px; + font-size: 12px; + } + + /* 移动端文章内容导航样式 */ + .toc-container.column { + position: fixed; + top: 60px; + right: 0; + bottom: 0; + width: 100%; + max-width: 300px; + z-index: 1050; + background: #fff; + box-shadow: -2px 0 8px rgba(0,0,0,0.1); + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + } + + .toc-container.column.show { + transform: translateX(0); + } + + .toc-container.column .widget { + height: 100%; + display: flex; + flex-direction: column; + } + + .toc-container.column .com-nav-bar-title { + padding: 10px 15px; + border-bottom: 1px solid #eee; + margin-bottom: 0; + } + + .toc-container.column .toc { + flex: 1; + overflow-y: auto; + padding: 10px 15px; + } + + /* 在移动端移除右侧边距,使内容占满整个屏幕宽度 */ + .article-content-inter-wrap { + margin-right: 0; + margin-left: 0; + } +} + +@media (min-width: 721px) { + .beautify-scrollbar-warp { + overflow-x: hidden; + } + + .beautify-scrollbar-warp:hover { + overflow: auto; + } + + .beautify-scrollbar-warp::-webkit-scrollbar { + width: 12px; + height: 4px; + } + + .beautify-scrollbar-warp::-webkit-scrollbar-thumb { + border: 4px solid transparent; + background-clip: padding-box; + border-radius: 7px; + } + + .for-menu { + display: none; + } +} + +@media (min-width: 992px) { + /* 在大屏幕上保留右边距,为目录留出空间 */ + .article-content-inter-wrap { + margin-right: 300px; + margin-left: 20px; + } +} + +/* 在小屏幕设备上隐藏内容导航 */ +@media (max-width: 991px) { + .toc-container.column:not(.show) { + display: none !important; + } +} + +.markdown-toc-list { + list-style: none; + padding-left: 0; + margin: 0; +} + +.markdown-toc-list li { + margin: 0; + padding: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toc-level-2 { + font-size: 0.9rem; + color: #333; + margin-left: 0; +} + +.toc-level-3 { + font-size: 0.8rem; + color: #666; + margin-left: 20px; +} + +.toc-level-4 { + font-size: 12px; + color: #999; + margin-left: 40px; +} + +.toc-level-5 { + font-size: 10px; + color: #999; + margin-left: 60px; +} + +.toc-level-6 { + font-size: 10px; + color: #999; + margin-left: 80px; +} + +/* 添加目录项选中状态样式 */ +.markdown-toc-list a.active { + color: #ff6900; + font-weight: bold; + border-left: 3px solid #ff6900; + padding-left: 10px; +} + +.toc-h2.active { + color: #ff6900; + font-weight: bold; +} + +.toc-h3.active { + color: #ff6900; + font-weight: bold; +} + diff --git a/paicoding-ui/src/main/resources/static/css/views/column-home.css b/paicoding-ui/src/main/resources/static/css/views/column-home.css new file mode 100644 index 000000000..ea8adbaab --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/column-home.css @@ -0,0 +1,257 @@ +.custom-home { + overflow: auto; + padding-top: 20px; + height: calc(100vh - 60px); +} + +.custom-home-wrap { + width: 1200px; + display: flex; + margin: 0 auto 20px; + min-height: calc(100% - 90px); +} + +.custom-home-body { + flex: 1; + margin-right: 20px; + border-radius: 8px; +} + +/* 详情列表 */ +.item { + display: flex; + padding: 25px; + box-sizing: border-box; + position: relative; + border-bottom: var(--pai-hr-color-1) 1px solid; +} + +.poster { + width: 110px; + height: 156px; + flex-shrink: 0; + position: relative; +} + +.poster img { + width: 100%; + height: 100%; + border-radius: 2px; +} + +.info { + position: relative; + flex-grow: 1; + overflow: hidden; + box-sizing: border-box; + font-size: 16px; + padding-left: 22px; +} + +.info .messages { + font-size: 14px; +} + +.author .name { + color: var(--pai-color-3-gray); + font-size: 14px; +} + +.title { + font-size: 20px; + line-height: 28px; +} + +.info .desc { + margin-top: 10px; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + color: var(--pai-color-3-gray); +} + +.user { + display: inline-flex; + align-items: center; +} + +.user img { + width: 26px; + height: 26px; + border-radius: 50%; + margin-right: 8px; + background-position: 50%; + background-size: cover; + background-repeat: no-repeat; +} + +.other { + margin-top: 6px; + display: flex; + align-items: center; + color: #8a919f; +} + +.author { + margin-top: 6px; +} + +/*限时免费*/ + +.new-tag-wrap { + padding: 0 6px; + height: 20px; + line-height: 20px; + color: var(--pai-color-fff-normal); + font-size: 12px; + border-radius: 2px; + display: inline-block; + vertical-align: middle; + cursor: default; + margin-right: 3px; + transform: translateY(-3px); + background-color: var(--pai-brand-5-bak); +} + +.info .tag { + display: inline-block; + vertical-align: middle; + cursor: default; + margin-right: 5px; + transform: translateY(-2px); +} + +/*level*/ + +.com-2-level { + display: inline-block; + vertical-align: middle; + box-sizing: border-box; + width: 30px; + height: 30px; + border: 2px solid #fff; + border-radius: 50%; + background-color: var(--pai-brand-5-bak); + font-size: 12px; + text-align: center; + line-height: 26px; + color: #fff; + font-style: oblique; + font-weight: 700; +} + +.uc-hero-level, .uc-hero-name { + margin-right: 10px; + margin-left: 10px; +} + +.com-2-level.skin-2 { + width: auto; + height: 16px; + border: none; + border-radius: 9px; + padding: 0 8px; + font-size: 12px; + line-height: 16px; + font-weight: 700; + font-style: normal; +} + +.com-2-level .text { + position: relative; + top: 1px; + display: block; + -webkit-transform: scale(.8); + transform: scale(.8); +} + +.com-2-level.skin-2 .text { + top: 0; + -webkit-transform: none; + transform: none; +} + +/*作者简介*/ +.self-description { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 4px; + font-size: 14px; + color: var(--pai-color-3-gray); +} + +/*限时免费*/ + +.sale-tooltip { + position: relative; + flex: 0 0 auto; + align-items: center; + background: linear-gradient(90deg,rgba(246,66,66,.25),rgba(246,66,66,0)); + border-radius: 100px; + padding: 0 48px 0 8px; + color: var(--pai-brand-6-mq); + font-size: 12px; + font-weight: 500; +} + +.sale-tooltip .count-down-text:before { + width: 6px; + content: "·"; + margin: 0 2px; +} + +.read-count:before { + width: 6px; + content: "·"; + margin: 0 4px; +} + +@media screen and (max-width: 768px) { + .custom-home { + padding-top: 0; + } + + .custom-home-right, + .self-description, + .article-count, + .sale-tooltip, + .read-count:before { + display: none; + } + + .custom-home-wrap { + width: 100%; + margin: 0; + display: block; + } + + .custom-home-body { + margin-right: 0; + } + + .other { + /*最多显示一行,超出部分省略号*/ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 0; + } + + .item { + padding: 15px; + } + + .poster { + width: 90px; + height: 140px; + } + + .title { + font-size: 16px; + line-height: 22px; + } +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/views/home.css b/paicoding-ui/src/main/resources/static/css/views/home.css new file mode 100644 index 000000000..b2d8415fc --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/home.css @@ -0,0 +1,255 @@ +.home { + /*修复返回顶部时不起效的 bug overflow 必须默认*/ + /*overflow: auto;*/ + height: calc(100vh - 60px); +} + +.home-wrap { + min-height: calc(100% - 90px); + padding-top: 20px; +} + +.home-inter-wrap { + max-width: 1200px; + margin: 0 auto; + display: flex; +} + +.home-body { + flex: 1; + margin-right: 20px; + background-color: #fff; + padding: 0 20px; + margin-bottom: 20px; +} + +.home-body-nav { + color: #262626; +} + +/* 头部导航 */ +.category--item { + cursor: pointer; + font-size: 18px; + font-weight: 700; + line-height: 2.3rem; + white-space: nowrap; +} + +.category--item:hover { + color: var(--pai-brand-2-hover); +} + +.category--item:first-child { + padding-left: 0; +} + +.category--active { + color: var(--pai-brand-1-normal); +} + +.category-wrap { + max-width: 1200px; + margin: 0 auto; + padding: 4px 0; +} + +.align-content-start { + overflow-x: auto; +} + +/* 头部搜索 */ +.search-has-result ul { + margin-bottom: 0; + list-style-type: none; + padding: 0; + font-size: 1rem; + font-weight: 500; + line-height: 1.77778rem; + margin-top: 0; +} + +.search-has-result ul li a { + --tw-border-opacity: 1; + border: 0 solid; + border-bottom-width: 1px; + border-color: rgba(127, 125, 131, var(--tw-border-opacity)); +} + +.search-has-result ul li span.text-sm { + line-height: 2.4rem; +} + +.hover\:bg-gray-400:hover { + --tw-bg-opacity: 1; + background-color: rgba(127, 125, 131, var(--tw-bg-opacity)); +} + +.search-result-block .search-no-result mark { + font-size: 1.2rem; +} + +.category-search-btn, +.category-cancel-btn { + align-self: center; + justify-items: center; + background-color: transparent; + background-image: none; + border: none; +} + +.category-search-btn:hover, +.category-cancel-btn:hover { + color: var(--pai-brand-2-hover); +} + +.svg-inline--fa, +svg:not(:root).svg-inline--fa { + overflow: visible; +} + +.svg-inline--fa.fa-w-16 { + width: 1.2em; +} + +.svg-inline--fa.fa-w-14 { + width: 1em; +} + +/*主要文章详情页 头部图片*/ +.home-carouse-wrap { + background-color: #346; + padding: 20px; +} + +.home-carouse-inter-wrap { + max-width: 1200px; + margin: 0 auto; + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.home-carouse-item { + width: calc((100% - 60px) * 0.25); + transition: all 0.2s ease 0s; + box-shadow: 0 6px 12px 0 rgb(0 0 0 / 25%); +} + +.home-carouse-item img { + width: 100%; + height: 150px; +} + +.home-carouse-item-body { + background-color: #fff; + padding: 20px; + height: 160px; +} + +.home-carouse-item-title { + font-size: 18px; + font-weight: 700; + height: 58px; + margin-bottom: 10px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.home-carouse-item-dot { + display: inline-block; + background-color: #a2a2a2; + border-radius: 100%; + height: 8px; + width: 8px; + margin-right: 4px; + flex-shrink: 0; +} + +.home-carouse-item-tag { + display: flex; + align-items: center; + color: #a2a2a2; + font-size: 14px; +} + +.home-carouse-item-first-text { + font-weight: 900; + padding: 0 8px; + align-items: center; + position: relative; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.home-carouse-item-tag .home-carouse-item-first-text:not(:last-child):after { + position: absolute; + right: -1px; + display: block; + content: " "; + width: 2px; + height: 2px; + border-radius: 50%; + background: #4e5969; +} + +.home-carouse-item-tag .author { + padding-left: 8px; + color: #a2a2a2; + display: inline-block; + max-width: 100px; + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.home-carouse-item:hover { + transform: translateY(-4px); +} + +/* home 适配 */ +@media screen and (max-width: 768px) { + .home-right, + .home-carouse-wrap { + display: none; + } + .home-body { + margin-right: 0; + } + + .home-wrap { + padding-top: 0; + } + + .user-article-item-value-text { + -webkit-line-clamp: 3; + } + .home-inter-wrap { + width: auto; + display: block; + } + .user-article-img { + width: 110px; + position: absolute; + top: 45%; + transform: translateY(-50%); + right: 0; + } + .align-content-start { + padding: 4px 18px; + } + .cdc-article-panel__operate { + margin-left: 8px; + } + .article-show-wrap { + margin-right: 8px; + } + + .cdc-article-panel__inner:has(.user-article-img) .cdc-article-panel__media { + max-width: 65%; + } +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/views/notice.css b/paicoding-ui/src/main/resources/static/css/views/notice.css new file mode 100644 index 000000000..a4616c67d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/notice.css @@ -0,0 +1,109 @@ +.notice-wrap { + height: calc(100vh - 60px); + overflow-y: auto; + background-color: #f4f5f5; +} +.notice-nav { + position: sticky; + top: 0; + margin-bottom: 10px; + background-color: var(--pai-bg-white-fff); + border-top: 1px solid #f1f1f1; +} +.notice-nav-inner { + width: 1000px; + margin: 0 auto; + display: flex; + align-items: center; + padding-left: 20px; +} +.notice-nav-item { + padding: 14px 0; + margin-right: 36px; + line-height: 16px; +} +.notice-nav-item--active { + padding: 14px 0; + margin-right: 36px; + color: var(--pai-brand-2-hover); +} +.unread-count { + display: inline-block; + color: var(--pai-color-fff-normal); + transform: scale(0.8); + font-size: 12px; + padding: 2px 8px; + background: var(--pai-brand-6-mq); + border-radius: 100px; + text-align: center; +} +.notice-content { + width: 1000px; + margin: 0 auto; + min-height: calc(100% - 133px); +} +.notification { + margin-bottom: 12px; + display: flex; + padding: 20px; + background-color: var(--pai-bg-white-fff); + border-radius: 2px; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%); + align-items: center; +} +.notification-img { + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 24px; +} +.notification-right { + flex: 1; + display: flex; + flex-direction: column; +} +.notification-content { + display: flex; + font-size: 15px; + margin-bottom: 8px; +} +.notification-comment { + font-size: 14px; + width: 100%; + min-height: 40px; + background-color: #fafbfc; + padding: 10px; + border-radius: 4px; + border: 1px solid #f1f1f2; + margin-bottom: 8px; +} +.notification a { + color: var(--pai-brand-1-normal); +} +.notification a:hover { + color: var(--pai-brand-2-hover); +} +.notification-bottom { + display: flex; + justify-content: space-between; +} +.notification-time { + font-size: 12px; + color: #ccc; +} +.notification-action { + display: flex; +} + +.notification a.notification-action-item { + display: flex; + align-items: center; + font-size: 12px; + margin-right: 8px; + cursor: pointer; + color: var(--pai-color-999-gray); +} + +.action-text { + font-size: 12px; +} diff --git a/paicoding-ui/src/main/resources/static/css/views/rank.css b/paicoding-ui/src/main/resources/static/css/views/rank.css new file mode 100644 index 000000000..6def9b2c6 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/rank.css @@ -0,0 +1,216 @@ +.custom-home { + overflow: auto; + padding-top: 20px; + height: calc(100vh - 60px); +} + +.custom-home-wrap { + width: 1200px; + display: flex; + margin: 0 auto 20px; + min-height: calc(100% - 90px); +} + +.hot-list-wrap { + display: flex; + flex-direction: column; + flex: 1; + width: 80%; + margin-left: 5%; + background-color: #fff; + padding: 1.66rem 1rem; + border-radius: 4px; +} + +.author-type-select { + padding: 0.25rem; + box-sizing: border-box; + background-color: #f2f3f5; + border-radius: 4px; +} + +.author-type-select, .hot-list-header { + display: flex; + flex-direction: row; + align-items: center; +} + +.author-type-link { + font-size: 1.16rem; + line-height: 1.83rem; + display: inline-block; + padding: 2px 1rem; + color: #8a919f; + border-radius: 4px; + font-weight: 400; +} + +.author-type-link-active { + background-color: #fff; + color: #252933; +} + +.hot-list-header { + padding: 0 1rem 1.33rem; + font-size: 1.5rem; + line-height: 2.16rem; + justify-content: space-between +} + +.author-item-link { + display: inline-block; + padding: 0; + margin: 0; + width: 100%; +} + +.author-item-wrap { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: 1.33rem 1rem; + border-radius: 4px; + cursor: pointer; +} + +.author-item-left { + display: flex; + flex-direction: row; + align-items: center; + min-width: 300px; +} + +.author-hot, .author-right { + display: flex; + flex-direction: row; + align-items: center +} + +.author-hot { + border-right: 1px solid #e4e6eb; + min-height: 3.33rem; + padding-right: 2.5rem; + margin-right: 2.5rem; + display: flex; + flex-direction: row; + align-items: center; +} + +.author-right { + justify-content: flex-end; + width: 21rem; + flex-shrink: 0; +} + +.author-number { + min-width: 2.66rem; + text-align: center; + font-size: 1.5rem; + font-weight: 600; + line-height: 2rem; + color: #515767; + margin-right: 2rem; + flex-shrink: 0; +} + +.author-number-1 { + background: linear-gradient(180deg, #f64242 30%, rgba(246, 66, 66, .4) 80%) +} + +.author-number-2 { + background: linear-gradient(180deg, #ff7426 30%, rgba(255, 116, 38, .4) 80%) +} + +.author-number-3 { + background: linear-gradient(180deg, #ffac0c 30%, rgba(255, 172, 12, .4) 80%) +} + +.author-number-1, .author-number-2, .author-number-3 { + -webkit-background-clip: text; + color: transparent; + font-family: Archivo; + font-size: 1.66rem +} + +.author-detail { + display: flex; + flex-direction: row; + align-items: center; + min-width: 285px; + max-width: 400px; +} + +.author-author-img { + width: 3rem; + height: 3rem; + border-radius: 24px; + margin-right: 1.66rem; +} + +.username { + font-size: 1em; + font-weight: 600; + color: #252933; + display: flex; + align-items: center; +} + +.username .name { + display: inline-block; + vertical-align: top; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.author-desc { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 0.66rem; + flex-wrap: wrap; +} + +.author-text { + color: #8a919f; + font-size: 0.9rem; +} + +.author-hot { + border-right: 1px solid #e4e6eb; + min-height: 3.33rem; + padding-right: 2.5rem; + margin-right: 2.5rem; +} + +.author-hot, .author-right { + display: flex; + flex-direction: row; + align-items: center +} + +.hot-number { + color: #252933; + font-size: 1.125rem; + font-weight: 500; + margin-right: 0.5rem; +} + +.hot-text { + font-size: 1.125rem; + color: #8a919f; + line-height: 1.83rem; +} + +.author-item-wrap .author-right .author-item-button { + width: 6.3rem; + box-sizing: border-box; + height: 2.83rem; + line-height: 2.5rem; + background-color: rgba(30, 128, 255, 0.05); + border-color: rgba(30, 128, 255, 0.3); + border-radius: 4px; + color: #1e80ff; + padding: 0; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/css/views/user.css b/paicoding-ui/src/main/resources/static/css/views/user.css new file mode 100644 index 000000000..fb00e6241 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/css/views/user.css @@ -0,0 +1,754 @@ +/**/ +.user-left-wrap { + background: linear-gradient( + 203.77deg, + rgba(44, 128, 255, 0.2) -31.27%, + rgba(44, 128, 255, 0) 54.55% + ), + linear-gradient( + 35.19deg, + rgba(255, 203, 154, 0.7) -24.65%, + rgba(255, 255, 255, 0) 38.1% + ), + rgba(255, 255, 255, 0.7); + border-radius: 8px; + padding-top: 27px; + padding-bottom: 24px; + margin-bottom: 16px; + display: flex; + flex-direction: column; + align-items: center; +} + +.user-head-name { + font-weight: 500; + font-size: 26px; + line-height: 120%; + color: rgb(51, 51, 51); + margin: 8px 0; +} + +.user-num-wrap { + margin: 40px auto; + width: 192px; + height: 63px; + display: flex; + flex-direction: row; + justify-content: space-between; +} +.user-head-num-wrap { + display: flex; + flex-direction: column; +} +.user-head-num { + font-weight: 600; + font-size: 32px; + height: 42px; + line-height: 42px; + color: rgb(51, 51, 51); + margin-bottom: 4px; + text-align: center; +} +.achievement-wrap, +.process-wrap { + background-color: rgb(255, 255, 255); + padding: 20px; + border-radius: 4px; + overflow: hidden; +} + +.achievement-title, +.process-title { + font-size: 18px; + line-height: 24px; + color: rgb(51, 51, 51); + font-weight: 600; + margin-bottom: 24px; +} +.achievement-item { + height: 32px; + line-height: 32px; + margin-bottom: 16px; + display: flex; + justify-content: space-between; +} + +.achievement-item span { + font-weight: 500; +} + +/* tag选择样式 */ +.tag-select-active { + color: var(--pai-brand-2-hover) !important; + border-bottom: 4px solid var(--pai-brand-2-hover); +} + +.tag-select { + color: #000000; + font-weight: 500; + margin: 0 0 0 32px; + position: relative; + display: inline-flex; + align-items: center; + padding: 12px 0; + font-size: 18px; + background: transparent; + outline: none; + cursor: pointer; +} + +.tag-select:first-child { + margin-left: 0; +} + +.user-select-tag-wrap { + border-bottom: 1px solid rgb(240 240 240); + margin-bottom: 16px; +} + +/* 整体布局 */ +.user { + overflow: auto; + height: calc(100vh - 60px); + background-color: #f7f8f9; +} + +.user-wrap { + margin: 0 auto; + width: 1200px; + display: flex; + flex-direction: column; + margin-bottom: 20px; + min-height: calc(100% - 90px); +} + +.user-body { + flex: 1; + border-radius: 4px; + padding: 20px; + background-color: #fff; + z-index: 10; + margin-right: 20px; +} + +.user-content { + display: flex; +} + +.user-left { + z-index: 10; + width: 30%; + border-radius: 8px; +} + +/* 关注 */ +.follow-select-tag { + padding: 0 24px; + color: #333; + border-right: 1px solid #e6e6e6; + margin: 0; + cursor: pointer; + font-size: 18px; + line-height: 22px; + font-weight: 500; +} + +.follow-select-tag:last-child { + padding-right: 0; + border-right: 0; +} + +.follow-select-tag:hover, +.follow-select-tag-active { + color: var(--pai-brand-2-hover); +} + +.follow-item { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: 8px 16px 8px 0; + height: fit-content; + font-size: 18px; +} + +.follow-item img { + width: 66px; + height: 66px; + border-radius: 50%; + margin-right: 15px; + font-size: 18px; +} + +.follow-item-icon { + width: 68px; + height: 24px; + border-radius: 40px; + display: flex; + align-items: center; + justify-content: center; + color: var(--pai-brand-1-normal); + border: 1px solid var(--pai-brand-1-normal); + font-size: 12px; + cursor: pointer; +} + +#saveModel .modal-dialog { + max-width: 900px; +} + +/* 个人信息编辑弹窗 */ +#saveModel .input-group { + height: 60px; + display: flex; + align-items: center; +} + +#saveModel .input-group:last-child { + border: 0; +} + +#saveModel .form-label { + width: 80px; + text-align: left; + font-weight: 500; + font-size: 14px; + color: #333; +} + +#saveModel .form-control { + height: 32px; + display: flex; + align-items: center; + color: var(--pai-color-3-black); + background: var(--pai-bg-light-2); + border: 1px solid var(--pai-border-color-1); + font-size: 14px; +} + +#saveModel .form-control:focus { + border-color: var(--pai-brand-3-click); + background: var(--pai-bg-white-fff); + box-shadow: none; +} + +#saveModel .modal-body { + display: flex; + padding: 20px; +} + +.person-img-wrap { + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + width: 136px; + margin-left: 60px; +} +.person-img-inter-wrap { + width: 90px; + height: 90px; + position: relative; +} +.person-img { + width: 100%; + height: 100%; + border-radius: 50%; +} +.person-upload-text { + color: #1d2129; + font-weight: 500; + font-size: 14px; + margin-top: 10px; + margin-bottom: 8px; +} +.person-upload-limit { + color: var(--pai-color-3-gray); + font-size: 12px; + line-height: 17px; + font-weight: 400; +} +.cancel-title { + color: var(--pai-brand-1-normal); + cursor: pointer; +} +.click-cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: var(--pai-color-fff-normal); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 50%; + background-color: rgba(29, 33, 41, 0.5); + z-index: 2; + visibility: hidden; + cursor: pointer; +} +.click-text { + font-size: 12px; + margin-top: 7px; + line-height: 17px; + font-weight: 400; +} +.click-input { + display: none; +} +.person-info { + width: 80%; +} + +/* 头部改造 */ +.user-bg { + background-image: url(../../img/lucky.png); + background-position: top; + background-repeat: no-repeat; + background-size: auto 288px; + position: relative; + height: 268px; + padding-top: 42px; +} + +.user-bg-mask { + background: linear-gradient( + 180deg, + hsla(0, 0%, 98%, 0), + hsla(0, 0%, 98%, 0.95) 85%, + #f9f9f9 + ); + bottom: 0; + height: 100px; + left: 0; + position: absolute; + width: 100%; +} + +.user-head { + position: relative; + border-radius: 8px; + padding-top: 36px; + clip-path: path( + "M85 0c18.703 0 35.339 8.853 45.945 22.597.41.53.84 1.132 1.293 1.804A24 24 0 0 0 152.148 35H1188c6.627 0 12 5.373 12 12v169c0 6.627-5.373 12-12 12H12c-6.627 0-12-5.373-12-12V47c0-6.627 5.373-12 12-12h5.857a24 24 0 0 0 19.916-10.608c.478-.712.933-1.345 1.364-1.901C49.747 8.807 66.345 0 85 0Z" + ); + background: linear-gradient( + 180deg, + hsla(0, 0%, 100%, 0.6), + hsla(0, 0%, 100%, 0.9) 76%, + #fff + ); + backdrop-filter: blur(10px); + width: 1200px; + margin: 0 auto; + z-index: 10; +} + +.user-head-img { + width: 90px; + height: 90px; + border-radius: 50%; + background-color: #fff; + position: absolute; + left: 40px; + top: 13px; +} + +.user-head-title-wrap { + margin-left: 160px; + display: flex; + justify-content: space-between; +} + +.user-head-title-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 380px; + font-size: 24px; + font-weight: 500; + padding-top: 12px; +} +.user-head-title-classify { + display: flex; + height: 86px; + padding-top: 20px; + align-items: center; +} +.user-head-title-classify-item { + width: 110px; + display: flex; + flex-direction: column; + align-items: center; + color: var(--pai-color-4-gray); +} +.user-head-title-classify-item span:last-child { + margin-top: 12px; +} + +.user-head-cell { + width: 1px; + height: 20px; + background-color: #adb5bd; +} +.user-head-footer { + display: flex; + justify-content: space-between; + padding: 2px 30px 6px; +} +.user-edit { + font-size: 16px; + padding-top: 14px; + width: 280px; + color: var(--pai-brand-1-normal); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} +.edit-btn:hover { + color: var(--pai-brand-2-hover); +} +.user-edit span:first-child { + color: var(--pai-color-3-gray); +} + +.user-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-bottom: 12px; +} +.user-text-icon { + cursor: pointer; +} + +.iconSize___29n8N { + filter: brightness(1.2); +} + +.text-base-pure { + color: var(--pai-color-999-gray); +} +.tw-flex-1 { + flex: 1 1 0%; +} + +.user-edit .edit-btn { + padding-left: 20px; +} + +.tags { + padding-bottom: 12px; + font-size: 14px; +} + +.tag-item { + align-items: center; + background: rgba(51,51,51,.05); + border-radius: 14px; + color: var(--pai-color-3-gray); + display: flex; + height: 28px; + margin-right: 8px; + padding-left: 8px; + padding-right: 8px; +} + +.tw-w-3 { + width: 12px; +} +.tw-h-3 { + height: 12px; +} +.tw-mr-1 { + margin-right: 4px; +} + +/* ========== 移动端适配 ========== */ + +/* 平板端适配 (769px - 1199px) */ +@media (min-width: 769px) and (max-width: 1199px) { + .user-wrap { + width: 100%; + padding: 0 20px; + } + + .user-head { + width: 100%; + } +} + +/* 手机端适配 (最大768px) */ +@media (max-width: 768px) { + .user-wrap { + width: 100%; + padding: 0 16px; + min-height: auto; + } + + .user-head { + width: 100%; + z-index: 20; + position: relative; + } + + .user-content { + flex-direction: column; + } + + .user-body { + width: 100%; + margin-right: 0; + margin-bottom: 16px; + padding: 16px; + z-index: 1; + } + + .user-left { + width: 100%; + } + + .user-bg { + height: 240px; + background-size: cover; + background-position: center; + z-index: 20; + } + + .user-head { + padding: 24px 16px 20px; + clip-path: none; + border-radius: 0; + z-index: 20; + position: relative; + } + + .user-head-img { + width: 60px; + height: 60px; + left: 16px; + top: 10px; + } + + .user-head-title-wrap { + margin-left: 90px; + flex-direction: column; + display: flex; + } + + .user-head-title-name { + font-size: 18px; + max-width: 100%; + padding-top: 8px; + order: 1; + } + + .user-head-title-classify { + display: none; + } + + .user-head-footer { + display: none; + } + + .user-head-title-classify-item { + width: 90px; + font-size: 12px; + gap: 4px; + margin: 0 12px; + } + + .user-head-title-classify-item span:first-child { + font-size: 12px; + order: 2; + } + + .user-head-title-classify-item span:last-child { + margin-top: 0; + font-size: 16px; + font-weight: 500; + order: 1; + color: #333; + } + + .user-head-cell { + height: 30px; + align-self: center; + } + + .user-edit { + width: 100%; + padding-top: 8px; + } + + .user-text { + font-size: 14px; + display: none; + } + + .tags { + font-size: 12px; + } + + .tag-item { + height: 24px; + margin-right: 6px; + padding-left: 6px; + padding-right: 6px; + } + + .user-select-tag-wrap { + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + } + + .user-select-tag-wrap::-webkit-scrollbar { + display: none; + } + + .tag-select { + margin: 0 0 0 20px; + font-size: 16px; + padding: 10px 0; + min-height: 44px; + display: inline-flex; + align-items: center; + } + + .tag-select:first-child { + margin-left: 0; + } + + .achievement-wrap, + .process-wrap { + padding: 16px; + margin-bottom: 16px; + } + + .achievement-title, + .process-title { + font-size: 16px; + margin-bottom: 16px; + } + + .achievement-item { + font-size: 14px; + margin-bottom: 12px; + } + + .follow-select-tag { + padding: 0 16px; + font-size: 16px; + } + + .follow-item { + padding: 8px 0; + font-size: 16px; + } + + .follow-item img { + width: 50px; + height: 50px; + margin-right: 12px; + } + + .follow-item-icon { + width: 60px; + height: 22px; + font-size: 11px; + min-height: 44px; + padding: 0 12px; + } + + #saveModel .modal-dialog { + max-width: 95%; + margin: 10px auto; + } + + #saveModel .modal-body { + flex-direction: column; + padding: 16px; + } + + .person-img-wrap { + margin-left: 0; + margin-bottom: 20px; + width: 100%; + } + + .person-info { + width: 100%; + } + + #saveModel .form-label { + width: 100%; + text-align: left; + margin-bottom: 8px; + } + + #saveModel .input-group { + flex-direction: column; + height: auto; + align-items: flex-start; + } + + .tag-select:active, + .follow-select-tag:active { + opacity: 0.7; + } + + .follow-item-icon:active, + .edit-btn:active { + transform: scale(0.95); + transition: transform 0.1s; + } +} + +/* 小屏手机适配 (最大576px) */ +@media (max-width: 576px) { + .user-wrap { + padding: 0 12px; + } + + .user-body { + padding: 12px; + } + + .user-bg { + height: 160px; + } + + .user-head { + padding: 16px 12px; + } + + .user-head-img { + width: 50px; + height: 50px; + } + + .user-head-title-wrap { + margin-left: 70px; + } + + .user-head-title-name { + font-size: 16px; + } + + .user-head-title-classify-item span:last-child { + font-size: 14px; + } + + .tag-select { + margin: 0 0 0 16px; + font-size: 14px; + padding: 8px 0; + } +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/Gulpfile.js b/paicoding-ui/src/main/resources/static/editormd/Gulpfile.js new file mode 100644 index 000000000..09ccf04ce --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/Gulpfile.js @@ -0,0 +1,342 @@ +"use strict"; + +var os = require("os"); +var gulp = require("gulp"); +var gutil = require("gulp-util"); +var sass = require("gulp-ruby-sass"); +var jshint = require("gulp-jshint"); +var uglify = require("gulp-uglifyjs"); +var rename = require("gulp-rename"); +var concat = require("gulp-concat"); +var notify = require("gulp-notify"); +var header = require("gulp-header"); +var minifycss = require("gulp-minify-css"); +//var jsdoc = require("gulp-jsdoc"); +//var jsdoc2md = require("gulp-jsdoc-to-markdown"); +var pkg = require("./package.json"); +var dateFormat = require("dateformatter").format; +var replace = require("gulp-replace"); + +pkg.name = "Editor.md"; +pkg.today = dateFormat; + +var headerComment = ["/*", + " * <%= pkg.name %>", + " *", + " * @file <%= fileName(file) %> ", + " * @version v<%= pkg.version %> ", + " * @description <%= pkg.description %>", + " * @license MIT License", + " * @author <%= pkg.author %>", + " * {@link <%= pkg.homepage %>}", + " * @updateTime <%= pkg.today('Y-m-d') %>", + " */", + "\r\n"].join("\r\n"); + +var headerMiniComment = "/*! <%= pkg.name %> v<%= pkg.version %> | <%= fileName(file) %> | <%= pkg.description %> | MIT License | By: <%= pkg.author %> | <%= pkg.homepage %> | <%=pkg.today('Y-m-d') %> */\r\n"; + +var scssTask = function(fileName, path) { + + path = path || "scss/"; + + var distPath = "css"; + + return sass(path + fileName + ".scss", { style: "expanded", sourcemap: false, noCache : true }) + .pipe(gulp.dest(distPath)) + .pipe(header(headerComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base); + return name[1].replace("\\", ""); + }})) + .pipe(gulp.dest(distPath)) + .pipe(rename({ suffix: ".min" })) + .pipe(gulp.dest(distPath)) + .pipe(minifycss()) + .pipe(gulp.dest(distPath)) + .pipe(header(headerMiniComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base); + return name[1].replace("\\", ""); + }})) + .pipe(gulp.dest(distPath)) + .pipe(notify({ message: fileName + ".scss task completed!" })); +}; + +gulp.task("scss", function() { + return scssTask("editormd"); +}); + +gulp.task("scss2", function() { + return scssTask("editormd.preview"); +}); + +gulp.task("scss3", function() { + return scssTask("editormd.logo"); +}); + +gulp.task("js", function() { + return gulp.src("./src/editormd.js") + .pipe(jshint("./.jshintrc")) + .pipe(jshint.reporter("default")) + .pipe(header(headerComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base); + return name[1].replace(/[\\\/]?/, ""); + }})) + .pipe(gulp.dest("./")) + .pipe(rename({ suffix: ".min" })) + .pipe(uglify()) // {outSourceMap: true, sourceRoot: './'} + .pipe(gulp.dest("./")) + .pipe(header(headerMiniComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base + ( (os.platform() === "win32") ? "\\" : "/") ); + return name[1].replace(/[\\\/]?/, ""); + }})) + .pipe(gulp.dest("./")) + .pipe(notify({ message: "editormd.js task complete" })); +}); + +gulp.task("amd", function() { + var replaceText1 = [ + 'var cmModePath = "codemirror/mode/";', + ' var cmAddonPath = "codemirror/addon/";', + '', + ' var codeMirrorModules = [', + ' "jquery", "marked", "prettify",', + ' "katex", "raphael", "underscore", "flowchart", "jqueryflowchart", "sequenceDiagram",', + '', + ' "codemirror/lib/codemirror",', + ' cmModePath + "css/css",', + ' cmModePath + "sass/sass",', + ' cmModePath + "shell/shell",', + ' cmModePath + "sql/sql",', + ' cmModePath + "clike/clike",', + ' cmModePath + "php/php",', + ' cmModePath + "xml/xml",', + ' cmModePath + "markdown/markdown",', + ' cmModePath + "javascript/javascript",', + ' cmModePath + "htmlmixed/htmlmixed",', + ' cmModePath + "gfm/gfm",', + ' cmModePath + "http/http",', + ' cmModePath + "go/go",', + ' cmModePath + "dart/dart",', + ' cmModePath + "coffeescript/coffeescript",', + ' cmModePath + "nginx/nginx",', + ' cmModePath + "python/python",', + ' cmModePath + "perl/perl",', + ' cmModePath + "lua/lua",', + ' cmModePath + "r/r", ', + ' cmModePath + "ruby/ruby", ', + ' cmModePath + "rst/rst",', + ' cmModePath + "smartymixed/smartymixed",', + ' cmModePath + "vb/vb",', + ' cmModePath + "vbscript/vbscript",', + ' cmModePath + "velocity/velocity",', + ' cmModePath + "xquery/xquery",', + ' cmModePath + "yaml/yaml",', + ' cmModePath + "erlang/erlang",', + ' cmModePath + "jade/jade",', + '', + ' cmAddonPath + "edit/trailingspace", ', + ' cmAddonPath + "dialog/dialog", ', + ' cmAddonPath + "search/searchcursor", ', + ' cmAddonPath + "search/search", ', + ' cmAddonPath + "scroll/annotatescrollbar", ', + ' cmAddonPath + "search/matchesonscrollbar", ', + ' cmAddonPath + "display/placeholder", ', + ' cmAddonPath + "edit/closetag", ', + ' cmAddonPath + "fold/foldcode",', + ' cmAddonPath + "fold/foldgutter",', + ' cmAddonPath + "fold/indent-fold",', + ' cmAddonPath + "fold/brace-fold",', + ' cmAddonPath + "fold/xml-fold", ', + ' cmAddonPath + "fold/markdown-fold",', + ' cmAddonPath + "fold/comment-fold", ', + ' cmAddonPath + "mode/overlay", ', + ' cmAddonPath + "selection/active-line", ', + ' cmAddonPath + "edit/closebrackets", ', + ' cmAddonPath + "display/fullscreen",', + ' cmAddonPath + "search/match-highlighter"', + ' ];', + '', + ' define(codeMirrorModules, factory);' + ].join("\r\n"); + + var replaceText2 = [ + "if (typeof define == \"function\" && define.amd) {", + " $ = arguments[0];", + " marked = arguments[1];", + " prettify = arguments[2];", + " katex = arguments[3];", + " Raphael = arguments[4];", + " _ = arguments[5];", + " flowchart = arguments[6];", + " CodeMirror = arguments[9];", + " }" + ].join("\r\n"); + + gulp.src("src/editormd.js") + .pipe(rename({ suffix: ".amd" })) + .pipe(gulp.dest('./')) + .pipe(header(headerComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base); + return name[1].replace(/[\\\/]?/, ""); + }})) + .pipe(gulp.dest("./")) + .pipe(replace("/* Require.js define replace */", replaceText1)) + .pipe(gulp.dest('./')) + .pipe(replace("/* Require.js assignment replace */", replaceText2)) + .pipe(gulp.dest('./')) + .pipe(rename({ suffix: ".min" })) + .pipe(uglify()) //{outSourceMap: true, sourceRoot: './'} + .pipe(gulp.dest("./")) + .pipe(header(headerMiniComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base + ( (os.platform() === "win32") ? "\\" : "/") ); + return name[1].replace(/[\\\/]?/, ""); + }})) + .pipe(gulp.dest("./")) + .pipe(notify({ message: "amd version task complete"})); +}); + + +var codeMirror = { + path : { + src : { + mode : "lib/codemirror/mode", + addon : "lib/codemirror/addon" + }, + dist : "lib/codemirror" + }, + modes : [ + "css", + "sass", + "shell", + "sql", + "clike", + "php", + "xml", + "markdown", + "javascript", + "htmlmixed", + "gfm", + "http", + "go", + "dart", + "coffeescript", + "nginx", + "python", + "perl", + "lua", + "r", + "ruby", + "rst", + "smartymixed", + "vb", + "vbscript", + "velocity", + "xquery", + "yaml", + "erlang", + "jade", + ], + + addons : [ + "edit/trailingspace", + "dialog/dialog", + "search/searchcursor", + "search/search", + "scroll/annotatescrollbar", + "search/matchesonscrollbar", + "display/placeholder", + "edit/closetag", + "fold/foldcode", + "fold/foldgutter", + "fold/indent-fold", + "fold/brace-fold", + "fold/xml-fold", + "fold/markdown-fold", + "fold/comment-fold", + "mode/overlay", + "selection/active-line", + "edit/closebrackets", + "display/fullscreen", + "search/match-highlighter" + ] +}; + +gulp.task("cm-mode", function() { + + var modes = [ + codeMirror.path.src.mode + "/meta.js" + ]; + + for(var i in codeMirror.modes) { + var mode = codeMirror.modes[i]; + modes.push(codeMirror.path.src.mode + "/" + mode + "/" + mode + ".js"); + } + + return gulp.src(modes) + .pipe(concat("modes.min.js")) + .pipe(gulp.dest(codeMirror.path.dist)) + .pipe(uglify()) // {outSourceMap: true, sourceRoot: codeMirror.path.dist} + .pipe(gulp.dest(codeMirror.path.dist)) + .pipe(header(headerMiniComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base + "\\"); + return (name[1]?name[1]:name[0]).replace(/\\/g, ""); + }})) + .pipe(gulp.dest(codeMirror.path.dist)) + .pipe(notify({ message: "codemirror-mode task complete!" })); +}); + +gulp.task("cm-addon", function() { + + var addons = []; + + for(var i in codeMirror.addons) { + var addon = codeMirror.addons[i]; + addons.push(codeMirror.path.src.addon + "/" + addon + ".js"); + } + + return gulp.src(addons) + .pipe(concat("addons.min.js")) + .pipe(gulp.dest(codeMirror.path.dist)) + .pipe(uglify()) //{outSourceMap: true, sourceRoot: codeMirror.path.dist} + .pipe(gulp.dest(codeMirror.path.dist)) + .pipe(header(headerMiniComment, {pkg : pkg, fileName : function(file) { + var name = file.path.split(file.base + "\\"); + return (name[1]?name[1]:name[0]).replace(/\\/g, ""); + }})) + .pipe(gulp.dest(codeMirror.path.dist)) + .pipe(notify({ message: "codemirror-addon.js task complete" })); +}); +/* +gulp.task("jsdoc", function(){ + return gulp.src(["./src/editormd.js", "README.md"]) + .pipe(jsdoc.parser()) + .pipe(jsdoc.generator("./docs/html")); +}); + +gulp.task("jsdoc2md", function() { + return gulp.src("src/js/editormd.js") + .pipe(jsdoc2md()) + .on("error", function(err){ + gutil.log(gutil.colors.red("jsdoc2md failed"), err.message); + }) + .pipe(rename(function(path) { + path.extname = ".md"; + })) + .pipe(gulp.dest("docs/markdown")); +}); +*/ +gulp.task("watch", function() { + gulp.watch("scss/editormd.scss", ["scss"]); + gulp.watch("scss/editormd.preview.scss", ["scss", "scss2"]); + gulp.watch("scss/editormd.logo.scss", ["scss", "scss3"]); + gulp.watch("src/editormd.js", ["js", "amd"]); +}); + +gulp.task("default", function() { + gulp.run("scss"); + gulp.run("scss2"); + gulp.run("scss3"); + gulp.run("js"); + gulp.run("amd"); + gulp.run("cm-addon"); + gulp.run("cm-mode"); +}); \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/css/editormd.css b/paicoding-ui/src/main/resources/static/editormd/css/editormd.css new file mode 100644 index 000000000..31865bae1 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/css/editormd.css @@ -0,0 +1,4488 @@ +/* + * Editor.md + * + * @file editormd.css + * @version v1.5.0 + * @description Open source online markdown editor. + * @license MIT License + * @author Pandao + * {@link https://github.com/pandao/editor.md} + * @updateTime 2015-06-09 + */ + +@charset "UTF-8"; +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */ +.editormd { + width: 90%; + height: 640px; + margin: 0 auto; + text-align: left; + overflow: hidden; + position: relative; + margin-bottom: 15px; + border: 1px solid #ddd; + font-family: "Meiryo UI", "Microsoft YaHei", "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, "Monaco", monospace, Tahoma, STXihei, "华文细黑", STHeiti, "Helvetica Neue", "Droid Sans", "wenquanyi micro hei", FreeSans, Arimo, Arial, SimSun, "宋体", Heiti, "黑体", sans-serif; +} +.editormd *, .editormd *:before, .editormd *:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.editormd a { + text-decoration: none; +} +.editormd img { + border: none; + vertical-align: middle; +} +.editormd > textarea, +.editormd .editormd-html-textarea, +.editormd .editormd-markdown-textarea { + width: 0; + height: 0; + outline: 0; + resize: none; +} +.editormd .editormd-html-textarea, +.editormd .editormd-markdown-textarea { + display: none; +} +.editormd input[type="text"], +.editormd input[type="button"], +.editormd input[type="submit"], +.editormd select, .editormd textarea, .editormd button { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; +} +.editormd ::-webkit-scrollbar { + height: 10px; + width: 7px; + background: rgba(0, 0, 0, 0.1); +} +.editormd ::-webkit-scrollbar:hover { + background: rgba(0, 0, 0, 0.2); +} +.editormd ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.3); + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + -ms-border-radius: 6px; + -o-border-radius: 6px; + border-radius: 6px; +} +.editormd ::-webkit-scrollbar-thumb:hover { + -webkit-box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.25); + /* Webkit browsers */ + -moz-box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.25); + /* Firefox */ + -ms-box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.25); + /* IE9 */ + -o-box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.25); + /* Opera(Old) */ + box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.25); + /* IE9+, News */ + background-color: rgba(0, 0, 0, 0.4); +} + +.editormd-user-unselect { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; +} + +.editormd-toolbar { + width: 100%; + min-height: 37px; + background: #fff; + display: none; + position: absolute; + top: 0; + left: 0; + z-index: 10; + border-bottom: 1px solid #ddd; +} + +.editormd-toolbar-container { + padding: 0 8px; + min-height: 35px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; +} + +.editormd-menu { + margin: 0; + padding: 0; + list-style: none; +} +.editormd-menu > li { + margin: 0; + padding: 5px 1px; + display: inline-block; + position: relative; +} +.editormd-menu > li.divider { + display: inline-block; + text-indent: -9999px; + margin: 0 5px; + height: 65%; + border-right: 1px solid #ddd; +} +.editormd-menu > li > a { + outline: 0; + color: #666; + display: inline-block; + min-width: 24px; + font-size: 16px; + text-decoration: none; + text-align: center; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + -ms-border-radius: 2px; + -o-border-radius: 2px; + border-radius: 2px; + border: 1px solid #fff; + -webkit-transition: all 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: all 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: all 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-menu > li > a:hover, .editormd-menu > li > a.active { + border: 1px solid #ddd; + background: #eee; +} +.editormd-menu > li > a > .fa { + text-align: center; + display: block; + padding: 5px; +} +.editormd-menu > li > a > .editormd-bold { + padding: 5px 2px; + display: inline-block; + font-weight: bold; +} +.editormd-menu > li:hover .editormd-dropdown-menu { + display: block; +} +.editormd-menu > li + li > a { + margin-left: 3px; +} + +.editormd-dropdown-menu { + display: none; + background: #fff; + border: 1px solid #ddd; + width: 148px; + list-style: none; + position: absolute; + top: 33px; + left: 0; + z-index: 100; + -webkit-box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.15); + /* Webkit browsers */ + -moz-box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.15); + /* Firefox */ + -ms-box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.15); + /* IE9 */ + -o-box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.15); + /* Opera(Old) */ + box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.15); + /* IE9+, News */ +} +.editormd-dropdown-menu:before, .editormd-dropdown-menu:after { + width: 0; + height: 0; + display: block; + content: ""; + position: absolute; + top: -11px; + left: 8px; + border: 5px solid transparent; +} +.editormd-dropdown-menu:before { + border-bottom-color: #ccc; +} +.editormd-dropdown-menu:after { + border-bottom-color: #ffffff; + top: -10px; +} +.editormd-dropdown-menu > li > a { + color: #666; + display: block; + text-decoration: none; + padding: 8px 10px; +} +.editormd-dropdown-menu > li > a:hover { + background: #f6f6f6; + -webkit-transition: all 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: all 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: all 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-dropdown-menu > li + li { + border-top: 1px solid #ddd; +} + +.editormd-container { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + padding: 35px 0 0; + position: relative; + background: #fff; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.editormd-dialog { + color: #666; + position: fixed; + z-index: 99999; + display: none; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; + -webkit-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + /* Webkit browsers */ + -moz-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + /* Firefox */ + -ms-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + /* IE9 */ + -o-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + /* Opera(Old) */ + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + /* IE9+, News */ + background: #fff; + font-size: 14px; +} + +.editormd-dialog-container { + position: relative; + padding: 20px; + line-height: 1.4; +} +.editormd-dialog-container h1 { + font-size: 24px; + margin-bottom: 10px; +} +.editormd-dialog-container h1 .fa { + color: #2C7EEA; + padding-right: 5px; +} +.editormd-dialog-container h1 small { + padding-left: 5px; + font-weight: normal; + font-size: 12px; + color: #999; +} +.editormd-dialog-container select { + color: #999; + padding: 3px 8px; + border: 1px solid #ddd; +} + +.editormd-dialog-close { + position: absolute; + top: 12px; + right: 15px; + font-size: 18px; + color: #ccc; + -webkit-transition: color 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: color 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: color 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-dialog-close:hover { + color: #999; +} + +.editormd-dialog-header { + padding: 11px 20px; + border-bottom: 1px solid #eee; + -webkit-transition: background 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: background 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-dialog-header:hover { + background: #f6f6f6; +} + +.editormd-dialog-title { + font-size: 14px; +} + +.editormd-dialog-footer { + padding: 10px 0 0 0; + text-align: right; +} + +.editormd-dialog-info { + width: 420px; +} +.editormd-dialog-info h1 { + font-weight: normal; +} +.editormd-dialog-info .editormd-dialog-container { + padding: 20px 25px 25px; +} +.editormd-dialog-info .editormd-dialog-close { + top: 10px; + right: 10px; +} +.editormd-dialog-info p > a, .editormd-dialog-info .hover-link:hover { + color: #2196F3; +} +.editormd-dialog-info .hover-link { + color: #666; +} +.editormd-dialog-info a .fa-external-link { + display: none; +} +.editormd-dialog-info a:hover { + color: #2196F3; +} +.editormd-dialog-info a:hover .fa-external-link { + display: inline-block; +} + +.editormd-mask, +.editormd-container-mask, +.editormd-dialog-mask { + display: none; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.editormd-mask, +.editormd-dialog-mask-bg { + background: #fff; + opacity: 0.5; + filter: alpha(opacity=50); +} + +.editormd-mask { + position: fixed; + background: #000; + opacity: 0.2; + /* W3C */ + filter: alpha(opacity=20); + /* IE */ + z-index: 99998; +} + +.editormd-container-mask, +.editormd-dialog-mask-con { + background: url(../images/loading.gif) no-repeat center center; + -webkit-background-size: 32px 32px; + /* Chrome, iOS, Safari */ + -moz-background-size: 32px 32px; + /* Firefox 3.6~4.0 */ + -o-background-size: 32px 32px; + /* Opera 9.5 */ + background-size: 32px 32px; + /* IE9+, New */ +} + +.editormd-container-mask { + z-index: 20; + display: block; + background-color: #fff; +} + +@media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min-device-pixel-ratio: 2) { + .editormd-container-mask, + .editormd-dialog-mask-con { + background-image: url(../images/loading@2x.gif); + } +} +@media only screen and (-webkit-min-device-pixel-ratio: 3), only screen and (min-device-pixel-ratio: 3) { + .editormd-container-mask, + .editormd-dialog-mask-con { + background-image: url(../images/loading@3x.gif); + } +} +.editormd-code-block-dialog textarea, +.editormd-preformatted-text-dialog textarea { + width: 100%; + height: 400px; + margin-bottom: 6px; + overflow: auto; + border: 1px solid #eee; + background: #fff; + padding: 15px; + resize: none; +} + +.editormd-code-toolbar { + color: #999; + font-size: 14px; + margin: -5px 0 10px; +} + +.editormd-grid-table { + width: 99%; + display: table; + border: 1px solid #ddd; + border-collapse: collapse; +} + +.editormd-grid-table-row { + width: 100%; + display: table-row; +} +.editormd-grid-table-row a { + font-size: 1.4em; + width: 5%; + height: 36px; + color: #999; + text-align: center; + display: table-cell; + vertical-align: middle; + border: 1px solid #ddd; + text-decoration: none; + -webkit-transition: background-color 300ms ease-out, color 100ms ease-in; + /* Safari, Chrome */ + -moz-transition: background-color 300ms ease-out, color 100ms ease-in; + /* Firefox 4.0~16.0 */ + transition: background-color 300ms ease-out, color 100ms ease-in; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-grid-table-row a.selected { + color: #666; + background-color: #eee; +} +.editormd-grid-table-row a:hover { + color: #777; + background-color: #f6f6f6; +} + +.editormd-tab-head { + list-style: none; + border-bottom: 1px solid #ddd; +} +.editormd-tab-head li { + display: inline-block; +} +.editormd-tab-head li a { + color: #999; + display: block; + padding: 6px 12px 5px; + text-align: center; + text-decoration: none; + margin-bottom: -1px; + border: 1px solid #ddd; + -webkit-border-top-left-radius: 3px; + -moz-border-top-left-radius: 3px; + -ms-border-top-left-radius: 3px; + -o-border-top-left-radius: 3px; + border-top-left-radius: 3px; + -webkit-border-top-right-radius: 3px; + -moz-border-top-right-radius: 3px; + -ms-border-top-right-radius: 3px; + -o-border-top-right-radius: 3px; + border-top-right-radius: 3px; + background: #f6f6f6; + -webkit-transition: all 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: all 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: all 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-tab-head li a:hover { + color: #666; + background: #eee; +} +.editormd-tab-head li.active a { + color: #666; + background: #fff; + border-bottom-color: #fff; +} +.editormd-tab-head li + li { + margin-left: 3px; +} + +.editormd-tab-box { + padding: 20px 0; +} + +.editormd-form { + color: #666; +} +.editormd-form label { + float: left; + display: block; + width: 75px; + text-align: left; + padding: 7px 0 15px 5px; + margin: 0 0 2px; + font-weight: normal; +} +.editormd-form br { + clear: both; +} +.editormd-form iframe { + display: none; +} +.editormd-form input:focus { + outline: 0; +} +.editormd-form input[type="text"], .editormd-form input[type="number"] { + color: #999; + padding: 8px; + border: 1px solid #ddd; +} +.editormd-form input[type="number"] { + width: 40px; + display: inline-block; + padding: 6px 8px; +} +.editormd-form input[type="text"] { + display: inline-block; + width: 264px; +} +.editormd-form .fa-btns { + display: inline-block; +} +.editormd-form .fa-btns a { + color: #999; + padding: 7px 10px 0 0; + display: inline-block; + text-decoration: none; + text-align: center; +} +.editormd-form .fa-btns .fa { + font-size: 1.3em; +} +.editormd-form .fa-btns label { + float: none; + display: inline-block; + width: auto; + text-align: left; + padding: 0 0 0 5px; + cursor: pointer; +} + +.editormd-form input[type="submit"], .editormd-form .editormd-btn, .editormd-form button, +.editormd-dialog-container input[type="submit"], +.editormd-dialog-container .editormd-btn, +.editormd-dialog-container button, +.editormd-dialog-footer input[type="submit"], +.editormd-dialog-footer .editormd-btn, +.editormd-dialog-footer button { + color: #666; + min-width: 75px; + cursor: pointer; + background: #fff; + padding: 7px 10px; + border: 1px solid #ddd; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; + -webkit-transition: background 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: background 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-form input[type="submit"]:hover, .editormd-form .editormd-btn:hover, .editormd-form button:hover, +.editormd-dialog-container input[type="submit"]:hover, +.editormd-dialog-container .editormd-btn:hover, +.editormd-dialog-container button:hover, +.editormd-dialog-footer input[type="submit"]:hover, +.editormd-dialog-footer .editormd-btn:hover, +.editormd-dialog-footer button:hover { + background: var(--pai-brand-7-light); +} +.editormd-form .editormd-btn, +.editormd-dialog-container .editormd-btn, +.editormd-dialog-footer .editormd-btn { + padding: 5px 8px 4px\0; +} +.editormd-form .editormd-btn + .editormd-btn, +.editormd-dialog-container .editormd-btn + .editormd-btn, +.editormd-dialog-footer .editormd-btn + .editormd-btn { + margin-left: 8px; +} + +.editormd-file-input { + width: 75px; + height: 32px; + margin-left: 8px; + position: relative; + display: inline-block; +} +.editormd-file-input input[type="file"] { + width: 75px; + height: 32px; + opacity: 0; + cursor: pointer; + background: #000; + display: inline-block; + position: absolute; + top: 0; + right: 0; +} +.editormd-file-input input[type="file"]::-webkit-file-upload-button { + visibility: hidden; +} +.editormd-file-input:hover input[type="submit"] { + background: var(--pai-brand-7-light); +} + +.editormd .CodeMirror, .editormd-preview { + display: inline-block; + width: 50%; + height: 100%; + vertical-align: top; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + margin: 0; +} + +.editormd-preview { + position: absolute; + top: 35px; + right: 0; + right: -1px\0; + overflow: auto; + line-height: 1.6; + display: none; + background: #fff; +} + +.editormd .CodeMirror { + z-index: 10; + float: left; + border-right: 1px solid #ddd; + font-size: 14px; + font-family: "YaHei Consolas Hybrid", Consolas, "微软雅黑", "Meiryo UI", "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, "Monaco", courier, monospace; + line-height: 1.6; + margin-top: 35px; +} +.editormd .CodeMirror pre { + font-size: 1.2em; + padding: 0 12px; +} +.editormd .CodeMirror-linenumbers { + padding: 0 5px; +} +.editormd .CodeMirror-selected { + background: #70B7FF; +} +.editormd .CodeMirror-focused .CodeMirror-selected { + background: #70B7FF; +} +.editormd .CodeMirror, .editormd .CodeMirror-scroll, .editormd .editormd-preview { + -webkit-overflow-scrolling: touch; +} +.editormd .styled-background { + background-color: #ff7; +} +.editormd .CodeMirror-focused .cm-matchhighlight { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVQI12NgYGBgkKzc8x9CMDAwAAAmhwSbidEoSQAAAABJRU5ErkJggg==); + background-position: bottom; + background-repeat: repeat-x; +} +.editormd .CodeMirror-empty.CodeMirror-focused { + outline: none; +} +.editormd .CodeMirror pre.CodeMirror-placeholder { + color: #999; +} +.editormd .cm-trailingspace { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg==); + background-position: bottom left; + background-repeat: repeat-x; +} +.editormd .cm-tab { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAYAAAAkuj5RAAAAAXNSR0IArs4c6QAAAGFJREFUSMft1LsRQFAQheHPowAKoACx3IgEKtaEHujDjORSgWTH/ZOdnZOcM/sgk/kFFWY0qV8foQwS4MKBCS3qR6ixBJvElOobYAtivseIE120FaowJPN75GMu8j/LfMwNjh4HUpwg4LUAAAAASUVORK5CYII=); + background-position: right; + background-repeat: no-repeat; +} + +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */ +/*! + * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */ +/* FONT PATH + * -------------------------- */ +@font-face { + font-family: 'FontAwesome'; + src: url("../fonts/fontawesome-webfont.eot?v=4.3.0"); + src: url("../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0") format("embedded-opentype"), url("../fonts/fontawesome-webfont.woff2?v=4.3.0") format("woff2"), url("../fonts/fontawesome-webfont.woff?v=4.3.0") format("woff"), url("../fonts/fontawesome-webfont.ttf?v=4.3.0") format("truetype"), url("../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular") format("svg"); + font-weight: normal; + font-style: normal; +} +.fa { + display: inline-block; + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transform: translate(0, 0); +} + +/* makes the font 33% larger relative to the icon container */ +.fa-lg { + font-size: 1.33333333em; + line-height: 0.75em; + vertical-align: -15%; +} + +.fa-2x { + font-size: 2em; +} + +.fa-3x { + font-size: 3em; +} + +.fa-4x { + font-size: 4em; +} + +.fa-5x { + font-size: 5em; +} + +.fa-fw { + width: 1.28571429em; + text-align: center; +} + +.fa-ul { + padding-left: 0; + margin-left: 2.14285714em; + list-style-type: none; +} + +.fa-ul > li { + position: relative; +} + +.fa-li { + position: absolute; + left: -2.14285714em; + width: 2.14285714em; + top: 0.14285714em; + text-align: center; +} + +.fa-li.fa-lg { + left: -1.85714286em; +} + +.fa-border { + padding: .2em .25em .15em; + border: solid 0.08em #eeeeee; + border-radius: .1em; +} + +.pull-right { + float: right; +} + +.pull-left { + float: left; +} + +.fa.pull-left { + margin-right: .3em; +} + +.fa.pull-right { + margin-left: .3em; +} + +.fa-spin { + -webkit-animation: fa-spin 2s infinite linear; + animation: fa-spin 2s infinite linear; +} + +.fa-pulse { + -webkit-animation: fa-spin 1s infinite steps(8); + animation: fa-spin 1s infinite steps(8); +} + +@-webkit-keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +@keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +.fa-rotate-90 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); + -webkit-transform: rotate(90deg); + -ms-transform: rotate(90deg); + transform: rotate(90deg); +} + +.fa-rotate-180 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg); +} + +.fa-rotate-270 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); + -webkit-transform: rotate(270deg); + -ms-transform: rotate(270deg); + transform: rotate(270deg); +} + +.fa-flip-horizontal { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1); + -webkit-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); +} + +.fa-flip-vertical { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1); + -webkit-transform: scale(1, -1); + -ms-transform: scale(1, -1); + transform: scale(1, -1); +} + +:root .fa-rotate-90, +:root .fa-rotate-180, +:root .fa-rotate-270, +:root .fa-flip-horizontal, +:root .fa-flip-vertical { + filter: none; +} + +.fa-stack { + position: relative; + display: inline-block; + width: 2em; + height: 2em; + line-height: 2em; + vertical-align: middle; +} + +.fa-stack-1x, +.fa-stack-2x { + position: absolute; + left: 0; + width: 100%; + text-align: center; +} + +.fa-stack-1x { + line-height: inherit; +} + +.fa-stack-2x { + font-size: 2em; +} + +.fa-inverse { + color: #ffffff; +} + +/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen + readers do not read off random characters that represent icons */ +.fa-glass:before { + content: "\f000"; +} + +.fa-music:before { + content: "\f001"; +} + +.fa-search:before { + content: "\f002"; +} + +.fa-envelope-o:before { + content: "\f003"; +} + +.fa-heart:before { + content: "\f004"; +} + +.fa-star:before { + content: "\f005"; +} + +.fa-star-o:before { + content: "\f006"; +} + +.fa-user:before { + content: "\f007"; +} + +.fa-film:before { + content: "\f008"; +} + +.fa-th-large:before { + content: "\f009"; +} + +.fa-th:before { + content: "\f00a"; +} + +.fa-th-list:before { + content: "\f00b"; +} + +.fa-check:before { + content: "\f00c"; +} + +.fa-remove:before, +.fa-close:before, +.fa-times:before { + content: "\f00d"; +} + +.fa-search-plus:before { + content: "\f00e"; +} + +.fa-search-minus:before { + content: "\f010"; +} + +.fa-power-off:before { + content: "\f011"; +} + +.fa-signal:before { + content: "\f012"; +} + +.fa-gear:before, +.fa-cog:before { + content: "\f013"; +} + +.fa-trash-o:before { + content: "\f014"; +} + +.fa-home:before { + content: "\f015"; +} + +.fa-file-o:before { + content: "\f016"; +} + +.fa-clock-o:before { + content: "\f017"; +} + +.fa-road:before { + content: "\f018"; +} + +.fa-download:before { + content: "\f019"; +} + +.fa-arrow-circle-o-down:before { + content: "\f01a"; +} + +.fa-arrow-circle-o-up:before { + content: "\f01b"; +} + +.fa-inbox:before { + content: "\f01c"; +} + +.fa-play-circle-o:before { + content: "\f01d"; +} + +.fa-rotate-right:before, +.fa-repeat:before { + content: "\f01e"; +} + +.fa-refresh:before { + content: "\f021"; +} + +.fa-list-alt:before { + content: "\f022"; +} + +.fa-lock:before { + content: "\f023"; +} + +.fa-flag:before { + content: "\f024"; +} + +.fa-headphones:before { + content: "\f025"; +} + +.fa-volume-off:before { + content: "\f026"; +} + +.fa-volume-down:before { + content: "\f027"; +} + +.fa-volume-up:before { + content: "\f028"; +} + +.fa-qrcode:before { + content: "\f029"; +} + +.fa-barcode:before { + content: "\f02a"; +} + +.fa-tag:before { + content: "\f02b"; +} + +.fa-tags:before { + content: "\f02c"; +} + +.fa-book:before { + content: "\f02d"; +} + +.fa-bookmark:before { + content: "\f02e"; +} + +.fa-print:before { + content: "\f02f"; +} + +.fa-camera:before { + content: "\f030"; +} + +.fa-font:before { + content: "\f031"; +} + +.fa-bold:before { + content: "\f032"; +} + +.fa-italic:before { + content: "\f033"; +} + +.fa-text-height:before { + content: "\f034"; +} + +.fa-text-width:before { + content: "\f035"; +} + +.fa-align-left:before { + content: "\f036"; +} + +.fa-align-center:before { + content: "\f037"; +} + +.fa-align-right:before { + content: "\f038"; +} + +.fa-align-justify:before { + content: "\f039"; +} + +.fa-list:before { + content: "\f03a"; +} + +.fa-dedent:before, +.fa-outdent:before { + content: "\f03b"; +} + +.fa-indent:before { + content: "\f03c"; +} + +.fa-video-camera:before { + content: "\f03d"; +} + +.fa-photo:before, +.fa-image:before, +.fa-picture-o:before { + content: "\f03e"; +} + +.fa-pencil:before { + content: "\f040"; +} + +.fa-map-marker:before { + content: "\f041"; +} + +.fa-adjust:before { + content: "\f042"; +} + +.fa-tint:before { + content: "\f043"; +} + +.fa-edit:before, +.fa-pencil-square-o:before { + content: "\f044"; +} + +.fa-share-square-o:before { + content: "\f045"; +} + +.fa-check-square-o:before { + content: "\f046"; +} + +.fa-arrows:before { + content: "\f047"; +} + +.fa-step-backward:before { + content: "\f048"; +} + +.fa-fast-backward:before { + content: "\f049"; +} + +.fa-backward:before { + content: "\f04a"; +} + +.fa-play:before { + content: "\f04b"; +} + +.fa-pause:before { + content: "\f04c"; +} + +.fa-stop:before { + content: "\f04d"; +} + +.fa-forward:before { + content: "\f04e"; +} + +.fa-fast-forward:before { + content: "\f050"; +} + +.fa-step-forward:before { + content: "\f051"; +} + +.fa-eject:before { + content: "\f052"; +} + +.fa-chevron-left:before { + content: "\f053"; +} + +.fa-chevron-right:before { + content: "\f054"; +} + +.fa-plus-circle:before { + content: "\f055"; +} + +.fa-minus-circle:before { + content: "\f056"; +} + +.fa-times-circle:before { + content: "\f057"; +} + +.fa-check-circle:before { + content: "\f058"; +} + +.fa-question-circle:before { + content: "\f059"; +} + +.fa-info-circle:before { + content: "\f05a"; +} + +.fa-crosshairs:before { + content: "\f05b"; +} + +.fa-times-circle-o:before { + content: "\f05c"; +} + +.fa-check-circle-o:before { + content: "\f05d"; +} + +.fa-ban:before { + content: "\f05e"; +} + +.fa-arrow-left:before { + content: "\f060"; +} + +.fa-arrow-right:before { + content: "\f061"; +} + +.fa-arrow-up:before { + content: "\f062"; +} + +.fa-arrow-down:before { + content: "\f063"; +} + +.fa-mail-forward:before, +.fa-share:before { + content: "\f064"; +} + +.fa-expand:before { + content: "\f065"; +} + +.fa-compress:before { + content: "\f066"; +} + +.fa-plus:before { + content: "\f067"; +} + +.fa-minus:before { + content: "\f068"; +} + +.fa-asterisk:before { + content: "\f069"; +} + +.fa-exclamation-circle:before { + content: "\f06a"; +} + +.fa-gift:before { + content: "\f06b"; +} + +.fa-leaf:before { + content: "\f06c"; +} + +.fa-fire:before { + content: "\f06d"; +} + +.fa-eye:before { + content: "\f06e"; +} + +.fa-eye-slash:before { + content: "\f070"; +} + +.fa-warning:before, +.fa-exclamation-triangle:before { + content: "\f071"; +} + +.fa-plane:before { + content: "\f072"; +} + +.fa-calendar:before { + content: "\f073"; +} + +.fa-random:before { + content: "\f074"; +} + +.fa-comment:before { + content: "\f075"; +} + +.fa-magnet:before { + content: "\f076"; +} + +.fa-chevron-up:before { + content: "\f077"; +} + +.fa-chevron-down:before { + content: "\f078"; +} + +.fa-retweet:before { + content: "\f079"; +} + +.fa-shopping-cart:before { + content: "\f07a"; +} + +.fa-folder:before { + content: "\f07b"; +} + +.fa-folder-open:before { + content: "\f07c"; +} + +.fa-arrows-v:before { + content: "\f07d"; +} + +.fa-arrows-h:before { + content: "\f07e"; +} + +.fa-bar-chart-o:before, +.fa-bar-chart:before { + content: "\f080"; +} + +.fa-twitter-square:before { + content: "\f081"; +} + +.fa-facebook-square:before { + content: "\f082"; +} + +.fa-camera-retro:before { + content: "\f083"; +} + +.fa-key:before { + content: "\f084"; +} + +.fa-gears:before, +.fa-cogs:before { + content: "\f085"; +} + +.fa-comments:before { + content: "\f086"; +} + +.fa-thumbs-o-up:before { + content: "\f087"; +} + +.fa-thumbs-o-down:before { + content: "\f088"; +} + +.fa-star-half:before { + content: "\f089"; +} + +.fa-heart-o:before { + content: "\f08a"; +} + +.fa-sign-out:before { + content: "\f08b"; +} + +.fa-linkedin-square:before { + content: "\f08c"; +} + +.fa-thumb-tack:before { + content: "\f08d"; +} + +.fa-external-link:before { + content: "\f08e"; +} + +.fa-sign-in:before { + content: "\f090"; +} + +.fa-trophy:before { + content: "\f091"; +} + +.fa-github-square:before { + content: "\f092"; +} + +.fa-upload:before { + content: "\f093"; +} + +.fa-lemon-o:before { + content: "\f094"; +} + +.fa-phone:before { + content: "\f095"; +} + +.fa-square-o:before { + content: "\f096"; +} + +.fa-bookmark-o:before { + content: "\f097"; +} + +.fa-phone-square:before { + content: "\f098"; +} + +.fa-twitter:before { + content: "\f099"; +} + +.fa-facebook-f:before, +.fa-facebook:before { + content: "\f09a"; +} + +.fa-github:before { + content: "\f09b"; +} + +.fa-unlock:before { + content: "\f09c"; +} + +.fa-credit-card:before { + content: "\f09d"; +} + +.fa-rss:before { + content: "\f09e"; +} + +.fa-hdd-o:before { + content: "\f0a0"; +} + +.fa-bullhorn:before { + content: "\f0a1"; +} + +.fa-bell:before { + content: "\f0f3"; +} + +.fa-certificate:before { + content: "\f0a3"; +} + +.fa-hand-o-right:before { + content: "\f0a4"; +} + +.fa-hand-o-left:before { + content: "\f0a5"; +} + +.fa-hand-o-up:before { + content: "\f0a6"; +} + +.fa-hand-o-down:before { + content: "\f0a7"; +} + +.fa-arrow-circle-left:before { + content: "\f0a8"; +} + +.fa-arrow-circle-right:before { + content: "\f0a9"; +} + +.fa-arrow-circle-up:before { + content: "\f0aa"; +} + +.fa-arrow-circle-down:before { + content: "\f0ab"; +} + +.fa-globe:before { + content: "\f0ac"; +} + +.fa-wrench:before { + content: "\f0ad"; +} + +.fa-tasks:before { + content: "\f0ae"; +} + +.fa-filter:before { + content: "\f0b0"; +} + +.fa-briefcase:before { + content: "\f0b1"; +} + +.fa-arrows-alt:before { + content: "\f0b2"; +} + +.fa-group:before, +.fa-users:before { + content: "\f0c0"; +} + +.fa-chain:before, +.fa-link:before { + content: "\f0c1"; +} + +.fa-cloud:before { + content: "\f0c2"; +} + +.fa-flask:before { + content: "\f0c3"; +} + +.fa-cut:before, +.fa-scissors:before { + content: "\f0c4"; +} + +.fa-copy:before, +.fa-files-o:before { + content: "\f0c5"; +} + +.fa-paperclip:before { + content: "\f0c6"; +} + +.fa-save:before, +.fa-floppy-o:before { + content: "\f0c7"; +} + +.fa-square:before { + content: "\f0c8"; +} + +.fa-navicon:before, +.fa-reorder:before, +.fa-bars:before { + content: "\f0c9"; +} + +.fa-list-ul:before { + content: "\f0ca"; +} + +.fa-list-ol:before { + content: "\f0cb"; +} + +.fa-strikethrough:before { + content: "\f0cc"; +} + +.fa-underline:before { + content: "\f0cd"; +} + +.fa-table:before { + content: "\f0ce"; +} + +.fa-magic:before { + content: "\f0d0"; +} + +.fa-truck:before { + content: "\f0d1"; +} + +.fa-pinterest:before { + content: "\f0d2"; +} + +.fa-pinterest-square:before { + content: "\f0d3"; +} + +.fa-google-plus-square:before { + content: "\f0d4"; +} + +.fa-google-plus:before { + content: "\f0d5"; +} + +.fa-money:before { + content: "\f0d6"; +} + +.fa-caret-down:before { + content: "\f0d7"; +} + +.fa-caret-up:before { + content: "\f0d8"; +} + +.fa-caret-left:before { + content: "\f0d9"; +} + +.fa-caret-right:before { + content: "\f0da"; +} + +.fa-columns:before { + content: "\f0db"; +} + +.fa-unsorted:before, +.fa-sort:before { + content: "\f0dc"; +} + +.fa-sort-down:before, +.fa-sort-desc:before { + content: "\f0dd"; +} + +.fa-sort-up:before, +.fa-sort-asc:before { + content: "\f0de"; +} + +.fa-envelope:before { + content: "\f0e0"; +} + +.fa-linkedin:before { + content: "\f0e1"; +} + +.fa-rotate-left:before, +.fa-undo:before { + content: "\f0e2"; +} + +.fa-legal:before, +.fa-gavel:before { + content: "\f0e3"; +} + +.fa-dashboard:before, +.fa-tachometer:before { + content: "\f0e4"; +} + +.fa-comment-o:before { + content: "\f0e5"; +} + +.fa-comments-o:before { + content: "\f0e6"; +} + +.fa-flash:before, +.fa-bolt:before { + content: "\f0e7"; +} + +.fa-sitemap:before { + content: "\f0e8"; +} + +.fa-umbrella:before { + content: "\f0e9"; +} + +.fa-paste:before, +.fa-clipboard:before { + content: "\f0ea"; +} + +.fa-lightbulb-o:before { + content: "\f0eb"; +} + +.fa-exchange:before { + content: "\f0ec"; +} + +.fa-cloud-download:before { + content: "\f0ed"; +} + +.fa-cloud-upload:before { + content: "\f0ee"; +} + +.fa-user-md:before { + content: "\f0f0"; +} + +.fa-stethoscope:before { + content: "\f0f1"; +} + +.fa-suitcase:before { + content: "\f0f2"; +} + +.fa-bell-o:before { + content: "\f0a2"; +} + +.fa-coffee:before { + content: "\f0f4"; +} + +.fa-cutlery:before { + content: "\f0f5"; +} + +.fa-file-text-o:before { + content: "\f0f6"; +} + +.fa-building-o:before { + content: "\f0f7"; +} + +.fa-hospital-o:before { + content: "\f0f8"; +} + +.fa-ambulance:before { + content: "\f0f9"; +} + +.fa-medkit:before { + content: "\f0fa"; +} + +.fa-fighter-jet:before { + content: "\f0fb"; +} + +.fa-beer:before { + content: "\f0fc"; +} + +.fa-h-square:before { + content: "\f0fd"; +} + +.fa-plus-square:before { + content: "\f0fe"; +} + +.fa-angle-double-left:before { + content: "\f100"; +} + +.fa-angle-double-right:before { + content: "\f101"; +} + +.fa-angle-double-up:before { + content: "\f102"; +} + +.fa-angle-double-down:before { + content: "\f103"; +} + +.fa-angle-left:before { + content: "\f104"; +} + +.fa-angle-right:before { + content: "\f105"; +} + +.fa-angle-up:before { + content: "\f106"; +} + +.fa-angle-down:before { + content: "\f107"; +} + +.fa-desktop:before { + content: "\f108"; +} + +.fa-laptop:before { + content: "\f109"; +} + +.fa-tablet:before { + content: "\f10a"; +} + +.fa-mobile-phone:before, +.fa-mobile:before { + content: "\f10b"; +} + +.fa-circle-o:before { + content: "\f10c"; +} + +.fa-quote-left:before { + content: "\f10d"; +} + +.fa-quote-right:before { + content: "\f10e"; +} + +.fa-spinner:before { + content: "\f110"; +} + +.fa-circle:before { + content: "\f111"; +} + +.fa-mail-reply:before, +.fa-reply:before { + content: "\f112"; +} + +.fa-github-alt:before { + content: "\f113"; +} + +.fa-folder-o:before { + content: "\f114"; +} + +.fa-folder-open-o:before { + content: "\f115"; +} + +.fa-smile-o:before { + content: "\f118"; +} + +.fa-frown-o:before { + content: "\f119"; +} + +.fa-meh-o:before { + content: "\f11a"; +} + +.fa-gamepad:before { + content: "\f11b"; +} + +.fa-keyboard-o:before { + content: "\f11c"; +} + +.fa-flag-o:before { + content: "\f11d"; +} + +.fa-flag-checkered:before { + content: "\f11e"; +} + +.fa-terminal:before { + content: "\f120"; +} + +.fa-code:before { + content: "\f121"; +} + +.fa-mail-reply-all:before, +.fa-reply-all:before { + content: "\f122"; +} + +.fa-star-half-empty:before, +.fa-star-half-full:before, +.fa-star-half-o:before { + content: "\f123"; +} + +.fa-location-arrow:before { + content: "\f124"; +} + +.fa-crop:before { + content: "\f125"; +} + +.fa-code-fork:before { + content: "\f126"; +} + +.fa-unlink:before, +.fa-chain-broken:before { + content: "\f127"; +} + +.fa-question:before { + content: "\f128"; +} + +.fa-info:before { + content: "\f129"; +} + +.fa-exclamation:before { + content: "\f12a"; +} + +.fa-superscript:before { + content: "\f12b"; +} + +.fa-subscript:before { + content: "\f12c"; +} + +.fa-eraser:before { + content: "\f12d"; +} + +.fa-puzzle-piece:before { + content: "\f12e"; +} + +.fa-microphone:before { + content: "\f130"; +} + +.fa-microphone-slash:before { + content: "\f131"; +} + +.fa-shield:before { + content: "\f132"; +} + +.fa-calendar-o:before { + content: "\f133"; +} + +.fa-fire-extinguisher:before { + content: "\f134"; +} + +.fa-rocket:before { + content: "\f135"; +} + +.fa-maxcdn:before { + content: "\f136"; +} + +.fa-chevron-circle-left:before { + content: "\f137"; +} + +.fa-chevron-circle-right:before { + content: "\f138"; +} + +.fa-chevron-circle-up:before { + content: "\f139"; +} + +.fa-chevron-circle-down:before { + content: "\f13a"; +} + +.fa-html5:before { + content: "\f13b"; +} + +.fa-css3:before { + content: "\f13c"; +} + +.fa-anchor:before { + content: "\f13d"; +} + +.fa-unlock-alt:before { + content: "\f13e"; +} + +.fa-bullseye:before { + content: "\f140"; +} + +.fa-ellipsis-h:before { + content: "\f141"; +} + +.fa-ellipsis-v:before { + content: "\f142"; +} + +.fa-rss-square:before { + content: "\f143"; +} + +.fa-play-circle:before { + content: "\f144"; +} + +.fa-ticket:before { + content: "\f145"; +} + +.fa-minus-square:before { + content: "\f146"; +} + +.fa-minus-square-o:before { + content: "\f147"; +} + +.fa-level-up:before { + content: "\f148"; +} + +.fa-level-down:before { + content: "\f149"; +} + +.fa-check-square:before { + content: "\f14a"; +} + +.fa-pencil-square:before { + content: "\f14b"; +} + +.fa-external-link-square:before { + content: "\f14c"; +} + +.fa-share-square:before { + content: "\f14d"; +} + +.fa-compass:before { + content: "\f14e"; +} + +.fa-toggle-down:before, +.fa-caret-square-o-down:before { + content: "\f150"; +} + +.fa-toggle-up:before, +.fa-caret-square-o-up:before { + content: "\f151"; +} + +.fa-toggle-right:before, +.fa-caret-square-o-right:before { + content: "\f152"; +} + +.fa-euro:before, +.fa-eur:before { + content: "\f153"; +} + +.fa-gbp:before { + content: "\f154"; +} + +.fa-dollar:before, +.fa-usd:before { + content: "\f155"; +} + +.fa-rupee:before, +.fa-inr:before { + content: "\f156"; +} + +.fa-cny:before, +.fa-rmb:before, +.fa-yen:before, +.fa-jpy:before { + content: "\f157"; +} + +.fa-ruble:before, +.fa-rouble:before, +.fa-rub:before { + content: "\f158"; +} + +.fa-won:before, +.fa-krw:before { + content: "\f159"; +} + +.fa-bitcoin:before, +.fa-btc:before { + content: "\f15a"; +} + +.fa-file:before { + content: "\f15b"; +} + +.fa-file-text:before { + content: "\f15c"; +} + +.fa-sort-alpha-asc:before { + content: "\f15d"; +} + +.fa-sort-alpha-desc:before { + content: "\f15e"; +} + +.fa-sort-amount-asc:before { + content: "\f160"; +} + +.fa-sort-amount-desc:before { + content: "\f161"; +} + +.fa-sort-numeric-asc:before { + content: "\f162"; +} + +.fa-sort-numeric-desc:before { + content: "\f163"; +} + +.fa-thumbs-up:before { + content: "\f164"; +} + +.fa-thumbs-down:before { + content: "\f165"; +} + +.fa-youtube-square:before { + content: "\f166"; +} + +.fa-youtube:before { + content: "\f167"; +} + +.fa-xing:before { + content: "\f168"; +} + +.fa-xing-square:before { + content: "\f169"; +} + +.fa-youtube-play:before { + content: "\f16a"; +} + +.fa-dropbox:before { + content: "\f16b"; +} + +.fa-stack-overflow:before { + content: "\f16c"; +} + +.fa-instagram:before { + content: "\f16d"; +} + +.fa-flickr:before { + content: "\f16e"; +} + +.fa-adn:before { + content: "\f170"; +} + +.fa-bitbucket:before { + content: "\f171"; +} + +.fa-bitbucket-square:before { + content: "\f172"; +} + +.fa-tumblr:before { + content: "\f173"; +} + +.fa-tumblr-square:before { + content: "\f174"; +} + +.fa-long-arrow-down:before { + content: "\f175"; +} + +.fa-long-arrow-up:before { + content: "\f176"; +} + +.fa-long-arrow-left:before { + content: "\f177"; +} + +.fa-long-arrow-right:before { + content: "\f178"; +} + +.fa-apple:before { + content: "\f179"; +} + +.fa-windows:before { + content: "\f17a"; +} + +.fa-android:before { + content: "\f17b"; +} + +.fa-linux:before { + content: "\f17c"; +} + +.fa-dribbble:before { + content: "\f17d"; +} + +.fa-skype:before { + content: "\f17e"; +} + +.fa-foursquare:before { + content: "\f180"; +} + +.fa-trello:before { + content: "\f181"; +} + +.fa-female:before { + content: "\f182"; +} + +.fa-male:before { + content: "\f183"; +} + +.fa-gittip:before, +.fa-gratipay:before { + content: "\f184"; +} + +.fa-sun-o:before { + content: "\f185"; +} + +.fa-moon-o:before { + content: "\f186"; +} + +.fa-archive:before { + content: "\f187"; +} + +.fa-bug:before { + content: "\f188"; +} + +.fa-vk:before { + content: "\f189"; +} + +.fa-weibo:before { + content: "\f18a"; +} + +.fa-renren:before { + content: "\f18b"; +} + +.fa-pagelines:before { + content: "\f18c"; +} + +.fa-stack-exchange:before { + content: "\f18d"; +} + +.fa-arrow-circle-o-right:before { + content: "\f18e"; +} + +.fa-arrow-circle-o-left:before { + content: "\f190"; +} + +.fa-toggle-left:before, +.fa-caret-square-o-left:before { + content: "\f191"; +} + +.fa-dot-circle-o:before { + content: "\f192"; +} + +.fa-wheelchair:before { + content: "\f193"; +} + +.fa-vimeo-square:before { + content: "\f194"; +} + +.fa-turkish-lira:before, +.fa-try:before { + content: "\f195"; +} + +.fa-plus-square-o:before { + content: "\f196"; +} + +.fa-space-shuttle:before { + content: "\f197"; +} + +.fa-slack:before { + content: "\f198"; +} + +.fa-envelope-square:before { + content: "\f199"; +} + +.fa-wordpress:before { + content: "\f19a"; +} + +.fa-openid:before { + content: "\f19b"; +} + +.fa-institution:before, +.fa-bank:before, +.fa-university:before { + content: "\f19c"; +} + +.fa-mortar-board:before, +.fa-graduation-cap:before { + content: "\f19d"; +} + +.fa-yahoo:before { + content: "\f19e"; +} + +.fa-google:before { + content: "\f1a0"; +} + +.fa-reddit:before { + content: "\f1a1"; +} + +.fa-reddit-square:before { + content: "\f1a2"; +} + +.fa-stumbleupon-circle:before { + content: "\f1a3"; +} + +.fa-stumbleupon:before { + content: "\f1a4"; +} + +.fa-delicious:before { + content: "\f1a5"; +} + +.fa-digg:before { + content: "\f1a6"; +} + +.fa-pied-piper:before { + content: "\f1a7"; +} + +.fa-pied-piper-alt:before { + content: "\f1a8"; +} + +.fa-drupal:before { + content: "\f1a9"; +} + +.fa-joomla:before { + content: "\f1aa"; +} + +.fa-language:before { + content: "\f1ab"; +} + +.fa-fax:before { + content: "\f1ac"; +} + +.fa-building:before { + content: "\f1ad"; +} + +.fa-child:before { + content: "\f1ae"; +} + +.fa-paw:before { + content: "\f1b0"; +} + +.fa-spoon:before { + content: "\f1b1"; +} + +.fa-cube:before { + content: "\f1b2"; +} + +.fa-cubes:before { + content: "\f1b3"; +} + +.fa-behance:before { + content: "\f1b4"; +} + +.fa-behance-square:before { + content: "\f1b5"; +} + +.fa-steam:before { + content: "\f1b6"; +} + +.fa-steam-square:before { + content: "\f1b7"; +} + +.fa-recycle:before { + content: "\f1b8"; +} + +.fa-automobile:before, +.fa-car:before { + content: "\f1b9"; +} + +.fa-cab:before, +.fa-taxi:before { + content: "\f1ba"; +} + +.fa-tree:before { + content: "\f1bb"; +} + +.fa-spotify:before { + content: "\f1bc"; +} + +.fa-deviantart:before { + content: "\f1bd"; +} + +.fa-soundcloud:before { + content: "\f1be"; +} + +.fa-database:before { + content: "\f1c0"; +} + +.fa-file-pdf-o:before { + content: "\f1c1"; +} + +.fa-file-word-o:before { + content: "\f1c2"; +} + +.fa-file-excel-o:before { + content: "\f1c3"; +} + +.fa-file-powerpoint-o:before { + content: "\f1c4"; +} + +.fa-file-photo-o:before, +.fa-file-picture-o:before, +.fa-file-image-o:before { + content: "\f1c5"; +} + +.fa-file-zip-o:before, +.fa-file-archive-o:before { + content: "\f1c6"; +} + +.fa-file-sound-o:before, +.fa-file-audio-o:before { + content: "\f1c7"; +} + +.fa-file-movie-o:before, +.fa-file-video-o:before { + content: "\f1c8"; +} + +.fa-file-code-o:before { + content: "\f1c9"; +} + +.fa-vine:before { + content: "\f1ca"; +} + +.fa-codepen:before { + content: "\f1cb"; +} + +.fa-jsfiddle:before { + content: "\f1cc"; +} + +.fa-life-bouy:before, +.fa-life-buoy:before, +.fa-life-saver:before, +.fa-support:before, +.fa-life-ring:before { + content: "\f1cd"; +} + +.fa-circle-o-notch:before { + content: "\f1ce"; +} + +.fa-ra:before, +.fa-rebel:before { + content: "\f1d0"; +} + +.fa-ge:before, +.fa-empire:before { + content: "\f1d1"; +} + +.fa-git-square:before { + content: "\f1d2"; +} + +.fa-git:before { + content: "\f1d3"; +} + +.fa-hacker-news:before { + content: "\f1d4"; +} + +.fa-tencent-weibo:before { + content: "\f1d5"; +} + +.fa-qq:before { + content: "\f1d6"; +} + +.fa-wechat:before, +.fa-weixin:before { + content: "\f1d7"; +} + +.fa-send:before, +.fa-paper-plane:before { + content: "\f1d8"; +} + +.fa-send-o:before, +.fa-paper-plane-o:before { + content: "\f1d9"; +} + +.fa-history:before { + content: "\f1da"; +} + +.fa-genderless:before, +.fa-circle-thin:before { + content: "\f1db"; +} + +.fa-header:before { + content: "\f1dc"; +} + +.fa-paragraph:before { + content: "\f1dd"; +} + +.fa-sliders:before { + content: "\f1de"; +} + +.fa-share-alt:before { + content: "\f1e0"; +} + +.fa-share-alt-square:before { + content: "\f1e1"; +} + +.fa-bomb:before { + content: "\f1e2"; +} + +.fa-soccer-ball-o:before, +.fa-futbol-o:before { + content: "\f1e3"; +} + +.fa-tty:before { + content: "\f1e4"; +} + +.fa-binoculars:before { + content: "\f1e5"; +} + +.fa-plug:before { + content: "\f1e6"; +} + +.fa-slideshare:before { + content: "\f1e7"; +} + +.fa-twitch:before { + content: "\f1e8"; +} + +.fa-yelp:before { + content: "\f1e9"; +} + +.fa-newspaper-o:before { + content: "\f1ea"; +} + +.fa-wifi:before { + content: "\f1eb"; +} + +.fa-calculator:before { + content: "\f1ec"; +} + +.fa-paypal:before { + content: "\f1ed"; +} + +.fa-google-wallet:before { + content: "\f1ee"; +} + +.fa-cc-visa:before { + content: "\f1f0"; +} + +.fa-cc-mastercard:before { + content: "\f1f1"; +} + +.fa-cc-discover:before { + content: "\f1f2"; +} + +.fa-cc-amex:before { + content: "\f1f3"; +} + +.fa-cc-paypal:before { + content: "\f1f4"; +} + +.fa-cc-stripe:before { + content: "\f1f5"; +} + +.fa-bell-slash:before { + content: "\f1f6"; +} + +.fa-bell-slash-o:before { + content: "\f1f7"; +} + +.fa-trash:before { + content: "\f1f8"; +} + +.fa-copyright:before { + content: "\f1f9"; +} + +.fa-at:before { + content: "\f1fa"; +} + +.fa-eyedropper:before { + content: "\f1fb"; +} + +.fa-paint-brush:before { + content: "\f1fc"; +} + +.fa-birthday-cake:before { + content: "\f1fd"; +} + +.fa-area-chart:before { + content: "\f1fe"; +} + +.fa-pie-chart:before { + content: "\f200"; +} + +.fa-line-chart:before { + content: "\f201"; +} + +.fa-lastfm:before { + content: "\f202"; +} + +.fa-lastfm-square:before { + content: "\f203"; +} + +.fa-toggle-off:before { + content: "\f204"; +} + +.fa-toggle-on:before { + content: "\f205"; +} + +.fa-bicycle:before { + content: "\f206"; +} + +.fa-bus:before { + content: "\f207"; +} + +.fa-ioxhost:before { + content: "\f208"; +} + +.fa-angellist:before { + content: "\f209"; +} + +.fa-cc:before { + content: "\f20a"; +} + +.fa-shekel:before, +.fa-sheqel:before, +.fa-ils:before { + content: "\f20b"; +} + +.fa-meanpath:before { + content: "\f20c"; +} + +.fa-buysellads:before { + content: "\f20d"; +} + +.fa-connectdevelop:before { + content: "\f20e"; +} + +.fa-dashcube:before { + content: "\f210"; +} + +.fa-forumbee:before { + content: "\f211"; +} + +.fa-leanpub:before { + content: "\f212"; +} + +.fa-sellsy:before { + content: "\f213"; +} + +.fa-shirtsinbulk:before { + content: "\f214"; +} + +.fa-simplybuilt:before { + content: "\f215"; +} + +.fa-skyatlas:before { + content: "\f216"; +} + +.fa-cart-plus:before { + content: "\f217"; +} + +.fa-cart-arrow-down:before { + content: "\f218"; +} + +.fa-diamond:before { + content: "\f219"; +} + +.fa-ship:before { + content: "\f21a"; +} + +.fa-user-secret:before { + content: "\f21b"; +} + +.fa-motorcycle:before { + content: "\f21c"; +} + +.fa-street-view:before { + content: "\f21d"; +} + +.fa-heartbeat:before { + content: "\f21e"; +} + +.fa-venus:before { + content: "\f221"; +} + +.fa-mars:before { + content: "\f222"; +} + +.fa-mercury:before { + content: "\f223"; +} + +.fa-transgender:before { + content: "\f224"; +} + +.fa-transgender-alt:before { + content: "\f225"; +} + +.fa-venus-double:before { + content: "\f226"; +} + +.fa-mars-double:before { + content: "\f227"; +} + +.fa-venus-mars:before { + content: "\f228"; +} + +.fa-mars-stroke:before { + content: "\f229"; +} + +.fa-mars-stroke-v:before { + content: "\f22a"; +} + +.fa-mars-stroke-h:before { + content: "\f22b"; +} + +.fa-neuter:before { + content: "\f22c"; +} + +.fa-facebook-official:before { + content: "\f230"; +} + +.fa-pinterest-p:before { + content: "\f231"; +} + +.fa-whatsapp:before { + content: "\f232"; +} + +.fa-server:before { + content: "\f233"; +} + +.fa-user-plus:before { + content: "\f234"; +} + +.fa-user-times:before { + content: "\f235"; +} + +.fa-hotel:before, +.fa-bed:before { + content: "\f236"; +} + +.fa-viacoin:before { + content: "\f237"; +} + +.fa-train:before { + content: "\f238"; +} + +.fa-subway:before { + content: "\f239"; +} + +.fa-medium:before { + content: "\f23a"; +} + +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */ +@font-face { + font-family: 'editormd-logo'; + src: url("../fonts/editormd-logo.eot?-5y8q6h"); + src: url("../fonts/editormd-logo.eot?#iefix-5y8q6h") format("embedded-opentype"), url("../fonts/editormd-logo.woff?-5y8q6h") format("woff"), url("../fonts/editormd-logo.ttf?-5y8q6h") format("truetype"), url("../fonts/editormd-logo.svg?-5y8q6h#icomoon") format("svg"); + font-weight: normal; + font-style: normal; +} +.editormd-logo, +.editormd-logo-1x, +.editormd-logo-2x, +.editormd-logo-3x, +.editormd-logo-4x, +.editormd-logo-5x, +.editormd-logo-6x, +.editormd-logo-7x, +.editormd-logo-8x { + font-family: 'editormd-logo'; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + font-size: inherit; + line-height: 1; + display: inline-block; + text-rendering: auto; + vertical-align: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.editormd-logo:before, +.editormd-logo-1x:before, +.editormd-logo-2x:before, +.editormd-logo-3x:before, +.editormd-logo-4x:before, +.editormd-logo-5x:before, +.editormd-logo-6x:before, +.editormd-logo-7x:before, +.editormd-logo-8x:before { + content: "\e1987"; + /* + HTML Entity 󡦇 + example: + */ +} + +.editormd-logo-1x { + font-size: 1em; +} + +.editormd-logo-lg { + font-size: 1.2em; +} + +.editormd-logo-2x { + font-size: 2em; +} + +.editormd-logo-3x { + font-size: 3em; +} + +.editormd-logo-4x { + font-size: 4em; +} + +.editormd-logo-5x { + font-size: 5em; +} + +.editormd-logo-6x { + font-size: 6em; +} + +.editormd-logo-7x { + font-size: 7em; +} + +.editormd-logo-8x { + font-size: 8em; +} + +.editormd-logo-color { + color: #2196F3; +} + +/*! github-markdown-css | The MIT License (MIT) | Copyright (c) Sindre Sorhus (sindresorhus.com) | https://github.com/sindresorhus/github-markdown-css */ +@font-face { + font-family: octicons-anchor; + src: url(data:font/woff;charset=utf-8;base64,d09GRgABAAAAAAYcAA0AAAAACjQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABMAAAABwAAAAca8vGTk9TLzIAAAFMAAAARAAAAFZG1VHVY21hcAAAAZAAAAA+AAABQgAP9AdjdnQgAAAB0AAAAAQAAAAEACICiGdhc3AAAAHUAAAACAAAAAj//wADZ2x5ZgAAAdwAAADRAAABEKyikaNoZWFkAAACsAAAAC0AAAA2AtXoA2hoZWEAAALgAAAAHAAAACQHngNFaG10eAAAAvwAAAAQAAAAEAwAACJsb2NhAAADDAAAAAoAAAAKALIAVG1heHAAAAMYAAAAHwAAACABEAB2bmFtZQAAAzgAAALBAAAFu3I9x/Nwb3N0AAAF/AAAAB0AAAAvaoFvbwAAAAEAAAAAzBdyYwAAAADP2IQvAAAAAM/bz7t4nGNgZGFgnMDAysDB1Ml0hoGBoR9CM75mMGLkYGBgYmBlZsAKAtJcUxgcPsR8iGF2+O/AEMPsznAYKMwIkgMA5REMOXicY2BgYGaAYBkGRgYQsAHyGMF8FgYFIM0ChED+h5j//yEk/3KoSgZGNgYYk4GRCUgwMaACRoZhDwCs7QgGAAAAIgKIAAAAAf//AAJ4nHWMMQrCQBBF/0zWrCCIKUQsTDCL2EXMohYGSSmorScInsRGL2DOYJe0Ntp7BK+gJ1BxF1stZvjz/v8DRghQzEc4kIgKwiAppcA9LtzKLSkdNhKFY3HF4lK69ExKslx7Xa+vPRVS43G98vG1DnkDMIBUgFN0MDXflU8tbaZOUkXUH0+U27RoRpOIyCKjbMCVejwypzJJG4jIwb43rfl6wbwanocrJm9XFYfskuVC5K/TPyczNU7b84CXcbxks1Un6H6tLH9vf2LRnn8Ax7A5WQAAAHicY2BkYGAA4teL1+yI57f5ysDNwgAC529f0kOmWRiYVgEpDgYmEA8AUzEKsQAAAHicY2BkYGB2+O/AEMPCAAJAkpEBFbAAADgKAe0EAAAiAAAAAAQAAAAEAAAAAAAAKgAqACoAiAAAeJxjYGRgYGBhsGFgYgABEMkFhAwM/xn0QAIAD6YBhwB4nI1Ty07cMBS9QwKlQapQW3VXySvEqDCZGbGaHULiIQ1FKgjWMxknMfLEke2A+IJu+wntrt/QbVf9gG75jK577Lg8K1qQPCfnnnt8fX1NRC/pmjrk/zprC+8D7tBy9DHgBXoWfQ44Av8t4Bj4Z8CLtBL9CniJluPXASf0Lm4CXqFX8Q84dOLnMB17N4c7tBo1AS/Qi+hTwBH4rwHHwN8DXqQ30XXAS7QaLwSc0Gn8NuAVWou/gFmnjLrEaEh9GmDdDGgL3B4JsrRPDU2hTOiMSuJUIdKQQayiAth69r6akSSFqIJuA19TrzCIaY8sIoxyrNIrL//pw7A2iMygkX5vDj+G+kuoLdX4GlGK/8Lnlz6/h9MpmoO9rafrz7ILXEHHaAx95s9lsI7AHNMBWEZHULnfAXwG9/ZqdzLI08iuwRloXE8kfhXYAvE23+23DU3t626rbs8/8adv+9DWknsHp3E17oCf+Z48rvEQNZ78paYM38qfk3v/u3l3u3GXN2Dmvmvpf1Srwk3pB/VSsp512bA/GG5i2WJ7wu430yQ5K3nFGiOqgtmSB5pJVSizwaacmUZzZhXLlZTq8qGGFY2YcSkqbth6aW1tRmlaCFs2016m5qn36SbJrqosG4uMV4aP2PHBmB3tjtmgN2izkGQyLWprekbIntJFing32a5rKWCN/SdSoga45EJykyQ7asZvHQ8PTm6cslIpwyeyjbVltNikc2HTR7YKh9LBl9DADC0U/jLcBZDKrMhUBfQBvXRzLtFtjU9eNHKin0x5InTqb8lNpfKv1s1xHzTXRqgKzek/mb7nB8RZTCDhGEX3kK/8Q75AmUM/eLkfA+0Hi908Kx4eNsMgudg5GLdRD7a84npi+YxNr5i5KIbW5izXas7cHXIMAau1OueZhfj+cOcP3P8MNIWLyYOBuxL6DRylJ4cAAAB4nGNgYoAALjDJyIAOWMCiTIxMLDmZedkABtIBygAAAA==) format("woff"); +} +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + color: #333; + overflow: hidden; + font-family: "Microsoft YaHei", Helvetica, "Meiryo UI", "Malgun Gothic", "Segoe UI", "Trebuchet MS", "Monaco", monospace, Tahoma, STXihei, "华文细黑", STHeiti, "Helvetica Neue", "Droid Sans", "wenquanyi micro hei", FreeSans, Arimo, Arial, SimSun, "宋体", Heiti, "黑体", sans-serif; + font-size: 16px; + line-height: 1.6; + word-wrap: break-word; +} + +.markdown-body a { + background: transparent; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline: 0; +} + +.markdown-body strong { + font-weight: bold; + color: var(--pai-brand-1-normal); +} + +.markdown-body h1 { + font-size: 2em; + margin: 0.67em 0; +} + +.markdown-body img { + border: 0; +} + +.markdown-body hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +.markdown-body pre { + overflow: auto; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre { + font-family: "Meiryo UI", "YaHei Consolas Hybrid", Consolas, "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, monospace, monospace; + font-size: 1em; +} + +.markdown-body input { + color: inherit; + font: inherit; + margin: 0; +} + +.markdown-body html input[disabled] { + cursor: default; +} + +.markdown-body input { + line-height: normal; +} + +.markdown-body input[type="checkbox"] { + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; +} + +.markdown-body table { + border-collapse: collapse; + border-spacing: 0; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body * { + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.markdown-body input { + font: 13px/1.4 Helvetica, arial, freesans, clean, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.markdown-body a { + color: var(--pai-brand-1-normal); + text-decoration: none; + border-bottom: 1px solid var(--pai-brand-1-normal); + font-size: 15px; +} + +.markdown-body a:hover, +.markdown-body a:active { + text-decoration: none; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #ddd; +} + +.markdown-body hr:before { + display: table; + content: ""; +} + +.markdown-body hr:after { + display: table; + clear: both; + content: ""; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 15px; + margin-bottom: 15px; + line-height: 1.1; +} + +.markdown-body h1 { + font-size: 30px; +} + +.markdown-body h2 { + font-size: 21px; +} + +.markdown-body h3 { + font-size: 16px; +} + +.markdown-body h4 { + font-size: 14px; +} + +.markdown-body h5 { + font-size: 12px; +} + +.markdown-body h6 { + font-size: 11px; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ul, +.markdown-body ol { + padding: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code { + font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font: 12px Consolas, "Liberation Mono", Menlo, Courier, monospace; +} + +.markdown-body .octicon { + font: normal normal 16px octicons-anchor; + line-height: 1; + display: inline-block; + text-decoration: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.markdown-body .octicon-link:before { + content: '\f05c'; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} + +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body .anchor { + position: absolute; + top: 0; + left: 0; + display: block; + padding-right: 6px; + padding-left: 30px; + margin-left: -30px; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + position: relative; + margin-top: 1em; + margin-bottom: 16px; + font-weight: bold; + line-height: 1.4; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + display: none; + color: #000; + vertical-align: middle; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + padding-left: 8px; + margin-left: -30px; + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + display: inline-block; +} + +.markdown-body h1 { + padding-bottom: 0.3em; + font-size: 2.25em; + line-height: 1.2; + border-bottom: 1px solid #eee; +} + +.markdown-body h1 .anchor { + line-height: 1; +} + +.markdown-body h2 { + margin: 10px auto; + height: 40px; + background-color: rgb(251, 251, 251); + border-bottom: 1px solid rgb(246, 246, 246); + overflow: hidden; + box-sizing: border-box; +} + +.markdown-body h2 .content{ + margin-left: -10px; + display: inline-block; + width: auto; + height: 40px; + background-color: var(--pai-brand-1-normal); + border-bottom-right-radius: 100px; + color: rgb(255, 255, 255); + padding-right: 30px; + padding-left: 30px; + line-height: 40px; + font-size: 18px; +} + +.markdown-body h2 .anchor { + line-height: 1; +} + +.markdown-body h3 { + margin: 1.2em 0 1em; + font-weight: bold; + color: var(--pai-brand-1-normal); + font-size: 18px; +} + +.markdown-body h3 .anchor { + line-height: 1.2; +} + +.markdown-body h4 { + font-size: 1.25em; +} + +.markdown-body h4 .anchor { + line-height: 1.2; +} + +.markdown-body h5 { + font-size: 1em; +} + +.markdown-body h5 .anchor { + line-height: 1.1; +} + +.markdown-body h6 { + font-size: 1em; + color: #777; +} + +.markdown-body h6 .anchor { + line-height: 1.1; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre { + margin-top: 0; + margin-bottom: 16px; +} + +/* +.markdown-body hr { + height: 4px; + padding: 0; + margin: 16px 0; + background-color: #e7e7e7; + border: 0 none; +}*/ +.markdown-body ul, +.markdown-body ol { + padding-left: 2em; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li > p { + margin-top: 16px; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: bold; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body blockquote { + padding: 0 15px; + color: #777; + border-left: 4px solid #ddd; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; + word-break: normal; + word-break: keep-all; +} + +.markdown-body table th { + font-weight: bold; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #ddd; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #ccc; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f8f8f8; +} + +.markdown-body img { + max-width: 100%; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.markdown-body code { + padding: 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + margin: 0; + font-size: 85%; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 3px; +} + +.markdown-body code:before, +.markdown-body code:after { + letter-spacing: -0.2em; + content: "\00a0"; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f7f7f7; + border-radius: 3px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre code { + display: inline; + max-width: initial; + padding: 0; + margin: 0; + overflow: initial; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body pre code:before, +.markdown-body pre code:after { + content: normal; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font-size: 11px; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +.markdown-body .pl-c { + color: #969896; +} + +.markdown-body .pl-c1, +.markdown-body .pl-mdh, +.markdown-body .pl-mm, +.markdown-body .pl-mp, +.markdown-body .pl-mr, +.markdown-body .pl-s1 .pl-v, +.markdown-body .pl-s3, +.markdown-body .pl-sc, +.markdown-body .pl-sv { + color: #0086b3; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #795da3; +} + +.markdown-body .pl-s1 .pl-s2, +.markdown-body .pl-smi, +.markdown-body .pl-smp, +.markdown-body .pl-stj, +.markdown-body .pl-vo, +.markdown-body .pl-vpf { + color: #333; +} + +.markdown-body .pl-ent { + color: #63a35c; +} + +.markdown-body .pl-k, +.markdown-body .pl-s, +.markdown-body .pl-st { + color: #a71d5d; +} + +.markdown-body .pl-pds, +.markdown-body .pl-s1, +.markdown-body .pl-s1 .pl-pse .pl-s2, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sra, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-src { + color: #df5000; +} + +.markdown-body .pl-mo, +.markdown-body .pl-v { + color: #1d3e81; +} + +.markdown-body .pl-id { + color: #b52a1d; +} + +.markdown-body .pl-ii { + background-color: #b52a1d; + color: #f8f8f8; +} + +.markdown-body .pl-sr .pl-cce { + color: #63a35c; + font-weight: bold; +} + +.markdown-body .pl-ml { + color: #693a17; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + color: #1d3e81; + font-weight: bold; +} + +.markdown-body .pl-mq { + color: #008080; +} + +.markdown-body .pl-mi { + color: #333; + font-style: italic; +} + +.markdown-body .pl-mb { + color: #333; + font-weight: bold; +} + +.markdown-body .pl-md, +.markdown-body .pl-mdhf { + background-color: #ffecec; + color: #bd2c00; +} + +.markdown-body .pl-mdht, +.markdown-body .pl-mi1 { + background-color: #eaffea; + color: #55a532; +} + +.markdown-body .pl-mdr { + color: #795da3; + font-weight: bold; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + float: left; + margin: 0.3em 0 0.25em -1.6em; + vertical-align: middle; +} + +.markdown-body :checked + .radio-label { + z-index: 1; + position: relative; + border-color: #4183c4; +} + +.editormd-preview-container, .editormd-html-preview { + text-align: left; + font-size: 1em; + line-height: 1.6; + padding: 20px; + overflow: auto; + width: 100%; + background-color: #fff; +} +.editormd-preview-container blockquote, .editormd-html-preview blockquote { + margin: 20px 5px; + color: var(--pai-color-3-black); + border-left: 4px solid var(--pai-brand-1-normal); + padding-left: 20px; + margin-left: 0; + font-size: 14px; + background: #FBF9FD; + line-height: 26px; +} + +.editormd-preview-container blockquote p, .editormd-html-preview blockquote p { + padding-top: 8px; + padding-bottom: 8px; +} + +.editormd-preview-container p code, .editormd-html-preview p code { + margin-left: 5px; + margin-right: 4px; +} +.editormd-preview-container abbr, .editormd-html-preview abbr { + background: #ffffdd; +} + +.editormd-preview-container ul li{ + list-style-type: disc; +} + +.editormd-preview-container ol li{ + list-style-type: decimal; +} + +.editormd-preview-container hr, .editormd-html-preview hr { + height: 1px; + border: none; + border-top: 1px solid var(--pai-brand-1-normal); + background: none; +} +.editormd-preview-container code, .editormd-html-preview code { + border: 1px solid #ddd; + background: #f6f6f6; + padding: 3px; + border-radius: 3px; + font-size: 14px; +} +.editormd-preview-container pre, .editormd-html-preview pre { + border: 1px solid #ddd; + background: #f6f6f6; + padding: 10px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; +} +.editormd-preview-container pre code, .editormd-html-preview pre code { + padding: 0; +} +.editormd-preview-container pre, .editormd-preview-container code, .editormd-preview-container kbd, .editormd-html-preview pre, .editormd-html-preview code, .editormd-html-preview kbd { + font-family: "YaHei Consolas Hybrid", Consolas, "Meiryo UI", "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, monospace, monospace; +} +.editormd-preview-container table thead tr, .editormd-html-preview table thead tr { + background-color: #F8F8F8; +} +.editormd-preview-container p.editormd-tex, .editormd-html-preview p.editormd-tex { + text-align: center; +} +.editormd-preview-container span.editormd-tex, .editormd-html-preview span.editormd-tex { + margin: 0 5px; +} +.editormd-preview-container .emoji, .editormd-html-preview .emoji { + width: 24px; + height: 24px; +} +.editormd-preview-container .katex, .editormd-html-preview .katex { + font-size: 1.2em; +} +.editormd-preview-container .sequence-diagram, .editormd-preview-container .flowchart, .editormd-html-preview .sequence-diagram, .editormd-html-preview .flowchart { + margin: 0 auto; + text-align: center; +} +.editormd-preview-container .sequence-diagram svg, .editormd-preview-container .flowchart svg, .editormd-html-preview .sequence-diagram svg, .editormd-html-preview .flowchart svg { + margin: 0 auto; +} +.editormd-preview-container .sequence-diagram text, .editormd-preview-container .flowchart text, .editormd-html-preview .sequence-diagram text, .editormd-html-preview .flowchart text { + font-size: 15px !important; + font-family: "YaHei Consolas Hybrid", Consolas, "Microsoft YaHei", "Malgun Gothic", "Segoe UI", Helvetica, Arial !important; +} + +/*! Pretty printing styles. Used with prettify.js. */ +/* SPAN elements with the classes below are added by prettyprint. */ +.pln { + color: #000; +} + +/* plain text */ +@media screen { + .str { + color: #080; + } + + /* string content */ + .kwd { + color: #008; + } + + /* a keyword */ + .com { + color: #800; + } + + /* a comment */ + .typ { + color: #606; + } + + /* a type name */ + .lit { + color: #066; + } + + /* a literal value */ + /* punctuation, lisp open bracket, lisp close bracket */ + .pun, .opn, .clo { + color: #660; + } + + .tag { + color: #008; + } + + /* a markup tag name */ + .atn { + color: #606; + } + + /* a markup attribute name */ + .atv { + color: #080; + } + + /* a markup attribute value */ + .dec, .var { + color: #606; + } + + /* a declaration; a variable name */ + .fun { + color: red; + } + + /* a function name */ +} +/* Use higher contrast and text-weight for printable form. */ +@media print, projection { + .str { + color: #060; + } + + .kwd { + color: #006; + font-weight: bold; + } + + .com { + color: #600; + font-style: italic; + } + + .typ { + color: #404; + font-weight: bold; + } + + .lit { + color: #044; + } + + .pun, .opn, .clo { + color: #440; + } + + .tag { + color: #006; + font-weight: bold; + } + + .atn { + color: #404; + } + + .atv { + color: #060; + } +} +/* Put a border around prettyprinted code snippets. */ +pre.prettyprint { + padding: 2px; + border: 1px solid #888; +} + +/* Specify class=linenums on a pre to get line numbering */ +ol.linenums { + margin-top: 0; + margin-bottom: 0; +} + +/* IE indents via margin-left */ +li.L0, +li.L1, +li.L2, +li.L3, +li.L5, +li.L6, +li.L7, +li.L8 { + list-style-type: none; +} + +/* Alternate shading for lines */ +li.L1, +li.L3, +li.L5, +li.L7, +li.L9 { + background: #eee; +} + +.editormd-preview-container pre.prettyprint, .editormd-html-preview pre.prettyprint { + padding: 10px; + border: 1px solid #ddd; + white-space: pre-wrap; + word-wrap: break-word; +} +.editormd-preview-container ol.linenums, .editormd-html-preview ol.linenums { + color: #999; + padding-left: 2.5em; +} +.editormd-preview-container ol.linenums li, .editormd-html-preview ol.linenums li { + list-style-type: decimal; +} +.editormd-preview-container ol.linenums li code, .editormd-html-preview ol.linenums li code { + border: none; + background: none; + padding: 0; +} + +.editormd-preview-container .editormd-toc-menu, .editormd-html-preview .editormd-toc-menu { + margin: 8px 0 12px 0; + display: inline-block; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc, .editormd-html-preview .editormd-toc-menu > .markdown-toc { + position: relative; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + border: 1px solid #ddd; + display: inline-block; + font-size: 1em; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul { + width: 160%; + min-width: 180px; + position: absolute; + left: -1px; + top: -2px; + z-index: 100; + padding: 0 10px 10px; + display: none; + background: #fff; + border: 1px solid #ddd; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Webkit browsers */ + -moz-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Firefox */ + -ms-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9 */ + -o-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Opera(Old) */ + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9+, News */ +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul > li ul, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul > li ul { + width: 100%; + min-width: 180px; + border: 1px solid #ddd; + display: none; + background: #fff; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul > li a, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul > li a { + color: #666; + padding: 6px 10px; + display: block; + -webkit-transition: background-color 500ms ease-out; + /* Safari, Chrome */ + -moz-transition: background-color 500ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background-color 500ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul > li a:hover, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul > li a:hover { + background-color: #f6f6f6; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li, .editormd-html-preview .editormd-toc-menu > .markdown-toc li { + position: relative; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul { + position: absolute; + top: 32px; + left: 10%; + display: none; + -webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Webkit browsers */ + -moz-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Firefox */ + -ms-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9 */ + -o-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Opera(Old) */ + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9+, News */ +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:before, .editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:after, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:before, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:after { + pointer-events: pointer-events; + position: absolute; + left: 15px; + top: -6px; + display: block; + content: ""; + width: 0; + height: 0; + border: 6px solid transparent; + border-width: 0 6px 6px; + z-index: 10; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:before, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:before { + border-bottom-color: #ccc; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:after, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:after { + border-bottom-color: #ffffff; + top: -5px; +} +.editormd-preview-container .editormd-toc-menu ul, .editormd-html-preview .editormd-toc-menu ul { + list-style: none; +} +.editormd-preview-container .editormd-toc-menu a, .editormd-html-preview .editormd-toc-menu a { + text-decoration: none; +} +.editormd-preview-container .editormd-toc-menu h1, .editormd-html-preview .editormd-toc-menu h1 { + font-size: 16px; + padding: 5px 0 10px 10px; + line-height: 1; + border-bottom: 1px solid #eee; +} +.editormd-preview-container .editormd-toc-menu h1 .fa, .editormd-html-preview .editormd-toc-menu h1 .fa { + padding-left: 10px; +} +.editormd-preview-container .editormd-toc-menu .toc-menu-btn, .editormd-html-preview .editormd-toc-menu .toc-menu-btn { + color: #666; + min-width: 180px; + padding: 5px 10px; + border-radius: 4px; + display: inline-block; + -webkit-transition: background-color 500ms ease-out; + /* Safari, Chrome */ + -moz-transition: background-color 500ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background-color 500ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-preview-container .editormd-toc-menu .toc-menu-btn:hover, .editormd-html-preview .editormd-toc-menu .toc-menu-btn:hover { + background-color: #f6f6f6; +} +.editormd-preview-container .editormd-toc-menu .toc-menu-btn .fa, .editormd-html-preview .editormd-toc-menu .toc-menu-btn .fa { + float: right; + padding: 3px 0 0 10px; + font-size: 1.3em; +} + +.markdown-body .editormd-toc-menu ul { + padding-left: 0; +} +.markdown-body .highlight pre, .markdown-body pre { + line-height: 1.6; +} + +hr.editormd-page-break { + border: 1px dotted #ccc; + font-size: 0; + height: 2px; +} + +@media only print { + hr.editormd-page-break { + background: none; + border: none; + height: 0; + } +} +.editormd-html-preview textarea { + display: none; +} +.editormd-html-preview hr.editormd-page-break { + background: none; + border: none; + height: 0; +} + +.editormd-preview-close-btn { + color: #fff; + padding: 4px 6px; + font-size: 18px; + -webkit-border-radius: 500px; + -moz-border-radius: 500px; + -ms-border-radius: 500px; + -o-border-radius: 500px; + border-radius: 500px; + display: none; + background-color: #ccc; + position: absolute; + top: 25px; + right: 35px; + z-index: 19; + -webkit-transition: background-color 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: background-color 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background-color 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-preview-close-btn:hover { + background-color: #999; +} + +.editormd-preview-active { + width: 100%; + padding: 40px; +} + +/* Preview dark theme */ +.editormd-preview-theme-dark { + color: #777; + background: #2C2827; +} +.editormd-preview-theme-dark .editormd-preview-container { + color: #888; + background-color: #2C2827; +} +.editormd-preview-theme-dark .editormd-preview-container pre.prettyprint { + border: none; +} +.editormd-preview-theme-dark .editormd-preview-container blockquote { + color: #555; + padding: 0.5em; + background: #222; + border-color: #333; +} +.editormd-preview-theme-dark .editormd-preview-container abbr { + color: #fff; + padding: 1px 3px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; + background: #ff9900; +} +.editormd-preview-theme-dark .editormd-preview-container code { + color: #fff; + border: none; + padding: 1px 3px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; + background: #5A9600; +} +.editormd-preview-theme-dark .editormd-preview-container table { + border: none; +} +.editormd-preview-theme-dark .editormd-preview-container .fa-emoji { + color: #B4BF42; +} +.editormd-preview-theme-dark .editormd-preview-container .katex { + color: #FEC93F; +} +.editormd-preview-theme-dark .editormd-toc-menu > .markdown-toc { + background: #fff; + border: none; +} +.editormd-preview-theme-dark .editormd-toc-menu > .markdown-toc h1 { + border-color: #ddd; +} +.editormd-preview-theme-dark .markdown-body h1, .editormd-preview-theme-dark .markdown-body h2, .editormd-preview-theme-dark .markdown-body hr { + border-color: #222; +} +.editormd-preview-theme-dark pre { + color: #999; + background-color: #111; + background-color: rgba(0, 0, 0, 0.4); + /* plain text */ +} +.editormd-preview-theme-dark pre .pln { + color: #999; +} +.editormd-preview-theme-dark li.L1, .editormd-preview-theme-dark li.L3, .editormd-preview-theme-dark li.L5, .editormd-preview-theme-dark li.L7, .editormd-preview-theme-dark li.L9 { + background: none; +} +.editormd-preview-theme-dark [class*=editormd-logo] { + color: #2196F3; +} +.editormd-preview-theme-dark .sequence-diagram text { + fill: #fff; +} +.editormd-preview-theme-dark .sequence-diagram rect, .editormd-preview-theme-dark .sequence-diagram path { + color: #fff; + fill: #64D1CB; + stroke: #64D1CB; +} +.editormd-preview-theme-dark .flowchart rect, .editormd-preview-theme-dark .flowchart path { + stroke: #A6C6FF; +} +.editormd-preview-theme-dark .flowchart rect { + fill: #A6C6FF; +} +.editormd-preview-theme-dark .flowchart text { + fill: #5879B4; +} + +@media screen { + .editormd-preview-theme-dark { + /* string content */ + /* a keyword */ + /* a comment */ + /* a type name */ + /* a literal value */ + /* punctuation, lisp open bracket, lisp close bracket */ + /* a markup tag name */ + /* a markup attribute name */ + /* a markup attribute value */ + /* a declaration; a variable name */ + /* a function name */ + } + .editormd-preview-theme-dark .str { + color: #080; + } + .editormd-preview-theme-dark .kwd { + color: #ff9900; + } + .editormd-preview-theme-dark .com { + color: #444444; + } + .editormd-preview-theme-dark .typ { + color: #606; + } + .editormd-preview-theme-dark .lit { + color: #066; + } + .editormd-preview-theme-dark .pun, .editormd-preview-theme-dark .opn, .editormd-preview-theme-dark .clo { + color: #660; + } + .editormd-preview-theme-dark .tag { + color: #ff9900; + } + .editormd-preview-theme-dark .atn { + color: #6C95F5; + } + .editormd-preview-theme-dark .atv { + color: #080; + } + .editormd-preview-theme-dark .dec, .editormd-preview-theme-dark .var { + color: #008BA7; + } + .editormd-preview-theme-dark .fun { + color: red; + } +} +.editormd-onlyread .editormd-toolbar { + display: none; +} +.editormd-onlyread .CodeMirror { + margin-top: 0; +} +.editormd-onlyread .editormd-preview { + top: 0; +} + +.editormd-fullscreen { + position: fixed; + top: 0; + left: 0; + border: none; + margin: 0 auto; +} + +/* Editor.md Dark theme */ +.editormd-theme-dark { + border-color: #1a1a17; +} +.editormd-theme-dark .editormd-toolbar { + background: #1A1A17; + border-color: #1a1a17; +} +.editormd-theme-dark .editormd-menu > li > a { + color: #777; + border-color: #1a1a17; +} +.editormd-theme-dark .editormd-menu > li > a:hover, .editormd-theme-dark .editormd-menu > li > a.active { + border-color: #333; + background: #333; +} +.editormd-theme-dark .editormd-menu > li.divider { + border-right: 1px solid #111; +} +.editormd-theme-dark .CodeMirror { + border-right: 1px solid rgba(0, 0, 0, 0.1); +} diff --git a/paicoding-ui/src/main/resources/static/editormd/css/editormd.logo.css b/paicoding-ui/src/main/resources/static/editormd/css/editormd.logo.css new file mode 100644 index 000000000..5f901bfa5 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/css/editormd.logo.css @@ -0,0 +1,98 @@ +/* + * Editor.md + * + * @file editormd.logo.css + * @version v1.5.0 + * @description Open source online markdown editor. + * @license MIT License + * @author Pandao + * {@link https://github.com/pandao/editor.md} + * @updateTime 2015-06-09 + */ + +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */ +@font-face { + font-family: 'editormd-logo'; + src: url("../fonts/editormd-logo.eot?-5y8q6h"); + src: url(".../fonts/editormd-logo.eot?#iefix-5y8q6h") format("embedded-opentype"), url("../fonts/editormd-logo.woff?-5y8q6h") format("woff"), url("../fonts/editormd-logo.ttf?-5y8q6h") format("truetype"), url("../fonts/editormd-logo.svg?-5y8q6h#icomoon") format("svg"); + font-weight: normal; + font-style: normal; +} +.editormd-logo, +.editormd-logo-1x, +.editormd-logo-2x, +.editormd-logo-3x, +.editormd-logo-4x, +.editormd-logo-5x, +.editormd-logo-6x, +.editormd-logo-7x, +.editormd-logo-8x { + font-family: 'editormd-logo'; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + font-size: inherit; + line-height: 1; + display: inline-block; + text-rendering: auto; + vertical-align: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.editormd-logo:before, +.editormd-logo-1x:before, +.editormd-logo-2x:before, +.editormd-logo-3x:before, +.editormd-logo-4x:before, +.editormd-logo-5x:before, +.editormd-logo-6x:before, +.editormd-logo-7x:before, +.editormd-logo-8x:before { + content: "\e1987"; + /* + HTML Entity 󡦇 + example: + */ +} + +.editormd-logo-1x { + font-size: 1em; +} + +.editormd-logo-lg { + font-size: 1.2em; +} + +.editormd-logo-2x { + font-size: 2em; +} + +.editormd-logo-3x { + font-size: 3em; +} + +.editormd-logo-4x { + font-size: 4em; +} + +.editormd-logo-5x { + font-size: 5em; +} + +.editormd-logo-6x { + font-size: 6em; +} + +.editormd-logo-7x { + font-size: 7em; +} + +.editormd-logo-8x { + font-size: 8em; +} + +.editormd-logo-color { + color: #2196F3; +} diff --git a/paicoding-ui/src/main/resources/static/editormd/css/editormd.logo.min.css b/paicoding-ui/src/main/resources/static/editormd/css/editormd.logo.min.css new file mode 100644 index 000000000..d1699782e --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/css/editormd.logo.min.css @@ -0,0 +1,2 @@ +/*! Editor.md v1.5.0 | editormd.logo.min.css | Open source online markdown editor. | MIT License | By: Pandao | https://github.com/pandao/editor.md | 2015-06-09 */ +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */@font-face{font-family:editormd-logo;src:url(../fonts/editormd-logo.eot?-5y8q6h);src:url(.../fonts/editormd-logo.eot?#iefix-5y8q6h)format("embedded-opentype"),url(../fonts/editormd-logo.woff?-5y8q6h)format("woff"),url(../fonts/editormd-logo.ttf?-5y8q6h)format("truetype"),url(../fonts/editormd-logo.svg?-5y8q6h#icomoon)format("svg");font-weight:400;font-style:normal}.editormd-logo,.editormd-logo-1x,.editormd-logo-2x,.editormd-logo-3x,.editormd-logo-4x,.editormd-logo-5x,.editormd-logo-6x,.editormd-logo-7x,.editormd-logo-8x{font-family:editormd-logo;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;font-size:inherit;line-height:1;display:inline-block;text-rendering:auto;vertical-align:inherit;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.editormd-logo-1x:before,.editormd-logo-2x:before,.editormd-logo-3x:before,.editormd-logo-4x:before,.editormd-logo-5x:before,.editormd-logo-6x:before,.editormd-logo-7x:before,.editormd-logo-8x:before,.editormd-logo:before{content:"\e1987"}.editormd-logo-1x{font-size:1em}.editormd-logo-lg{font-size:1.2em}.editormd-logo-2x{font-size:2em}.editormd-logo-3x{font-size:3em}.editormd-logo-4x{font-size:4em}.editormd-logo-5x{font-size:5em}.editormd-logo-6x{font-size:6em}.editormd-logo-7x{font-size:7em}.editormd-logo-8x{font-size:8em}.editormd-logo-color{color:#2196F3} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/css/editormd.min.css b/paicoding-ui/src/main/resources/static/editormd/css/editormd.min.css new file mode 100644 index 000000000..05abb501c --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/css/editormd.min.css @@ -0,0 +1,5 @@ +/*! Editor.md v1.5.0 | editormd.min.css | Open source online markdown editor. | MIT License | By: Pandao | https://github.com/pandao/editor.md | 2015-06-09 */ +@charset "UTF-8";/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */.fa-ul,.markdown-body .task-list-item,li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}.editormd-form br,.markdown-body hr:after{clear:both}.editormd{width:90%;height:640px;margin:0 auto 15px;text-align:left;overflow:hidden;position:relative;border:1px solid #ddd;font-family:"Meiryo UI","Microsoft YaHei","Malgun Gothic","Segoe UI","Trebuchet MS",Helvetica,Monaco,monospace,Tahoma,STXihei,"华文细黑",STHeiti,"Helvetica Neue","Droid Sans","wenquanyi micro hei",FreeSans,Arimo,Arial,SimSun,"宋体",Heiti,"黑体",sans-serif}.editormd *,.editormd :after,.editormd :before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.editormd a{text-decoration:none}.editormd img{border:none;vertical-align:middle}.editormd .editormd-html-textarea,.editormd .editormd-markdown-textarea,.editormd>textarea{width:0;height:0;outline:0;resize:none}.editormd .editormd-html-textarea,.editormd .editormd-markdown-textarea{display:none}.editormd button,.editormd input[type=text],.editormd input[type=button],.editormd input[type=submit],.editormd select,.editormd textarea{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;appearance:none}.editormd ::-webkit-scrollbar{height:10px;width:7px;background:rgba(0,0,0,.1)}.editormd ::-webkit-scrollbar:hover{background:rgba(0,0,0,.2)}.editormd ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.3);-webkit-border-radius:6px;-moz-border-radius:6px;-ms-border-radius:6px;-o-border-radius:6px;border-radius:6px}.editormd ::-webkit-scrollbar-thumb:hover{-webkit-box-shadow:inset 1px 1px 1px rgba(0,0,0,.25);-moz-box-shadow:inset 1px 1px 1px rgba(0,0,0,.25);-ms-box-shadow:inset 1px 1px 1px rgba(0,0,0,.25);-o-box-shadow:inset 1px 1px 1px rgba(0,0,0,.25);box-shadow:inset 1px 1px 1px rgba(0,0,0,.25);background-color:rgba(0,0,0,.4)}.editormd-user-unselect{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.editormd-toolbar{width:100%;min-height:37px;background:#fff;display:none;position:absolute;top:0;left:0;z-index:10;border-bottom:1px solid #ddd}.editormd-toolbar-container{padding:0 8px;min-height:35px;-o-user-select:none;user-select:none}.editormd-toolbar-container,.markdown-body .octicon{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.editormd-menu,.markdown-body ol,.markdown-body td,.markdown-body th,.markdown-body ul{padding:0}.editormd-menu{margin:0;list-style:none}.editormd-menu>li{margin:0;padding:5px 1px;display:inline-block;position:relative}.editormd-menu>li.divider{display:inline-block;text-indent:-9999px;margin:0 5px;height:65%;border-right:1px solid #ddd}.editormd-menu>li>a{outline:0;color:#666;display:inline-block;min-width:24px;font-size:16px;text-decoration:none;text-align:center;-webkit-border-radius:2px;-moz-border-radius:2px;-ms-border-radius:2px;-o-border-radius:2px;border-radius:2px;border:1px solid #fff;transition:all 300ms ease-out}.editormd-dropdown-menu>li>a:hover,.editormd-menu>li>a{-webkit-transition:all 300ms ease-out;-moz-transition:all 300ms ease-out}.editormd-menu>li>a.active,.editormd-menu>li>a:hover{border:1px solid #ddd;background:#eee}.editormd-menu>li>a>.fa{text-align:center;display:block;padding:5px}.editormd-menu>li>a>.editormd-bold{padding:5px 2px;display:inline-block;font-weight:700}.editormd-menu>li:hover .editormd-dropdown-menu{display:block}.editormd-menu>li+li>a{margin-left:3px}.editormd-dropdown-menu{display:none;background:#fff;border:1px solid #ddd;width:148px;list-style:none;position:absolute;top:33px;left:0;z-index:100;-webkit-box-shadow:1px 2px 6px rgba(0,0,0,.15);-moz-box-shadow:1px 2px 6px rgba(0,0,0,.15);-ms-box-shadow:1px 2px 6px rgba(0,0,0,.15);-o-box-shadow:1px 2px 6px rgba(0,0,0,.15);box-shadow:1px 2px 6px rgba(0,0,0,.15)}.editormd-dropdown-menu:after,.editormd-dropdown-menu:before{width:0;height:0;display:block;content:"";position:absolute;top:-11px;left:8px;border:5px solid transparent}.editormd-dropdown-menu:before{border-bottom-color:#ccc}.editormd-dropdown-menu:after{border-bottom-color:#fff;top:-10px}.editormd-dropdown-menu>li>a{color:#666;display:block;text-decoration:none;padding:8px 10px}.editormd-dropdown-menu>li>a:hover{background:#f6f6f6;transition:all 300ms ease-out}.editormd-dropdown-menu>li+li{border-top:1px solid #ddd}.editormd-container{margin:0;width:100%;height:100%;overflow:hidden;padding:35px 0 0;position:relative;background:#fff;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.editormd-dialog{color:#666;position:fixed;z-index:99999;display:none;-webkit-border-radius:3px;-moz-border-radius:3px;-ms-border-radius:3px;-o-border-radius:3px;border-radius:3px;-webkit-box-shadow:0 0 10px rgba(0,0,0,.3);-moz-box-shadow:0 0 10px rgba(0,0,0,.3);-ms-box-shadow:0 0 10px rgba(0,0,0,.3);-o-box-shadow:0 0 10px rgba(0,0,0,.3);box-shadow:0 0 10px rgba(0,0,0,.3);background:#fff;font-size:14px}.editormd-dialog-container{position:relative;padding:20px;line-height:1.4}.editormd-dialog-container h1{font-size:24px;margin-bottom:10px}.editormd-dialog-container h1 .fa{color:#2C7EEA;padding-right:5px}.editormd-dialog-container h1 small{padding-left:5px;font-weight:400;font-size:12px;color:#999}.editormd-dialog-container select{color:#999;padding:3px 8px;border:1px solid #ddd}.editormd-dialog-close{position:absolute;top:12px;right:15px;font-size:18px;color:#ccc;-webkit-transition:color 300ms ease-out;-moz-transition:color 300ms ease-out;transition:color 300ms ease-out}.editormd-dialog-close:hover{color:#999}.editormd-dialog-header{padding:11px 20px;border-bottom:1px solid #eee;-webkit-transition:background 300ms ease-out;-moz-transition:background 300ms ease-out;transition:background 300ms ease-out}.editormd-dialog-header:hover{background:#f6f6f6}.editormd-dialog-title{font-size:14px}.editormd-dialog-footer{padding:10px 0 0;text-align:right}.editormd-dialog-info{width:420px}.editormd-dialog-info h1{font-weight:400}.editormd-dialog-info .editormd-dialog-container{padding:20px 25px 25px}.editormd-dialog-info .editormd-dialog-close{top:10px;right:10px}.editormd-dialog-info .hover-link:hover,.editormd-dialog-info p>a{color:#2196F3}.editormd-dialog-info .hover-link{color:#666}.editormd-dialog-info a .fa-external-link{display:none}.editormd-dialog-info a:hover{color:#2196F3}.editormd-dialog-info a:hover .fa-external-link{display:inline-block}.editormd-container-mask,.editormd-dialog-mask,.editormd-mask{display:none;width:100%;height:100%;position:absolute;top:0;left:0}.editormd-dialog-mask-bg,.editormd-mask{background:#fff;opacity:.5;filter:alpha(opacity=50)}.editormd-mask{position:fixed;background:#000;opacity:.2;filter:alpha(opacity=20);z-index:99998}.editormd-container-mask,.editormd-dialog-mask-con{background:url(../images/loading.gif)center center no-repeat;-webkit-background-size:32px 32px;-moz-background-size:32px 32px;-o-background-size:32px 32px;background-size:32px 32px}.editormd-container-mask{z-index:20;display:block;background-color:#fff}@media only screen and (-webkit-min-device-pixel-ratio:2),only screen and (min-device-pixel-ratio:2){.editormd-container-mask,.editormd-dialog-mask-con{background-image:url(../images/loading@2x.gif)}}@media only screen and (-webkit-min-device-pixel-ratio:3),only screen and (min-device-pixel-ratio:3){.editormd-container-mask,.editormd-dialog-mask-con{background-image:url(../images/loading@3x.gif)}}.editormd-code-block-dialog textarea,.editormd-preformatted-text-dialog textarea{width:100%;height:400px;margin-bottom:6px;overflow:auto;border:1px solid #eee;background:#fff;padding:15px;resize:none}.editormd-code-toolbar{color:#999;font-size:14px;margin:-5px 0 10px}.editormd-grid-table{width:99%;display:table;border:1px solid #ddd;border-collapse:collapse}.editormd-grid-table-row{width:100%;display:table-row}.editormd-grid-table-row a{font-size:1.4em;width:5%;height:36px;color:#999;text-align:center;display:table-cell;vertical-align:middle;border:1px solid #ddd;text-decoration:none;-webkit-transition:background-color 300ms ease-out,color 100ms ease-in;-moz-transition:background-color 300ms ease-out,color 100ms ease-in;transition:background-color 300ms ease-out,color 100ms ease-in}.editormd-grid-table-row a.selected{color:#666;background-color:#eee}.editormd-grid-table-row a:hover{color:#777;background-color:#f6f6f6}.editormd-tab-head{list-style:none;border-bottom:1px solid #ddd}.editormd-tab-head li{display:inline-block}.editormd-tab-head li a{color:#999;display:block;padding:6px 12px 5px;text-align:center;text-decoration:none;margin-bottom:-1px;border:1px solid #ddd;-webkit-border-top-left-radius:3px;-moz-border-top-left-radius:3px;-ms-border-top-left-radius:3px;-o-border-top-left-radius:3px;border-top-left-radius:3px;-webkit-border-top-right-radius:3px;-moz-border-top-right-radius:3px;-ms-border-top-right-radius:3px;-o-border-top-right-radius:3px;border-top-right-radius:3px;background:#f6f6f6;-webkit-transition:all 300ms ease-out;-moz-transition:all 300ms ease-out;transition:all 300ms ease-out}.editormd-tab-head li a:hover{color:#666;background:#eee}.editormd-tab-head li.active a{color:#666;background:#fff;border-bottom-color:#fff}.editormd-tab-head li+li{margin-left:3px}.editormd-tab-box{padding:20px 0}.editormd-form{color:#666}.editormd-form label{float:left;display:block;width:75px;text-align:left;padding:7px 0 15px 5px;margin:0 0 2px;font-weight:400}.editormd-form iframe{display:none}.editormd-form input:focus{outline:0}.editormd-form input[type=text],.editormd-form input[type=number]{color:#999;padding:8px;border:1px solid #ddd}.editormd-form input[type=number]{width:40px;display:inline-block;padding:6px 8px}.editormd-form input[type=text]{display:inline-block;width:264px}.editormd-form .fa-btns{display:inline-block}.editormd-form .fa-btns a{color:#999;padding:7px 10px 0 0;display:inline-block;text-decoration:none;text-align:center}.editormd-form .fa-btns .fa{font-size:1.3em}.editormd-form .fa-btns label{float:none;display:inline-block;width:auto;text-align:left;padding:0 0 0 5px;cursor:pointer}.fa-fw,.fa-li{text-align:center}.editormd-dialog-container .editormd-btn,.editormd-dialog-container button,.editormd-dialog-container input[type=submit],.editormd-dialog-footer .editormd-btn,.editormd-dialog-footer button,.editormd-dialog-footer input[type=submit],.editormd-form .editormd-btn,.editormd-form button,.editormd-form input[type=submit]{color:#666;min-width:75px;cursor:pointer;background:#fff;padding:7px 10px;border:1px solid #ddd;-webkit-border-radius:3px;-moz-border-radius:3px;-ms-border-radius:3px;-o-border-radius:3px;border-radius:3px;-webkit-transition:background 300ms ease-out;-moz-transition:background 300ms ease-out;transition:background 300ms ease-out}.editormd-dialog-container .editormd-btn:hover,.editormd-dialog-container button:hover,.editormd-dialog-container input[type=submit]:hover,.editormd-dialog-footer .editormd-btn:hover,.editormd-dialog-footer button:hover,.editormd-dialog-footer input[type=submit]:hover,.editormd-form .editormd-btn:hover,.editormd-form button:hover,.editormd-form input[type=submit]:hover{background:#eee}.editormd-dialog-container .editormd-btn+.editormd-btn,.editormd-dialog-footer .editormd-btn+.editormd-btn,.editormd-form .editormd-btn+.editormd-btn{margin-left:8px}.editormd-file-input{width:75px;height:32px;margin-left:8px;position:relative;display:inline-block}.editormd-file-input input[type=file]{width:75px;height:32px;opacity:0;cursor:pointer;background:#000;display:inline-block;position:absolute;top:0;right:0}.editormd-file-input input[type=file]::-webkit-file-upload-button{visibility:hidden}.editormd-file-input:hover input[type=submit]{background:#eee}.editormd .CodeMirror,.editormd-preview{display:inline-block;width:50%;height:100%;vertical-align:top;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;margin:0}.editormd-preview{position:absolute;top:35px;right:0;overflow:auto;line-height:1.6;display:none;background:#fff}.fa,.fa-stack{display:inline-block}.editormd .CodeMirror{z-index:10;float:left;border-right:1px solid #ddd;font-size:14px;font-family:"YaHei Consolas Hybrid",Consolas,"微软雅黑","Meiryo UI","Malgun Gothic","Segoe UI","Trebuchet MS",Helvetica,Monaco,courier,monospace;line-height:1.6;margin-top:35px}.editormd .CodeMirror pre{font-size:14px;padding:0 12px}.editormd .CodeMirror-linenumbers{padding:0 5px}.editormd .CodeMirror-focused .CodeMirror-selected,.editormd .CodeMirror-selected{background:#70B7FF}.editormd .CodeMirror,.editormd .CodeMirror-scroll,.editormd .editormd-preview{-webkit-overflow-scrolling:touch}.editormd .styled-background{background-color:#ff7}.editormd .CodeMirror-focused .cm-matchhighlight{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVQI12NgYGBgkKzc8x9CMDAwAAAmhwSbidEoSQAAAABJRU5ErkJggg==);background-position:bottom;background-repeat:repeat-x}.editormd .CodeMirror-empty.CodeMirror-focused{outline:0}.editormd .CodeMirror pre.CodeMirror-placeholder{color:#999}.editormd .cm-trailingspace{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg==);background-position:bottom left;background-repeat:repeat-x}.editormd .cm-tab{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAYAAAAkuj5RAAAAAXNSR0IArs4c6QAAAGFJREFUSMft1LsRQFAQheHPowAKoACx3IgEKtaEHujDjORSgWTH/ZOdnZOcM/sgk/kFFWY0qV8foQwS4MKBCS3qR6ixBJvElOobYAtivseIE120FaowJPN75GMu8j/LfMwNjh4HUpwg4LUAAAAASUVORK5CYII=)right no-repeat}/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 *//*! + * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:FontAwesome;src:url(../fonts/fontawesome-webfont.eot?v=4.3.0);src:url(../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0)format("embedded-opentype"),url(../fonts/fontawesome-webfont.woff2?v=4.3.0)format("woff2"),url(../fonts/fontawesome-webfont.woff?v=4.3.0)format("woff"),url(../fonts/fontawesome-webfont.ttf?v=4.3.0)format("truetype"),url(../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular)format("svg");font-weight:400;font-style:normal}.fa{font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transform:translate(0,0)}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em}.fa-ul{padding-left:0;margin-left:2.14285714em}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1,-1);-ms-transform:scale(1,-1);transform:scale(1,-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{filter:none}.fa-stack{position:relative;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-close:before,.fa-remove:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-repeat:before,.fa-rotate-right:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-exclamation-triangle:before,.fa-warning:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-floppy-o:before,.fa-save:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-bolt:before,.fa-flash:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-chain-broken:before,.fa-unlink:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:"\f150"}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:"\f151"}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:"\f152"}.fa-eur:before,.fa-euro:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-inr:before,.fa-rupee:before{content:"\f156"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:"\f158"}.fa-krw:before,.fa-won:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-try:before,.fa-turkish-lira:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-bank:before,.fa-institution:before,.fa-university:before{content:"\f19c"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:"\f1c5"}.fa-file-archive-o:before,.fa-file-zip-o:before{content:"\f1c6"}.fa-file-audio-o:before,.fa-file-sound-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-empire:before,.fa-ge:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-paper-plane:before,.fa-send:before{content:"\f1d8"}.fa-paper-plane-o:before,.fa-send-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before,.fa-genderless:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-bed:before,.fa-hotel:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */@font-face{font-family:editormd-logo;src:url(../fonts/editormd-logo.eot?-5y8q6h);src:url(.../fonts/editormd-logo.eot?#iefix-5y8q6h)format("embedded-opentype"),url(../fonts/editormd-logo.woff?-5y8q6h)format("woff"),url(../fonts/editormd-logo.ttf?-5y8q6h)format("truetype"),url(../fonts/editormd-logo.svg?-5y8q6h#icomoon)format("svg");font-weight:400;font-style:normal}.editormd-logo,.editormd-logo-1x,.editormd-logo-2x,.editormd-logo-3x,.editormd-logo-4x,.editormd-logo-5x,.editormd-logo-6x,.editormd-logo-7x,.editormd-logo-8x{font-family:editormd-logo;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;font-size:inherit;line-height:1;display:inline-block;text-rendering:auto;vertical-align:inherit;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.markdown-body hr:after,.markdown-body hr:before{content:"";display:table}.editormd-logo-1x:before,.editormd-logo-2x:before,.editormd-logo-3x:before,.editormd-logo-4x:before,.editormd-logo-5x:before,.editormd-logo-6x:before,.editormd-logo-7x:before,.editormd-logo-8x:before,.editormd-logo:before{content:"\e1987"}.editormd-logo-1x{font-size:1em}.editormd-logo-lg{font-size:1.2em}.editormd-logo-2x{font-size:2em}.editormd-logo-3x{font-size:3em}.editormd-logo-4x{font-size:4em}.editormd-logo-5x{font-size:5em}.editormd-logo-6x{font-size:6em}.editormd-logo-7x{font-size:7em}.editormd-logo-8x{font-size:8em}.editormd-logo-color{color:#2196F3}/*! github-markdown-css | The MIT License (MIT) | Copyright (c) Sindre Sorhus (sindresorhus.com) | https://github.com/sindresorhus/github-markdown-css */@font-face{font-family:octicons-anchor;src:url(data:font/woff;charset=utf-8;base64,d09GRgABAAAAAAYcAA0AAAAACjQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABMAAAABwAAAAca8vGTk9TLzIAAAFMAAAARAAAAFZG1VHVY21hcAAAAZAAAAA+AAABQgAP9AdjdnQgAAAB0AAAAAQAAAAEACICiGdhc3AAAAHUAAAACAAAAAj//wADZ2x5ZgAAAdwAAADRAAABEKyikaNoZWFkAAACsAAAAC0AAAA2AtXoA2hoZWEAAALgAAAAHAAAACQHngNFaG10eAAAAvwAAAAQAAAAEAwAACJsb2NhAAADDAAAAAoAAAAKALIAVG1heHAAAAMYAAAAHwAAACABEAB2bmFtZQAAAzgAAALBAAAFu3I9x/Nwb3N0AAAF/AAAAB0AAAAvaoFvbwAAAAEAAAAAzBdyYwAAAADP2IQvAAAAAM/bz7t4nGNgZGFgnMDAysDB1Ml0hoGBoR9CM75mMGLkYGBgYmBlZsAKAtJcUxgcPsR8iGF2+O/AEMPsznAYKMwIkgMA5REMOXicY2BgYGaAYBkGRgYQsAHyGMF8FgYFIM0ChED+h5j//yEk/3KoSgZGNgYYk4GRCUgwMaACRoZhDwCs7QgGAAAAIgKIAAAAAf//AAJ4nHWMMQrCQBBF/0zWrCCIKUQsTDCL2EXMohYGSSmorScInsRGL2DOYJe0Ntp7BK+gJ1BxF1stZvjz/v8DRghQzEc4kIgKwiAppcA9LtzKLSkdNhKFY3HF4lK69ExKslx7Xa+vPRVS43G98vG1DnkDMIBUgFN0MDXflU8tbaZOUkXUH0+U27RoRpOIyCKjbMCVejwypzJJG4jIwb43rfl6wbwanocrJm9XFYfskuVC5K/TPyczNU7b84CXcbxks1Un6H6tLH9vf2LRnn8Ax7A5WQAAAHicY2BkYGAA4teL1+yI57f5ysDNwgAC529f0kOmWRiYVgEpDgYmEA8AUzEKsQAAAHicY2BkYGB2+O/AEMPCAAJAkpEBFbAAADgKAe0EAAAiAAAAAAQAAAAEAAAAAAAAKgAqACoAiAAAeJxjYGRgYGBhsGFgYgABEMkFhAwM/xn0QAIAD6YBhwB4nI1Ty07cMBS9QwKlQapQW3VXySvEqDCZGbGaHULiIQ1FKgjWMxknMfLEke2A+IJu+wntrt/QbVf9gG75jK577Lg8K1qQPCfnnnt8fX1NRC/pmjrk/zprC+8D7tBy9DHgBXoWfQ44Av8t4Bj4Z8CLtBL9CniJluPXASf0Lm4CXqFX8Q84dOLnMB17N4c7tBo1AS/Qi+hTwBH4rwHHwN8DXqQ30XXAS7QaLwSc0Gn8NuAVWou/gFmnjLrEaEh9GmDdDGgL3B4JsrRPDU2hTOiMSuJUIdKQQayiAth69r6akSSFqIJuA19TrzCIaY8sIoxyrNIrL//pw7A2iMygkX5vDj+G+kuoLdX4GlGK/8Lnlz6/h9MpmoO9rafrz7ILXEHHaAx95s9lsI7AHNMBWEZHULnfAXwG9/ZqdzLI08iuwRloXE8kfhXYAvE23+23DU3t626rbs8/8adv+9DWknsHp3E17oCf+Z48rvEQNZ78paYM38qfk3v/u3l3u3GXN2Dmvmvpf1Srwk3pB/VSsp512bA/GG5i2WJ7wu430yQ5K3nFGiOqgtmSB5pJVSizwaacmUZzZhXLlZTq8qGGFY2YcSkqbth6aW1tRmlaCFs2016m5qn36SbJrqosG4uMV4aP2PHBmB3tjtmgN2izkGQyLWprekbIntJFing32a5rKWCN/SdSoga45EJykyQ7asZvHQ8PTm6cslIpwyeyjbVltNikc2HTR7YKh9LBl9DADC0U/jLcBZDKrMhUBfQBvXRzLtFtjU9eNHKin0x5InTqb8lNpfKv1s1xHzTXRqgKzek/mb7nB8RZTCDhGEX3kK/8Q75AmUM/eLkfA+0Hi908Kx4eNsMgudg5GLdRD7a84npi+YxNr5i5KIbW5izXas7cHXIMAau1OueZhfj+cOcP3P8MNIWLyYOBuxL6DRylJ4cAAAB4nGNgYoAALjDJyIAOWMCiTIxMLDmZedkABtIBygAAAA==)format("woff")}.markdown-body{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;color:#333;overflow:hidden;font-family:"Microsoft YaHei",Helvetica,"Meiryo UI","Malgun Gothic","Segoe UI","Trebuchet MS",Monaco,monospace,Tahoma,STXihei,"华文细黑",STHeiti,"Helvetica Neue","Droid Sans","wenquanyi micro hei",FreeSans,Arimo,Arial,SimSun,"宋体",Heiti,"黑体",sans-serif;font-size:16px;line-height:1.6;word-wrap:break-word}.markdown-body strong{font-weight:700}.markdown-body h1{margin:.67em 0}.markdown-body img{border:0}.markdown-body hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}.markdown-body input{color:inherit;margin:0;line-height:normal;font:13px/1.4 Helvetica,arial,freesans,clean,sans-serif,"Segoe UI Emoji","Segoe UI Symbol"}.markdown-body html input[disabled]{cursor:default}.markdown-body input[type=checkbox]{-moz-box-sizing:border-box;box-sizing:border-box;padding:0}.markdown-body *{-moz-box-sizing:border-box;box-sizing:border-box}.markdown-body a{background:0 0;color:#4183c4;text-decoration:none}.markdown-body a:active,.markdown-body a:hover{outline:0;text-decoration:underline}.markdown-body hr{margin:15px 0;overflow:hidden;background:0 0;border:0;border-bottom:1px solid #ddd}.markdown-body h1,.markdown-body h2{padding-bottom:.3em;border-bottom:1px solid #eee}.markdown-body blockquote{margin:0}.markdown-body ol ol,.markdown-body ul ol{list-style-type:lower-roman}.markdown-body ol ol ol,.markdown-body ol ul ol,.markdown-body ul ol ol,.markdown-body ul ul ol{list-style-type:lower-alpha}.markdown-body dd{margin-left:0}.markdown-body code{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace}.markdown-body pre{font:12px Consolas,"Liberation Mono",Menlo,Courier,monospace;word-wrap:normal}.markdown-body .octicon{font:normal normal 16px octicons-anchor;line-height:1;display:inline-block;text-decoration:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;user-select:none}.markdown-body .octicon-link:before{content:'\f05c'}.markdown-body>:first-child{margin-top:0!important}.markdown-body>:last-child{margin-bottom:0!important}.markdown-body .anchor{position:absolute;top:0;left:0;display:block;padding-right:6px;padding-left:30px;margin-left:-30px}.markdown-body .anchor:focus{outline:0}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{position:relative;margin-top:1em;margin-bottom:16px;font-weight:700;line-height:1.4}.markdown-body h1 .octicon-link,.markdown-body h2 .octicon-link,.markdown-body h3 .octicon-link,.markdown-body h4 .octicon-link,.markdown-body h5 .octicon-link,.markdown-body h6 .octicon-link{display:none;color:#000;vertical-align:middle}.markdown-body h1:hover .anchor,.markdown-body h2:hover .anchor,.markdown-body h3:hover .anchor,.markdown-body h4:hover .anchor,.markdown-body h5:hover .anchor,.markdown-body h6:hover .anchor{padding-left:8px;margin-left:-30px;text-decoration:none}.markdown-body h1:hover .anchor .octicon-link,.markdown-body h2:hover .anchor .octicon-link,.markdown-body h3:hover .anchor .octicon-link,.markdown-body h4:hover .anchor .octicon-link,.markdown-body h5:hover .anchor .octicon-link,.markdown-body h6:hover .anchor .octicon-link{display:inline-block}.markdown-body h1{font-size:2.25em;line-height:1.2}.markdown-body h1 .anchor{line-height:1}.markdown-body h2{font-size:1.75em;line-height:1.225}.markdown-body h2 .anchor{line-height:1}.markdown-body h3{font-size:1.5em;line-height:1.43}.markdown-body h3 .anchor,.markdown-body h4 .anchor{line-height:1.2}.markdown-body h4{font-size:1.25em}.markdown-body h5 .anchor,.markdown-body h6 .anchor{line-height:1.1}.markdown-body h5{font-size:1em}.markdown-body h6{font-size:1em;color:#777}.markdown-body blockquote,.markdown-body dl,.markdown-body ol,.markdown-body p,.markdown-body pre,.markdown-body table,.markdown-body ul{margin-top:0;margin-bottom:16px}.markdown-body ol,.markdown-body ul{padding-left:2em}.markdown-body ol ol,.markdown-body ol ul,.markdown-body ul ol,.markdown-body ul ul{margin-top:0;margin-bottom:0}.markdown-body li>p{margin-top:16px}.markdown-body dl{padding:0}.markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:700}.markdown-body dl dd{padding:0 16px;margin-bottom:16px}.markdown-body blockquote{padding:0 15px;color:#777;border-left:4px solid #ddd}.markdown-body blockquote>:first-child{margin-top:0}.markdown-body blockquote>:last-child{margin-bottom:0}.markdown-body table{border-collapse:collapse;border-spacing:0;display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all}.markdown-body table th{font-weight:700}.markdown-body table td,.markdown-body table th{padding:6px 13px;border:1px solid #ddd}.markdown-body table tr{background-color:#fff;border-top:1px solid #ccc}.markdown-body table tr:nth-child(2n){background-color:#f8f8f8}.markdown-body img{max-width:100%;-moz-box-sizing:border-box;box-sizing:border-box}.markdown-body code{padding:.2em 0;margin:0;font-size:85%;background-color:rgba(0,0,0,.04);border-radius:3px}.markdown-body code:after,.markdown-body code:before{letter-spacing:-.2em;content:"\00a0"}.markdown-body pre>code{padding:0;margin:0;font-size:100%;word-break:normal;white-space:pre;background:0 0;border:0}.markdown-body .highlight{margin-bottom:16px}.markdown-body .highlight pre,.markdown-body pre{padding:16px;overflow:auto;font-size:85%;background-color:#f7f7f7;border-radius:3px}.markdown-body .highlight pre{margin-bottom:0;word-break:normal}.markdown-body pre code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;word-wrap:normal;background-color:transparent;border:0}.markdown-body pre code:after,.markdown-body pre code:before{content:normal}.markdown-body .pl-c{color:#969896}.markdown-body .pl-c1,.markdown-body .pl-mdh,.markdown-body .pl-mm,.markdown-body .pl-mp,.markdown-body .pl-mr,.markdown-body .pl-s1 .pl-v,.markdown-body .pl-s3,.markdown-body .pl-sc,.markdown-body .pl-sv{color:#0086b3}.markdown-body .pl-e,.markdown-body .pl-en{color:#795da3}.markdown-body .pl-s1 .pl-s2,.markdown-body .pl-smi,.markdown-body .pl-smp,.markdown-body .pl-stj,.markdown-body .pl-vo,.markdown-body .pl-vpf{color:#333}.markdown-body .pl-ent{color:#63a35c}.markdown-body .pl-k,.markdown-body .pl-s,.markdown-body .pl-st{color:#a71d5d}.markdown-body .pl-pds,.markdown-body .pl-s1,.markdown-body .pl-s1 .pl-pse .pl-s2,.markdown-body .pl-sr,.markdown-body .pl-sr .pl-cce,.markdown-body .pl-sr .pl-sra,.markdown-body .pl-sr .pl-sre,.markdown-body .pl-src{color:#df5000}.markdown-body .pl-mo,.markdown-body .pl-v{color:#1d3e81}.markdown-body .pl-id{color:#b52a1d}.markdown-body .pl-ii{background-color:#b52a1d;color:#f8f8f8}.markdown-body .pl-sr .pl-cce{color:#63a35c;font-weight:700}.markdown-body .pl-ml{color:#693a17}.markdown-body .pl-mh,.markdown-body .pl-mh .pl-en,.markdown-body .pl-ms{color:#1d3e81;font-weight:700}.markdown-body .pl-mq{color:teal}.markdown-body .pl-mi{color:#333;font-style:italic}.markdown-body .pl-mb{color:#333;font-weight:700}.markdown-body .pl-md,.markdown-body .pl-mdhf{background-color:#ffecec;color:#bd2c00}.markdown-body .pl-mdht,.markdown-body .pl-mi1{background-color:#eaffea;color:#55a532}.markdown-body .pl-mdr{color:#795da3;font-weight:700}.markdown-body kbd{display:inline-block;padding:3px 5px;font:11px Consolas,"Liberation Mono",Menlo,Courier,monospace;line-height:10px;color:#555;vertical-align:middle;background-color:#fcfcfc;border:1px solid #ccc;border-bottom-color:#bbb;border-radius:3px;box-shadow:inset 0 -1px 0 #bbb}.markdown-body .task-list-item+.task-list-item{margin-top:3px}.markdown-body .task-list-item input{float:left;margin:.3em 0 .25em -1.6em;vertical-align:middle}.markdown-body :checked+.radio-label{z-index:1;position:relative;border-color:#4183c4}.editormd-html-preview,.editormd-preview-container{text-align:left;font-size:14px;line-height:1.6;padding:20px;overflow:auto;width:100%;background-color:#fff}.editormd-html-preview blockquote,.editormd-preview-container blockquote{color:#666;border-left:4px solid #ddd;padding-left:20px;margin-left:0;font-size:14px;font-style:italic}.editormd-html-preview p code,.editormd-preview-container p code{margin-left:5px;margin-right:4px}.editormd-html-preview abbr,.editormd-preview-container abbr{background:#ffd}.editormd-html-preview hr,.editormd-preview-container hr{height:1px;border:none;border-top:1px solid #ddd;background:0 0}.editormd-html-preview code,.editormd-preview-container code{border:1px solid #ddd;background:#f6f6f6;padding:3px;border-radius:3px;font-size:14px}.editormd-html-preview pre,.editormd-preview-container pre{border:1px solid #ddd;background:#f6f6f6;padding:10px;-webkit-border-radius:3px;-moz-border-radius:3px;-ms-border-radius:3px;-o-border-radius:3px;border-radius:3px}.editormd-html-preview pre code,.editormd-preview-container pre code{padding:0}.editormd-html-preview code,.editormd-html-preview kbd,.editormd-html-preview pre,.editormd-preview-container code,.editormd-preview-container kbd,.editormd-preview-container pre{font-family:"YaHei Consolas Hybrid",Consolas,"Meiryo UI","Malgun Gothic","Segoe UI","Trebuchet MS",Helvetica,monospace,monospace}.editormd-html-preview table thead tr,.editormd-preview-container table thead tr{background-color:#F8F8F8}.editormd-html-preview p.editormd-tex,.editormd-preview-container p.editormd-tex{text-align:center}.editormd-html-preview span.editormd-tex,.editormd-preview-container span.editormd-tex{margin:0 5px}.editormd-html-preview .emoji,.editormd-preview-container .emoji{width:24px;height:24px}.editormd-html-preview .katex,.editormd-preview-container .katex{font-size:1.4em}.editormd-html-preview .flowchart,.editormd-html-preview .sequence-diagram,.editormd-preview-container .flowchart,.editormd-preview-container .sequence-diagram{margin:0 auto;text-align:center}.editormd-html-preview .flowchart svg,.editormd-html-preview .sequence-diagram svg,.editormd-preview-container .flowchart svg,.editormd-preview-container .sequence-diagram svg{margin:0 auto}.editormd-html-preview .flowchart text,.editormd-html-preview .sequence-diagram text,.editormd-preview-container .flowchart text,.editormd-preview-container .sequence-diagram text{font-size:15px!important;font-family:"YaHei Consolas Hybrid",Consolas,"Microsoft YaHei","Malgun Gothic","Segoe UI",Helvetica,Arial!important}/*! Pretty printing styles. Used with prettify.js. */.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.clo,.opn,.pun{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.kwd,.tag,.typ{font-weight:700}.str{color:#060}.kwd{color:#006}.com{color:#600;font-style:italic}.typ{color:#404}.lit{color:#044}.clo,.opn,.pun{color:#440}.tag{color:#006}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}.editormd-html-preview pre.prettyprint,.editormd-preview-container pre.prettyprint{padding:10px;border:1px solid #ddd;white-space:pre-wrap;word-wrap:break-word}.editormd-html-preview ol.linenums,.editormd-preview-container ol.linenums{color:#999;padding-left:2.5em}.editormd-html-preview ol.linenums li,.editormd-preview-container ol.linenums li{list-style-type:decimal}.editormd-html-preview ol.linenums li code,.editormd-preview-container ol.linenums li code{border:none;background:0 0;padding:0}.editormd-html-preview .editormd-toc-menu,.editormd-preview-container .editormd-toc-menu{margin:8px 0 12px;display:inline-block}.editormd-html-preview .editormd-toc-menu>.markdown-toc,.editormd-preview-container .editormd-toc-menu>.markdown-toc{position:relative;-webkit-border-radius:4px;-moz-border-radius:4px;-ms-border-radius:4px;-o-border-radius:4px;border-radius:4px;border:1px solid #ddd;display:inline-block;font-size:1em}.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul{width:160%;min-width:180px;position:absolute;left:-1px;top:-2px;z-index:100;padding:0 10px 10px;display:none;background:#fff;border:1px solid #ddd;-webkit-border-radius:4px;-moz-border-radius:4px;-ms-border-radius:4px;-o-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 3px 5px rgba(0,0,0,.2);-moz-box-shadow:0 3px 5px rgba(0,0,0,.2);-ms-box-shadow:0 3px 5px rgba(0,0,0,.2);-o-box-shadow:0 3px 5px rgba(0,0,0,.2);box-shadow:0 3px 5px rgba(0,0,0,.2)}.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul>li ul,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul>li ul{width:100%;min-width:180px;border:1px solid #ddd;display:none;background:#fff;-webkit-border-radius:4px;-moz-border-radius:4px;-ms-border-radius:4px;-o-border-radius:4px;border-radius:4px}.editormd-html-preview .editormd-toc-menu .toc-menu-btn:hover,.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul>li a:hover,.editormd-preview-container .editormd-toc-menu .toc-menu-btn:hover,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul>li a:hover{background-color:#f6f6f6}.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul>li a,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul>li a{color:#666;padding:6px 10px;display:block;-webkit-transition:background-color 500ms ease-out;-moz-transition:background-color 500ms ease-out;transition:background-color 500ms ease-out}.editormd-html-preview .editormd-toc-menu>.markdown-toc li,.editormd-preview-container .editormd-toc-menu>.markdown-toc li{position:relative}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul{position:absolute;top:32px;left:10%;display:none;-webkit-box-shadow:0 3px 5px rgba(0,0,0,.2);-moz-box-shadow:0 3px 5px rgba(0,0,0,.2);-ms-box-shadow:0 3px 5px rgba(0,0,0,.2);-o-box-shadow:0 3px 5px rgba(0,0,0,.2);box-shadow:0 3px 5px rgba(0,0,0,.2)}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:after,.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:before,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:after,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:before{pointer-events:pointer-events;position:absolute;left:15px;top:-6px;display:block;content:"";width:0;height:0;border:6px solid transparent;border-width:0 6px 6px;z-index:10}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:before,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:before{border-bottom-color:#ccc}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:after,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:after{border-bottom-color:#fff;top:-5px}.editormd-html-preview .editormd-toc-menu ul,.editormd-preview-container .editormd-toc-menu ul{list-style:none}.editormd-html-preview .editormd-toc-menu a,.editormd-preview-container .editormd-toc-menu a{text-decoration:none}.editormd-html-preview .editormd-toc-menu h1,.editormd-preview-container .editormd-toc-menu h1{font-size:16px;padding:5px 0 10px 10px;line-height:1;border-bottom:1px solid #eee}.editormd-html-preview .editormd-toc-menu h1 .fa,.editormd-preview-container .editormd-toc-menu h1 .fa{padding-left:10px}.editormd-html-preview .editormd-toc-menu .toc-menu-btn,.editormd-preview-container .editormd-toc-menu .toc-menu-btn{color:#666;min-width:180px;padding:5px 10px;border-radius:4px;display:inline-block;-webkit-transition:background-color 500ms ease-out;-moz-transition:background-color 500ms ease-out;transition:background-color 500ms ease-out}.editormd-html-preview textarea,.editormd-onlyread .editormd-toolbar{display:none}.editormd-html-preview .editormd-toc-menu .toc-menu-btn .fa,.editormd-preview-container .editormd-toc-menu .toc-menu-btn .fa{float:right;padding:3px 0 0 10px;font-size:1.3em}.markdown-body .editormd-toc-menu ul{padding-left:0}.markdown-body .highlight pre,.markdown-body pre{line-height:1.6}hr.editormd-page-break{border:1px dotted #ccc;font-size:0;height:2px}@media only print{hr.editormd-page-break{background:0 0;border:none;height:0}}.editormd-html-preview hr.editormd-page-break{background:0 0;border:none;height:0}.editormd-preview-close-btn{color:#fff;padding:4px 6px;font-size:18px;-webkit-border-radius:500px;-moz-border-radius:500px;-ms-border-radius:500px;-o-border-radius:500px;border-radius:500px;display:none;background-color:#ccc;position:absolute;top:25px;right:35px;z-index:19;-webkit-transition:background-color 300ms ease-out;-moz-transition:background-color 300ms ease-out;transition:background-color 300ms ease-out}.editormd-preview-close-btn:hover{background-color:#999}.editormd-preview-active{width:100%;padding:40px}.editormd-preview-theme-dark{color:#777;background:#2C2827}.editormd-preview-theme-dark .editormd-preview-container{color:#888;background-color:#2C2827}.editormd-preview-theme-dark .editormd-preview-container pre.prettyprint{border:none}.editormd-preview-theme-dark .editormd-preview-container blockquote{color:#555;padding:.5em;background:#222;border-color:#333}.editormd-preview-theme-dark .editormd-preview-container abbr{color:#fff;padding:1px 3px;-webkit-border-radius:3px;-moz-border-radius:3px;-ms-border-radius:3px;-o-border-radius:3px;border-radius:3px;background:#f90}.editormd-preview-theme-dark .editormd-preview-container code{color:#fff;border:none;padding:1px 3px;-webkit-border-radius:3px;-moz-border-radius:3px;-ms-border-radius:3px;-o-border-radius:3px;border-radius:3px;background:#5A9600}.editormd-preview-theme-dark .editormd-preview-container table{border:none}.editormd-preview-theme-dark .editormd-preview-container .fa-emoji{color:#B4BF42}.editormd-preview-theme-dark .editormd-preview-container .katex{color:#FEC93F}.editormd-preview-theme-dark .editormd-toc-menu>.markdown-toc{background:#fff;border:none}.editormd-preview-theme-dark .editormd-toc-menu>.markdown-toc h1{border-color:#ddd}.editormd-preview-theme-dark .markdown-body h1,.editormd-preview-theme-dark .markdown-body h2,.editormd-preview-theme-dark .markdown-body hr{border-color:#222}.editormd-preview-theme-dark pre{color:#999;background-color:#111;background-color:rgba(0,0,0,.4)}.editormd-preview-theme-dark pre .pln{color:#999}.editormd-preview-theme-dark li.L1,.editormd-preview-theme-dark li.L3,.editormd-preview-theme-dark li.L5,.editormd-preview-theme-dark li.L7,.editormd-preview-theme-dark li.L9{background:0 0}.editormd-preview-theme-dark [class*=editormd-logo]{color:#2196F3}.editormd-preview-theme-dark .sequence-diagram text{fill:#fff}.editormd-preview-theme-dark .sequence-diagram path,.editormd-preview-theme-dark .sequence-diagram rect{color:#fff;fill:#64D1CB;stroke:#64D1CB}.editormd-preview-theme-dark .flowchart path,.editormd-preview-theme-dark .flowchart rect{stroke:#A6C6FF}.editormd-preview-theme-dark .flowchart rect{fill:#A6C6FF}.editormd-preview-theme-dark .flowchart text{fill:#5879B4}@media screen{.editormd-preview-theme-dark .str{color:#080}.editormd-preview-theme-dark .kwd{color:#f90}.editormd-preview-theme-dark .com{color:#444}.editormd-preview-theme-dark .typ{color:#606}.editormd-preview-theme-dark .lit{color:#066}.editormd-preview-theme-dark .clo,.editormd-preview-theme-dark .opn,.editormd-preview-theme-dark .pun{color:#660}.editormd-preview-theme-dark .tag{color:#f90}.editormd-preview-theme-dark .atn{color:#6C95F5}.editormd-preview-theme-dark .atv{color:#080}.editormd-preview-theme-dark .dec,.editormd-preview-theme-dark .var{color:#008BA7}.editormd-preview-theme-dark .fun{color:red}}.editormd-onlyread .CodeMirror{margin-top:0}.editormd-onlyread .editormd-preview{top:0}.editormd-fullscreen{position:fixed;top:0;left:0;border:none;margin:0 auto}.editormd-theme-dark{border-color:#1a1a17}.editormd-theme-dark .editormd-toolbar{background:#1A1A17;border-color:#1a1a17}.editormd-theme-dark .editormd-menu>li>a{color:#777;border-color:#1a1a17}.editormd-theme-dark .editormd-menu>li>a.active,.editormd-theme-dark .editormd-menu>li>a:hover{border-color:#333;background:#333}.editormd-theme-dark .editormd-menu>li.divider{border-right:1px solid #111}.editormd-theme-dark .CodeMirror{border-right:1px solid rgba(0,0,0,.1)} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/css/editormd.preview.css b/paicoding-ui/src/main/resources/static/editormd/css/editormd.preview.css new file mode 100644 index 000000000..60303304a --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/css/editormd.preview.css @@ -0,0 +1,3554 @@ +/* + * Editor.md + * + * @file editormd.preview.css + * @version v1.5.0 + * @description Open source online markdown editor. + * @license MIT License + * @author Pandao + * {@link https://github.com/pandao/editor.md} + * @updateTime 2015-06-09 + */ + +@charset "UTF-8"; +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */ +/*! + * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */ +/* FONT PATH + * -------------------------- */ +@font-face { + font-family: 'FontAwesome'; + src: url("../fonts/fontawesome-webfont.eot?v=4.3.0"); + src: url("../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0") format("embedded-opentype"), url("../fonts/fontawesome-webfont.woff2?v=4.3.0") format("woff2"), url("../fonts/fontawesome-webfont.woff?v=4.3.0") format("woff"), url("../fonts/fontawesome-webfont.ttf?v=4.3.0") format("truetype"), url("../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular") format("svg"); + font-weight: normal; + font-style: normal; +} +.fa { + display: inline-block; + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transform: translate(0, 0); +} + +/* makes the font 33% larger relative to the icon container */ +.fa-lg { + font-size: 1.33333333em; + line-height: 0.75em; + vertical-align: -15%; +} + +.fa-2x { + font-size: 2em; +} + +.fa-3x { + font-size: 3em; +} + +.fa-4x { + font-size: 4em; +} + +.fa-5x { + font-size: 5em; +} + +.fa-fw { + width: 1.28571429em; + text-align: center; +} + +.fa-ul { + padding-left: 0; + margin-left: 2.14285714em; + list-style-type: none; +} + +.fa-ul > li { + position: relative; +} + +.fa-li { + position: absolute; + left: -2.14285714em; + width: 2.14285714em; + top: 0.14285714em; + text-align: center; +} + +.fa-li.fa-lg { + left: -1.85714286em; +} + +.fa-border { + padding: .2em .25em .15em; + border: solid 0.08em #eeeeee; + border-radius: .1em; +} + +.pull-right { + float: right; +} + +.pull-left { + float: left; +} + +.fa.pull-left { + margin-right: .3em; +} + +.fa.pull-right { + margin-left: .3em; +} + +.fa-spin { + -webkit-animation: fa-spin 2s infinite linear; + animation: fa-spin 2s infinite linear; +} + +.fa-pulse { + -webkit-animation: fa-spin 1s infinite steps(8); + animation: fa-spin 1s infinite steps(8); +} + +@-webkit-keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +@keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +.fa-rotate-90 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); + -webkit-transform: rotate(90deg); + -ms-transform: rotate(90deg); + transform: rotate(90deg); +} + +.fa-rotate-180 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg); +} + +.fa-rotate-270 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); + -webkit-transform: rotate(270deg); + -ms-transform: rotate(270deg); + transform: rotate(270deg); +} + +.fa-flip-horizontal { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1); + -webkit-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); +} + +.fa-flip-vertical { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1); + -webkit-transform: scale(1, -1); + -ms-transform: scale(1, -1); + transform: scale(1, -1); +} + +:root .fa-rotate-90, +:root .fa-rotate-180, +:root .fa-rotate-270, +:root .fa-flip-horizontal, +:root .fa-flip-vertical { + filter: none; +} + +.fa-stack { + position: relative; + display: inline-block; + width: 2em; + height: 2em; + line-height: 2em; + vertical-align: middle; +} + +.fa-stack-1x, +.fa-stack-2x { + position: absolute; + left: 0; + width: 100%; + text-align: center; +} + +.fa-stack-1x { + line-height: inherit; +} + +.fa-stack-2x { + font-size: 2em; +} + +.fa-inverse { + color: #ffffff; +} + +/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen + readers do not read off random characters that represent icons */ +.fa-glass:before { + content: "\f000"; +} + +.fa-music:before { + content: "\f001"; +} + +.fa-search:before { + content: "\f002"; +} + +.fa-envelope-o:before { + content: "\f003"; +} + +.fa-heart:before { + content: "\f004"; +} + +.fa-star:before { + content: "\f005"; +} + +.fa-star-o:before { + content: "\f006"; +} + +.fa-user:before { + content: "\f007"; +} + +.fa-film:before { + content: "\f008"; +} + +.fa-th-large:before { + content: "\f009"; +} + +.fa-th:before { + content: "\f00a"; +} + +.fa-th-list:before { + content: "\f00b"; +} + +.fa-check:before { + content: "\f00c"; +} + +.fa-remove:before, +.fa-close:before, +.fa-times:before { + content: "\f00d"; +} + +.fa-search-plus:before { + content: "\f00e"; +} + +.fa-search-minus:before { + content: "\f010"; +} + +.fa-power-off:before { + content: "\f011"; +} + +.fa-signal:before { + content: "\f012"; +} + +.fa-gear:before, +.fa-cog:before { + content: "\f013"; +} + +.fa-trash-o:before { + content: "\f014"; +} + +.fa-home:before { + content: "\f015"; +} + +.fa-file-o:before { + content: "\f016"; +} + +.fa-clock-o:before { + content: "\f017"; +} + +.fa-road:before { + content: "\f018"; +} + +.fa-download:before { + content: "\f019"; +} + +.fa-arrow-circle-o-down:before { + content: "\f01a"; +} + +.fa-arrow-circle-o-up:before { + content: "\f01b"; +} + +.fa-inbox:before { + content: "\f01c"; +} + +.fa-play-circle-o:before { + content: "\f01d"; +} + +.fa-rotate-right:before, +.fa-repeat:before { + content: "\f01e"; +} + +.fa-refresh:before { + content: "\f021"; +} + +.fa-list-alt:before { + content: "\f022"; +} + +.fa-lock:before { + content: "\f023"; +} + +.fa-flag:before { + content: "\f024"; +} + +.fa-headphones:before { + content: "\f025"; +} + +.fa-volume-off:before { + content: "\f026"; +} + +.fa-volume-down:before { + content: "\f027"; +} + +.fa-volume-up:before { + content: "\f028"; +} + +.fa-qrcode:before { + content: "\f029"; +} + +.fa-barcode:before { + content: "\f02a"; +} + +.fa-tag:before { + content: "\f02b"; +} + +.fa-tags:before { + content: "\f02c"; +} + +.fa-book:before { + content: "\f02d"; +} + +.fa-bookmark:before { + content: "\f02e"; +} + +.fa-print:before { + content: "\f02f"; +} + +.fa-camera:before { + content: "\f030"; +} + +.fa-font:before { + content: "\f031"; +} + +.fa-bold:before { + content: "\f032"; +} + +.fa-italic:before { + content: "\f033"; +} + +.fa-text-height:before { + content: "\f034"; +} + +.fa-text-width:before { + content: "\f035"; +} + +.fa-align-left:before { + content: "\f036"; +} + +.fa-align-center:before { + content: "\f037"; +} + +.fa-align-right:before { + content: "\f038"; +} + +.fa-align-justify:before { + content: "\f039"; +} + +.fa-list:before { + content: "\f03a"; +} + +.fa-dedent:before, +.fa-outdent:before { + content: "\f03b"; +} + +.fa-indent:before { + content: "\f03c"; +} + +.fa-video-camera:before { + content: "\f03d"; +} + +.fa-photo:before, +.fa-image:before, +.fa-picture-o:before { + content: "\f03e"; +} + +.fa-pencil:before { + content: "\f040"; +} + +.fa-map-marker:before { + content: "\f041"; +} + +.fa-adjust:before { + content: "\f042"; +} + +.fa-tint:before { + content: "\f043"; +} + +.fa-edit:before, +.fa-pencil-square-o:before { + content: "\f044"; +} + +.fa-share-square-o:before { + content: "\f045"; +} + +.fa-check-square-o:before { + content: "\f046"; +} + +.fa-arrows:before { + content: "\f047"; +} + +.fa-step-backward:before { + content: "\f048"; +} + +.fa-fast-backward:before { + content: "\f049"; +} + +.fa-backward:before { + content: "\f04a"; +} + +.fa-play:before { + content: "\f04b"; +} + +.fa-pause:before { + content: "\f04c"; +} + +.fa-stop:before { + content: "\f04d"; +} + +.fa-forward:before { + content: "\f04e"; +} + +.fa-fast-forward:before { + content: "\f050"; +} + +.fa-step-forward:before { + content: "\f051"; +} + +.fa-eject:before { + content: "\f052"; +} + +.fa-chevron-left:before { + content: "\f053"; +} + +.fa-chevron-right:before { + content: "\f054"; +} + +.fa-plus-circle:before { + content: "\f055"; +} + +.fa-minus-circle:before { + content: "\f056"; +} + +.fa-times-circle:before { + content: "\f057"; +} + +.fa-check-circle:before { + content: "\f058"; +} + +.fa-question-circle:before { + content: "\f059"; +} + +.fa-info-circle:before { + content: "\f05a"; +} + +.fa-crosshairs:before { + content: "\f05b"; +} + +.fa-times-circle-o:before { + content: "\f05c"; +} + +.fa-check-circle-o:before { + content: "\f05d"; +} + +.fa-ban:before { + content: "\f05e"; +} + +.fa-arrow-left:before { + content: "\f060"; +} + +.fa-arrow-right:before { + content: "\f061"; +} + +.fa-arrow-up:before { + content: "\f062"; +} + +.fa-arrow-down:before { + content: "\f063"; +} + +.fa-mail-forward:before, +.fa-share:before { + content: "\f064"; +} + +.fa-expand:before { + content: "\f065"; +} + +.fa-compress:before { + content: "\f066"; +} + +.fa-plus:before { + content: "\f067"; +} + +.fa-minus:before { + content: "\f068"; +} + +.fa-asterisk:before { + content: "\f069"; +} + +.fa-exclamation-circle:before { + content: "\f06a"; +} + +.fa-gift:before { + content: "\f06b"; +} + +.fa-leaf:before { + content: "\f06c"; +} + +.fa-fire:before { + content: "\f06d"; +} + +.fa-eye:before { + content: "\f06e"; +} + +.fa-eye-slash:before { + content: "\f070"; +} + +.fa-warning:before, +.fa-exclamation-triangle:before { + content: "\f071"; +} + +.fa-plane:before { + content: "\f072"; +} + +.fa-calendar:before { + content: "\f073"; +} + +.fa-random:before { + content: "\f074"; +} + +.fa-comment:before { + content: "\f075"; +} + +.fa-magnet:before { + content: "\f076"; +} + +.fa-chevron-up:before { + content: "\f077"; +} + +.fa-chevron-down:before { + content: "\f078"; +} + +.fa-retweet:before { + content: "\f079"; +} + +.fa-shopping-cart:before { + content: "\f07a"; +} + +.fa-folder:before { + content: "\f07b"; +} + +.fa-folder-open:before { + content: "\f07c"; +} + +.fa-arrows-v:before { + content: "\f07d"; +} + +.fa-arrows-h:before { + content: "\f07e"; +} + +.fa-bar-chart-o:before, +.fa-bar-chart:before { + content: "\f080"; +} + +.fa-twitter-square:before { + content: "\f081"; +} + +.fa-facebook-square:before { + content: "\f082"; +} + +.fa-camera-retro:before { + content: "\f083"; +} + +.fa-key:before { + content: "\f084"; +} + +.fa-gears:before, +.fa-cogs:before { + content: "\f085"; +} + +.fa-comments:before { + content: "\f086"; +} + +.fa-thumbs-o-up:before { + content: "\f087"; +} + +.fa-thumbs-o-down:before { + content: "\f088"; +} + +.fa-star-half:before { + content: "\f089"; +} + +.fa-heart-o:before { + content: "\f08a"; +} + +.fa-sign-out:before { + content: "\f08b"; +} + +.fa-linkedin-square:before { + content: "\f08c"; +} + +.fa-thumb-tack:before { + content: "\f08d"; +} + +.fa-external-link:before { + content: "\f08e"; +} + +.fa-sign-in:before { + content: "\f090"; +} + +.fa-trophy:before { + content: "\f091"; +} + +.fa-github-square:before { + content: "\f092"; +} + +.fa-upload:before { + content: "\f093"; +} + +.fa-lemon-o:before { + content: "\f094"; +} + +.fa-phone:before { + content: "\f095"; +} + +.fa-square-o:before { + content: "\f096"; +} + +.fa-bookmark-o:before { + content: "\f097"; +} + +.fa-phone-square:before { + content: "\f098"; +} + +.fa-twitter:before { + content: "\f099"; +} + +.fa-facebook-f:before, +.fa-facebook:before { + content: "\f09a"; +} + +.fa-github:before { + content: "\f09b"; +} + +.fa-unlock:before { + content: "\f09c"; +} + +.fa-credit-card:before { + content: "\f09d"; +} + +.fa-rss:before { + content: "\f09e"; +} + +.fa-hdd-o:before { + content: "\f0a0"; +} + +.fa-bullhorn:before { + content: "\f0a1"; +} + +.fa-bell:before { + content: "\f0f3"; +} + +.fa-certificate:before { + content: "\f0a3"; +} + +.fa-hand-o-right:before { + content: "\f0a4"; +} + +.fa-hand-o-left:before { + content: "\f0a5"; +} + +.fa-hand-o-up:before { + content: "\f0a6"; +} + +.fa-hand-o-down:before { + content: "\f0a7"; +} + +.fa-arrow-circle-left:before { + content: "\f0a8"; +} + +.fa-arrow-circle-right:before { + content: "\f0a9"; +} + +.fa-arrow-circle-up:before { + content: "\f0aa"; +} + +.fa-arrow-circle-down:before { + content: "\f0ab"; +} + +.fa-globe:before { + content: "\f0ac"; +} + +.fa-wrench:before { + content: "\f0ad"; +} + +.fa-tasks:before { + content: "\f0ae"; +} + +.fa-filter:before { + content: "\f0b0"; +} + +.fa-briefcase:before { + content: "\f0b1"; +} + +.fa-arrows-alt:before { + content: "\f0b2"; +} + +.fa-group:before, +.fa-users:before { + content: "\f0c0"; +} + +.fa-chain:before, +.fa-link:before { + content: "\f0c1"; +} + +.fa-cloud:before { + content: "\f0c2"; +} + +.fa-flask:before { + content: "\f0c3"; +} + +.fa-cut:before, +.fa-scissors:before { + content: "\f0c4"; +} + +.fa-copy:before, +.fa-files-o:before { + content: "\f0c5"; +} + +.fa-paperclip:before { + content: "\f0c6"; +} + +.fa-save:before, +.fa-floppy-o:before { + content: "\f0c7"; +} + +.fa-square:before { + content: "\f0c8"; +} + +.fa-navicon:before, +.fa-reorder:before, +.fa-bars:before { + content: "\f0c9"; +} + +.fa-list-ul:before { + content: "\f0ca"; +} + +.fa-list-ol:before { + content: "\f0cb"; +} + +.fa-strikethrough:before { + content: "\f0cc"; +} + +.fa-underline:before { + content: "\f0cd"; +} + +.fa-table:before { + content: "\f0ce"; +} + +.fa-magic:before { + content: "\f0d0"; +} + +.fa-truck:before { + content: "\f0d1"; +} + +.fa-pinterest:before { + content: "\f0d2"; +} + +.fa-pinterest-square:before { + content: "\f0d3"; +} + +.fa-google-plus-square:before { + content: "\f0d4"; +} + +.fa-google-plus:before { + content: "\f0d5"; +} + +.fa-money:before { + content: "\f0d6"; +} + +.fa-caret-down:before { + content: "\f0d7"; +} + +.fa-caret-up:before { + content: "\f0d8"; +} + +.fa-caret-left:before { + content: "\f0d9"; +} + +.fa-caret-right:before { + content: "\f0da"; +} + +.fa-columns:before { + content: "\f0db"; +} + +.fa-unsorted:before, +.fa-sort:before { + content: "\f0dc"; +} + +.fa-sort-down:before, +.fa-sort-desc:before { + content: "\f0dd"; +} + +.fa-sort-up:before, +.fa-sort-asc:before { + content: "\f0de"; +} + +.fa-envelope:before { + content: "\f0e0"; +} + +.fa-linkedin:before { + content: "\f0e1"; +} + +.fa-rotate-left:before, +.fa-undo:before { + content: "\f0e2"; +} + +.fa-legal:before, +.fa-gavel:before { + content: "\f0e3"; +} + +.fa-dashboard:before, +.fa-tachometer:before { + content: "\f0e4"; +} + +.fa-comment-o:before { + content: "\f0e5"; +} + +.fa-comments-o:before { + content: "\f0e6"; +} + +.fa-flash:before, +.fa-bolt:before { + content: "\f0e7"; +} + +.fa-sitemap:before { + content: "\f0e8"; +} + +.fa-umbrella:before { + content: "\f0e9"; +} + +.fa-paste:before, +.fa-clipboard:before { + content: "\f0ea"; +} + +.fa-lightbulb-o:before { + content: "\f0eb"; +} + +.fa-exchange:before { + content: "\f0ec"; +} + +.fa-cloud-download:before { + content: "\f0ed"; +} + +.fa-cloud-upload:before { + content: "\f0ee"; +} + +.fa-user-md:before { + content: "\f0f0"; +} + +.fa-stethoscope:before { + content: "\f0f1"; +} + +.fa-suitcase:before { + content: "\f0f2"; +} + +.fa-bell-o:before { + content: "\f0a2"; +} + +.fa-coffee:before { + content: "\f0f4"; +} + +.fa-cutlery:before { + content: "\f0f5"; +} + +.fa-file-text-o:before { + content: "\f0f6"; +} + +.fa-building-o:before { + content: "\f0f7"; +} + +.fa-hospital-o:before { + content: "\f0f8"; +} + +.fa-ambulance:before { + content: "\f0f9"; +} + +.fa-medkit:before { + content: "\f0fa"; +} + +.fa-fighter-jet:before { + content: "\f0fb"; +} + +.fa-beer:before { + content: "\f0fc"; +} + +.fa-h-square:before { + content: "\f0fd"; +} + +.fa-plus-square:before { + content: "\f0fe"; +} + +.fa-angle-double-left:before { + content: "\f100"; +} + +.fa-angle-double-right:before { + content: "\f101"; +} + +.fa-angle-double-up:before { + content: "\f102"; +} + +.fa-angle-double-down:before { + content: "\f103"; +} + +.fa-angle-left:before { + content: "\f104"; +} + +.fa-angle-right:before { + content: "\f105"; +} + +.fa-angle-up:before { + content: "\f106"; +} + +.fa-angle-down:before { + content: "\f107"; +} + +.fa-desktop:before { + content: "\f108"; +} + +.fa-laptop:before { + content: "\f109"; +} + +.fa-tablet:before { + content: "\f10a"; +} + +.fa-mobile-phone:before, +.fa-mobile:before { + content: "\f10b"; +} + +.fa-circle-o:before { + content: "\f10c"; +} + +.fa-quote-left:before { + content: "\f10d"; +} + +.fa-quote-right:before { + content: "\f10e"; +} + +.fa-spinner:before { + content: "\f110"; +} + +.fa-circle:before { + content: "\f111"; +} + +.fa-mail-reply:before, +.fa-reply:before { + content: "\f112"; +} + +.fa-github-alt:before { + content: "\f113"; +} + +.fa-folder-o:before { + content: "\f114"; +} + +.fa-folder-open-o:before { + content: "\f115"; +} + +.fa-smile-o:before { + content: "\f118"; +} + +.fa-frown-o:before { + content: "\f119"; +} + +.fa-meh-o:before { + content: "\f11a"; +} + +.fa-gamepad:before { + content: "\f11b"; +} + +.fa-keyboard-o:before { + content: "\f11c"; +} + +.fa-flag-o:before { + content: "\f11d"; +} + +.fa-flag-checkered:before { + content: "\f11e"; +} + +.fa-terminal:before { + content: "\f120"; +} + +.fa-code:before { + content: "\f121"; +} + +.fa-mail-reply-all:before, +.fa-reply-all:before { + content: "\f122"; +} + +.fa-star-half-empty:before, +.fa-star-half-full:before, +.fa-star-half-o:before { + content: "\f123"; +} + +.fa-location-arrow:before { + content: "\f124"; +} + +.fa-crop:before { + content: "\f125"; +} + +.fa-code-fork:before { + content: "\f126"; +} + +.fa-unlink:before, +.fa-chain-broken:before { + content: "\f127"; +} + +.fa-question:before { + content: "\f128"; +} + +.fa-info:before { + content: "\f129"; +} + +.fa-exclamation:before { + content: "\f12a"; +} + +.fa-superscript:before { + content: "\f12b"; +} + +.fa-subscript:before { + content: "\f12c"; +} + +.fa-eraser:before { + content: "\f12d"; +} + +.fa-puzzle-piece:before { + content: "\f12e"; +} + +.fa-microphone:before { + content: "\f130"; +} + +.fa-microphone-slash:before { + content: "\f131"; +} + +.fa-shield:before { + content: "\f132"; +} + +.fa-calendar-o:before { + content: "\f133"; +} + +.fa-fire-extinguisher:before { + content: "\f134"; +} + +.fa-rocket:before { + content: "\f135"; +} + +.fa-maxcdn:before { + content: "\f136"; +} + +.fa-chevron-circle-left:before { + content: "\f137"; +} + +.fa-chevron-circle-right:before { + content: "\f138"; +} + +.fa-chevron-circle-up:before { + content: "\f139"; +} + +.fa-chevron-circle-down:before { + content: "\f13a"; +} + +.fa-html5:before { + content: "\f13b"; +} + +.fa-css3:before { + content: "\f13c"; +} + +.fa-anchor:before { + content: "\f13d"; +} + +.fa-unlock-alt:before { + content: "\f13e"; +} + +.fa-bullseye:before { + content: "\f140"; +} + +.fa-ellipsis-h:before { + content: "\f141"; +} + +.fa-ellipsis-v:before { + content: "\f142"; +} + +.fa-rss-square:before { + content: "\f143"; +} + +.fa-play-circle:before { + content: "\f144"; +} + +.fa-ticket:before { + content: "\f145"; +} + +.fa-minus-square:before { + content: "\f146"; +} + +.fa-minus-square-o:before { + content: "\f147"; +} + +.fa-level-up:before { + content: "\f148"; +} + +.fa-level-down:before { + content: "\f149"; +} + +.fa-check-square:before { + content: "\f14a"; +} + +.fa-pencil-square:before { + content: "\f14b"; +} + +.fa-external-link-square:before { + content: "\f14c"; +} + +.fa-share-square:before { + content: "\f14d"; +} + +.fa-compass:before { + content: "\f14e"; +} + +.fa-toggle-down:before, +.fa-caret-square-o-down:before { + content: "\f150"; +} + +.fa-toggle-up:before, +.fa-caret-square-o-up:before { + content: "\f151"; +} + +.fa-toggle-right:before, +.fa-caret-square-o-right:before { + content: "\f152"; +} + +.fa-euro:before, +.fa-eur:before { + content: "\f153"; +} + +.fa-gbp:before { + content: "\f154"; +} + +.fa-dollar:before, +.fa-usd:before { + content: "\f155"; +} + +.fa-rupee:before, +.fa-inr:before { + content: "\f156"; +} + +.fa-cny:before, +.fa-rmb:before, +.fa-yen:before, +.fa-jpy:before { + content: "\f157"; +} + +.fa-ruble:before, +.fa-rouble:before, +.fa-rub:before { + content: "\f158"; +} + +.fa-won:before, +.fa-krw:before { + content: "\f159"; +} + +.fa-bitcoin:before, +.fa-btc:before { + content: "\f15a"; +} + +.fa-file:before { + content: "\f15b"; +} + +.fa-file-text:before { + content: "\f15c"; +} + +.fa-sort-alpha-asc:before { + content: "\f15d"; +} + +.fa-sort-alpha-desc:before { + content: "\f15e"; +} + +.fa-sort-amount-asc:before { + content: "\f160"; +} + +.fa-sort-amount-desc:before { + content: "\f161"; +} + +.fa-sort-numeric-asc:before { + content: "\f162"; +} + +.fa-sort-numeric-desc:before { + content: "\f163"; +} + +.fa-thumbs-up:before { + content: "\f164"; +} + +.fa-thumbs-down:before { + content: "\f165"; +} + +.fa-youtube-square:before { + content: "\f166"; +} + +.fa-youtube:before { + content: "\f167"; +} + +.fa-xing:before { + content: "\f168"; +} + +.fa-xing-square:before { + content: "\f169"; +} + +.fa-youtube-play:before { + content: "\f16a"; +} + +.fa-dropbox:before { + content: "\f16b"; +} + +.fa-stack-overflow:before { + content: "\f16c"; +} + +.fa-instagram:before { + content: "\f16d"; +} + +.fa-flickr:before { + content: "\f16e"; +} + +.fa-adn:before { + content: "\f170"; +} + +.fa-bitbucket:before { + content: "\f171"; +} + +.fa-bitbucket-square:before { + content: "\f172"; +} + +.fa-tumblr:before { + content: "\f173"; +} + +.fa-tumblr-square:before { + content: "\f174"; +} + +.fa-long-arrow-down:before { + content: "\f175"; +} + +.fa-long-arrow-up:before { + content: "\f176"; +} + +.fa-long-arrow-left:before { + content: "\f177"; +} + +.fa-long-arrow-right:before { + content: "\f178"; +} + +.fa-apple:before { + content: "\f179"; +} + +.fa-windows:before { + content: "\f17a"; +} + +.fa-android:before { + content: "\f17b"; +} + +.fa-linux:before { + content: "\f17c"; +} + +.fa-dribbble:before { + content: "\f17d"; +} + +.fa-skype:before { + content: "\f17e"; +} + +.fa-foursquare:before { + content: "\f180"; +} + +.fa-trello:before { + content: "\f181"; +} + +.fa-female:before { + content: "\f182"; +} + +.fa-male:before { + content: "\f183"; +} + +.fa-gittip:before, +.fa-gratipay:before { + content: "\f184"; +} + +.fa-sun-o:before { + content: "\f185"; +} + +.fa-moon-o:before { + content: "\f186"; +} + +.fa-archive:before { + content: "\f187"; +} + +.fa-bug:before { + content: "\f188"; +} + +.fa-vk:before { + content: "\f189"; +} + +.fa-weibo:before { + content: "\f18a"; +} + +.fa-renren:before { + content: "\f18b"; +} + +.fa-pagelines:before { + content: "\f18c"; +} + +.fa-stack-exchange:before { + content: "\f18d"; +} + +.fa-arrow-circle-o-right:before { + content: "\f18e"; +} + +.fa-arrow-circle-o-left:before { + content: "\f190"; +} + +.fa-toggle-left:before, +.fa-caret-square-o-left:before { + content: "\f191"; +} + +.fa-dot-circle-o:before { + content: "\f192"; +} + +.fa-wheelchair:before { + content: "\f193"; +} + +.fa-vimeo-square:before { + content: "\f194"; +} + +.fa-turkish-lira:before, +.fa-try:before { + content: "\f195"; +} + +.fa-plus-square-o:before { + content: "\f196"; +} + +.fa-space-shuttle:before { + content: "\f197"; +} + +.fa-slack:before { + content: "\f198"; +} + +.fa-envelope-square:before { + content: "\f199"; +} + +.fa-wordpress:before { + content: "\f19a"; +} + +.fa-openid:before { + content: "\f19b"; +} + +.fa-institution:before, +.fa-bank:before, +.fa-university:before { + content: "\f19c"; +} + +.fa-mortar-board:before, +.fa-graduation-cap:before { + content: "\f19d"; +} + +.fa-yahoo:before { + content: "\f19e"; +} + +.fa-google:before { + content: "\f1a0"; +} + +.fa-reddit:before { + content: "\f1a1"; +} + +.fa-reddit-square:before { + content: "\f1a2"; +} + +.fa-stumbleupon-circle:before { + content: "\f1a3"; +} + +.fa-stumbleupon:before { + content: "\f1a4"; +} + +.fa-delicious:before { + content: "\f1a5"; +} + +.fa-digg:before { + content: "\f1a6"; +} + +.fa-pied-piper:before { + content: "\f1a7"; +} + +.fa-pied-piper-alt:before { + content: "\f1a8"; +} + +.fa-drupal:before { + content: "\f1a9"; +} + +.fa-joomla:before { + content: "\f1aa"; +} + +.fa-language:before { + content: "\f1ab"; +} + +.fa-fax:before { + content: "\f1ac"; +} + +.fa-building:before { + content: "\f1ad"; +} + +.fa-child:before { + content: "\f1ae"; +} + +.fa-paw:before { + content: "\f1b0"; +} + +.fa-spoon:before { + content: "\f1b1"; +} + +.fa-cube:before { + content: "\f1b2"; +} + +.fa-cubes:before { + content: "\f1b3"; +} + +.fa-behance:before { + content: "\f1b4"; +} + +.fa-behance-square:before { + content: "\f1b5"; +} + +.fa-steam:before { + content: "\f1b6"; +} + +.fa-steam-square:before { + content: "\f1b7"; +} + +.fa-recycle:before { + content: "\f1b8"; +} + +.fa-automobile:before, +.fa-car:before { + content: "\f1b9"; +} + +.fa-cab:before, +.fa-taxi:before { + content: "\f1ba"; +} + +.fa-tree:before { + content: "\f1bb"; +} + +.fa-spotify:before { + content: "\f1bc"; +} + +.fa-deviantart:before { + content: "\f1bd"; +} + +.fa-soundcloud:before { + content: "\f1be"; +} + +.fa-database:before { + content: "\f1c0"; +} + +.fa-file-pdf-o:before { + content: "\f1c1"; +} + +.fa-file-word-o:before { + content: "\f1c2"; +} + +.fa-file-excel-o:before { + content: "\f1c3"; +} + +.fa-file-powerpoint-o:before { + content: "\f1c4"; +} + +.fa-file-photo-o:before, +.fa-file-picture-o:before, +.fa-file-image-o:before { + content: "\f1c5"; +} + +.fa-file-zip-o:before, +.fa-file-archive-o:before { + content: "\f1c6"; +} + +.fa-file-sound-o:before, +.fa-file-audio-o:before { + content: "\f1c7"; +} + +.fa-file-movie-o:before, +.fa-file-video-o:before { + content: "\f1c8"; +} + +.fa-file-code-o:before { + content: "\f1c9"; +} + +.fa-vine:before { + content: "\f1ca"; +} + +.fa-codepen:before { + content: "\f1cb"; +} + +.fa-jsfiddle:before { + content: "\f1cc"; +} + +.fa-life-bouy:before, +.fa-life-buoy:before, +.fa-life-saver:before, +.fa-support:before, +.fa-life-ring:before { + content: "\f1cd"; +} + +.fa-circle-o-notch:before { + content: "\f1ce"; +} + +.fa-ra:before, +.fa-rebel:before { + content: "\f1d0"; +} + +.fa-ge:before, +.fa-empire:before { + content: "\f1d1"; +} + +.fa-git-square:before { + content: "\f1d2"; +} + +.fa-git:before { + content: "\f1d3"; +} + +.fa-hacker-news:before { + content: "\f1d4"; +} + +.fa-tencent-weibo:before { + content: "\f1d5"; +} + +.fa-qq:before { + content: "\f1d6"; +} + +.fa-wechat:before, +.fa-weixin:before { + content: "\f1d7"; +} + +.fa-send:before, +.fa-paper-plane:before { + content: "\f1d8"; +} + +.fa-send-o:before, +.fa-paper-plane-o:before { + content: "\f1d9"; +} + +.fa-history:before { + content: "\f1da"; +} + +.fa-genderless:before, +.fa-circle-thin:before { + content: "\f1db"; +} + +.fa-header:before { + content: "\f1dc"; +} + +.fa-paragraph:before { + content: "\f1dd"; +} + +.fa-sliders:before { + content: "\f1de"; +} + +.fa-share-alt:before { + content: "\f1e0"; +} + +.fa-share-alt-square:before { + content: "\f1e1"; +} + +.fa-bomb:before { + content: "\f1e2"; +} + +.fa-soccer-ball-o:before, +.fa-futbol-o:before { + content: "\f1e3"; +} + +.fa-tty:before { + content: "\f1e4"; +} + +.fa-binoculars:before { + content: "\f1e5"; +} + +.fa-plug:before { + content: "\f1e6"; +} + +.fa-slideshare:before { + content: "\f1e7"; +} + +.fa-twitch:before { + content: "\f1e8"; +} + +.fa-yelp:before { + content: "\f1e9"; +} + +.fa-newspaper-o:before { + content: "\f1ea"; +} + +.fa-wifi:before { + content: "\f1eb"; +} + +.fa-calculator:before { + content: "\f1ec"; +} + +.fa-paypal:before { + content: "\f1ed"; +} + +.fa-google-wallet:before { + content: "\f1ee"; +} + +.fa-cc-visa:before { + content: "\f1f0"; +} + +.fa-cc-mastercard:before { + content: "\f1f1"; +} + +.fa-cc-discover:before { + content: "\f1f2"; +} + +.fa-cc-amex:before { + content: "\f1f3"; +} + +.fa-cc-paypal:before { + content: "\f1f4"; +} + +.fa-cc-stripe:before { + content: "\f1f5"; +} + +.fa-bell-slash:before { + content: "\f1f6"; +} + +.fa-bell-slash-o:before { + content: "\f1f7"; +} + +.fa-trash:before { + content: "\f1f8"; +} + +.fa-copyright:before { + content: "\f1f9"; +} + +.fa-at:before { + content: "\f1fa"; +} + +.fa-eyedropper:before { + content: "\f1fb"; +} + +.fa-paint-brush:before { + content: "\f1fc"; +} + +.fa-birthday-cake:before { + content: "\f1fd"; +} + +.fa-area-chart:before { + content: "\f1fe"; +} + +.fa-pie-chart:before { + content: "\f200"; +} + +.fa-line-chart:before { + content: "\f201"; +} + +.fa-lastfm:before { + content: "\f202"; +} + +.fa-lastfm-square:before { + content: "\f203"; +} + +.fa-toggle-off:before { + content: "\f204"; +} + +.fa-toggle-on:before { + content: "\f205"; +} + +.fa-bicycle:before { + content: "\f206"; +} + +.fa-bus:before { + content: "\f207"; +} + +.fa-ioxhost:before { + content: "\f208"; +} + +.fa-angellist:before { + content: "\f209"; +} + +.fa-cc:before { + content: "\f20a"; +} + +.fa-shekel:before, +.fa-sheqel:before, +.fa-ils:before { + content: "\f20b"; +} + +.fa-meanpath:before { + content: "\f20c"; +} + +.fa-buysellads:before { + content: "\f20d"; +} + +.fa-connectdevelop:before { + content: "\f20e"; +} + +.fa-dashcube:before { + content: "\f210"; +} + +.fa-forumbee:before { + content: "\f211"; +} + +.fa-leanpub:before { + content: "\f212"; +} + +.fa-sellsy:before { + content: "\f213"; +} + +.fa-shirtsinbulk:before { + content: "\f214"; +} + +.fa-simplybuilt:before { + content: "\f215"; +} + +.fa-skyatlas:before { + content: "\f216"; +} + +.fa-cart-plus:before { + content: "\f217"; +} + +.fa-cart-arrow-down:before { + content: "\f218"; +} + +.fa-diamond:before { + content: "\f219"; +} + +.fa-ship:before { + content: "\f21a"; +} + +.fa-user-secret:before { + content: "\f21b"; +} + +.fa-motorcycle:before { + content: "\f21c"; +} + +.fa-street-view:before { + content: "\f21d"; +} + +.fa-heartbeat:before { + content: "\f21e"; +} + +.fa-venus:before { + content: "\f221"; +} + +.fa-mars:before { + content: "\f222"; +} + +.fa-mercury:before { + content: "\f223"; +} + +.fa-transgender:before { + content: "\f224"; +} + +.fa-transgender-alt:before { + content: "\f225"; +} + +.fa-venus-double:before { + content: "\f226"; +} + +.fa-mars-double:before { + content: "\f227"; +} + +.fa-venus-mars:before { + content: "\f228"; +} + +.fa-mars-stroke:before { + content: "\f229"; +} + +.fa-mars-stroke-v:before { + content: "\f22a"; +} + +.fa-mars-stroke-h:before { + content: "\f22b"; +} + +.fa-neuter:before { + content: "\f22c"; +} + +.fa-facebook-official:before { + content: "\f230"; +} + +.fa-pinterest-p:before { + content: "\f231"; +} + +.fa-whatsapp:before { + content: "\f232"; +} + +.fa-server:before { + content: "\f233"; +} + +.fa-user-plus:before { + content: "\f234"; +} + +.fa-user-times:before { + content: "\f235"; +} + +.fa-hotel:before, +.fa-bed:before { + content: "\f236"; +} + +.fa-viacoin:before { + content: "\f237"; +} + +.fa-train:before { + content: "\f238"; +} + +.fa-subway:before { + content: "\f239"; +} + +.fa-medium:before { + content: "\f23a"; +} + +/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */ +@font-face { + font-family: 'editormd-logo'; + src: url("../fonts/editormd-logo.eot?-5y8q6h"); + src: url(".../fonts/editormd-logo.eot?#iefix-5y8q6h") format("embedded-opentype"), url("../fonts/editormd-logo.woff?-5y8q6h") format("woff"), url("../fonts/editormd-logo.ttf?-5y8q6h") format("truetype"), url("../fonts/editormd-logo.svg?-5y8q6h#icomoon") format("svg"); + font-weight: normal; + font-style: normal; +} +.editormd-logo, +.editormd-logo-1x, +.editormd-logo-2x, +.editormd-logo-3x, +.editormd-logo-4x, +.editormd-logo-5x, +.editormd-logo-6x, +.editormd-logo-7x, +.editormd-logo-8x { + font-family: 'editormd-logo'; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + font-size: inherit; + line-height: 1; + display: inline-block; + text-rendering: auto; + vertical-align: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.editormd-logo:before, +.editormd-logo-1x:before, +.editormd-logo-2x:before, +.editormd-logo-3x:before, +.editormd-logo-4x:before, +.editormd-logo-5x:before, +.editormd-logo-6x:before, +.editormd-logo-7x:before, +.editormd-logo-8x:before { + content: "\e1987"; + /* + HTML Entity 󡦇 + example: + */ +} + +.editormd-logo-1x { + font-size: 1em; +} + +.editormd-logo-lg { + font-size: 1.2em; +} + +.editormd-logo-2x { + font-size: 2em; +} + +.editormd-logo-3x { + font-size: 3em; +} + +.editormd-logo-4x { + font-size: 4em; +} + +.editormd-logo-5x { + font-size: 5em; +} + +.editormd-logo-6x { + font-size: 6em; +} + +.editormd-logo-7x { + font-size: 7em; +} + +.editormd-logo-8x { + font-size: 8em; +} + +.editormd-logo-color { + color: #2196F3; +} + +/*! github-markdown-css | The MIT License (MIT) | Copyright (c) Sindre Sorhus (sindresorhus.com) | https://github.com/sindresorhus/github-markdown-css */ +@font-face { + font-family: octicons-anchor; + src: url(data:font/woff;charset=utf-8;base64,d09GRgABAAAAAAYcAA0AAAAACjQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABMAAAABwAAAAca8vGTk9TLzIAAAFMAAAARAAAAFZG1VHVY21hcAAAAZAAAAA+AAABQgAP9AdjdnQgAAAB0AAAAAQAAAAEACICiGdhc3AAAAHUAAAACAAAAAj//wADZ2x5ZgAAAdwAAADRAAABEKyikaNoZWFkAAACsAAAAC0AAAA2AtXoA2hoZWEAAALgAAAAHAAAACQHngNFaG10eAAAAvwAAAAQAAAAEAwAACJsb2NhAAADDAAAAAoAAAAKALIAVG1heHAAAAMYAAAAHwAAACABEAB2bmFtZQAAAzgAAALBAAAFu3I9x/Nwb3N0AAAF/AAAAB0AAAAvaoFvbwAAAAEAAAAAzBdyYwAAAADP2IQvAAAAAM/bz7t4nGNgZGFgnMDAysDB1Ml0hoGBoR9CM75mMGLkYGBgYmBlZsAKAtJcUxgcPsR8iGF2+O/AEMPsznAYKMwIkgMA5REMOXicY2BgYGaAYBkGRgYQsAHyGMF8FgYFIM0ChED+h5j//yEk/3KoSgZGNgYYk4GRCUgwMaACRoZhDwCs7QgGAAAAIgKIAAAAAf//AAJ4nHWMMQrCQBBF/0zWrCCIKUQsTDCL2EXMohYGSSmorScInsRGL2DOYJe0Ntp7BK+gJ1BxF1stZvjz/v8DRghQzEc4kIgKwiAppcA9LtzKLSkdNhKFY3HF4lK69ExKslx7Xa+vPRVS43G98vG1DnkDMIBUgFN0MDXflU8tbaZOUkXUH0+U27RoRpOIyCKjbMCVejwypzJJG4jIwb43rfl6wbwanocrJm9XFYfskuVC5K/TPyczNU7b84CXcbxks1Un6H6tLH9vf2LRnn8Ax7A5WQAAAHicY2BkYGAA4teL1+yI57f5ysDNwgAC529f0kOmWRiYVgEpDgYmEA8AUzEKsQAAAHicY2BkYGB2+O/AEMPCAAJAkpEBFbAAADgKAe0EAAAiAAAAAAQAAAAEAAAAAAAAKgAqACoAiAAAeJxjYGRgYGBhsGFgYgABEMkFhAwM/xn0QAIAD6YBhwB4nI1Ty07cMBS9QwKlQapQW3VXySvEqDCZGbGaHULiIQ1FKgjWMxknMfLEke2A+IJu+wntrt/QbVf9gG75jK577Lg8K1qQPCfnnnt8fX1NRC/pmjrk/zprC+8D7tBy9DHgBXoWfQ44Av8t4Bj4Z8CLtBL9CniJluPXASf0Lm4CXqFX8Q84dOLnMB17N4c7tBo1AS/Qi+hTwBH4rwHHwN8DXqQ30XXAS7QaLwSc0Gn8NuAVWou/gFmnjLrEaEh9GmDdDGgL3B4JsrRPDU2hTOiMSuJUIdKQQayiAth69r6akSSFqIJuA19TrzCIaY8sIoxyrNIrL//pw7A2iMygkX5vDj+G+kuoLdX4GlGK/8Lnlz6/h9MpmoO9rafrz7ILXEHHaAx95s9lsI7AHNMBWEZHULnfAXwG9/ZqdzLI08iuwRloXE8kfhXYAvE23+23DU3t626rbs8/8adv+9DWknsHp3E17oCf+Z48rvEQNZ78paYM38qfk3v/u3l3u3GXN2Dmvmvpf1Srwk3pB/VSsp512bA/GG5i2WJ7wu430yQ5K3nFGiOqgtmSB5pJVSizwaacmUZzZhXLlZTq8qGGFY2YcSkqbth6aW1tRmlaCFs2016m5qn36SbJrqosG4uMV4aP2PHBmB3tjtmgN2izkGQyLWprekbIntJFing32a5rKWCN/SdSoga45EJykyQ7asZvHQ8PTm6cslIpwyeyjbVltNikc2HTR7YKh9LBl9DADC0U/jLcBZDKrMhUBfQBvXRzLtFtjU9eNHKin0x5InTqb8lNpfKv1s1xHzTXRqgKzek/mb7nB8RZTCDhGEX3kK/8Q75AmUM/eLkfA+0Hi908Kx4eNsMgudg5GLdRD7a84npi+YxNr5i5KIbW5izXas7cHXIMAau1OueZhfj+cOcP3P8MNIWLyYOBuxL6DRylJ4cAAAB4nGNgYoAALjDJyIAOWMCiTIxMLDmZedkABtIBygAAAA==) format("woff"); +} +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + color: #333; + overflow: hidden; + font-family: "Microsoft YaHei", Helvetica, "Meiryo UI", "Malgun Gothic", "Segoe UI", "Trebuchet MS", "Monaco", monospace, Tahoma, STXihei, "华文细黑", STHeiti, "Helvetica Neue", "Droid Sans", "wenquanyi micro hei", FreeSans, Arimo, Arial, SimSun, "宋体", Heiti, "黑体", sans-serif; + font-size: 16px; + line-height: 1.6; + word-wrap: break-word; +} + +.markdown-body a { + background: transparent; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline: 0; +} + +.markdown-body strong { + font-weight: bold; +} + +.markdown-body h1 { + font-size: 2em; + margin: 0.67em 0; +} + +.markdown-body img { + border: 0; +} + +.markdown-body hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +.markdown-body pre { + overflow: auto; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre { + font-family: "Meiryo UI", "YaHei Consolas Hybrid", Consolas, "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, monospace, monospace; + font-size: 1em; +} + +.markdown-body input { + color: inherit; + font: inherit; + margin: 0; +} + +.markdown-body html input[disabled] { + cursor: default; +} + +.markdown-body input { + line-height: normal; +} + +.markdown-body input[type="checkbox"] { + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; +} + +.markdown-body table { + border-collapse: collapse; + border-spacing: 0; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body * { + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.markdown-body input { + font: 13px/1.4 Helvetica, arial, freesans, clean, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.markdown-body a { + color: #4183c4; + text-decoration: none; +} + +.markdown-body a:hover, +.markdown-body a:active { + text-decoration: underline; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #ddd; +} + +.markdown-body hr:before { + display: table; + content: ""; +} + +.markdown-body hr:after { + display: table; + clear: both; + content: ""; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 15px; + margin-bottom: 15px; + line-height: 1.1; +} + +.markdown-body h1 { + font-size: 30px; +} + +.markdown-body h2 { + font-size: 21px; +} + +.markdown-body h3 { + font-size: 16px; +} + +.markdown-body h4 { + font-size: 14px; +} + +.markdown-body h5 { + font-size: 12px; +} + +.markdown-body h6 { + font-size: 11px; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ul, +.markdown-body ol { + padding: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code { + font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font: 12px Consolas, "Liberation Mono", Menlo, Courier, monospace; +} + +.markdown-body .octicon { + font: normal normal 16px octicons-anchor; + line-height: 1; + display: inline-block; + text-decoration: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.markdown-body .octicon-link:before { + content: '\f05c'; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} + +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body .anchor { + position: absolute; + top: 0; + left: 0; + display: block; + padding-right: 6px; + padding-left: 30px; + margin-left: -30px; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + position: relative; + margin-top: 1em; + margin-bottom: 16px; + font-weight: bold; + line-height: 1.4; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + display: none; + color: #000; + vertical-align: middle; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + padding-left: 8px; + margin-left: -30px; + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + display: inline-block; +} + +.markdown-body h1 { + padding-bottom: 0.3em; + font-size: 2.25em; + line-height: 1.2; + border-bottom: 1px solid #eee; +} + +.markdown-body h1 .anchor { + line-height: 1; +} + +.markdown-body h2 { + padding-bottom: 0.3em; + font-size: 1.75em; + line-height: 1.225; + border-bottom: 1px solid #eee; +} + +.markdown-body h2 .anchor { + line-height: 1; +} + +.markdown-body h3 { + font-size: 1.5em; + line-height: 1.43; +} + +.markdown-body h3 .anchor { + line-height: 1.2; +} + +.markdown-body h4 { + font-size: 1.25em; +} + +.markdown-body h4 .anchor { + line-height: 1.2; +} + +.markdown-body h5 { + font-size: 1em; +} + +.markdown-body h5 .anchor { + line-height: 1.1; +} + +.markdown-body h6 { + font-size: 1em; + color: #777; +} + +.markdown-body h6 .anchor { + line-height: 1.1; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre { + margin-top: 0; + margin-bottom: 16px; +} + +/* +.markdown-body hr { + height: 4px; + padding: 0; + margin: 16px 0; + background-color: #e7e7e7; + border: 0 none; +}*/ +.markdown-body ul, +.markdown-body ol { + padding-left: 2em; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li > p { + margin-top: 16px; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: bold; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body blockquote { + padding: 0 15px; + color: #777; + border-left: 4px solid #ddd; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; + word-break: normal; + word-break: keep-all; +} + +.markdown-body table th { + font-weight: bold; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #ddd; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #ccc; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f8f8f8; +} + +.markdown-body img { + max-width: 100%; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.markdown-body code { + padding: 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + margin: 0; + font-size: 85%; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 3px; +} + +.markdown-body code:before, +.markdown-body code:after { + letter-spacing: -0.2em; + content: "\00a0"; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f7f7f7; + border-radius: 3px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre code { + display: inline; + max-width: initial; + padding: 0; + margin: 0; + overflow: initial; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body pre code:before, +.markdown-body pre code:after { + content: normal; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font-size: 11px; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +.markdown-body .pl-c { + color: #969896; +} + +.markdown-body .pl-c1, +.markdown-body .pl-mdh, +.markdown-body .pl-mm, +.markdown-body .pl-mp, +.markdown-body .pl-mr, +.markdown-body .pl-s1 .pl-v, +.markdown-body .pl-s3, +.markdown-body .pl-sc, +.markdown-body .pl-sv { + color: #0086b3; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #795da3; +} + +.markdown-body .pl-s1 .pl-s2, +.markdown-body .pl-smi, +.markdown-body .pl-smp, +.markdown-body .pl-stj, +.markdown-body .pl-vo, +.markdown-body .pl-vpf { + color: #333; +} + +.markdown-body .pl-ent { + color: #63a35c; +} + +.markdown-body .pl-k, +.markdown-body .pl-s, +.markdown-body .pl-st { + color: #a71d5d; +} + +.markdown-body .pl-pds, +.markdown-body .pl-s1, +.markdown-body .pl-s1 .pl-pse .pl-s2, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sra, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-src { + color: #df5000; +} + +.markdown-body .pl-mo, +.markdown-body .pl-v { + color: #1d3e81; +} + +.markdown-body .pl-id { + color: #b52a1d; +} + +.markdown-body .pl-ii { + background-color: #b52a1d; + color: #f8f8f8; +} + +.markdown-body .pl-sr .pl-cce { + color: #63a35c; + font-weight: bold; +} + +.markdown-body .pl-ml { + color: #693a17; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + color: #1d3e81; + font-weight: bold; +} + +.markdown-body .pl-mq { + color: #008080; +} + +.markdown-body .pl-mi { + color: #333; + font-style: italic; +} + +.markdown-body .pl-mb { + color: #333; + font-weight: bold; +} + +.markdown-body .pl-md, +.markdown-body .pl-mdhf { + background-color: #ffecec; + color: #bd2c00; +} + +.markdown-body .pl-mdht, +.markdown-body .pl-mi1 { + background-color: #eaffea; + color: #55a532; +} + +.markdown-body .pl-mdr { + color: #795da3; + font-weight: bold; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + float: left; + margin: 0.3em 0 0.25em -1.6em; + vertical-align: middle; +} + +.markdown-body :checked + .radio-label { + z-index: 1; + position: relative; + border-color: #4183c4; +} + +.editormd-preview-container, .editormd-html-preview { + text-align: left; + font-size: 14px; + line-height: 1.6; + padding: 20px; + overflow: auto; + width: 100%; + background-color: #fff; +} +.editormd-preview-container blockquote, .editormd-html-preview blockquote { + color: #666; + border-left: 4px solid #ddd; + padding-left: 20px; + margin-left: 0; + font-size: 14px; + font-style: italic; +} +.editormd-preview-container p code, .editormd-html-preview p code { + margin-left: 5px; + margin-right: 4px; +} +.editormd-preview-container abbr, .editormd-html-preview abbr { + background: #ffffdd; +} +.editormd-preview-container hr, .editormd-html-preview hr { + height: 1px; + border: none; + border-top: 1px solid #ddd; + background: none; +} +.editormd-preview-container code, .editormd-html-preview code { + border: 1px solid #ddd; + background: #f6f6f6; + padding: 3px; + border-radius: 3px; + font-size: 14px; +} +.editormd-preview-container pre, .editormd-html-preview pre { + border: 1px solid #ddd; + background: #f6f6f6; + padding: 10px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; +} +.editormd-preview-container pre code, .editormd-html-preview pre code { + padding: 0; +} +.editormd-preview-container pre, .editormd-preview-container code, .editormd-preview-container kbd, .editormd-html-preview pre, .editormd-html-preview code, .editormd-html-preview kbd { + font-family: "YaHei Consolas Hybrid", Consolas, "Meiryo UI", "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, monospace, monospace; +} +.editormd-preview-container table thead tr, .editormd-html-preview table thead tr { + background-color: #F8F8F8; +} +.editormd-preview-container p.editormd-tex, .editormd-html-preview p.editormd-tex { + text-align: center; +} +.editormd-preview-container span.editormd-tex, .editormd-html-preview span.editormd-tex { + margin: 0 5px; +} +.editormd-preview-container .emoji, .editormd-html-preview .emoji { + width: 24px; + height: 24px; +} +.editormd-preview-container .katex, .editormd-html-preview .katex { + font-size: 1.4em; +} +.editormd-preview-container .sequence-diagram, .editormd-preview-container .flowchart, .editormd-html-preview .sequence-diagram, .editormd-html-preview .flowchart { + margin: 0 auto; + text-align: center; +} +.editormd-preview-container .sequence-diagram svg, .editormd-preview-container .flowchart svg, .editormd-html-preview .sequence-diagram svg, .editormd-html-preview .flowchart svg { + margin: 0 auto; +} +.editormd-preview-container .sequence-diagram text, .editormd-preview-container .flowchart text, .editormd-html-preview .sequence-diagram text, .editormd-html-preview .flowchart text { + font-size: 15px !important; + font-family: "YaHei Consolas Hybrid", Consolas, "Microsoft YaHei", "Malgun Gothic", "Segoe UI", Helvetica, Arial !important; +} + +/*! Pretty printing styles. Used with prettify.js. */ +/* SPAN elements with the classes below are added by prettyprint. */ +.pln { + color: #000; +} + +/* plain text */ +@media screen { + .str { + color: #080; + } + + /* string content */ + .kwd { + color: #008; + } + + /* a keyword */ + .com { + color: #800; + } + + /* a comment */ + .typ { + color: #606; + } + + /* a type name */ + .lit { + color: #066; + } + + /* a literal value */ + /* punctuation, lisp open bracket, lisp close bracket */ + .pun, .opn, .clo { + color: #660; + } + + .tag { + color: #008; + } + + /* a markup tag name */ + .atn { + color: #606; + } + + /* a markup attribute name */ + .atv { + color: #080; + } + + /* a markup attribute value */ + .dec, .var { + color: #606; + } + + /* a declaration; a variable name */ + .fun { + color: red; + } + + /* a function name */ +} +/* Use higher contrast and text-weight for printable form. */ +@media print, projection { + .str { + color: #060; + } + + .kwd { + color: #006; + font-weight: bold; + } + + .com { + color: #600; + font-style: italic; + } + + .typ { + color: #404; + font-weight: bold; + } + + .lit { + color: #044; + } + + .pun, .opn, .clo { + color: #440; + } + + .tag { + color: #006; + font-weight: bold; + } + + .atn { + color: #404; + } + + .atv { + color: #060; + } +} +/* Put a border around prettyprinted code snippets. */ +pre.prettyprint { + padding: 2px; + border: 1px solid #888; +} + +/* Specify class=linenums on a pre to get line numbering */ +ol.linenums { + margin-top: 0; + margin-bottom: 0; +} + +/* IE indents via margin-left */ +li.L0, +li.L1, +li.L2, +li.L3, +li.L5, +li.L6, +li.L7, +li.L8 { + list-style-type: none; +} + +/* Alternate shading for lines */ +li.L1, +li.L3, +li.L5, +li.L7, +li.L9 { + background: #eee; +} + +.editormd-preview-container pre.prettyprint, .editormd-html-preview pre.prettyprint { + padding: 10px; + border: 1px solid #ddd; + white-space: pre-wrap; + word-wrap: break-word; +} +.editormd-preview-container ol.linenums, .editormd-html-preview ol.linenums { + color: #999; + padding-left: 2.5em; +} +.editormd-preview-container ol.linenums li, .editormd-html-preview ol.linenums li { + list-style-type: decimal; +} +.editormd-preview-container ol.linenums li code, .editormd-html-preview ol.linenums li code { + border: none; + background: none; + padding: 0; +} + +.editormd-preview-container .editormd-toc-menu, .editormd-html-preview .editormd-toc-menu { + margin: 8px 0 12px 0; + display: inline-block; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc, .editormd-html-preview .editormd-toc-menu > .markdown-toc { + position: relative; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + border: 1px solid #ddd; + display: inline-block; + font-size: 1em; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul { + width: 160%; + min-width: 180px; + position: absolute; + left: -1px; + top: -2px; + z-index: 100; + padding: 0 10px 10px; + display: none; + background: #fff; + border: 1px solid #ddd; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Webkit browsers */ + -moz-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Firefox */ + -ms-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9 */ + -o-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Opera(Old) */ + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9+, News */ +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul > li ul, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul > li ul { + width: 100%; + min-width: 180px; + border: 1px solid #ddd; + display: none; + background: #fff; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul > li a, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul > li a { + color: #666; + padding: 6px 10px; + display: block; + -webkit-transition: background-color 500ms ease-out; + /* Safari, Chrome */ + -moz-transition: background-color 500ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background-color 500ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc > ul > li a:hover, .editormd-html-preview .editormd-toc-menu > .markdown-toc > ul > li a:hover { + background-color: #f6f6f6; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li, .editormd-html-preview .editormd-toc-menu > .markdown-toc li { + position: relative; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul { + position: absolute; + top: 32px; + left: 10%; + display: none; + -webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Webkit browsers */ + -moz-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Firefox */ + -ms-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9 */ + -o-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* Opera(Old) */ + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); + /* IE9+, News */ +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:before, .editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:after, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:before, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:after { + pointer-events: pointer-events; + position: absolute; + left: 15px; + top: -6px; + display: block; + content: ""; + width: 0; + height: 0; + border: 6px solid transparent; + border-width: 0 6px 6px; + z-index: 10; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:before, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:before { + border-bottom-color: #ccc; +} +.editormd-preview-container .editormd-toc-menu > .markdown-toc li > ul:after, .editormd-html-preview .editormd-toc-menu > .markdown-toc li > ul:after { + border-bottom-color: #ffffff; + top: -5px; +} +.editormd-preview-container .editormd-toc-menu ul, .editormd-html-preview .editormd-toc-menu ul { + list-style: none; +} +.editormd-preview-container .editormd-toc-menu a, .editormd-html-preview .editormd-toc-menu a { + text-decoration: none; +} +.editormd-preview-container .editormd-toc-menu h1, .editormd-html-preview .editormd-toc-menu h1 { + font-size: 16px; + padding: 5px 0 10px 10px; + line-height: 1; + border-bottom: 1px solid #eee; +} +.editormd-preview-container .editormd-toc-menu h1 .fa, .editormd-html-preview .editormd-toc-menu h1 .fa { + padding-left: 10px; +} +.editormd-preview-container .editormd-toc-menu .toc-menu-btn, .editormd-html-preview .editormd-toc-menu .toc-menu-btn { + color: #666; + min-width: 180px; + padding: 5px 10px; + border-radius: 4px; + display: inline-block; + -webkit-transition: background-color 500ms ease-out; + /* Safari, Chrome */ + -moz-transition: background-color 500ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background-color 500ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-preview-container .editormd-toc-menu .toc-menu-btn:hover, .editormd-html-preview .editormd-toc-menu .toc-menu-btn:hover { + background-color: #f6f6f6; +} +.editormd-preview-container .editormd-toc-menu .toc-menu-btn .fa, .editormd-html-preview .editormd-toc-menu .toc-menu-btn .fa { + float: right; + padding: 3px 0 0 10px; + font-size: 1.3em; +} + +.markdown-body .editormd-toc-menu ul { + padding-left: 0; +} +.markdown-body .highlight pre, .markdown-body pre { + line-height: 1.6; +} + +hr.editormd-page-break { + border: 1px dotted #ccc; + font-size: 0; + height: 2px; +} + +@media only print { + hr.editormd-page-break { + background: none; + border: none; + height: 0; + } +} +.editormd-html-preview textarea { + display: none; +} +.editormd-html-preview hr.editormd-page-break { + background: none; + border: none; + height: 0; +} + +.editormd-preview-close-btn { + color: #fff; + padding: 4px 6px; + font-size: 18px; + -webkit-border-radius: 500px; + -moz-border-radius: 500px; + -ms-border-radius: 500px; + -o-border-radius: 500px; + border-radius: 500px; + display: none; + background-color: #ccc; + position: absolute; + top: 25px; + right: 35px; + z-index: 19; + -webkit-transition: background-color 300ms ease-out; + /* Safari, Chrome */ + -moz-transition: background-color 300ms ease-out; + /* Firefox 4.0~16.0 */ + transition: background-color 300ms ease-out; + /* IE >9, FF >15, Opera >12.0 */ +} +.editormd-preview-close-btn:hover { + background-color: #999; +} + +.editormd-preview-active { + width: 100%; + padding: 40px; +} diff --git a/paicoding-ui/src/main/resources/static/editormd/css/editormd.preview.min.css b/paicoding-ui/src/main/resources/static/editormd/css/editormd.preview.min.css new file mode 100644 index 000000000..a0f22adae --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/css/editormd.preview.min.css @@ -0,0 +1,5 @@ +/*! Editor.md v1.5.0 | editormd.preview.min.css | Open source online markdown editor. | MIT License | By: Pandao | https://github.com/pandao/editor.md | 2015-06-09 */ +@charset "UTF-8";/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 *//*! + * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */.fa-ul,.markdown-body .task-list-item,li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}.fa-fw,.fa-li{text-align:center}.fa,.fa-stack{display:inline-block}.fa,.markdown-body .octicon{-moz-osx-font-smoothing:grayscale}@font-face{font-family:FontAwesome;src:url(../fonts/fontawesome-webfont.eot?v=4.3.0);src:url(../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0)format("embedded-opentype"),url(../fonts/fontawesome-webfont.woff2?v=4.3.0)format("woff2"),url(../fonts/fontawesome-webfont.woff?v=4.3.0)format("woff"),url(../fonts/fontawesome-webfont.ttf?v=4.3.0)format("truetype"),url(../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular)format("svg");font-weight:400;font-style:normal}.fa{font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;transform:translate(0,0)}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em}.fa-ul{padding-left:0;margin-left:2.14285714em}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1,-1);-ms-transform:scale(1,-1);transform:scale(1,-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{filter:none}.fa-stack{position:relative;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-close:before,.fa-remove:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-repeat:before,.fa-rotate-right:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-exclamation-triangle:before,.fa-warning:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-floppy-o:before,.fa-save:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-bolt:before,.fa-flash:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-chain-broken:before,.fa-unlink:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:"\f150"}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:"\f151"}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:"\f152"}.fa-eur:before,.fa-euro:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-inr:before,.fa-rupee:before{content:"\f156"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:"\f158"}.fa-krw:before,.fa-won:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-try:before,.fa-turkish-lira:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-bank:before,.fa-institution:before,.fa-university:before{content:"\f19c"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:"\f1c5"}.fa-file-archive-o:before,.fa-file-zip-o:before{content:"\f1c6"}.fa-file-audio-o:before,.fa-file-sound-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-empire:before,.fa-ge:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-paper-plane:before,.fa-send:before{content:"\f1d8"}.fa-paper-plane-o:before,.fa-send-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before,.fa-genderless:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-bed:before,.fa-hotel:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}/*! prefixes.scss v0.1.0 | Author: Pandao | https://github.com/pandao/prefixes.scss | MIT license | Copyright (c) 2015 */@font-face{font-family:editormd-logo;src:url(../fonts/editormd-logo.eot?-5y8q6h);src:url(.../fonts/editormd-logo.eot?#iefix-5y8q6h)format("embedded-opentype"),url(../fonts/editormd-logo.woff?-5y8q6h)format("woff"),url(../fonts/editormd-logo.ttf?-5y8q6h)format("truetype"),url(../fonts/editormd-logo.svg?-5y8q6h#icomoon)format("svg");font-weight:400;font-style:normal}.editormd-logo,.editormd-logo-1x,.editormd-logo-2x,.editormd-logo-3x,.editormd-logo-4x,.editormd-logo-5x,.editormd-logo-6x,.editormd-logo-7x,.editormd-logo-8x{font-family:editormd-logo;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;font-size:inherit;line-height:1;display:inline-block;text-rendering:auto;vertical-align:inherit;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.markdown-body hr:after,.markdown-body hr:before{content:"";display:table}.editormd-logo-1x:before,.editormd-logo-2x:before,.editormd-logo-3x:before,.editormd-logo-4x:before,.editormd-logo-5x:before,.editormd-logo-6x:before,.editormd-logo-7x:before,.editormd-logo-8x:before,.editormd-logo:before{content:"\e1987"}.editormd-logo-1x{font-size:1em}.editormd-logo-lg{font-size:1.2em}.editormd-logo-2x{font-size:2em}.editormd-logo-3x{font-size:3em}.editormd-logo-4x{font-size:4em}.editormd-logo-5x{font-size:5em}.editormd-logo-6x{font-size:6em}.editormd-logo-7x{font-size:7em}.editormd-logo-8x{font-size:8em}.editormd-logo-color{color:#2196F3}/*! github-markdown-css | The MIT License (MIT) | Copyright (c) Sindre Sorhus (sindresorhus.com) | https://github.com/sindresorhus/github-markdown-css */@font-face{font-family:octicons-anchor;src:url(data:font/woff;charset=utf-8;base64,d09GRgABAAAAAAYcAA0AAAAACjQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABMAAAABwAAAAca8vGTk9TLzIAAAFMAAAARAAAAFZG1VHVY21hcAAAAZAAAAA+AAABQgAP9AdjdnQgAAAB0AAAAAQAAAAEACICiGdhc3AAAAHUAAAACAAAAAj//wADZ2x5ZgAAAdwAAADRAAABEKyikaNoZWFkAAACsAAAAC0AAAA2AtXoA2hoZWEAAALgAAAAHAAAACQHngNFaG10eAAAAvwAAAAQAAAAEAwAACJsb2NhAAADDAAAAAoAAAAKALIAVG1heHAAAAMYAAAAHwAAACABEAB2bmFtZQAAAzgAAALBAAAFu3I9x/Nwb3N0AAAF/AAAAB0AAAAvaoFvbwAAAAEAAAAAzBdyYwAAAADP2IQvAAAAAM/bz7t4nGNgZGFgnMDAysDB1Ml0hoGBoR9CM75mMGLkYGBgYmBlZsAKAtJcUxgcPsR8iGF2+O/AEMPsznAYKMwIkgMA5REMOXicY2BgYGaAYBkGRgYQsAHyGMF8FgYFIM0ChED+h5j//yEk/3KoSgZGNgYYk4GRCUgwMaACRoZhDwCs7QgGAAAAIgKIAAAAAf//AAJ4nHWMMQrCQBBF/0zWrCCIKUQsTDCL2EXMohYGSSmorScInsRGL2DOYJe0Ntp7BK+gJ1BxF1stZvjz/v8DRghQzEc4kIgKwiAppcA9LtzKLSkdNhKFY3HF4lK69ExKslx7Xa+vPRVS43G98vG1DnkDMIBUgFN0MDXflU8tbaZOUkXUH0+U27RoRpOIyCKjbMCVejwypzJJG4jIwb43rfl6wbwanocrJm9XFYfskuVC5K/TPyczNU7b84CXcbxks1Un6H6tLH9vf2LRnn8Ax7A5WQAAAHicY2BkYGAA4teL1+yI57f5ysDNwgAC529f0kOmWRiYVgEpDgYmEA8AUzEKsQAAAHicY2BkYGB2+O/AEMPCAAJAkpEBFbAAADgKAe0EAAAiAAAAAAQAAAAEAAAAAAAAKgAqACoAiAAAeJxjYGRgYGBhsGFgYgABEMkFhAwM/xn0QAIAD6YBhwB4nI1Ty07cMBS9QwKlQapQW3VXySvEqDCZGbGaHULiIQ1FKgjWMxknMfLEke2A+IJu+wntrt/QbVf9gG75jK577Lg8K1qQPCfnnnt8fX1NRC/pmjrk/zprC+8D7tBy9DHgBXoWfQ44Av8t4Bj4Z8CLtBL9CniJluPXASf0Lm4CXqFX8Q84dOLnMB17N4c7tBo1AS/Qi+hTwBH4rwHHwN8DXqQ30XXAS7QaLwSc0Gn8NuAVWou/gFmnjLrEaEh9GmDdDGgL3B4JsrRPDU2hTOiMSuJUIdKQQayiAth69r6akSSFqIJuA19TrzCIaY8sIoxyrNIrL//pw7A2iMygkX5vDj+G+kuoLdX4GlGK/8Lnlz6/h9MpmoO9rafrz7ILXEHHaAx95s9lsI7AHNMBWEZHULnfAXwG9/ZqdzLI08iuwRloXE8kfhXYAvE23+23DU3t626rbs8/8adv+9DWknsHp3E17oCf+Z48rvEQNZ78paYM38qfk3v/u3l3u3GXN2Dmvmvpf1Srwk3pB/VSsp512bA/GG5i2WJ7wu430yQ5K3nFGiOqgtmSB5pJVSizwaacmUZzZhXLlZTq8qGGFY2YcSkqbth6aW1tRmlaCFs2016m5qn36SbJrqosG4uMV4aP2PHBmB3tjtmgN2izkGQyLWprekbIntJFing32a5rKWCN/SdSoga45EJykyQ7asZvHQ8PTm6cslIpwyeyjbVltNikc2HTR7YKh9LBl9DADC0U/jLcBZDKrMhUBfQBvXRzLtFtjU9eNHKin0x5InTqb8lNpfKv1s1xHzTXRqgKzek/mb7nB8RZTCDhGEX3kK/8Q75AmUM/eLkfA+0Hi908Kx4eNsMgudg5GLdRD7a84npi+YxNr5i5KIbW5izXas7cHXIMAau1OueZhfj+cOcP3P8MNIWLyYOBuxL6DRylJ4cAAAB4nGNgYoAALjDJyIAOWMCiTIxMLDmZedkABtIBygAAAA==)format("woff")}.markdown-body{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;color:#333;overflow:hidden;font-family:"Microsoft YaHei",Helvetica,"Meiryo UI","Malgun Gothic","Segoe UI","Trebuchet MS",Monaco,monospace,Tahoma,STXihei,"华文细黑",STHeiti,"Helvetica Neue","Droid Sans","wenquanyi micro hei",FreeSans,Arimo,Arial,SimSun,"宋体",Heiti,"黑体",sans-serif;font-size:16px;line-height:1.6;word-wrap:break-word}.markdown-body strong{font-weight:700}.markdown-body h1{margin:.67em 0}.markdown-body img{border:0}.markdown-body hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}.markdown-body input{color:inherit;margin:0;line-height:normal;font:13px/1.4 Helvetica,arial,freesans,clean,sans-serif,"Segoe UI Emoji","Segoe UI Symbol"}.markdown-body html input[disabled]{cursor:default}.markdown-body input[type=checkbox]{-moz-box-sizing:border-box;box-sizing:border-box;padding:0}.markdown-body td,.markdown-body th{padding:0}.markdown-body *{-moz-box-sizing:border-box;box-sizing:border-box}.markdown-body a{background:0 0;color:#4183c4;text-decoration:none}.markdown-body a:active,.markdown-body a:hover{outline:0;text-decoration:underline}.markdown-body hr{margin:15px 0;overflow:hidden;background:0 0;border:0;border-bottom:1px solid #ddd}.markdown-body h1,.markdown-body h2{padding-bottom:.3em;border-bottom:1px solid #eee}.markdown-body hr:after{clear:both}.markdown-body blockquote{margin:0}.markdown-body ol,.markdown-body ul{padding:0}.markdown-body ol ol,.markdown-body ul ol{list-style-type:lower-roman}.markdown-body ol ol ol,.markdown-body ol ul ol,.markdown-body ul ol ol,.markdown-body ul ul ol{list-style-type:lower-alpha}.markdown-body dd{margin-left:0}.markdown-body code{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace}.markdown-body pre{font:12px Consolas,"Liberation Mono",Menlo,Courier,monospace;word-wrap:normal}.markdown-body .octicon{font:normal normal 16px octicons-anchor;line-height:1;display:inline-block;text-decoration:none;-webkit-font-smoothing:antialiased;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.markdown-body .octicon-link:before{content:'\f05c'}.markdown-body>:first-child{margin-top:0!important}.markdown-body>:last-child{margin-bottom:0!important}.markdown-body .anchor{position:absolute;top:0;left:0;display:block;padding-right:6px;padding-left:30px;margin-left:-30px}.markdown-body .anchor:focus{outline:0}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{position:relative;margin-top:1em;margin-bottom:16px;font-weight:700;line-height:1.4}.markdown-body h1 .octicon-link,.markdown-body h2 .octicon-link,.markdown-body h3 .octicon-link,.markdown-body h4 .octicon-link,.markdown-body h5 .octicon-link,.markdown-body h6 .octicon-link{display:none;color:#000;vertical-align:middle}.markdown-body h1:hover .anchor,.markdown-body h2:hover .anchor,.markdown-body h3:hover .anchor,.markdown-body h4:hover .anchor,.markdown-body h5:hover .anchor,.markdown-body h6:hover .anchor{padding-left:8px;margin-left:-30px;text-decoration:none}.markdown-body h1:hover .anchor .octicon-link,.markdown-body h2:hover .anchor .octicon-link,.markdown-body h3:hover .anchor .octicon-link,.markdown-body h4:hover .anchor .octicon-link,.markdown-body h5:hover .anchor .octicon-link,.markdown-body h6:hover .anchor .octicon-link{display:inline-block}.markdown-body h1{font-size:2.25em;line-height:1.2}.markdown-body h1 .anchor{line-height:1}.markdown-body h2{font-size:1.75em;line-height:1.225}.markdown-body h2 .anchor{line-height:1}.markdown-body h3{font-size:1.5em;line-height:1.43}.markdown-body h3 .anchor,.markdown-body h4 .anchor{line-height:1.2}.markdown-body h4{font-size:1.25em}.markdown-body h5 .anchor,.markdown-body h6 .anchor{line-height:1.1}.markdown-body h5{font-size:1em}.markdown-body h6{font-size:1em;color:#777}.markdown-body blockquote,.markdown-body dl,.markdown-body ol,.markdown-body p,.markdown-body pre,.markdown-body table,.markdown-body ul{margin-top:0;margin-bottom:16px}.markdown-body ol,.markdown-body ul{padding-left:2em}.markdown-body ol ol,.markdown-body ol ul,.markdown-body ul ol,.markdown-body ul ul{margin-top:0;margin-bottom:0}.markdown-body li>p{margin-top:16px}.markdown-body dl{padding:0}.markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:700}.markdown-body dl dd{padding:0 16px;margin-bottom:16px}.markdown-body blockquote{padding:0 15px;color:#777;border-left:4px solid #ddd}.markdown-body blockquote>:first-child{margin-top:0}.markdown-body blockquote>:last-child{margin-bottom:0}.markdown-body table{border-collapse:collapse;border-spacing:0;display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all}.markdown-body table th{font-weight:700}.markdown-body table td,.markdown-body table th{padding:6px 13px;border:1px solid #ddd}.markdown-body table tr{background-color:#fff;border-top:1px solid #ccc}.markdown-body table tr:nth-child(2n){background-color:#f8f8f8}.markdown-body img{max-width:100%;-moz-box-sizing:border-box;box-sizing:border-box}.markdown-body code{padding:.2em 0;margin:0;font-size:85%;background-color:rgba(0,0,0,.04);border-radius:3px}.markdown-body code:after,.markdown-body code:before{letter-spacing:-.2em;content:"\00a0"}.markdown-body pre>code{padding:0;margin:0;font-size:100%;word-break:normal;white-space:pre;background:0 0;border:0}.markdown-body .highlight{margin-bottom:16px}.markdown-body .highlight pre,.markdown-body pre{padding:16px;overflow:auto;font-size:85%;background-color:#f7f7f7;border-radius:3px}.markdown-body .highlight pre{margin-bottom:0;word-break:normal}.markdown-body pre code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;word-wrap:normal;background-color:transparent;border:0}.markdown-body pre code:after,.markdown-body pre code:before{content:normal}.markdown-body .pl-c{color:#969896}.markdown-body .pl-c1,.markdown-body .pl-mdh,.markdown-body .pl-mm,.markdown-body .pl-mp,.markdown-body .pl-mr,.markdown-body .pl-s1 .pl-v,.markdown-body .pl-s3,.markdown-body .pl-sc,.markdown-body .pl-sv{color:#0086b3}.markdown-body .pl-e,.markdown-body .pl-en{color:#795da3}.markdown-body .pl-s1 .pl-s2,.markdown-body .pl-smi,.markdown-body .pl-smp,.markdown-body .pl-stj,.markdown-body .pl-vo,.markdown-body .pl-vpf{color:#333}.markdown-body .pl-ent{color:#63a35c}.markdown-body .pl-k,.markdown-body .pl-s,.markdown-body .pl-st{color:#a71d5d}.markdown-body .pl-pds,.markdown-body .pl-s1,.markdown-body .pl-s1 .pl-pse .pl-s2,.markdown-body .pl-sr,.markdown-body .pl-sr .pl-cce,.markdown-body .pl-sr .pl-sra,.markdown-body .pl-sr .pl-sre,.markdown-body .pl-src{color:#df5000}.markdown-body .pl-mo,.markdown-body .pl-v{color:#1d3e81}.markdown-body .pl-id{color:#b52a1d}.markdown-body .pl-ii{background-color:#b52a1d;color:#f8f8f8}.markdown-body .pl-sr .pl-cce{color:#63a35c;font-weight:700}.markdown-body .pl-ml{color:#693a17}.markdown-body .pl-mh,.markdown-body .pl-mh .pl-en,.markdown-body .pl-ms{color:#1d3e81;font-weight:700}.markdown-body .pl-mq{color:teal}.markdown-body .pl-mi{color:#333;font-style:italic}.markdown-body .pl-mb{color:#333;font-weight:700}.markdown-body .pl-md,.markdown-body .pl-mdhf{background-color:#ffecec;color:#bd2c00}.markdown-body .pl-mdht,.markdown-body .pl-mi1{background-color:#eaffea;color:#55a532}.markdown-body .pl-mdr{color:#795da3;font-weight:700}.markdown-body kbd{display:inline-block;padding:3px 5px;font:11px Consolas,"Liberation Mono",Menlo,Courier,monospace;line-height:10px;color:#555;vertical-align:middle;background-color:#fcfcfc;border:1px solid #ccc;border-bottom-color:#bbb;border-radius:3px;box-shadow:inset 0 -1px 0 #bbb}.markdown-body .task-list-item+.task-list-item{margin-top:3px}.markdown-body .task-list-item input{float:left;margin:.3em 0 .25em -1.6em;vertical-align:middle}.markdown-body :checked+.radio-label{z-index:1;position:relative;border-color:#4183c4}.editormd-html-preview,.editormd-preview-container{text-align:left;font-size:14px;line-height:1.6;padding:20px;overflow:auto;width:100%;background-color:#fff}.editormd-html-preview blockquote,.editormd-preview-container blockquote{color:#666;border-left:4px solid #ddd;padding-left:20px;margin-left:0;font-size:14px;font-style:italic}.editormd-html-preview p code,.editormd-preview-container p code{margin-left:5px;margin-right:4px}.editormd-html-preview abbr,.editormd-preview-container abbr{background:#ffd}.editormd-html-preview hr,.editormd-preview-container hr{height:1px;border:none;border-top:1px solid #ddd;background:0 0}.editormd-html-preview code,.editormd-preview-container code{border:1px solid #ddd;background:#f6f6f6;padding:3px;border-radius:3px;font-size:14px}.editormd-html-preview pre,.editormd-preview-container pre{border:1px solid #ddd;background:#f6f6f6;padding:10px;-webkit-border-radius:3px;-moz-border-radius:3px;-ms-border-radius:3px;-o-border-radius:3px;border-radius:3px}.editormd-html-preview pre code,.editormd-preview-container pre code{padding:0}.editormd-html-preview code,.editormd-html-preview kbd,.editormd-html-preview pre,.editormd-preview-container code,.editormd-preview-container kbd,.editormd-preview-container pre{font-family:"YaHei Consolas Hybrid",Consolas,"Meiryo UI","Malgun Gothic","Segoe UI","Trebuchet MS",Helvetica,monospace,monospace}.editormd-html-preview table thead tr,.editormd-preview-container table thead tr{background-color:#F8F8F8}.editormd-html-preview p.editormd-tex,.editormd-preview-container p.editormd-tex{text-align:center}.editormd-html-preview span.editormd-tex,.editormd-preview-container span.editormd-tex{margin:0 5px}.editormd-html-preview .emoji,.editormd-preview-container .emoji{width:24px;height:24px}.editormd-html-preview .katex,.editormd-preview-container .katex{font-size:1.4em}.editormd-html-preview .flowchart,.editormd-html-preview .sequence-diagram,.editormd-preview-container .flowchart,.editormd-preview-container .sequence-diagram{margin:0 auto;text-align:center}.editormd-html-preview .flowchart svg,.editormd-html-preview .sequence-diagram svg,.editormd-preview-container .flowchart svg,.editormd-preview-container .sequence-diagram svg{margin:0 auto}.editormd-html-preview .flowchart text,.editormd-html-preview .sequence-diagram text,.editormd-preview-container .flowchart text,.editormd-preview-container .sequence-diagram text{font-size:15px!important;font-family:"YaHei Consolas Hybrid",Consolas,"Microsoft YaHei","Malgun Gothic","Segoe UI",Helvetica,Arial!important}/*! Pretty printing styles. Used with prettify.js. */.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.clo,.opn,.pun{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.kwd,.tag,.typ{font-weight:700}.str{color:#060}.kwd{color:#006}.com{color:#600;font-style:italic}.typ{color:#404}.lit{color:#044}.clo,.opn,.pun{color:#440}.tag{color:#006}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}.editormd-html-preview pre.prettyprint,.editormd-preview-container pre.prettyprint{padding:10px;border:1px solid #ddd;white-space:pre-wrap;word-wrap:break-word}.editormd-html-preview ol.linenums,.editormd-preview-container ol.linenums{color:#999;padding-left:2.5em}.editormd-html-preview ol.linenums li,.editormd-preview-container ol.linenums li{list-style-type:decimal}.editormd-html-preview ol.linenums li code,.editormd-preview-container ol.linenums li code{border:none;background:0 0;padding:0}.editormd-html-preview .editormd-toc-menu,.editormd-preview-container .editormd-toc-menu{margin:8px 0 12px;display:inline-block}.editormd-html-preview .editormd-toc-menu>.markdown-toc,.editormd-preview-container .editormd-toc-menu>.markdown-toc{position:relative;-webkit-border-radius:4px;-moz-border-radius:4px;-ms-border-radius:4px;-o-border-radius:4px;border-radius:4px;border:1px solid #ddd;display:inline-block;font-size:1em}.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul{width:160%;min-width:180px;position:absolute;left:-1px;top:-2px;z-index:100;padding:0 10px 10px;display:none;background:#fff;border:1px solid #ddd;-webkit-border-radius:4px;-moz-border-radius:4px;-ms-border-radius:4px;-o-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 3px 5px rgba(0,0,0,.2);-moz-box-shadow:0 3px 5px rgba(0,0,0,.2);-ms-box-shadow:0 3px 5px rgba(0,0,0,.2);-o-box-shadow:0 3px 5px rgba(0,0,0,.2);box-shadow:0 3px 5px rgba(0,0,0,.2)}.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul>li ul,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul>li ul{width:100%;min-width:180px;border:1px solid #ddd;display:none;background:#fff;-webkit-border-radius:4px;-moz-border-radius:4px;-ms-border-radius:4px;-o-border-radius:4px;border-radius:4px}.editormd-html-preview .editormd-toc-menu .toc-menu-btn:hover,.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul>li a:hover,.editormd-preview-container .editormd-toc-menu .toc-menu-btn:hover,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul>li a:hover{background-color:#f6f6f6}.editormd-html-preview .editormd-toc-menu>.markdown-toc>ul>li a,.editormd-preview-container .editormd-toc-menu>.markdown-toc>ul>li a{color:#666;padding:6px 10px;display:block;-webkit-transition:background-color 500ms ease-out;-moz-transition:background-color 500ms ease-out;transition:background-color 500ms ease-out}.editormd-html-preview .editormd-toc-menu>.markdown-toc li,.editormd-preview-container .editormd-toc-menu>.markdown-toc li{position:relative}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul{position:absolute;top:32px;left:10%;display:none;-webkit-box-shadow:0 3px 5px rgba(0,0,0,.2);-moz-box-shadow:0 3px 5px rgba(0,0,0,.2);-ms-box-shadow:0 3px 5px rgba(0,0,0,.2);-o-box-shadow:0 3px 5px rgba(0,0,0,.2);box-shadow:0 3px 5px rgba(0,0,0,.2)}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:after,.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:before,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:after,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:before{pointer-events:pointer-events;position:absolute;left:15px;top:-6px;display:block;content:"";width:0;height:0;border:6px solid transparent;border-width:0 6px 6px;z-index:10}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:before,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:before{border-bottom-color:#ccc}.editormd-html-preview .editormd-toc-menu>.markdown-toc li>ul:after,.editormd-preview-container .editormd-toc-menu>.markdown-toc li>ul:after{border-bottom-color:#fff;top:-5px}.editormd-html-preview .editormd-toc-menu ul,.editormd-preview-container .editormd-toc-menu ul{list-style:none}.editormd-html-preview .editormd-toc-menu a,.editormd-preview-container .editormd-toc-menu a{text-decoration:none}.editormd-html-preview .editormd-toc-menu h1,.editormd-preview-container .editormd-toc-menu h1{font-size:16px;padding:5px 0 10px 10px;line-height:1;border-bottom:1px solid #eee}.editormd-html-preview .editormd-toc-menu h1 .fa,.editormd-preview-container .editormd-toc-menu h1 .fa{padding-left:10px}.editormd-html-preview .editormd-toc-menu .toc-menu-btn,.editormd-preview-container .editormd-toc-menu .toc-menu-btn{color:#666;min-width:180px;padding:5px 10px;border-radius:4px;display:inline-block;-webkit-transition:background-color 500ms ease-out;-moz-transition:background-color 500ms ease-out;transition:background-color 500ms ease-out}.editormd-html-preview .editormd-toc-menu .toc-menu-btn .fa,.editormd-preview-container .editormd-toc-menu .toc-menu-btn .fa{float:right;padding:3px 0 0 10px;font-size:1.3em}.markdown-body .editormd-toc-menu ul{padding-left:0}.markdown-body .highlight pre,.markdown-body pre{line-height:1.6}hr.editormd-page-break{border:1px dotted #ccc;font-size:0;height:2px}@media only print{hr.editormd-page-break{background:0 0;border:none;height:0}}.editormd-html-preview textarea{display:none}.editormd-html-preview hr.editormd-page-break{background:0 0;border:none;height:0}.editormd-preview-close-btn{color:#fff;padding:4px 6px;font-size:18px;-webkit-border-radius:500px;-moz-border-radius:500px;-ms-border-radius:500px;-o-border-radius:500px;border-radius:500px;display:none;background-color:#ccc;position:absolute;top:25px;right:35px;z-index:19;-webkit-transition:background-color 300ms ease-out;-moz-transition:background-color 300ms ease-out;transition:background-color 300ms ease-out}.editormd-preview-close-btn:hover{background-color:#999}.editormd-preview-active{width:100%;padding:40px} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/editormd.js.html b/paicoding-ui/src/main/resources/static/editormd/docs/editormd.js.html new file mode 100644 index 000000000..c191f8a90 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/editormd.js.html @@ -0,0 +1,4407 @@ + + + + + JSDoc: Source: editormd.js + + + + + + + + + + +

+ +

Source: editormd.js

+ + + + + + +
+
+
/*
+ * Editor.md
+ *
+ * @file        editormd.js 
+ * @version     v1.4.5 
+ * @description Open source online markdown editor.
+ * @license     MIT License
+ * @author      Pandao
+ * {@link       https://github.com/pandao/editor.md}
+ * @updateTime  2015-06-02
+ */
+
+;(function(factory) {
+    "use strict";
+    
+	// CommonJS/Node.js
+	if (typeof require === "function" && typeof exports === "object" && typeof module === "object")
+    { 
+        module.exports = factory;
+    }
+	else if (typeof define === "function")  // AMD/CMD/Sea.js
+	{
+        if (define.amd) // for Require.js
+        {
+            /* Require.js define replace */
+        } 
+        else 
+        {
+		    define(["jquery"], factory);  // for Sea.js
+        }
+	} 
+	else
+	{ 
+        window.editormd = factory();
+	}
+    
+}(function() {    
+
+    /* Require.js assignment replace */
+    
+    "use strict";
+    
+    var $ = (typeof (jQuery) !== "undefined") ? jQuery : Zepto;
+
+	if (typeof ($) === "undefined") {
+		return ;
+	}
+    
+    /**
+     * editormd
+     * 
+     * @param   {String} id           编辑器的ID
+     * @param   {Object} options      配置选项 Key/Value
+     * @returns {Object} editormd     返回editormd对象
+     */
+    
+    var editormd         = function (id, options) {
+        return new editormd.fn.init(id, options);
+    };
+    
+    editormd.title        = editormd.$name = "Editor.md";
+    editormd.version      = "1.4.5";
+    editormd.homePage     = "https://pandao.github.io/editor.md/";
+    editormd.classPrefix  = "editormd-";
+    
+    editormd.toolbarModes = {
+        full : [
+            "undo", "redo", "|", 
+            "bold", "del", "italic", "quote", "ucwords", "uppercase", "lowercase", "|", 
+            "h1", "h2", "h3", "h4", "h5", "h6", "|", 
+            "list-ul", "list-ol", "hr", "|",
+            "link", "reference-link", "image", "code", "preformatted-text", "code-block", "table", "datetime", "emoji", "html-entities", "pagebreak", "|",
+            "goto-line", "watch", "preview", "fullscreen", "clear", "search", "|",
+            "help", "info"
+        ],
+        simple : [
+            "undo", "redo", "|", 
+            "bold", "del", "italic", "quote", "uppercase", "lowercase", "|", 
+            "h1", "h2", "h3", "h4", "h5", "h6", "|", 
+            "list-ul", "list-ol", "hr", "|",
+            "watch", "preview", "fullscreen", "|",
+            "help", "info"
+        ],
+        mini : [
+            "undo", "redo", "|",
+            "watch", "preview", "|",
+            "help", "info"
+        ]
+    };
+    
+    editormd.defaults     = {
+        mode                 : "gfm",          //gfm or markdown
+        theme                : "default",
+        name                 : "",
+        value                : "",             // value for CodeMirror, if mode not gfm/markdown
+        markdown             : "",
+        appendMarkdown       : "",             // if in init textarea value not empty, append markdown to textarea
+        width                : "100%",
+        height               : "100%",
+        path                 : "./lib/",       // Dependents module file directory
+        pluginPath           : "",             // If this empty, default use settings.path + "../plugins/"
+        delay                : 300,            // Delay parse markdown to html, Uint : ms
+        autoLoadModules      : true,           // Automatic load dependent module files
+        watch                : true,
+        placeholder          : "Enjoy Markdown! coding now...",
+        gotoLine             : true,
+        codeFold             : false,
+        autoHeight           : false,
+		autoFocus            : true,
+        autoCloseTags        : true,
+        searchReplace        : true,
+        syncScrolling        : true,
+        readOnly             : false,
+        tabSize              : 4,
+		indentUnit           : 4,
+        lineNumbers          : true,
+		lineWrapping         : true,
+		autoCloseBrackets    : true,
+		showTrailingSpace    : true,
+		matchBrackets        : true,
+		indentWithTabs       : true,
+		styleSelectedText    : true,
+        matchWordHighlight   : true,           // options: true, false, "onselected"
+        styleActiveLine      : true,           // Highlight the current line
+        dialogLockScreen     : true,
+        dialogShowMask       : true,
+        dialogDraggable      : true,
+        dialogMaskBgColor    : "#fff",
+        dialogMaskOpacity    : 0.1,
+        fontSize             : "13px",
+        saveHTMLToTextarea   : false,
+        disabledKeyMaps      : [],
+        
+        onload               : function() {},
+        onresize             : function() {},
+        onchange             : function() {},
+        onwatch              : null,
+        onunwatch            : null,
+        onpreviewing         : function() {},
+        onpreviewed          : function() {},
+        onfullscreen         : function() {},
+        onfullscreenExit     : function() {},
+        onscroll             : function() {},
+        onpreviewscroll      : function() {},
+        
+        imageUpload          : false,
+        imageFormats         : ["jpg", "jpeg", "gif", "png", "bmp", "webp"],
+        imageUploadURL       : "",
+        crossDomainUpload    : false,
+        uploadCallbackURL    : "",
+        
+        toc                  : true,           // Table of contents
+        tocm                 : false,           // Using [TOCM], auto create ToC dropdown menu
+        tocTitle             : "",             // for ToC dropdown menu btn
+        tocDropdown          : false,
+        tocContainer         : "",
+        tocStartLevel        : 1,              // Said from H1 to create ToC
+        htmlDecode           : false,          // Open the HTML tag identification 
+        pageBreak            : true,           // Enable parse page break [========]
+        atLink               : true,           // for @link
+        emailLink            : true,           // for email address auto link
+        taskList             : false,          // Enable Github Flavored Markdown task lists
+        emoji                : false,          // :emoji: , Support Github emoji, Twitter Emoji (Twemoji);
+                                               // Support FontAwesome icon emoji :fa-xxx: > Using fontAwesome icon web fonts;
+                                               // Support Editor.md logo icon emoji :editormd-logo: :editormd-logo-1x: > 1~8x;
+        tex                  : false,          // TeX(LaTeX), based on KaTeX
+        flowChart            : false,          // flowChart.js only support IE9+
+        sequenceDiagram      : false,          // sequenceDiagram.js only support IE9+
+        previewCodeHighlight : true,
+                
+        toolbar              : true,           // show/hide toolbar
+        toolbarAutoFixed     : true,           // on window scroll auto fixed position
+        toolbarIcons         : "full",
+        toolbarTitles        : {},
+        toolbarHandlers      : {
+            ucwords : function() {
+                return editormd.toolbarHandlers.ucwords;
+            },
+            lowercase : function() {
+                return editormd.toolbarHandlers.lowercase;
+            }
+        },
+        toolbarCustomIcons   : {               // using html tag create toolbar icon, unused default <a> tag.
+            lowercase        : "<a href=\"javascript:;\" title=\"Lowercase\" unselectable=\"on\"><i class=\"fa\" name=\"lowercase\" style=\"font-size:24px;margin-top: -10px;\">a</i></a>",
+            "ucwords"        : "<a href=\"javascript:;\" title=\"ucwords\" unselectable=\"on\"><i class=\"fa\" name=\"ucwords\" style=\"font-size:20px;margin-top: -3px;\">Aa</i></a>"
+        }, 
+        toolbarIconsClass    : {
+            undo             : "fa-undo",
+            redo             : "fa-repeat",
+            bold             : "fa-bold",
+            del              : "fa-strikethrough",
+            italic           : "fa-italic",
+            quote            : "fa-quote-left",
+            uppercase        : "fa-font",
+            h1               : editormd.classPrefix + "bold",
+            h2               : editormd.classPrefix + "bold",
+            h3               : editormd.classPrefix + "bold",
+            h4               : editormd.classPrefix + "bold",
+            h5               : editormd.classPrefix + "bold",
+            h6               : editormd.classPrefix + "bold",
+            "list-ul"        : "fa-list-ul",
+            "list-ol"        : "fa-list-ol",
+            hr               : "fa-minus",
+            link             : "fa-link",
+            "reference-link" : "fa-anchor",
+            image            : "fa-picture-o",
+            code             : "fa-code",
+            "preformatted-text" : "fa-file-code-o",
+            "code-block"     : "fa-file-code-o",
+            table            : "fa-table",
+            datetime         : "fa-clock-o",
+            emoji            : "fa-smile-o",
+            "html-entities"  : "fa-copyright",
+            pagebreak        : "fa-newspaper-o",
+            "goto-line"      : "fa-terminal", // fa-crosshairs
+            watch            : "fa-eye-slash",
+            unwatch          : "fa-eye",
+            preview          : "fa-desktop",
+            search           : "fa-search",
+            fullscreen       : "fa-arrows-alt",
+            clear            : "fa-eraser",
+            help             : "fa-question-circle",
+            info             : "fa-info-circle"
+        },        
+        toolbarIconTexts     : {},
+        
+        lang : {
+            name        : "zh-cn",
+            description : "开源在线Markdown编辑器<br/>Open source online Markdown editor.",
+            tocTitle    : "目录",
+            toolbar     : {
+                undo             : "撤销(Ctrl+Z)",
+                redo             : "重做(Ctrl+Y)",
+                bold             : "粗体",
+                del              : "删除线",
+                italic           : "斜体",
+                quote            : "引用",
+                ucwords          : "将每个单词首字母转成大写",
+                uppercase        : "将所选转换成大写",
+                lowercase        : "将所选转换成小写",
+                h1               : "标题1",
+                h2               : "标题2",
+                h3               : "标题3",
+                h4               : "标题4",
+                h5               : "标题5",
+                h6               : "标题6",
+                "list-ul"        : "无序列表",
+                "list-ol"        : "有序列表",
+                hr               : "横线",
+                link             : "链接",
+                "reference-link" : "引用链接",
+                image            : "添加图片",
+                code             : "行内代码",
+                "preformatted-text" : "预格式文本 / 代码块(缩进风格)",
+                "code-block"     : "代码块(多语言风格)",
+                table            : "添加表格",
+                datetime         : "日期时间",
+                emoji            : "Emoji表情",
+                "html-entities"  : "HTML实体字符",
+                pagebreak        : "插入分页符",
+                "goto-line"      : "跳转到行",
+                watch            : "关闭实时预览",
+                unwatch          : "开启实时预览",
+                preview          : "全窗口预览HTML(按 Shift + ESC还原)",
+                fullscreen       : "全屏(按ESC还原)",
+                clear            : "清空",
+                search           : "搜索",
+                help             : "使用帮助",
+                info             : "关于" + editormd.title
+            },
+            buttons : {
+                enter  : "确定",
+                cancel : "取消",
+                close  : "关闭"
+            },
+            dialog : {
+                link : {
+                    title    : "添加链接",
+                    url      : "链接地址",
+                    urlTitle : "链接标题",
+                    urlEmpty : "错误:请填写链接地址。"
+                },
+                referenceLink : {
+                    title    : "添加引用链接",
+                    name     : "引用名称",
+                    url      : "链接地址",
+                    urlId    : "链接ID",
+                    urlTitle : "链接标题",
+                    nameEmpty: "错误:引用链接的名称不能为空。",
+                    idEmpty  : "错误:请填写引用链接的ID。",
+                    urlEmpty : "错误:请填写引用链接的URL地址。"
+                },
+                image : {
+                    title    : "添加图片",
+                    url      : "图片地址",
+                    link     : "图片链接",
+                    alt      : "图片描述",
+                    uploadButton     : "本地上传",
+                    imageURLEmpty    : "错误:图片地址不能为空。",
+                    uploadFileEmpty  : "错误:上传的图片不能为空。",
+                    formatNotAllowed : "错误:只允许上传图片文件,允许上传的图片文件格式有:"
+                },
+                preformattedText : {
+                    title             : "添加预格式文本或代码块", 
+                    emptyAlert        : "错误:请填写预格式文本或代码的内容。"
+                },
+                codeBlock : {
+                    title             : "添加代码块",                    
+                    selectLabel       : "代码语言:",
+                    selectDefaultText : "请选择代码语言",
+                    otherLanguage     : "其他语言",
+                    unselectedLanguageAlert : "错误:请选择代码所属的语言类型。",
+                    codeEmptyAlert    : "错误:请填写代码内容。"
+                },
+                htmlEntities : {
+                    title : "HTML 实体字符"
+                },
+                help : {
+                    title : "使用帮助"
+                }
+            }
+        }
+    };
+    
+    editormd.classNames  = {
+        tex : editormd.classPrefix + "tex"
+    };
+
+    editormd.dialogZindex = 99999;
+    
+    editormd.$katex       = null;
+    editormd.$marked      = null;
+    editormd.$CodeMirror  = null;
+    editormd.$prettyPrint = null;
+    
+    var timer, flowchartTimer;
+
+    editormd.prototype    = editormd.fn = {
+        state : {
+            watching   : false,
+            loaded     : false,
+            preview    : false,
+            fullscreen : false
+        },
+        
+        /**
+         * 构造函数/实例初始化
+         * Constructor / instance initialization
+         * 
+         * @param   {String}   id            编辑器的ID
+         * @param   {Object}   [options={}]  配置选项 Key/Value
+         * @returns {editormd}               返回editormd的实例对象
+         */
+        
+        init : function (id, options) {
+            
+            options              = options || {};
+            
+            if (typeof id === "object")
+            {
+                options = id;
+            }
+            
+            var _this            = this;
+            var classPrefix      = this.classPrefix  = editormd.classPrefix; 
+            var settings         = this.settings     = $.extend(true, editormd.defaults, options);
+            
+            id                   = (typeof id === "object") ? settings.id : id;
+            
+            var editor           = this.editor       = $("#" + id);
+            
+            this.id              = id;
+            this.lang            = settings.lang;
+            
+            var classNames       = this.classNames   = {
+                textarea : {
+                    html     : classPrefix + "html-textarea",
+                    markdown : classPrefix + "markdown-textarea"
+                }
+            };
+            
+            settings.pluginPath = (settings.pluginPath === "") ? settings.path + "../plugins/" : settings.pluginPath; 
+            
+            this.state.watching = (settings.watch) ? true : false;
+            
+            if ( !editor.hasClass("editormd") ) {
+                editor.addClass("editormd");
+            }
+            
+            editor.css({
+                width  : (typeof settings.width  === "number") ? settings.width  + "px" : settings.width,
+                height : (typeof settings.height === "number") ? settings.height + "px" : settings.height
+            });
+            
+            if (settings.autoHeight)
+            {
+                editor.css("height", "auto");
+            }
+                        
+            var markdownTextarea = this.markdownTextarea = editor.children("textarea");
+            
+            if (markdownTextarea.length < 1)
+            {
+                editor.append("<textarea></textarea>");
+                markdownTextarea = this.markdownTextarea = editor.children("textarea");
+            }
+            
+            markdownTextarea.addClass(classNames.textarea.markdown).attr("placeholder", settings.placeholder);
+            
+            if (typeof markdownTextarea.attr("name") === "undefined" || markdownTextarea.attr("name") === "")
+            {
+                markdownTextarea.attr("name", (settings.name !== "") ? settings.name : id + "-markdown-doc");
+            }
+            
+            var appendElements = [
+                (!settings.readOnly) ? "<a href=\"javascript:;\" class=\"fa fa-close " + classPrefix + "preview-close-btn\"></a>" : "",
+                ( (settings.saveHTMLToTextarea) ? "<textarea class=\"" + classNames.textarea.html + "\" name=\"" + id + "-html-code\"></textarea>" : "" ),
+                "<div class=\"" + classPrefix + "preview\"><div class=\"markdown-body " + classPrefix + "preview-container\"></div></div>",
+                "<div class=\"" + classPrefix + "container-mask\" style=\"display:block;\"></div>",
+                "<div class=\"" + classPrefix + "mask\"></div>"
+            ].join("\n");
+            
+            editor.append(appendElements).addClass(classPrefix + "vertical");
+            
+            this.mask          = editor.children("." + classPrefix + "mask");    
+            this.containerMask = editor.children("." + classPrefix  + "container-mask");
+            
+            if (settings.markdown !== "")
+            {
+                markdownTextarea.val(settings.markdown);
+            }
+            
+            if (settings.appendMarkdown !== "")
+            {
+                markdownTextarea.val(markdownTextarea.val() + settings.appendMarkdown);
+            }
+            
+            this.htmlTextarea     = editor.children("." + classNames.textarea.html);            
+            this.preview          = editor.children("." + classPrefix + "preview");
+            this.previewContainer = this.preview.children("." + classPrefix + "preview-container");
+            
+            if (typeof define === "function" && define.amd)
+            {
+                if (typeof katex !== "undefined") 
+                {
+                    editormd.$katex = katex;
+                }
+                
+                if (settings.searchReplace && !settings.readOnly) 
+                {
+                    editormd.loadCSS(settings.path + "codemirror/addon/dialog/dialog");
+                    editormd.loadCSS(settings.path + "codemirror/addon/search/matchesonscrollbar");
+                }
+            }
+            
+            if ((typeof define === "function" && define.amd) || !settings.autoLoadModules)
+            {
+                if (typeof CodeMirror !== "undefined") {
+                    editormd.$CodeMirror = CodeMirror;
+                }
+                
+                if (typeof marked     !== "undefined") {
+                    editormd.$marked     = marked;
+                }
+                
+                this.setCodeMirror().setToolbar().loadedDisplay();
+            } 
+            else 
+            {
+                this.loadQueues();
+            }
+
+            return this;
+        },
+        
+        /**
+         * 所需组件加载队列
+         * Required components loading queue
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        loadQueues : function() {
+            var _this        = this;
+            var settings     = this.settings;
+            var loadPath     = settings.path;
+                                
+            var loadFlowChartOrSequenceDiagram = function() {
+                
+                if (editormd.isIE8) 
+                {
+                    _this.loadedDisplay();
+                    
+                    return ;
+                }
+
+                if (settings.flowChart || settings.sequenceDiagram) 
+                {
+                    editormd.loadScript(loadPath + "raphael.min", function() {
+
+                        editormd.loadScript(loadPath + "underscore.min", function() {  
+
+                            if (!settings.flowChart && settings.sequenceDiagram) 
+                            {
+                                editormd.loadScript(loadPath + "sequence-diagram.min", function() {
+                                    _this.loadedDisplay();
+                                });
+                            }
+                            else if (settings.flowChart && !settings.sequenceDiagram) 
+                            {      
+                                editormd.loadScript(loadPath + "flowchart.min", function() {  
+                                    editormd.loadScript(loadPath + "jquery.flowchart.min", function() {
+                                        _this.loadedDisplay();
+                                    });
+                                });
+                            }
+                            else if (settings.flowChart && settings.sequenceDiagram) 
+                            {  
+                                editormd.loadScript(loadPath + "flowchart.min", function() {  
+                                    editormd.loadScript(loadPath + "jquery.flowchart.min", function() {
+                                        editormd.loadScript(loadPath + "sequence-diagram.min", function() {
+                                            _this.loadedDisplay();
+                                        });
+                                    });
+                                });
+                            }
+                        });
+
+                    });
+                } 
+                else
+                {
+                    _this.loadedDisplay();
+                }
+            }; 
+
+            editormd.loadCSS(loadPath + "codemirror/codemirror.min");
+            
+            if (settings.searchReplace && !settings.readOnly)
+            {
+                editormd.loadCSS(loadPath + "codemirror/addon/dialog/dialog");
+                editormd.loadCSS(loadPath + "codemirror/addon/search/matchesonscrollbar");
+            }
+            
+            if (settings.codeFold)
+            {
+                editormd.loadCSS(loadPath + "codemirror/addon/fold/foldgutter");            
+            }
+            
+            editormd.loadScript(loadPath + "codemirror/codemirror.min", function() {
+                editormd.$CodeMirror = CodeMirror;
+                
+                editormd.loadScript(loadPath + "codemirror/modes.min", function() {
+                    
+                    editormd.loadScript(loadPath + "codemirror/addons.min", function() {
+                        
+                        _this.setCodeMirror();
+                        
+                        if (settings.mode !== "gfm" && settings.mode !== "markdown") 
+                        {
+                            _this.loadedDisplay();
+                            
+                            return false;
+                        }
+                        
+                        _this.setToolbar();
+
+                        editormd.loadScript(loadPath + "marked.min", function() {
+
+                            editormd.$marked = marked;
+                                
+                            if (settings.previewCodeHighlight) 
+                            {
+                                editormd.loadScript(loadPath + "prettify.min", function() {
+                                    loadFlowChartOrSequenceDiagram();
+                                });
+                            } 
+                            else
+                            {                  
+                                loadFlowChartOrSequenceDiagram();
+                            }
+                        });
+                        
+                    });
+                    
+                });
+                
+            });
+
+            return this;
+        },
+        
+        /**
+         * 设置CodeMirror的主题
+         * Setting CodeMirror theme
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        setTheme : function(theme) {  
+            var settings   = this.settings;  
+            settings.theme = theme;  
+            
+            if (theme !== "default")
+            {
+                editormd.loadCSS(settings.path + "codemirror/theme/" + settings.theme);
+            }
+            
+            this.cm.setOption("theme", theme);
+            
+            return this;
+        },
+        
+        /**
+         * 配置和初始化CodeMirror组件
+         * CodeMirror initialization
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        setCodeMirror : function() { 
+            var settings         = this.settings;
+            var editor           = this.editor;
+            
+            if (settings.theme !== "default")
+            {
+                editormd.loadCSS(settings.path + "codemirror/theme/" + settings.theme);
+            }
+            
+            var codeMirrorConfig = {
+                mode                      : settings.mode,
+                theme                     : settings.theme,
+                tabSize                   : settings.tabSize,
+                dragDrop                  : false,
+                autofocus                 : settings.autoFocus,
+                autoCloseTags             : settings.autoCloseTags,
+                readOnly                  : (settings.readOnly) ? "nocursor" : false,
+                indentUnit                : settings.indentUnit,
+                lineNumbers               : settings.lineNumbers,
+                lineWrapping              : settings.lineWrapping,
+                extraKeys                 : {
+                                                "Ctrl-Q": function(cm) { 
+                                                    cm.foldCode(cm.getCursor()); 
+                                                }
+                                            },
+                foldGutter                : settings.codeFold,
+                gutters                   : ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
+                matchBrackets             : settings.matchBrackets,
+                indentWithTabs            : settings.indentWithTabs,
+                styleActiveLine           : settings.styleActiveLine,
+                styleSelectedText         : settings.styleSelectedText,
+                autoCloseBrackets         : settings.autoCloseBrackets,
+                showTrailingSpace         : settings.showTrailingSpace,
+                highlightSelectionMatches : ( (!settings.matchWordHighlight) ? false : { showToken: (settings.matchWordHighlight === "onselected") ? false : /\w/ } )
+            };
+            
+            this.codeEditor = this.cm        = editormd.$CodeMirror.fromTextArea(this.markdownTextarea[0], codeMirrorConfig);
+            this.codeMirror = this.cmElement = editor.children(".CodeMirror");
+            
+            if (settings.value !== "")
+            {
+                this.cm.setValue(settings.value);
+            }
+
+            this.codeMirror.css({
+                fontSize : settings.fontSize,
+                width    : (!settings.watch) ? "100%" : "50%"
+            });
+            
+            if (settings.autoHeight)
+            {
+                this.codeMirror.css("height", "auto");
+                this.cm.setOption("viewportMargin", Infinity);
+            }
+
+            return this;
+        },
+        
+        /**
+         * 获取CodeMirror的配置选项
+         * Get CodeMirror setting options
+         * 
+         * @returns {Mixed}                  return CodeMirror setting option value
+         */
+        
+        getCodeMirrorOption : function(key) {            
+            return this.cm.getOption(key);
+        },
+        
+        /**
+         * 配置和重配置CodeMirror的选项
+         * CodeMirror setting options / resettings
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        setCodeMirrorOption : function(key, value) {
+            
+            this.cm.setOption(key, value);
+            
+            return this;
+        },
+        
+        /**
+         * 添加 CodeMirror 键盘快捷键
+         * Add CodeMirror keyboard shortcuts key map
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        addKeyMap : function(map, bottom) {
+            this.cm.addKeyMap(map, bottom);
+            
+            return this;
+        },
+        
+        /**
+         * 移除 CodeMirror 键盘快捷键
+         * Remove CodeMirror keyboard shortcuts key map
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        removeKeyMap : function(map) {
+            this.cm.removeKeyMap(map);
+            
+            return this;
+        },
+        
+        /**
+         * 跳转到指定的行
+         * Goto CodeMirror line
+         * 
+         * @param   {String|Intiger}   line      line number or "first"|"last"
+         * @returns {editormd}                   返回editormd的实例对象
+         */
+        
+        gotoLine : function (line) {
+            
+            var settings = this.settings;
+            
+            if (!settings.gotoLine)
+            {
+                return this;
+            }
+            
+            var cm       = this.cm;
+            var editor   = this.editor;
+            var count    = cm.lineCount();
+            var preview  = this.preview;
+            
+            if (typeof line === "string")
+            {
+                if(line === "last")
+                {
+                    line = count;
+                }
+            
+                if (line === "first")
+                {
+                    line = 1;
+                }
+            }
+            
+            if (typeof line !== "number") 
+            {  
+                alert("Error: The line number must be an integer.");
+                return this;
+            }
+            
+            line  = parseInt(line) - 1;
+            
+            if (line > count)
+            {
+                alert("Error: The line number range 1-" + count);
+                
+                return this;
+            }
+            
+            cm.setCursor( {line : line, ch : 0} );
+            
+            var scrollInfo   = cm.getScrollInfo();
+            var clientHeight = scrollInfo.clientHeight; 
+            var coords       = cm.charCoords({line : line, ch : 0}, "local");
+            
+            cm.scrollTo(null, (coords.top + coords.bottom - clientHeight) / 2);
+            
+            if (settings.watch)
+            {            
+                var cmScroll  = this.codeMirror.find(".CodeMirror-scroll")[0];
+                var height    = $(cmScroll).height(); 
+                var scrollTop = cmScroll.scrollTop;         
+                var percent   = (scrollTop / cmScroll.scrollHeight);
+
+                if (scrollTop === 0)
+                {
+                    preview.scrollTop(0);
+                } 
+                else if (scrollTop + height >= cmScroll.scrollHeight - 16)
+                { 
+                    preview.scrollTop(preview[0].scrollHeight);                    
+                } 
+                else
+                {                    
+                    preview.scrollTop(preview[0].scrollHeight * percent);
+                }
+            }
+
+            cm.focus();
+            
+            return this;
+        },
+        
+        /**
+         * 扩展当前实例对象,可同时设置多个或者只设置一个
+         * Extend editormd instance object, can mutil setting.
+         * 
+         * @returns {editormd}                  this(editormd instance object.)
+         */
+        
+        extend : function() {
+            if (typeof arguments[1] !== "undefined")
+            {
+                if (typeof arguments[1] === "function")
+                {
+                    arguments[1] = $.proxy(arguments[1], this);
+                }
+
+                this[arguments[0]] = arguments[1];
+            }
+            
+            if (typeof arguments[0] === "object" && typeof arguments[0].length === "undefined")
+            {
+                $.extend(true, this, arguments[0]);
+            }
+
+            return this;
+        },
+        
+        /**
+         * 设置或扩展当前实例对象,单个设置
+         * Extend editormd instance object, one by one
+         * 
+         * @param   {String|Object}   key       option key
+         * @param   {String|Object}   value     option value
+         * @returns {editormd}                  this(editormd instance object.)
+         */
+        
+        set : function (key, value) {
+            
+            if (typeof value !== "undefined" && typeof value === "function")
+            {
+                value = $.proxy(value, this);
+            }
+            
+            this[key] = value;
+
+            return this;
+        },
+        
+        /**
+         * 重新配置
+         * Resetting editor options
+         * 
+         * @param   {String|Object}   key       option key
+         * @param   {String|Object}   value     option value
+         * @returns {editormd}                  this(editormd instance object.)
+         */
+        
+        config : function(key, value) {
+            var settings = this.settings;
+            
+            if (typeof key === "object")
+            {
+                settings = $.extend(true, settings, key);
+            }
+            
+            if (typeof key === "string")
+            {
+                settings[key] = value;
+            }
+            
+            this.settings = settings;
+            this.recreate();
+            
+            return this;
+        },
+        
+        /**
+         * 注册事件处理方法
+         * Bind editor event handle
+         * 
+         * @param   {String}     eventType      event type
+         * @param   {Function}   callback       回调函数
+         * @returns {editormd}                  this(editormd instance object.)
+         */
+        
+        on : function(eventType, callback) {
+            var settings = this.settings;
+            
+            if (typeof settings["on" + eventType] !== "undefined") 
+            {                
+                settings["on" + eventType] = $.proxy(callback, this);      
+            }
+
+            return this;
+        },
+        
+        /**
+         * 解除事件处理方法
+         * Unbind editor event handle
+         * 
+         * @param   {String}   eventType          event type
+         * @returns {editormd}                    this(editormd instance object.)
+         */
+        
+        off : function(eventType) {
+            var settings = this.settings;
+            
+            if (typeof settings["on" + eventType] !== "undefined") 
+            {
+                settings["on" + eventType] = function(){};
+            }
+            
+            return this;
+        },
+        
+        /**
+         * 显示工具栏
+         * Display toolbar
+         * 
+         * @param   {Function} [callback=function(){}] 回调函数
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        showToolbar : function(callback) {
+            var settings = this.settings;
+            
+            if(settings.readOnly) {
+                return this;
+            }
+            
+            if (settings.toolbar && (this.toolbar.length < 1 || this.toolbar.find("." + this.classPrefix + "menu").html() === "") )
+            {
+                this.setToolbar();
+            }
+            
+            settings.toolbar = true; 
+            
+            this.toolbar.show();
+            this.resize();
+            
+            $.proxy(callback || function(){}, this)();
+
+            return this;
+        },
+        
+        /**
+         * 隐藏工具栏
+         * Hide toolbar
+         * 
+         * @param   {Function} [callback=function(){}] 回调函数
+         * @returns {editormd}                         this(editormd instance object.)
+         */
+        
+        hideToolbar : function(callback) { 
+            var settings = this.settings;
+            
+            settings.toolbar = false;  
+            this.toolbar.hide();
+            this.resize();
+            
+            $.proxy(callback || function(){}, this)();
+
+            return this;
+        },
+        
+        /**
+         * 页面滚动时工具栏的固定定位
+         * Set toolbar in window scroll auto fixed position
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        setToolbarAutoFixed : function(fixed) {
+            
+            var state    = this.state;
+            var editor   = this.editor;
+            var toolbar  = this.toolbar;
+            var settings = this.settings;
+            
+            if (typeof fixed !== "undefined")
+            {
+                settings.toolbarAutoFixed = fixed;
+            }
+            
+            var autoFixedHandle = function(){
+                var $window = $(window);
+                var top     = $window.scrollTop();
+                
+                if (!settings.toolbarAutoFixed)
+                {
+                    return false;
+                }
+
+                if (top - editor.offset().top > 10 && top < editor.height())
+                {
+                    toolbar.css({
+                        position : "fixed",
+                        width    : editor.width() + "px",
+                        left     : ($window.width() - editor.width()) / 2 + "px"
+                    });
+                }
+                else
+                {
+                    toolbar.css({
+                        position : "absolute",
+                        width    : "100%",
+                        left     : 0
+                    });
+                }
+            };
+            
+            if (!state.fullscreen && !state.preview && settings.toolbar && settings.toolbarAutoFixed)
+            {
+                $(window).bind("scroll", autoFixedHandle);
+            }
+
+            return this;
+        },
+        
+        /**
+         * 配置和初始化工具栏
+         * Set toolbar and Initialization
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        setToolbar : function() {
+            var settings    = this.settings;  
+            
+            if(settings.readOnly) {
+                return this;
+            }
+            
+            var editor      = this.editor;
+            var preview     = this.preview;
+            var classPrefix = this.classPrefix;
+            
+            var toolbar     = this.toolbar = editor.children("." + classPrefix + "toolbar");
+            
+            if (settings.toolbar && toolbar.length < 1)
+            {            
+                var toolbarHTML = "<div class=\"" + classPrefix + "toolbar\"><div class=\"" + classPrefix + "toolbar-container\"><ul class=\"" + classPrefix + "menu\"></ul></div></div>";
+                
+                editor.append(toolbarHTML);
+                toolbar = this.toolbar = editor.children("." + classPrefix + "toolbar");
+            }
+            
+            if (!settings.toolbar) 
+            {
+                toolbar.hide();
+                
+                return this;
+            }
+            
+            toolbar.show();
+            
+            var icons       = (typeof settings.toolbarIcons === "function") ? settings.toolbarIcons() 
+                            : ((typeof settings.toolbarIcons === "string")  ? editormd.toolbarModes[settings.toolbarIcons] : settings.toolbarIcons);
+            
+            var toolbarMenu = toolbar.find("." + this.classPrefix + "menu"), menu = "";
+            var pullRight   = false;
+            
+            for (var i = 0, len = icons.length; i < len; i++)
+            {
+                var name = icons[i];
+
+                if (name === "||") 
+                { 
+                    pullRight = true;
+                } 
+                else if (name === "|")
+                {
+                    menu += "<li class=\"divider\" unselectable=\"on\">|</li>";
+                }
+                else
+                {
+                    var isHeader = (/h(\d)/.test(name));
+                    var index    = name;
+                    
+                    if (name === "watch" && !settings.watch) {
+                        index = "unwatch";
+                    }
+                    
+                    var title     = settings.lang.toolbar[index];
+                    var iconTexts = settings.toolbarIconTexts[index];
+                    var iconClass = settings.toolbarIconsClass[index];
+                    
+                    title     = (typeof title     === "undefined") ? "" : title;
+                    iconTexts = (typeof iconTexts === "undefined") ? "" : iconTexts;
+                    iconClass = (typeof iconClass === "undefined") ? "" : iconClass;
+
+                    var menuItem = pullRight ? "<li class=\"pull-right\">" : "<li>";
+                    
+                    if (typeof settings.toolbarCustomIcons[name] !== "undefined" && typeof settings.toolbarCustomIcons[name] !== "function")
+                    {
+                        menuItem += settings.toolbarCustomIcons[name];
+                    }
+                    else 
+                    {
+                        menuItem += "<a href=\"javascript:;\" title=\"" + title + "\" unselectable=\"on\">";
+                        menuItem += "<i class=\"fa " + iconClass + "\" name=\""+name+"\" unselectable=\"on\">"+((isHeader) ? name.toUpperCase() : ( (iconClass === "") ? iconTexts : "") ) + "</i>";
+                        menuItem += "</a>";
+                    }
+
+                    menuItem += "</li>";
+
+                    menu = pullRight ? menuItem + menu : menu + menuItem;
+                }
+            }
+
+            toolbarMenu.html(menu);
+            
+            toolbarMenu.find("[title=\"Lowercase\"]").attr("title", settings.lang.toolbar.lowercase);
+            toolbarMenu.find("[title=\"ucwords\"]").attr("title", settings.lang.toolbar.ucwords);
+            
+            this.setToolbarHandler();
+            this.setToolbarAutoFixed();
+
+            return this;
+        },
+        
+        /**
+         * 工具栏图标事件处理对象序列
+         * Get toolbar icons event handlers
+         * 
+         * @param   {Object}   cm    CodeMirror的实例对象
+         * @param   {String}   name  要获取的事件处理器名称
+         * @returns {Object}         返回处理对象序列
+         */
+            
+        dialogLockScreen : function() {
+            $.proxy(editormd.dialogLockScreen, this)();
+            
+            return this;
+        },
+
+        dialogShowMask : function(dialog) {
+            $.proxy(editormd.dialogShowMask, this)(dialog);
+            
+            return this;
+        },
+        
+        getToolbarHandles : function(name) {  
+            var toolbarHandlers = this.toolbarHandlers = editormd.toolbarHandlers;
+            
+            return (name && typeof toolbarIconHandlers[name] !== "undefined") ? toolbarHandlers[name] : toolbarHandlers;
+        },
+        
+        /**
+         * 工具栏图标事件处理器
+         * Bind toolbar icons event handle
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        setToolbarHandler : function() {
+            var _this               = this;
+            var settings            = this.settings;
+            
+            if (!settings.toolbar || settings.readOnly) {
+                return this;
+            }
+            
+            var toolbar             = this.toolbar;
+            var cm                  = this.cm;
+            var classPrefix         = this.classPrefix;           
+            var toolbarIcons        = this.toolbarIcons = toolbar.find("." + classPrefix + "menu > li > a");  
+            var toolbarIconHandlers = this.getToolbarHandles();  
+                
+            toolbarIcons.bind(editormd.mouseOrTouch("click", "touchend"), function(event) {
+
+                var icon                = $(this).children(".fa");
+                var name                = icon.attr("name");
+                var cursor              = cm.getCursor();
+                var selection           = cm.getSelection();
+
+                if (name === "") {
+                    return ;
+                }
+                
+                _this.activeIcon = icon;
+
+                if (typeof toolbarIconHandlers[name] !== "undefined") 
+                {
+                    $.proxy(toolbarIconHandlers[name], _this)(cm);
+                }
+                else 
+                {
+                    if (typeof settings.toolbarHandlers[name] !== "undefined") 
+                    {
+                        $.proxy(settings.toolbarHandlers[name], _this)(cm, icon, cursor, selection);
+                    }
+                }
+                
+                if (name !== "link" && name !== "reference-link" && name !== "image" && name !== "code-block" && 
+                    name !== "preformatted-text" && name !== "watch" && name !== "preview" && name !== "search" && name !== "fullscreen" && name !== "info") 
+                {
+                    cm.focus();
+                }
+
+                return false;
+
+            });
+
+            return this;
+        },
+        
+        /**
+         * 动态创建对话框
+         * Creating custom dialogs
+         * 
+         * @param   {Object} options  配置项键值对 Key/Value
+         * @returns {dialog}          返回创建的dialog的jQuery实例对象
+         */
+        
+        createDialog : function(options) {            
+            return $.proxy(editormd.createDialog, this)(options);
+        },
+        
+        /**
+         * 创建关于Editor.md的对话框
+         * Create about Editor.md dialog
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        createInfoDialog : function() {
+            var _this        = this;
+			var editor       = this.editor;
+            var classPrefix  = this.classPrefix;  
+            
+            var infoDialogHTML = [
+                "<div class=\"" + classPrefix + "dialog " + classPrefix + "dialog-info\" style=\"\">",
+                "<div class=\"" + classPrefix + "dialog-container\">",
+                "<h1><i class=\"editormd-logo editormd-logo-lg editormd-logo-color\"></i> " + editormd.title + "<small>v" + editormd.version + "</small></h1>",
+                "<p>" + this.lang.description + "</p>",
+                "<p style=\"margin: 10px 0 20px 0;\"><a href=\"" + editormd.homePage + "\" target=\"_blank\">" + editormd.homePage + " <i class=\"fa fa-external-link\"></i></a></p>",
+                "<p style=\"font-size: 0.85em;\">Copyright &copy; 2015 <a href=\"https://github.com/pandao\" target=\"_blank\" class=\"hover-link\">Pandao</a>, The <a href=\"https://github.com/pandao/editor.md/blob/master/LICENSE\" target=\"_blank\" class=\"hover-link\">MIT</a> License.</p>",
+                "</div>",
+                "<a href=\"javascript:;\" class=\"fa fa-close " + classPrefix + "dialog-close\"></a>",
+                "</div>"
+            ].join("\n");
+
+            editor.append(infoDialogHTML);
+            
+            var infoDialog  = this.infoDialog = editor.children("." + classPrefix + "dialog-info");
+
+            infoDialog.find("." + classPrefix + "dialog-close").bind(editormd.mouseOrTouch("click", "touchend"), function() {
+                _this.hideInfoDialog();
+            });
+            
+            infoDialog.css("border", (editormd.isIE8) ? "1px solid #ddd" : "").css("z-index", editormd.dialogZindex).show();
+            
+            this.infoDialogPosition();
+
+            return this;
+        },
+        
+        /**
+         * 关于Editor.md对话居中定位
+         * Editor.md dialog position handle
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        infoDialogPosition : function() {
+            var infoDialog = this.infoDialog;
+            
+			var _infoDialogPosition = function() {
+				infoDialog.css({
+					top  : ($(window).height() - infoDialog.height()) / 2 + "px",
+					left : ($(window).width()  - infoDialog.width()) / 2  + "px"
+				});
+			};
+
+			_infoDialogPosition();
+
+			$(window).resize(_infoDialogPosition);
+            
+            return this;
+        },
+        
+        /**
+         * 显示关于Editor.md
+         * Display about Editor.md dialog
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        showInfoDialog : function() {
+
+            $("html,body").css("overflow-x", "hidden");
+            
+            var _this       = this;
+			var editor      = this.editor;
+            var settings    = this.settings;         
+			var infoDialog  = this.infoDialog = editor.children("." + this.classPrefix + "dialog-info");
+            
+            if (infoDialog.length < 1)
+            {
+                this.createInfoDialog();
+            }
+            
+            this.lockScreen(true);
+            
+            this.mask.css({
+						opacity         : settings.dialogMaskOpacity,
+						backgroundColor : settings.dialogMaskBgColor
+					}).show();
+
+			infoDialog.css("z-index", editormd.dialogZindex).show();
+
+			this.infoDialogPosition();
+
+            return this;
+        },
+        
+        /**
+         * 隐藏关于Editor.md
+         * Hide about Editor.md dialog
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        hideInfoDialog : function() {            
+            $("html,body").css("overflow-x", "");
+            this.infoDialog.hide();
+            this.mask.hide();
+            this.lockScreen(false);
+
+            return this;
+        },
+        
+        /**
+         * 锁屏
+         * lock screen
+         * 
+         * @param   {Boolean}    lock    Boolean 布尔值,是否锁屏
+         * @returns {editormd}           返回editormd的实例对象
+         */
+        
+        lockScreen : function(lock) {
+            editormd.lockScreen(lock);
+
+            return this;
+        },
+        
+        /**
+         * 编辑器界面重建,用于动态语言包或模块加载等
+         * Recreate editor
+         * 
+         * @returns {editormd}  返回editormd的实例对象
+         */
+        
+        recreate : function() {
+            var _this            = this;
+            var editor           = this.editor;
+            var settings         = this.settings;
+            
+            this.codeMirror.remove();
+            
+            this.setCodeMirror();
+
+            if (!settings.readOnly) 
+            {
+                if (editor.find(".editormd-dialog").length > 0) {
+                    editor.find(".editormd-dialog").remove();
+                }
+                
+                if (settings.toolbar) 
+                {  
+                    this.getToolbarHandles();                  
+                    this.setToolbar();
+                }
+            }
+            
+            this.loadedDisplay(true);
+
+            return this;
+        },
+        
+        /**
+         * 高亮预览HTML的pre代码部分
+         * highlight of preview codes
+         * 
+         * @returns {editormd}             返回editormd的实例对象
+         */
+        
+        previewCodeHighlight : function() {    
+            var settings         = this.settings;
+            var previewContainer = this.previewContainer;
+            
+            if (settings.previewCodeHighlight) 
+            {
+                previewContainer.find("pre").addClass("prettyprint linenums");
+                
+                if (typeof prettyPrint !== "undefined")
+                {                    
+                    prettyPrint();
+                }
+            }
+
+            return this;
+        },
+        
+        /**
+         * 解析TeX(KaTeX)科学公式
+         * TeX(KaTeX) Renderer
+         * 
+         * @returns {editormd}             返回editormd的实例对象
+         */
+        
+        katexRender : function() {
+            
+            if (timer === null)
+            {
+                return this;
+            }
+            
+            this.previewContainer.find("." + editormd.classNames.tex).each(function(){
+                var tex  = $(this);
+                editormd.$katex.render(tex.text(), tex[0]);
+            });   
+
+            return this;
+        },
+        
+        /**
+         * 解析和渲染流程图及时序图
+         * FlowChart and SequenceDiagram Renderer
+         * 
+         * @returns {editormd}             返回editormd的实例对象
+         */
+        
+        flowChartAndSequenceDiagramRender : function() {
+            
+            var settings         = this.settings;
+            var previewContainer = this.previewContainer;
+            
+            if (editormd.isIE8) {
+                return this;
+            }
+
+            if (settings.flowChart) {
+                if (flowchartTimer === null) {
+                    return this;
+                }
+                
+                previewContainer.find(".flowchart").flowChart(); 
+            }
+
+            if (settings.sequenceDiagram) {
+                previewContainer.find(".sequence-diagram").sequenceDiagram({theme: "simple"});
+            }
+
+            return this;
+        },
+        
+        /**
+         * 注册键盘快捷键处理
+         * Register CodeMirror keyMaps (keyboard shortcuts).
+         * 
+         * @param   {Object}    keyMap      KeyMap key/value {"(Ctrl/Shift/Alt)-Key" : function(){}}
+         * @returns {editormd}              return this
+         */
+        
+        registerKeyMaps : function(keyMap) {
+            
+            var _this           = this;
+            var cm              = this.cm;
+            var settings        = this.settings;
+            var toolbarHandlers = editormd.toolbarHandlers;
+            var disabledKeyMaps = settings.disabledKeyMaps;
+            
+            keyMap              = keyMap || null;
+            
+            if (keyMap)
+            {
+                for (var i in keyMap)
+                {
+                    if ($.inArray(i, disabledKeyMaps) < 0)
+                    {
+                        var map = {};
+                        map[i]  = keyMap[i];
+
+                        cm.addKeyMap(keyMap);
+                    }
+                }
+            }
+            else
+            {
+                for (var k in editormd.keyMaps)
+                {
+                    var _keyMap = editormd.keyMaps[k];
+                    var handle = (typeof _keyMap === "string") ? $.proxy(toolbarHandlers[_keyMap], _this) : $.proxy(_keyMap, _this);
+                    
+                    if ($.inArray(k, ["F9", "F10", "F11"]) < 0 && $.inArray(k, disabledKeyMaps) < 0)
+                    {
+                        var _map = {};
+                        _map[k] = handle;
+
+                        cm.addKeyMap(_map);
+                    }
+                }
+                
+                $(window).keydown(function(event) {
+                    
+                    var keymaps = {
+                        "120" : "F9",
+                        "121" : "F10",
+                        "122" : "F11"
+                    };
+                    
+                    if ( $.inArray(keymaps[event.keyCode], disabledKeyMaps) < 0 )
+                    {
+                        switch (event.keyCode)
+                        {
+                            case 120:
+                                    $.proxy(toolbarHandlers["watch"], _this)();
+                                    return false;
+                                break;
+                                
+                            case 121:
+                                    $.proxy(toolbarHandlers["preview"], _this)();
+                                    return false;
+                                break;
+                                
+                            case 122:
+                                    $.proxy(toolbarHandlers["fullscreen"], _this)();                        
+                                    return false;
+                                break;
+                                
+                            default:
+                                break;
+                        }
+                    }
+                });
+            }
+
+            return this;
+        },
+        
+        bindScrollEvent : function() {
+            
+            var _this            = this;
+            var preview          = this.preview;
+            var settings         = this.settings;
+            var codeMirror       = this.codeMirror;
+            var mouseOrTouch     = editormd.mouseOrTouch;
+            
+            if (!settings.syncScrolling) {
+                return this;
+            }
+                
+            var cmBindScroll = function() {    
+                codeMirror.find(".CodeMirror-scroll").bind(mouseOrTouch("scroll", "touchmove"), function(event) {
+                    var height    = $(this).height();
+                    var scrollTop = $(this).scrollTop();                    
+                    var percent   = (scrollTop / $(this)[0].scrollHeight);
+
+                    if (scrollTop === 0) 
+                    {
+                        preview.scrollTop(0);
+                    } 
+                    else if (scrollTop + height >= $(this)[0].scrollHeight - 16)
+                    { 
+                        preview.scrollTop(preview[0].scrollHeight);                        
+                    } 
+                    else
+                    {                    
+                        preview.scrollTop(preview[0].scrollHeight * percent);
+                    }
+                    
+                    $.proxy(settings.onscroll, _this)(event);
+                });
+            };
+
+            var cmUnbindScroll = function() {
+                codeMirror.find(".CodeMirror-scroll").unbind(mouseOrTouch("scroll", "touchmove"));
+            };
+
+            var previewBindScroll = function() {
+                
+                preview.bind(mouseOrTouch("scroll", "touchmove"), function(event) {
+                    var height    = $(this).height();
+                    var scrollTop = $(this).scrollTop();         
+                    var percent   = (scrollTop / $(this)[0].scrollHeight);
+                    var codeView  = codeMirror.find(".CodeMirror-scroll");
+
+                    if(scrollTop === 0) 
+                    {
+                        codeView.scrollTop(0);
+                    }
+                    else if (scrollTop + height >= $(this)[0].scrollHeight)
+                    {
+                        codeView.scrollTop(codeView[0].scrollHeight);                        
+                    }
+                    else 
+                    {
+                        codeView.scrollTop(codeView[0].scrollHeight * percent);
+                    }
+                    
+                    $.proxy(settings.onpreviewscroll, _this)(event);
+                });
+
+            };
+
+            var previewUnbindScroll = function() {
+                preview.unbind(mouseOrTouch("scroll", "touchmove"));
+            }; 
+
+			codeMirror.bind({
+				mouseover  : cmBindScroll,
+				mouseout   : cmUnbindScroll,
+				touchstart : cmBindScroll,
+				touchend   : cmUnbindScroll
+			});
+            
+			preview.bind({
+				mouseover  : previewBindScroll,
+				mouseout   : previewUnbindScroll,
+				touchstart : previewBindScroll,
+				touchend   : previewUnbindScroll
+			});
+
+            return this;
+        },
+        
+        bindChangeEvent : function() {
+            
+            var _this            = this;
+            var cm               = this.cm;
+            var settings         = this.settings;
+            
+            if (!settings.syncScrolling) {
+                return this;
+            }
+            
+            cm.on("change", function(_cm, changeObj) {
+                
+                if (settings.watch)
+                {           
+                    _this.previewContainer.css("padding", settings.autoHeight ? "20px 20px 50px 40px" : "20px");
+                }
+                
+                timer = setTimeout(function() {
+                    clearTimeout(timer);
+                    _this.save();
+                    timer = null;
+                }, settings.delay);
+            });
+
+            return this;
+        },
+        
+        /**
+         * 加载队列完成之后的显示处理
+         * Display handle of the module queues loaded after.
+         * 
+         * @param   {Boolean}   recreate   是否为重建编辑器
+         * @returns {editormd}             返回editormd的实例对象
+         */
+        
+        loadedDisplay : function(recreate) {
+            
+            recreate             = recreate || false;
+            
+            var _this            = this;
+            var editor           = this.editor;
+            var preview          = this.preview;
+            var settings         = this.settings;
+            
+            this.containerMask.hide();
+            
+            this.save();
+            
+            if (settings.watch) {
+                preview.show();
+            }
+            
+            editor.data("oldWidth", editor.width()).data("oldHeight", editor.height()); // 为了兼容Zepto
+            
+            this.resize();
+            this.registerKeyMaps();
+            
+            $(window).resize(function(){
+                _this.resize();
+            });
+            
+            this.bindScrollEvent().bindChangeEvent();
+            
+            if (!recreate)
+            {
+                $.proxy(settings.onload, this)();
+            }
+            
+            this.state.loaded = true;
+
+            return this;
+        },
+        
+        /**
+         * 设置编辑器的宽度
+         * Set editor width
+         * 
+         * @param   {Number|String} width  编辑器宽度值
+         * @returns {editormd}             返回editormd的实例对象
+         */
+        
+        width : function(width) {
+                
+            this.editor.css("width", (typeof width === "number") ? width  + "px" : width);            
+            this.resize();
+            
+            return this;
+        },
+        
+        /**
+         * 设置编辑器的高度
+         * Set editor height
+         * 
+         * @param   {Number|String} height  编辑器高度值
+         * @returns {editormd}              返回editormd的实例对象
+         */
+        
+        height : function(height) {
+                
+            this.editor.css("height", (typeof height === "number")  ? height  + "px" : height);            
+            this.resize();
+            
+            return this;
+        },
+        
+        /**
+         * 调整编辑器的尺寸和布局
+         * Resize editor layout
+         * 
+         * @param   {Number|String} [width=null]  编辑器宽度值
+         * @param   {Number|String} [height=null] 编辑器高度值
+         * @returns {editormd}                    返回editormd的实例对象
+         */
+        
+        resize : function(width, height) {
+            
+            width  = width  || null;
+            height = height || null;
+            
+            var state      = this.state;
+            var editor     = this.editor;
+            var preview    = this.preview;
+            var toolbar    = this.toolbar;
+            var settings   = this.settings;
+            var codeMirror = this.codeMirror;
+            
+            if (width)
+            {
+                editor.css("width", (typeof width  === "number") ? width  + "px" : width);
+            }
+            
+            if (settings.autoHeight && !state.fullscreen && !state.preview)
+            {
+                editor.css("height", "auto");
+                codeMirror.css("height", "auto");
+            } 
+            else 
+            {
+                if (height) 
+                {
+                    editor.css("height", (typeof height === "number") ? height + "px" : height);
+                }
+                
+                if (state.fullscreen)
+                {
+                    editor.height($(window).height());
+                }
+
+                if (settings.toolbar && !settings.readOnly) 
+                {
+                    codeMirror.css("margin-top", toolbar.height() + 1).height(editor.height() - toolbar.height());
+                } 
+                else
+                {
+                    codeMirror.css("margin-top", 0).height(editor.height());
+                }
+            }
+            
+            if(settings.watch) 
+            {
+                codeMirror.width(editor.width() / 2);
+                preview.width((!state.preview) ? editor.width() / 2 : editor.width());
+                
+                this.previewContainer.css("padding", settings.autoHeight ? "20px 20px 50px 40px" : "20px");
+                
+                if (settings.toolbar && !settings.readOnly) 
+                {
+                    preview.css("top", toolbar.height());
+                } 
+                else 
+                {
+                    preview.css("top", 0);
+                }
+                
+                if (settings.autoHeight && !state.fullscreen && !state.preview)
+                {
+                    preview.height("");
+                }
+                else
+                {                
+                    preview.height((settings.toolbar && !settings.readOnly) ? editor.height() - toolbar.height() : editor.height());
+                }
+            } 
+            else 
+            {
+                codeMirror.width(editor.width());
+                preview.hide();
+            }
+            
+            if (state.loaded) 
+            {
+                $.proxy(settings.onresize, this)();
+            }
+
+            return this;
+        },
+        
+        /**
+         * 解析和保存Markdown代码
+         * Parse & Saving Markdown source code
+         * 
+         * @returns {editormd}     返回editormd的实例对象
+         */
+        
+        save : function() {
+            
+            if (timer === null)
+            {
+                return this;
+            }
+            
+            var _this            = this;
+            var state            = this.state;
+            var settings         = this.settings;
+            var cm               = this.cm;            
+            var cmValue          = cm.getValue();
+            var previewContainer = this.previewContainer;
+
+            if (settings.mode !== "gfm" && settings.mode !== "markdown") 
+            {
+                this.markdownTextarea.val(cmValue);
+                
+                return this;
+            }
+            
+            var marked          = editormd.$marked;
+            var markdownToC     = this.markdownToC = [];            
+            var rendererOptions = this.markedRendererOptions = {  
+                toc                  : settings.toc,
+                tocm                 : settings.tocm,
+                tocStartLevel        : settings.tocStartLevel,
+                pageBreak            : settings.pageBreak,
+                taskList             : settings.taskList,
+                emoji                : settings.emoji,
+                tex                  : settings.tex,
+                atLink               : settings.atLink,           // for @link
+                emailLink            : settings.emailLink,        // for mail address auto link
+                flowChart            : settings.flowChart,
+                sequenceDiagram      : settings.sequenceDiagram,
+                previewCodeHighlight : settings.previewCodeHighlight,
+            };
+            
+            var markedOptions = this.markedOptions = {
+                renderer    : editormd.markedRenderer(markdownToC, rendererOptions),
+                gfm         : true,
+                tables      : true,
+                breaks      : true,
+                pedantic    : false,
+                sanitize    : (settings.htmlDecode) ? false : true,  // 关闭忽略HTML标签,即开启识别HTML标签,默认为false
+                smartLists  : true,
+                smartypants : true
+            };
+            
+            marked.setOptions(markedOptions);
+        
+            cmValue            = editormd.filterHTMLTags(cmValue, settings.htmlDecode);
+            
+            var newMarkdownDoc = editormd.$marked(cmValue, markedOptions);
+            
+            //console.log("cmValue", cmValue, this.markdownTextarea, this.htmlTextarea);
+            
+            this.markdownTextarea.text(cmValue);
+            
+            cm.save();
+            
+            if (settings.saveHTMLToTextarea) 
+            {
+                this.htmlTextarea.text(newMarkdownDoc);
+            }
+            
+            if(settings.watch || (!settings.watch && state.preview))
+            {
+                previewContainer.html(newMarkdownDoc);
+
+                this.previewCodeHighlight();
+                
+                if (settings.toc) 
+                {
+                    var tocContainer = (settings.tocContainer === "") ? previewContainer : $(settings.tocContainer);
+                    var tocMenu      = tocContainer.find("." + this.classPrefix + "toc-menu");
+                    
+                    tocContainer.attr("previewContainer", (settings.tocContainer === "") ? "true" : "false");
+                    
+                    if (settings.tocContainer !== "" && tocMenu.length > 0)
+                    {
+                        tocMenu.remove();
+                    }
+                    
+                    editormd.markdownToCRenderer(markdownToC, tocContainer, settings.tocDropdown, settings.tocStartLevel);
+            
+                    if (settings.tocDropdown || tocContainer.find("." + this.classPrefix + "toc-menu").length > 0)
+                    {
+                        editormd.tocDropdownMenu(tocContainer, (settings.tocTitle !== "") ? settings.tocTitle : this.lang.tocTitle);
+                    }
+            
+                    if (settings.tocContainer !== "")
+                    {
+                        previewContainer.find(".markdown-toc").css("border", "none");
+                    }
+                }
+                
+                if (settings.tex)
+                {
+                    if (!editormd.kaTeXLoaded && settings.autoLoadModules) 
+                    {
+                        editormd.loadKaTeX(function() {
+                            editormd.$katex = katex;
+                            editormd.kaTeXLoaded = true;
+                            _this.katexRender();
+                        });
+                    } 
+                    else 
+                    {
+                        editormd.$katex = katex;
+                        this.katexRender();
+                    }
+                }                
+                
+                if (settings.flowChart || settings.sequenceDiagram)
+                {
+                    flowchartTimer = setTimeout(function(){
+                        clearTimeout(flowchartTimer);
+                        _this.flowChartAndSequenceDiagramRender();
+                        flowchartTimer = null;
+                    }, 10);
+                }
+
+                if (state.loaded) 
+                {
+                    $.proxy(settings.onchange, this)();
+                }
+            }
+
+            return this;
+        },
+        
+        /**
+         * 聚焦光标位置
+         * Focusing the cursor position
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        focus : function() {
+            this.cm.focus();
+
+            return this;
+        },
+        
+        /**
+         * 设置光标的位置
+         * Set cursor position
+         * 
+         * @param   {Object}    cursor 要设置的光标位置键值对象,例:{line:1, ch:0}
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        setCursor : function(cursor) {
+            this.cm.setCursor(cursor);
+
+            return this;
+        },
+        
+        /**
+         * 获取当前光标的位置
+         * Get the current position of the cursor
+         * 
+         * @returns {Cursor}         返回一个光标Cursor对象
+         */
+        
+        getCursor : function() {
+            return this.cm.getCursor();
+        },
+        
+        /**
+         * 设置光标选中的范围
+         * Set cursor selected ranges
+         * 
+         * @param   {Object}    from   开始位置的光标键值对象,例:{line:1, ch:0}
+         * @param   {Object}    to     结束位置的光标键值对象,例:{line:1, ch:0}
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        setSelection : function(from, to) {
+        
+            this.cm.setSelection(from, to);
+        
+            return this;
+        },
+        
+        /**
+         * 获取光标选中的文本
+         * Get the texts from cursor selected
+         * 
+         * @returns {String}         返回选中文本的字符串形式
+         */
+        
+        getSelection : function() {
+            return this.cm.getSelection();
+        },
+        
+        /**
+         * 设置光标选中的文本范围
+         * Set the cursor selection ranges
+         * 
+         * @param   {Array}    ranges  cursor selection ranges array
+         * @returns {Array}            return this
+         */
+        
+        setSelections : function(ranges) {
+            this.cm.setSelections(ranges);
+            
+            return this;
+        },
+        
+        /**
+         * 获取光标选中的文本范围
+         * Get the cursor selection ranges
+         * 
+         * @returns {Array}         return selection ranges array
+         */
+        
+        getSelections : function() {
+            return this.cm.getSelections();
+        },
+        
+        /**
+         * 替换当前光标选中的文本或在当前光标处插入新字符
+         * Replace the text at the current cursor selected or insert a new character at the current cursor position
+         * 
+         * @param   {String}    value  要插入的字符值
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        replaceSelection : function(value) {
+            this.cm.replaceSelection(value);
+
+            return this;
+        },
+        
+        /**
+         * 在当前光标处插入新字符
+         * Insert a new character at the current cursor position
+         *
+         * 同replaceSelection()方法
+         * With the replaceSelection() method
+         * 
+         * @param   {String}    value  要插入的字符值
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        insertValue : function(value) {
+            this.replaceSelection(value);
+
+            return this;
+        },
+        
+        /**
+         * 追加markdown
+         * append Markdown to editor
+         * 
+         * @param   {String}    md     要追加的markdown源文档
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        appendMarkdown : function(md) {
+            var settings = this.settings;
+            var cm       = this.cm;
+            
+            cm.setValue(cm.getValue() + md);
+            
+            return this;
+        },
+        
+        /**
+         * 设置和传入编辑器的markdown源文档
+         * Set Markdown source document
+         * 
+         * @param   {String}    md     要传入的markdown源文档
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        setMarkdown : function(md) {
+            this.cm.setValue(md || this.settings.markdown);
+            
+            return this;
+        },
+        
+        /**
+         * 获取编辑器的markdown源文档
+         * Set Editor.md markdown/CodeMirror value
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        getMarkdown : function() {
+            return this.cm.getValue();
+        },
+        
+        /**
+         * 获取编辑器的源文档
+         * Get CodeMirror value
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        getValue : function() {
+            return this.cm.getValue();
+        },
+        
+        /**
+         * 设置编辑器的源文档
+         * Set CodeMirror value
+         * 
+         * @param   {String}     value   set code/value/string/text
+         * @returns {editormd}           返回editormd的实例对象
+         */
+        
+        setValue : function(value) {
+            this.cm.setValue(value);
+            
+            return this;
+        },
+        
+        /**
+         * 清空编辑器
+         * Empty CodeMirror editor container
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        clear : function() {
+            this.cm.setValue("");
+            
+            return this;
+        },
+        
+        /**
+         * 获取解析后存放在Textarea的HTML源码
+         * Get parsed html code from Textarea
+         * 
+         * @returns {String}               返回HTML源码
+         */
+        
+        getHTML : function() {
+            if (!this.settings.saveHTMLToTextarea)
+            {
+                alert("Error: settings.saveHTMLToTextarea == false");
+
+                return false;
+            }
+            
+            return this.htmlTextarea.val();
+        },
+        
+        /**
+         * getHTML()的别名
+         * getHTML (alias)
+         * 
+         * @returns {String}           Return html code 返回HTML源码
+         */
+        
+        getTextareaSavedHTML : function() {
+            return this.getHTML();
+        },
+        
+        /**
+         * 获取预览窗口的HTML源码
+         * Get html from preview container
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        getPreviewedHTML : function() {
+            if (!this.settings.watch)
+            {
+                alert("Error: settings.watch == false");
+
+                return false;
+            }
+            
+            return this.previewContainer.html();
+        },
+        
+        /**
+         * 开启实时预览
+         * Enable real-time watching
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        watch : function(callback) {     
+            var settings        = this.settings;
+            
+            if ($.inArray(settings.mode, ["gfm", "markdown"]) < 0)
+            {
+                return this;
+            }
+            
+            this.state.watching = settings.watch = true;
+            this.preview.show();
+            
+            if (this.toolbar)
+            {
+                var watchIcon   = settings.toolbarIconsClass.watch;
+                var unWatchIcon = settings.toolbarIconsClass.unwatch;
+                
+                var icon        = this.toolbar.find(".fa[name=watch]");
+                icon.parent().attr("title", settings.lang.toolbar.watch);
+                icon.removeClass(unWatchIcon).addClass(watchIcon);
+            }
+            
+            this.codeMirror.css("border-right", "1px solid #ddd").width(this.editor.width() / 2); 
+            
+            timer = 0;
+            
+            this.save().resize();
+            
+            if (!settings.onwatch)
+            {
+                settings.onwatch = callback || function() {};
+            }
+            
+            $.proxy(settings.onwatch, this)();
+            
+            return this;
+        },
+        
+        /**
+         * 关闭实时预览
+         * Disable real-time watching
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        unwatch : function(callback) {
+            var settings        = this.settings;
+            this.state.watching = settings.watch = false;
+            this.preview.hide();
+            
+            if (this.toolbar) 
+            {
+                var watchIcon   = settings.toolbarIconsClass.watch;
+                var unWatchIcon = settings.toolbarIconsClass.unwatch;
+                
+                var icon    = this.toolbar.find(".fa[name=watch]");
+                icon.parent().attr("title", settings.lang.toolbar.unwatch);
+                icon.removeClass(watchIcon).addClass(unWatchIcon);
+            }
+            
+            this.codeMirror.css("border-right", "none").width(this.editor.width());
+            
+            this.resize();
+            
+            if (!settings.onunwatch)
+            {
+                settings.onunwatch = callback || function() {};
+            }
+            
+            $.proxy(settings.onunwatch, this)();
+            
+            return this;
+        },
+        
+        /**
+         * 显示编辑器
+         * Show editor
+         * 
+         * @param   {Function} [callback=function()] 回调函数
+         * @returns {editormd}                       返回editormd的实例对象
+         */
+        
+        show : function(callback) {
+            callback  = callback || function() {};
+            
+            var _this = this;
+            this.editor.show(0, function() {
+                $.proxy(callback, _this)();
+            });
+            
+            return this;
+        },
+        
+        /**
+         * 隐藏编辑器
+         * Hide editor
+         * 
+         * @param   {Function} [callback=function()] 回调函数
+         * @returns {editormd}                       返回editormd的实例对象
+         */
+        
+        hide : function(callback) {
+            callback  = callback || function() {};
+            
+            var _this = this;
+            this.editor.hide(0, function() {
+                $.proxy(callback, _this)();
+            });
+            
+            return this;
+        },
+        
+        /**
+         * 隐藏编辑器部分,只预览HTML
+         * Enter preview html state
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        previewing : function() {
+            
+            var _this            = this;
+            var editor           = this.editor;
+            var preview          = this.preview;
+            var toolbar          = this.toolbar;
+            var settings         = this.settings;
+            var codeMirror       = this.codeMirror;
+            
+            if ($.inArray(settings.mode, ["gfm", "markdown"]) < 0) {
+                return this;
+            }
+            
+            if (settings.toolbar && toolbar) {
+                toolbar.toggle();
+                toolbar.find(".fa[name=preview]").toggleClass("active");
+            }
+            
+            codeMirror.toggle();
+            
+            var escHandle = function(event) {
+                if (event.shiftKey && event.keyCode === 27) {
+                    _this.previewed();
+                }
+            };
+
+            if (codeMirror.css("display") === "none") // 为了兼容Zepto,而不使用codeMirror.is(":hidden")
+            {
+                this.state.preview = true;
+
+                if (this.state.fullscreen) {
+                    preview.css("background", "#fff");
+                }
+                
+                editor.find("." + this.classPrefix + "preview-close-btn").show().bind(editormd.mouseOrTouch("click", "touchend"), function(){
+                    _this.previewed();
+                });
+            
+                if (!settings.watch)
+                {
+                    this.save();
+                }
+
+                preview.show().css({
+                    position  : "static",
+                    top       : 0,
+                    width     : editor.width(),
+                    height    : (settings.autoHeight && !this.state.fullscreen) ? "auto" : editor.height()
+                });
+                
+                if (this.state.loaded)
+                {
+                    $.proxy(settings.onpreviewing, this)();
+                }
+
+                $(window).bind("keyup", escHandle);
+            } 
+            else 
+            {
+                $(window).unbind("keyup", escHandle);
+                this.previewed();
+            }
+        },
+        
+        /**
+         * 显示编辑器部分,退出只预览HTML
+         * Exit preview html state
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        previewed : function() {
+            
+            var editor           = this.editor;
+            var preview          = this.preview;
+            var toolbar          = this.toolbar;
+            var settings         = this.settings;
+            var previewCloseBtn  = editor.find("." + this.classPrefix + "preview-close-btn");
+
+            this.state.preview   = false;
+            
+            this.codeMirror.show();
+            
+            if (settings.toolbar) {
+                toolbar.show();
+            }
+            
+            preview[(settings.watch) ? "show" : "hide"]();
+            
+            previewCloseBtn.hide().unbind(editormd.mouseOrTouch("click", "touchend"));
+            
+            preview.css({ 
+                background : null,
+                position   : "absolute",
+                width      : editor.width() / 2,
+                height     : (settings.autoHeight && !this.state.fullscreen) ? "auto" : editor.height() - toolbar.height(),
+                top        : (settings.toolbar)    ? toolbar.height() : 0
+            });
+
+            if (this.state.loaded)
+            {
+                $.proxy(settings.onpreviewed, this)();
+            }
+            
+            return this;
+        },
+        
+        /**
+         * 编辑器全屏显示
+         * Fullscreen show
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        fullscreen : function() {
+            
+            var _this            = this;
+            var state            = this.state;
+            var editor           = this.editor;
+            var preview          = this.preview;
+            var toolbar          = this.toolbar;
+            var settings         = this.settings;
+            var fullscreenClass  = this.classPrefix + "fullscreen";
+            
+            if (toolbar) {
+                toolbar.find(".fa[name=fullscreen]").parent().toggleClass("active"); 
+            }
+            
+            var escHandle = function(event) {
+                if (!event.shiftKey && event.keyCode === 27) 
+                {
+                    if (state.fullscreen)
+                    {
+                        _this.fullscreenExit();
+                    }
+                }
+            };
+
+            if (!editor.hasClass(fullscreenClass)) 
+            {
+                state.fullscreen = true;
+
+                $("html,body").css("overflow", "hidden");
+                
+                editor.css({
+                    position : "fixed", 
+                    top      : 0, 
+                    left     : 0, 
+                    margin   : 0, 
+                    border   : "none",
+                    width    : $(window).width(),
+                    height   : $(window).height()
+                }).addClass(fullscreenClass);
+
+                this.resize();
+    
+                $.proxy(settings.onfullscreen, this)();
+
+                $(window).bind("keyup", escHandle);
+            }
+            else
+            {           
+                $(window).unbind("keyup", escHandle); 
+                this.fullscreenExit();
+            }
+
+            return this;
+        },
+        
+        /**
+         * 编辑器退出全屏显示
+         * Exit fullscreen state
+         * 
+         * @returns {editormd}         返回editormd的实例对象
+         */
+        
+        fullscreenExit : function() {
+            
+            var editor            = this.editor;
+            var settings          = this.settings;
+            var toolbar           = this.toolbar;
+            var fullscreenClass   = this.classPrefix + "fullscreen";  
+            
+            this.state.fullscreen = false;
+            
+            if (toolbar) {
+                toolbar.find(".fa[name=fullscreen]").parent().removeClass("active"); 
+            }
+
+            $("html,body").css("overflow", "");
+
+            editor.css({
+                position : "", 
+                top      : "",
+                left     : "", 
+                margin   : "0 auto 15px", 
+                width    : editor.data("oldWidth"),
+                height   : editor.data("oldHeight"),
+                border   : "1px solid #ddd"
+            }).removeClass(fullscreenClass);
+
+            this.resize();
+            
+            $.proxy(settings.onfullscreenExit, this)();
+
+            return this;
+        },
+        
+        /**
+         * 加载并执行插件
+         * Load and execute the plugin
+         * 
+         * @param   {String}     name    plugin name / function name
+         * @param   {String}     path    plugin load path
+         * @returns {editormd}           返回editormd的实例对象
+         */
+        
+        executePlugin : function(name, path) {
+            
+            var _this    = this;
+            var cm       = this.cm;
+            var settings = this.settings;
+            
+            path = settings.pluginPath + path;
+            
+            if (typeof define === "function") 
+            {            
+                if (typeof this[name] === "undefined")
+                {
+                    alert("Error: " + name + " plugin is not found, you are not load this plugin.");
+                    
+                    return this;
+                }
+                
+                this[name](cm);
+                
+                return this;
+            }
+            
+            if ($.inArray(path, editormd.loadFiles.plugin) < 0)
+            {
+                editormd.loadPlugin(path, function() {
+                    editormd.loadPlugins[name] = _this[name];
+                    _this[name](cm);
+                });
+            }
+            else
+            {
+                $.proxy(editormd.loadPlugins[name], this)(cm);
+            }
+            
+            return this;
+        },
+                
+        /**
+         * 搜索替换
+         * Search & replace
+         * 
+         * @param   {String}     command    CodeMirror serach commands, "find, fintNext, fintPrev, clearSearch, replace, replaceAll"
+         * @returns {editormd}              return this
+         */
+        
+        search : function(command) {
+            var settings = this.settings;
+            
+            if (!settings.searchReplace)
+            {
+                alert("Error: settings.searchReplace == false");
+                return this;
+            }
+            
+            if (!settings.readOnly)
+            {
+                this.cm.execCommand(command || "find");
+            }
+            
+            return this;
+        },
+        
+        searchReplace : function() {            
+            this.search("replace");
+            
+            return this;
+        },
+        
+        searchReplaceAll : function() {          
+            this.search("replaceAll");
+            
+            return this;
+        }
+    };
+    
+    editormd.fn.init.prototype = editormd.fn; 
+   
+    /**
+     * 锁屏
+     * lock screen when dialog opening
+     * 
+     * @returns {void}
+     */
+
+    editormd.dialogLockScreen = function() {
+        var settings = this.settings || {dialogLockScreen : true};
+        
+        if (settings.dialogLockScreen) 
+        {
+            $("html,body").css("overflow", "hidden");
+        }
+    };
+   
+    /**
+     * 显示透明背景层
+     * Display mask layer when dialog opening
+     * 
+     * @param   {Object}     dialog    dialog jQuery object
+     * @returns {void}
+     */
+    
+    editormd.dialogShowMask = function(dialog) {
+        var editor   = this.editor;
+        var settings = this.settings || {dialogShowMask : true};
+        
+        dialog.css({
+            top  : ($(window).height() - dialog.height()) / 2 + "px",
+            left : ($(window).width()  - dialog.width())  / 2 + "px"
+        });
+
+        if (settings.dialogShowMask) {
+            editor.children("." + this.classPrefix + "mask").css("z-index", parseInt(dialog.css("z-index")) - 1).show();
+        }
+    };
+
+    editormd.toolbarHandlers = {
+        undo : function() {
+            this.cm.undo();
+        },
+        
+        redo : function() {
+            this.cm.redo();
+        },
+        
+        bold : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("**" + selection + "**");
+
+            if(selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 2);
+            }
+        },
+        
+        del : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("~~" + selection + "~~");
+
+            if(selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 2);
+            }
+        },
+
+        italic : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("*" + selection + "*");
+
+            if(selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 1);
+            }
+        },
+
+        quote : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("> " + selection);
+            cm.setCursor(cursor.line, (selection === "") ? cursor.ch + 2 : cursor.ch + selection.length + 2);
+        },
+        
+        ucfirst : function() {
+            var cm         = this.cm;
+            var selection  = cm.getSelection();
+            var selections = cm.listSelections();
+
+            cm.replaceSelection(editormd.firstUpperCase(selection));
+            cm.setSelections(selections);
+        },
+        
+        ucwords : function() {
+            var cm         = this.cm;
+            var selection  = cm.getSelection();
+            var selections = cm.listSelections();
+
+            cm.replaceSelection(editormd.wordsFirstUpperCase(selection));
+            cm.setSelections(selections);
+        },
+        
+        uppercase : function() {
+            var cm         = this.cm;
+            var selection  = cm.getSelection();
+            var selections = cm.listSelections();
+
+            cm.replaceSelection(selection.toUpperCase());
+            cm.setSelections(selections);
+        },
+        
+        lowercase : function() {
+            var cm         = this.cm;
+            var cursor     = cm.getCursor();
+            var selection  = cm.getSelection();
+            var selections = cm.listSelections();
+            
+            cm.replaceSelection(selection.toLowerCase());
+            cm.setSelections(selections);
+        },
+
+        h1 : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("# " + selection);
+        },
+
+        h2 : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("## " + selection);
+        },
+
+        h3 : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("### " + selection);
+        },
+
+        h4 : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("#### " + selection);
+        },
+
+        h5 : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("##### " + selection);
+        },
+
+        h6 : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("###### " + selection);
+        },
+
+        "list-ul" : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            if (selection === "") 
+            {
+                cm.replaceSelection("- " + selection);
+            } 
+            else 
+            {
+                var selectionText = selection.split("\n");
+
+                for (var i = 0, len = selectionText.length; i < len; i++) 
+                {
+                    selectionText[i] = (selectionText[i] === "") ? "" : "- " + selectionText[i];
+                }
+
+                cm.replaceSelection(selectionText.join("\n"));
+            }
+        },
+
+        "list-ol" : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            if(selection === "") 
+            {
+                cm.replaceSelection("1. " + selection);
+            }
+            else
+            {
+                var selectionText = selection.split("\n");
+
+                for (var i = 0, len = selectionText.length; i < len; i++) 
+                {
+                    selectionText[i] = (selectionText[i] === "") ? "" : (i+1) + ". " + selectionText[i];
+                }
+
+                cm.replaceSelection(selectionText.join("\n"));
+            }
+        },
+
+        hr : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("------------");
+        },
+
+        tex : function() {
+            if (!this.settings.tex)
+            {
+                alert("settings.tex === false");
+                return this;
+            }
+            
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("$$" + selection + "$$");
+
+            if(selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 2);
+            }
+        },
+
+        link : function() {
+            this.executePlugin("linkDialog", "link-dialog/link-dialog");
+        },
+
+        "reference-link" : function() {
+            this.executePlugin("referenceLinkDialog", "reference-link-dialog/reference-link-dialog");
+        },
+
+        pagebreak : function() {
+            if (!this.settings.pageBreak)
+            {
+                alert("settings.pageBreak === false");
+                return this;
+            }
+            
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("\r\n[========]\r\n");
+        },
+
+        image : function() {
+            this.executePlugin("imageDialog", "image-dialog/image-dialog");
+        },
+        
+        code : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+
+            cm.replaceSelection("`" + selection + "`");
+
+            if (selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 1);
+            }
+        },
+
+        "code-block" : function() {
+            this.executePlugin("codeBlockDialog", "code-block-dialog/code-block-dialog");            
+        },
+
+        "preformatted-text" : function() {
+            this.executePlugin("preformattedTextDialog", "preformatted-text-dialog/preformatted-text-dialog");
+        },
+        
+        table : function() {
+            this.executePlugin("tableDialog", "table-dialog/table-dialog");         
+        },
+        
+        datetime : function() {
+            var cm        = this.cm;
+            var selection = cm.getSelection();
+            var date      = new Date();
+            var langName  = this.settings.lang.name;
+            var datefmt   = editormd.dateFormat() + " " + editormd.dateFormat((langName === "zh-cn" || langName === "zh-tw") ? "cn-week-day" : "week-day");
+
+            cm.replaceSelection(datefmt);
+        },
+        
+        emoji : function() {
+            this.executePlugin("emojiDialog", "emoji-dialog/emoji-dialog");
+        },
+                
+        "html-entities" : function() {
+            this.executePlugin("htmlEntitiesDialog", "html-entities-dialog/html-entities-dialog");
+        },
+                
+        "goto-line" : function() {
+            this.executePlugin("gotoLineDialog", "goto-line-dialog/goto-line-dialog");
+        },
+
+        watch : function() {    
+            this[this.settings.watch ? "unwatch" : "watch"]();
+        },
+
+        preview : function() {
+            this.previewing();
+        },
+
+        fullscreen : function() {
+            this.fullscreen();
+        },
+
+        clear : function() {
+            this.clear();
+        },
+        
+        search : function() {
+            this.search();
+        },
+
+        help : function() {
+            this.executePlugin("helpDialog", "help-dialog/help-dialog");
+        },
+
+        info : function() {
+            this.showInfoDialog();
+        }
+    };
+    
+    editormd.keyMaps = {
+        "Ctrl-1"       : "h1",
+        "Ctrl-2"       : "h2",
+        "Ctrl-3"       : "h3",
+        "Ctrl-4"       : "h4",
+        "Ctrl-5"       : "h5",
+        "Ctrl-6"       : "h6",
+        "Ctrl-B"       : "bold",  // if this is string ==  editormd.toolbarHandlers.xxxx
+        "Ctrl-D"       : "datetime",
+        
+        "Ctrl-E"       : function() { // emoji
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+            
+            if (!this.settings.emoji)
+            {
+                alert("Error: settings.emoji == false");
+                return ;
+            }
+
+            cm.replaceSelection(":" + selection + ":");
+
+            if (selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 1);
+            }
+        },
+        "Ctrl-Alt-G"   : "goto-line",
+        "Ctrl-H"       : "hr",
+        "Ctrl-I"       : "italic",
+        "Ctrl-K"       : "code",
+        
+        "Ctrl-L"        : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+            
+            var title = (selection === "") ? "" : " \""+selection+"\"";
+
+            cm.replaceSelection("[" + selection + "]("+title+")");
+
+            if (selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 1);
+            }
+        },
+        "Ctrl-U"         : "list-ul",
+        
+        "Shift-Ctrl-A"   : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+            
+            if (!this.settings.atLink)
+            {
+                alert("Error: settings.atLink == false");
+                return ;
+            }
+
+            cm.replaceSelection("@" + selection);
+
+            if (selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 1);
+            }
+        },
+        
+        "Shift-Ctrl-C"     : "code",
+        "Shift-Ctrl-Q"     : "quote",
+        "Shift-Ctrl-S"     : "del",
+        "Shift-Ctrl-K"     : "tex",  // KaTeX
+        
+        "Shift-Alt-C"      : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+            
+            cm.replaceSelection(["```", selection, "```"].join("\n"));
+
+            if (selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 3);
+            } 
+        },
+        
+        "Shift-Ctrl-Alt-C" : "code-block",
+        "Shift-Ctrl-H"     : "html-entities",
+        "Shift-Alt-H"      : "help",
+        "Shift-Ctrl-E"     : "emoji",
+        "Shift-Ctrl-U"     : "uppercase",
+        "Shift-Alt-U"      : "ucwords",
+        "Shift-Ctrl-Alt-U" : "ucfirst",
+        "Shift-Alt-L"      : "lowercase",
+        
+        "Shift-Ctrl-I"     : function() {
+            var cm        = this.cm;
+            var cursor    = cm.getCursor();
+            var selection = cm.getSelection();
+            
+            var title = (selection === "") ? "" : " \""+selection+"\"";
+
+            cm.replaceSelection("![" + selection + "]("+title+")");
+
+            if (selection === "") {
+                cm.setCursor(cursor.line, cursor.ch + 4);
+            }
+        },
+        
+        "Shift-Ctrl-Alt-I" : "image",
+        "Shift-Ctrl-L"     : "link",
+        "Shift-Ctrl-O"     : "list-ol",
+        "Shift-Ctrl-P"     : "preformatted-text",
+        "Shift-Ctrl-T"     : "table",
+        "Shift-Alt-P"      : "pagebreak",
+        "F9"               : "watch",
+        "F10"              : "preview",
+        "F11"              : "fullscreen",
+    };
+    
+    /**
+     * 清除字符串两边的空格
+     * Clear the space of strings both sides.
+     * 
+     * @param   {String}    str            string
+     * @returns {String}                   trimed string    
+     */
+    
+    var trim = function(str) {
+        return (!String.prototype.trim) ? str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "") : str.trim();
+    };
+    
+    editormd.trim = trim;
+    
+    /**
+     * 所有单词首字母大写
+     * Words first to uppercase
+     * 
+     * @param   {String}    str            string
+     * @returns {String}                   string
+     */
+    
+    var ucwords = function (str) {
+        return str.toLowerCase().replace(/\b(\w)|\s(\w)/g, function($1) {  
+            return $1.toUpperCase();
+        });
+    };
+    
+    editormd.ucwords = editormd.wordsFirstUpperCase = ucwords;
+    
+    /**
+     * 字符串首字母大写
+     * Only string first char to uppercase
+     * 
+     * @param   {String}    str            string
+     * @returns {String}                   string
+     */
+    
+    var firstUpperCase = function(str) {        
+        return str.toLowerCase().replace(/\b(\w)/, function($1){
+            return $1.toUpperCase();
+        });
+    };
+    
+    var ucfirst = firstUpperCase;
+    
+    editormd.firstUpperCase = editormd.ucfirst = firstUpperCase;
+    
+    editormd.urls = {
+        atLinkBase : "https://github.com/"
+    };
+    
+    editormd.regexs = {
+        atLink        : /@(\w+)/g,
+        email         : /(\w+)@(\w+)\.(\w+)\.?(\w+)?/g,
+        emailLink     : /(mailto:)?([\w\.\_]+)@(\w+)\.(\w+)\.?(\w+)?/g,
+        emoji         : /:([\w\+-]+):/g,
+        emojiDatetime : /(\d{2}:\d{2}:\d{2})/g,
+        twemoji       : /:(tw-([\w]+)-?(\w+)?):/g,
+        fontAwesome   : /:(fa-([\w]+)(-(\w+)){0,}):/g,
+        editormdLogo  : /:(editormd-logo-?(\w+)?):/g,
+        pageBreak     : /^\[[=]{8,}\]$/
+    };
+
+    // Emoji graphics files url path
+    editormd.emoji     = {
+        path  : "http://www.emoji-cheat-sheet.com/graphics/emojis/",
+        ext   : ".png"
+    };
+
+    // Twitter Emoji (Twemoji)  graphics files url path    
+    editormd.twemoji = {
+        path : "http://twemoji.maxcdn.com/36x36/",
+        ext  : ".png"
+    };
+
+    /**
+     * 自定义marked的解析器
+     * Custom Marked renderer rules
+     * 
+     * @param   {Array}    markdownToC     传入用于接收TOC的数组
+     * @returns {Renderer} markedRenderer  返回marked的Renderer自定义对象
+     */
+
+    editormd.markedRenderer = function(markdownToC, options) {
+        var defaults = {
+            toc                  : true,           // Table of contents
+            tocm                 : false,
+            tocStartLevel        : 1,              // Said from H1 to create ToC  
+            pageBreak            : true,
+            atLink               : true,           // for @link
+            emailLink            : true,           // for mail address auto link
+            taskList             : false,          // Enable Github Flavored Markdown task lists
+            emoji                : false,          // :emoji: , Support Twemoji, fontAwesome, Editor.md logo emojis.
+            tex                  : false,          // TeX(LaTeX), based on KaTeX
+            flowChart            : false,          // flowChart.js only support IE9+
+            sequenceDiagram      : false,          // sequenceDiagram.js only support IE9+
+        };
+        
+        var settings        = $.extend(defaults, options || {});    
+        var marked          = editormd.$marked;
+        var markedRenderer  = new marked.Renderer();
+        markdownToC         = markdownToC || [];        
+            
+        var regexs          = editormd.regexs;
+        var atLinkReg       = regexs.atLink;
+        var emojiReg        = regexs.emoji;
+        var emailReg        = regexs.email;
+        var emailLinkReg    = regexs.emailLink;
+        var twemojiReg      = regexs.twemoji;
+        var faIconReg       = regexs.fontAwesome;
+        var editormdLogoReg = regexs.editormdLogo;
+        var pageBreakReg    = regexs.pageBreak;
+
+        markedRenderer.emoji = function(text) {
+            
+            text = text.replace(editormd.regexs.emojiDatetime, function($1) {           
+                return $1.replace(/:/g, "&#58;");
+            });
+            
+            var matchs = text.match(emojiReg);
+
+            if (!matchs || !settings.emoji) {
+                return text;
+            }
+
+            for (var i = 0, len = matchs.length; i < len; i++)
+            {            
+                if (matchs[i] === ":+1:") {
+                    matchs[i] = ":\\+1:";
+                }
+
+                text = text.replace(new RegExp(matchs[i]), function($1, $2){
+                    var faMatchs = $1.match(faIconReg);
+                    var name     = $1.replace(/:/g, "");
+
+                    if (faMatchs)
+                    {                        
+                        for (var fa = 0, len1 = faMatchs.length; fa < len1; fa++)
+                        {
+                            var faName = faMatchs[fa].replace(/:/g, "");
+                            
+                            return "<i class=\"fa " + faName + " fa-emoji\" title=\"" + faName.replace("fa-", "") + "\"></i>";
+                        }
+                    }
+                    else
+                    {
+                        var emdlogoMathcs = $1.match(editormdLogoReg);
+                        var twemojiMatchs = $1.match(twemojiReg);
+
+                        if (emdlogoMathcs)                                        
+                        {                            
+                            for (var x = 0, len2 = emdlogoMathcs.length; x < len2; x++)
+                            {
+                                var logoName = emdlogoMathcs[x].replace(/:/g, "");
+                                return "<i class=\"" + logoName + "\" title=\"Editor.md logo (" + logoName + ")\"></i>";
+                            }
+                        }
+                        else if (twemojiMatchs) 
+                        {
+                            for (var t = 0, len3 = twemojiMatchs.length; t < len3; t++)
+                            {
+                                var twe = twemojiMatchs[t].replace(/:/g, "").replace("tw-", "");
+                                return "<img src=\"" + editormd.twemoji.path + twe + editormd.twemoji.ext + "\" title=\"twemoji-" + twe + "\" alt=\"twemoji-" + twe + "\" class=\"emoji twemoji\" />";
+                            }
+                        }
+                        else
+                        {
+                            var src = (name === "+1") ? "plus1" : name;
+                            src     = (src === "black_large_square") ? "black_square" : src;
+
+                            return "<img src=\"" + editormd.emoji.path + src + editormd.emoji.ext + "\" class=\"emoji\" title=\"&#58;" + name + "&#58;\" alt=\"&#58;" + name + "&#58;\" />";
+                        }
+                    }
+                });
+            }
+
+            return text;
+        };
+
+        markedRenderer.atLink = function(text) {
+
+            if (atLinkReg.test(text))
+            { 
+                if (settings.atLink) 
+                {
+                    text = text.replace(emailReg, function($1, $2, $3, $4) {
+                        return $1.replace(/@/g, "_#_&#64;_#_");
+                    });
+
+                    text = text.replace(atLinkReg, function($1, $2) {
+                        return "<a href=\"" + editormd.urls.atLinkBase + "" + $2 + "\" title=\"&#64;" + $2 + "\" class=\"at-link\">" + $1 + "</a>";
+                    }).replace(/_#_&#64;_#_/g, "@");
+                }
+                
+                if (settings.emailLink)
+                {
+                    text = text.replace(emailLinkReg, function($1, $2, $3, $4, $5) {
+                        return (!$2 && $.inArray($5, "jpg|jpeg|png|gif|webp|ico|icon|pdf".split("|")) < 0) ? "<a href=\"mailto:" + $1 + "\">"+$1+"</a>" : $1;
+                    });
+                }
+
+                return text;
+            }
+
+            return text;
+        };
+                
+        markedRenderer.link = function (href, title, text) {
+
+            if (this.options.sanitize) {
+                try {
+                    var prot = decodeURIComponent(unescape(href)).replace(/[^\w:]/g,"").toLowerCase();
+                } catch(e) {
+                    return "";
+                }
+
+                if (prot.indexOf("javascript:") === 0) {
+                    return "";
+                }
+            }
+
+            var out = "<a href=\"" + href + "\"";
+            
+            if (atLinkReg.test(title) || atLinkReg.test(text))
+            {
+                if (title)
+                {
+                    out += " title=\"" + title.replace(/@/g, "&#64;");
+                }
+                
+                return out + "\">" + text.replace(/@/g, "&#64;") + "</a>";
+            }
+
+            if (title) {
+                out += " title=\"" + title + "\"";
+            }
+
+            out += ">" + text + "</a>";
+
+            return out;
+        };
+        
+        markedRenderer.heading = function(text, level, raw) {
+                    
+            var linkText       = text;
+            var hasLinkReg     = /\s*\<a\s*href\=\"(.*)\"\s*([^\>]*)\>(.*)\<\/a\>\s*/;
+            var getLinkTextReg = /\s*\<a\s*([^\>]+)\>([^\>]*)\<\/a\>\s*/g;
+
+            if (hasLinkReg.test(text)) 
+            {
+                var tempText = [];
+                text         = text.split(/\<a\s*([^\>]+)\>([^\>]*)\<\/a\>/);
+
+                for (var i = 0, len = text.length; i < len; i++)
+                {
+                    tempText.push(text[i].replace(/\s*href\=\"(.*)\"\s*/g, ""));
+                }
+
+                text = tempText.join(" ");
+            }
+            
+            text = trim(text);
+            
+            var escapedText    = text.toLowerCase().replace(/[^\w]+/g, "-");
+            var toc = {
+                text  : text,
+                level : level,
+                slug  : escapedText
+            };
+            
+            var isChinese = /^[\u4e00-\u9fa5]+$/.test(text);
+            var id        = (isChinese) ? escape(text).replace(/\%/g, "") : text.toLowerCase().replace(/[^\w]+/g, "-");
+
+            markdownToC.push(toc);
+            
+            var headingHTML = "<h" + level + " id=\"h"+ level + "-" + this.options.headerPrefix + id +"\">";
+            
+            headingHTML    += "<a name=\"" + text + "\" class=\"reference-link\"></a>";
+            headingHTML    += "<span class=\"header-link octicon1 octicon-link\"></span>";
+            headingHTML    += (hasLinkReg) ? this.atLink(this.emoji(linkText)) : this.atLink(this.emoji(text));
+            headingHTML    += "</h" + level + ">";
+
+            return headingHTML;
+        };
+        
+        markedRenderer.pageBreak = function(text) {
+            if (pageBreakReg.test(text) && settings.pageBreak)
+            {
+                text = "<hr style=\"page-break-after:always;\" class=\"page-break editormd-page-break\" />";
+            }
+            
+            return text;
+        };
+
+        markedRenderer.paragraph = function(text) {
+            var isTeXInline     = /\$\$(.*)\$\$/g.test(text);
+            var isTeXLine       = /^\$\$(.*)\$\$$/.test(text);
+            var isTeXAddClass   = (isTeXLine)     ? " class=\"" + editormd.classNames.tex + "\"" : "";
+            var isToC           = (settings.tocm) ? /^(\[TOC\]|\[TOCM\])$/.test(text) : /^\[TOC\]$/.test(text);
+            var isToCMenu       = /^\[TOCM\]$/.test(text);
+            
+            if (!isTeXLine && isTeXInline) 
+            {
+                text = text.replace(/(\$\$([^\$]*)\$\$)+/g, function($1, $2) {
+                    return "<span class=\"" + editormd.classNames.tex + "\">" + $2.replace(/\$/g, "") + "</span>";
+                });
+            } 
+            else 
+            {
+                text = (isTeXLine) ? text.replace(/\$/g, "") : text;
+            }
+            
+            var tocHTML = "<div class=\"markdown-toc editormd-markdown-toc\">" + text + "</div>";
+            
+            return (isToC) ? ( (isToCMenu) ? "<div class=\"editormd-toc-menu\">" + tocHTML + "</div><br/>" : tocHTML )
+                           : ( (pageBreakReg.test(text)) ? this.pageBreak(text) : "<p" + isTeXAddClass + ">" + this.atLink(this.emoji(text)) + "</p>\n" );
+        };
+
+        markedRenderer.code = function (code, lang, escaped) { 
+
+            if (lang === "seq" || lang === "sequence")
+            {
+                return "<div class=\"sequence-diagram\">" + code + "</div>";
+            } 
+            else if ( lang === "flow")
+            {
+                return "<div class=\"flowchart\">" + code + "</div>";
+            } 
+            else 
+            {
+
+                return marked.Renderer.prototype.code.apply(this, arguments);
+            }
+        };
+
+        markedRenderer.tablecell = function(content, flags) {
+            var type = (flags.header) ? "th" : "td";
+            var tag  = (flags.align)  ? "<" + type +" style=\"text-align:" + flags.align + "\">" : "<" + type + ">";
+            
+            return tag + this.atLink(this.emoji(content)) + "</" + type + ">\n";
+        };
+
+        markedRenderer.listitem = function(text) {
+            if (settings.taskList && /^\s*\[[x\s]\]\s*/.test(text)) 
+            {
+                text = text.replace(/^\s*\[\s\]\s*/, "<input type=\"checkbox\" class=\"task-list-item-checkbox\" /> ")
+                           .replace(/^\s*\[x\]\s*/,  "<input type=\"checkbox\" class=\"task-list-item-checkbox\" checked disabled /> ");
+
+                return "<li style=\"list-style: none;\">" + this.atLink(this.emoji(text)) + "</li>";
+            }
+            else 
+            {
+                return "<li>" + this.atLink(this.emoji(text)) + "</li>";
+            }
+        };
+        
+        return markedRenderer;
+    };
+    
+    /**
+     *
+     * 生成TOC(Table of Contents)
+     * Creating ToC (Table of Contents)
+     * 
+     * @param   {Array}    toc             从marked获取的TOC数组列表
+     * @param   {Element}  container       插入TOC的容器元素
+     * @param   {Integer}  startLevel      Hx 起始层级
+     * @returns {Object}   tocContainer    返回ToC列表容器层的jQuery对象元素
+     */
+    
+    editormd.markdownToCRenderer = function(toc, container, tocDropdown, startLevel) {
+        
+        var html        = "";    
+        var lastLevel   = 0;
+        var classPrefix = this.classPrefix;
+        
+        startLevel      = startLevel  || 1;
+        
+        for (var i = 0, len = toc.length; i < len; i++) 
+        {
+            var text  = toc[i].text;
+            var level = toc[i].level;
+            
+            if (level < startLevel) {
+                continue;
+            }
+            
+            if (level > lastLevel) 
+            {
+                html += "";
+            }
+            else if (level < lastLevel) 
+            {
+                html += (new Array(lastLevel - level + 2)).join("</ul></li>");
+            } 
+            else 
+            {
+                html += "</ul></li>";
+            }
+
+            html += "<li><a class=\"toc-level-" + level + "\" href=\"#" + text + "\" level=\"" + level + "\">" + text + "</a><ul>";
+            lastLevel = level;
+        }
+        
+        var tocContainer = container.find(".markdown-toc");
+        
+        if (tocContainer.length < 1 && container.attr("previewContainer") === "false")
+        {
+            var tocHTML = "<div class=\"markdown-toc " + classPrefix + "markdown-toc\"></div>";
+            
+            tocHTML = (tocDropdown) ? "<div class=\"" + classPrefix + "toc-menu\">" + tocHTML + "</div>" : tocHTML;
+            
+            container.html(tocHTML);
+            
+            tocContainer = container.find(".markdown-toc");
+        }
+        
+        if (tocDropdown)
+        {
+            tocContainer.wrap("<div class=\"" + classPrefix + "toc-menu\"></div><br/>");
+        }
+        
+        tocContainer.html("<ul class=\"markdown-toc-list\"></ul>").children(".markdown-toc-list").html(html.replace(/\r?\n?\<ul\>\<\/ul\>/g, ""));
+        
+        return tocContainer;
+    };
+    
+    /**
+     *
+     * 生成TOC下拉菜单
+     * Creating ToC dropdown menu
+     * 
+     * @param   {Object}   container       插入TOC的容器jQuery对象元素
+     * @param   {String}   tocTitle        ToC title
+     * @returns {Object}                   return toc-menu object
+     */
+    
+    editormd.tocDropdownMenu = function(container, tocTitle) {
+        
+        tocTitle      = tocTitle || "Table of Contents";
+        
+        var zindex    = 400;
+        var tocMenus  = container.find("." + this.classPrefix + "toc-menu");
+
+        tocMenus.each(function() {
+            var $this  = $(this);
+            var toc    = $this.children(".markdown-toc");
+            var icon   = "<i class=\"fa fa-angle-down\"></i>";
+            var btn    = "<a href=\"javascript:;\" class=\"toc-menu-btn\">" + icon + tocTitle + "</a>";
+            var menu   = toc.children("ul");            
+            var list   = menu.find("li");
+            
+            toc.append(btn);
+            
+            list.first().before("<li><h1>" + tocTitle + " " + icon + "</h1></li>");
+            
+            $this.mouseover(function(){
+                menu.show();
+
+                list.each(function(){
+                    var li = $(this);
+                    var ul = li.children("ul");
+
+                    if (ul.html() === "")
+                    {
+                        ul.remove();
+                    }
+
+                    if (ul.length > 0 && ul.html() !== "")
+                    {
+                        var firstA = li.children("a").first();
+
+                        if (firstA.children(".fa").length < 1)
+                        {
+                            firstA.append( $(icon).css({ float:"right", paddingTop:"4px" }) );
+                        }
+                    }
+
+                    li.mouseover(function(){
+                        ul.css("z-index", zindex).show();
+                        zindex += 1;
+                    }).mouseleave(function(){
+                        ul.hide();
+                    });
+                });
+            }).mouseleave(function(){
+                menu.hide();
+            }); 
+        });       
+        
+        return tocMenus;
+    };
+    
+    /**
+     * 简单地过滤指定的HTML标签
+     * Filter custom html tags
+     * 
+     * @param   {String}   html          要过滤HTML
+     * @param   {String}   filters       要过滤的标签
+     * @returns {String}   html          返回过滤的HTML
+     */
+    
+    editormd.filterHTMLTags = function(html, filters) {
+        
+        if (typeof html !== "string") {
+            html = new String(html);
+        }
+            
+        if (typeof filters !== "string") {
+            return html;
+        }
+
+        var expression = filters.split("|");
+        var filterTags = expression[0].split(",");
+        var attrs      = expression[1];
+
+        for (var i = 0, len = filterTags.length; i < len; i++)
+        {
+            var tag = filterTags[i];
+
+            html = html.replace(new RegExp("\<\s*" + tag + "\s*([^\>]*)\>([^\>]*)\<\s*\/" + tag + "\s*\>", "igm"), "");
+        }
+
+        if (typeof attrs !== "undefined")
+        {
+            var htmlTagRegex = /\<(\w+)\s*([^\>]*)\>([^\>]*)\<\/(\w+)\>/ig;
+
+            if (attrs === "*")
+            {
+                html = html.replace(htmlTagRegex, function($1, $2, $3, $4, $5) {
+                    return "<" + $2 + ">" + $4 + "</" + $5 + ">";
+                });         
+            }
+            else if (attrs === "on*")
+            {
+                html = html.replace(htmlTagRegex, function($1, $2, $3, $4, $5) {
+                    var el = $("<" + $2 + ">" + $4 + "</" + $5 + ">");
+                    var _attrs = $($1)[0].attributes;
+                    var $attrs = {};
+                    
+                    $.each(_attrs, function(i, e) {
+                        $attrs[e.nodeName] = e.nodeValue;
+                    });
+                    
+                    $.each($attrs, function(i) {                
+                        if (i.indexOf("on") === 0) {
+                            delete $attrs[i];
+                        }
+                    });
+                    
+                    el.attr($attrs);
+
+                    return el[0].outerHTML;
+                });
+            }
+            else
+            {
+                html = html.replace(htmlTagRegex, function($1, $2, $3, $4) {
+                    var filterAttrs = attrs.split(",");
+                    var el = $($1);
+                    el.html($4);
+
+                    $.each(filterAttrs, function(i) {
+                        el.attr(filterAttrs[i], null);
+                    });
+
+                    return el[0].outerHTML;
+                });
+            }
+        }
+        
+        return html;
+    };
+    
+    /**
+     * 将Markdown文档解析为HTML用于前台显示
+     * Parse Markdown to HTML for Font-end preview.
+     * 
+     * @param   {String}   id            用于显示HTML的对象ID
+     * @param   {Object}   [options={}]  配置选项,可选
+     * @returns {Object}   div           返回jQuery对象元素
+     */
+    
+    editormd.markdownToHTML = function(id, options) {
+        var defaults = {
+            gfm                  : true,
+            toc                  : true,
+            tocm                 : false,
+            tocStartLevel        : 1,
+            tocTitle             : "目录",
+            tocDropdown          : false,
+            markdown             : "",
+            htmlDecode           : false,
+            autoLoadKaTeX        : true,
+            pageBreak            : true,
+            atLink               : true,    // for @link
+            emailLink            : true,    // for mail address auto link
+            tex                  : false,
+            taskList             : false,   // Github Flavored Markdown task lists
+            emoji                : false,
+            flowChart            : false,
+            sequenceDiagram      : false,
+            previewCodeHighlight : true
+        };
+        
+        editormd.$marked  = marked;
+
+        var div           = $("#" + id);
+        var settings      = div.settings = $.extend(true, defaults, options || {});
+        var saveTo        = div.find("textarea");
+        
+        if (saveTo.length < 1)
+        {
+            div.append("<textarea></textarea>");
+            saveTo        = div.find("textarea");
+        }        
+        
+        var markdownDoc   = (settings.markdown === "") ? saveTo.val() : settings.markdown; 
+        var markdownToC   = [];
+
+        var rendererOptions = {  
+            toc                  : settings.toc,
+            tocm                 : settings.tocm,
+            tocStartLevel        : settings.tocStartLevel,
+            taskList             : settings.taskList,
+            emoji                : settings.emoji,
+            tex                  : settings.tex,
+            pageBreak            : settings.pageBreak,
+            atLink               : settings.atLink,           // for @link
+            emailLink            : settings.emailLink,        // for mail address auto link
+            flowChart            : settings.flowChart,
+            sequenceDiagram      : settings.sequenceDiagram,
+            previewCodeHighlight : settings.previewCodeHighlight,
+        };
+
+        var markedOptions = {
+            renderer    : editormd.markedRenderer(markdownToC, rendererOptions),
+            gfm         : settings.gfm,
+            tables      : true,
+            breaks      : true,
+            pedantic    : false,
+            sanitize    : (settings.htmlDecode) ? false : true, // 是否忽略HTML标签,即是否开启HTML标签解析,为了安全性,默认不开启
+            smartLists  : true,
+            smartypants : true
+        };
+        
+		markdownDoc = new String(markdownDoc);
+        markdownDoc = editormd.filterHTMLTags(markdownDoc, settings.htmlDecode);
+        
+        var markdownParsed = marked(markdownDoc, markedOptions);
+        
+        saveTo.val(markdownDoc);
+        
+        div.addClass("markdown-body " + this.classPrefix + "html-preview").append(markdownParsed);
+         
+        if (settings.toc) 
+        {
+            div.tocContainer = this.markdownToCRenderer(markdownToC, div, settings.tocDropdown, settings.tocStartLevel);
+            
+            if (settings.tocDropdown || div.find("." + this.classPrefix + "toc-menu").length > 0)
+            {
+                this.tocDropdownMenu(div, settings.tocTitle);
+            }
+        }
+            
+        if (settings.previewCodeHighlight) 
+        {
+            div.find("pre").addClass("prettyprint linenums");
+            prettyPrint();
+        }
+        
+        if (!editormd.isIE8) 
+        {
+            if (settings.flowChart) {
+                div.find(".flowchart").flowChart(); 
+            }
+
+            if (settings.sequenceDiagram) {
+                div.find(".sequence-diagram").sequenceDiagram({theme: "simple"});
+            }
+        }
+
+        if (settings.tex)
+        {
+            var katexHandle = function() {
+                div.find("." + editormd.classNames.tex).each(function(){
+                    var tex  = $(this);
+                    katex.render(tex.html().replace(/&lt;/g, "<").replace(/&gt;/g, ">"), tex[0]);
+                });
+            };
+            
+            if (settings.autoLoadKaTeX && !editormd.$katex && !editormd.kaTeXLoaded)
+            {
+                this.loadKaTeX(function() {
+                    editormd.$katex      = katex;
+                    editormd.kaTeXLoaded = true;
+                    katexHandle();
+                });
+            }
+            else
+            {
+                katexHandle();
+            }
+        }
+        
+        div.getMarkdown = function() {            
+            return saveTo.val();
+        };
+        
+        return div;
+    };
+    
+    editormd.themes = [
+        "default", "3024-day", "3024-night",
+        "ambiance", "ambiance-mobile",
+        "base16-dark", "base16-light", "blackboard",
+        "cobalt",
+        "eclipse", "elegant", "erlang-dark",
+        "lesser-dark",
+        "mbo", "mdn-like", "midnight", "monokai",
+        "neat", "neo", "night",
+        "paraiso-dark", "paraiso-light", "pastel-on-dark",
+        "rubyblue",
+        "solarized",
+        "the-matrix", "tomorrow-night-eighties", "twilight",
+        "vibrant-ink",
+        "xq-dark", "xq-light"
+    ];
+
+    editormd.loadPlugins = {};
+    
+    editormd.loadFiles = {
+        js     : [],
+        css    : [],
+        plugin : []
+    };
+    
+    /**
+     * 动态加载Editor.md插件,但不立即执行
+     * Load editor.md plugins
+     * 
+     * @param {String}   fileName              插件文件路径
+     * @param {Function} [callback=function()] 加载成功后执行的回调函数
+     * @param {String}   [into="head"]         嵌入页面的位置
+     */
+    
+    editormd.loadPlugin = function(fileName, callback, into) {
+        callback   = callback || function() {};
+        
+        this.loadScript(fileName, function() {
+            editormd.loadFiles.plugin.push(fileName);
+            callback();
+        }, into);
+    };
+    
+    /**
+     * 动态加载CSS文件的方法
+     * Load css file method
+     * 
+     * @param {String}   fileName              CSS文件名
+     * @param {Function} [callback=function()] 加载成功后执行的回调函数
+     * @param {String}   [into="head"]         嵌入页面的位置
+     */
+    
+    editormd.loadCSS   = function(fileName, callback, into) {
+        into       = into     || "head";        
+        callback   = callback || function() {};
+        
+        var css    = document.createElement("link");
+        css.type   = "text/css";
+        css.rel    = "stylesheet";
+        css.onload = css.onreadystatechange = function() {
+            editormd.loadFiles.css.push(fileName);
+            callback();
+        };
+
+        css.href   = fileName + ".css";
+
+        if(into === "head") {
+            document.getElementsByTagName("head")[0].appendChild(css);
+        } else {
+            document.body.appendChild(css);
+        }
+    };
+    
+    editormd.isIE    = (navigator.appName == "Microsoft Internet Explorer");
+    editormd.isIE8   = (editormd.isIE && navigator.appVersion.match(/8./i) == "8.");
+
+    /**
+     * 动态加载JS文件的方法
+     * Load javascript file method
+     * 
+     * @param {String}   fileName              JS文件名
+     * @param {Function} [callback=function()] 加载成功后执行的回调函数
+     * @param {String}   [into="head"]         嵌入页面的位置
+     */
+
+    editormd.loadScript = function(fileName, callback, into) {
+        
+        into          = into     || "head";
+        callback      = callback || function() {};
+        
+        var script    = null; 
+        script        = document.createElement("script");
+        script.id     = fileName.replace(/[\./]+/g, "-");
+        script.type   = "text/javascript";        
+        script.src    = fileName + ".js";
+        
+        if (editormd.isIE8) 
+        {            
+            script.onreadystatechange = function() {
+                if(script.readyState) 
+                {
+                    if (script.readyState === "loaded" || script.readyState === "complete") 
+                    {
+                        script.onreadystatechange = null; 
+                        editormd.loadFiles.js.push(fileName);
+                        callback();
+                    }
+                } 
+            };
+        }
+        else
+        {
+            script.onload = function() {
+                editormd.loadFiles.js.push(fileName);
+                callback();
+            };
+        }
+
+        if (into === "head") {
+            document.getElementsByTagName("head")[0].appendChild(script);
+        } else {
+            document.body.appendChild(script);
+        }
+    };
+    
+    // 使用国外的CDN,加载速度有时会很慢,或者自定义URL
+    // You can custom KaTeX load url.
+    editormd.katexURL  = {
+        css : "//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min",
+        js  : "//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min"
+    };
+    
+    editormd.kaTeXLoaded = false;
+    
+    /**
+     * 加载KaTeX文件
+     * load KaTeX files
+     * 
+     * @param {Function} [callback=function()]  加载成功后执行的回调函数
+     */
+    
+    editormd.loadKaTeX = function (callback) {
+        editormd.loadCSS(editormd.katexURL.css, function(){
+            editormd.loadScript(editormd.katexURL.js, callback || function(){});
+        });
+    };
+        
+    /**
+     * 锁屏
+     * lock screen
+     * 
+     * @param   {Boolean}   lock   Boolean 布尔值,是否锁屏
+     * @returns {void}
+     */
+    
+    editormd.lockScreen = function(lock) {
+        $("html,body").css("overflow", (lock) ? "hidden" : "");
+    };
+        
+    /**
+     * 动态创建对话框
+     * Creating custom dialogs
+     * 
+     * @param   {Object} options 配置项键值对 Key/Value
+     * @returns {dialog} 返回创建的dialog的jQuery实例对象
+     */
+
+    editormd.createDialog = function(options) {
+        var defaults = {
+            name : "",
+            width : 420,
+            height: 240,
+            title : "",
+            drag  : true,
+            closed : true,
+            content : "",
+            mask : true,
+            maskStyle : {
+                backgroundColor : "#fff",
+                opacity : 0.1
+            },
+            lockScreen : true,
+            footer : true,
+            buttons : false
+        };
+
+        options          = $.extend(true, defaults, options);
+
+        var editor       = this.editor;
+        var classPrefix  = editormd.classPrefix;
+        var guid         = (new Date()).getTime();
+        var dialogName   = ( (options.name === "") ? classPrefix + "dialog-" + guid : options.name);
+        var mouseOrTouch = editormd.mouseOrTouch;
+
+        var html         = "<div class=\"" + classPrefix + "dialog " + dialogName + "\">";
+
+        if (options.title !== "")
+        {
+            html += "<div class=\"" + classPrefix + "dialog-header\"" + ( (options.drag) ? " style=\"cursor: move;\"" : "" ) + ">";
+            html += "<strong class=\"" + classPrefix + "dialog-title\">" + options.title + "</strong>";
+            html += "</div>";
+        }
+
+        if (options.closed)
+        {
+            html += "<a href=\"javascript:;\" class=\"fa fa-close " + classPrefix + "dialog-close\"></a>";
+        }
+
+        html += "<div class=\"" + classPrefix + "dialog-container\">" + options.content;                    
+
+        if (options.footer || typeof options.footer === "string") 
+        {
+            html += "<div class=\"" + classPrefix + "dialog-footer\">" + ( (typeof options.footer === "boolean") ? "" : options.footer) + "</div>";
+        }
+
+        html += "</div>";
+
+        html += "<div class=\"" + classPrefix + "dialog-mask " + classPrefix + "dialog-mask-bg\"></div>";
+        html += "<div class=\"" + classPrefix + "dialog-mask " + classPrefix + "dialog-mask-con\"></div>";
+        html += "</div>";
+
+        editor.append(html);
+
+        var dialog = editor.find("." + dialogName);
+
+        dialog.lockScreen = function(lock) {
+            if (options.lockScreen)
+            {                
+                $("html,body").css("overflow", (lock) ? "hidden" : "");
+            }
+
+            return dialog;
+        };
+
+        dialog.showMask = function() {
+            if (options.mask)
+            {
+                editor.find("." + classPrefix + "mask").css(options.maskStyle).css("z-index", editormd.dialogZindex - 1).show();
+            }
+            return dialog;
+        };
+
+        dialog.hideMask = function() {
+            if (options.mask)
+            {
+                editor.find("." + classPrefix + "mask").hide();
+            }
+
+            return dialog;
+        };
+
+        dialog.loading = function(show) {                        
+            var loading = dialog.find("." + classPrefix + "dialog-mask");
+            loading[(show) ? "show" : "hide"]();
+
+            return dialog;
+        };
+
+        dialog.lockScreen(true).showMask();
+
+        dialog.show().css({
+            zIndex : editormd.dialogZindex,
+            border : (editormd.isIE8) ? "1px solid #ddd" : "",
+            width  : (typeof options.width  === "number") ? options.width + "px"  : options.width,
+            height : (typeof options.height === "number") ? options.height + "px" : options.height
+        });
+
+        var dialogPosition = function(){
+            dialog.css({
+                top    : ($(window).height() - dialog.height()) / 2 + "px",
+                left   : ($(window).width() - dialog.width()) / 2 + "px"
+            });
+        };
+
+        dialogPosition();
+
+        $(window).resize(dialogPosition);
+
+        dialog.children("." + classPrefix + "dialog-close").bind(mouseOrTouch("click", "touchend"), function() {
+            dialog.hide().lockScreen(false).hideMask();
+        });
+
+        if (typeof options.buttons === "object")
+        {
+            var footer = dialog.footer = dialog.find("." + classPrefix + "dialog-footer");
+
+            for (var key in options.buttons)
+            {
+                var btn = options.buttons[key];
+                var btnClassName = classPrefix + key + "-btn";
+
+                footer.append("<button class=\"" + classPrefix + "btn " + btnClassName + "\">" + btn[0] + "</button>");
+                btn[1] = $.proxy(btn[1], dialog);
+                footer.children("." + btnClassName).bind(mouseOrTouch("click", "touchend"), btn[1]);
+            }
+        }
+
+        if (options.title !== "" && options.drag)
+        {                        
+            var posX, posY;
+            var dialogHeader = dialog.children("." + classPrefix + "dialog-header");
+
+            if (!options.mask) {
+                dialogHeader.bind(mouseOrTouch("click", "touchend"), function(){
+                    editormd.dialogZindex += 2;
+                    dialog.css("z-index", editormd.dialogZindex);
+                });
+            }
+
+            dialogHeader.mousedown(function(e) {
+                e = e || window.event;  //IE
+                posX = e.clientX - parseInt(dialog[0].style.left);
+                posY = e.clientY - parseInt(dialog[0].style.top);
+
+                document.onmousemove = moveAction;                   
+            });
+
+            var userCanSelect = function (obj) {
+                obj.removeClass(classPrefix + "user-unselect").off("selectstart");
+            };
+
+            var userUnselect = function (obj) {
+                obj.addClass(classPrefix + "user-unselect").on("selectstart", function(event) { // selectstart for IE                        
+                    return false;
+                });
+            };
+
+            var moveAction = function (e) {
+                e = e || window.event;  //IE
+
+                var left, top, nowLeft = parseInt(dialog[0].style.left), nowTop = parseInt(dialog[0].style.top);
+
+                if( nowLeft >= 0 ) {
+                    if( nowLeft + dialog.width() <= $(window).width()) {
+                        left = e.clientX - posX;
+                    } else {	
+                        left = $(window).width() - dialog.width();
+                        document.onmousemove = null;
+                    }
+                } else {
+                    left = 0;
+                    document.onmousemove = null;
+                }
+
+                if( nowTop >= 0 ) {
+                    top = e.clientY - posY;
+                } else {
+                    top = 0;
+                    document.onmousemove = null;
+                }
+
+
+                document.onselectstart = function() {
+                    return false;
+                };
+
+                userUnselect($("body"));
+                userUnselect(dialog);
+                dialog[0].style.left = left + "px";
+                dialog[0].style.top  = top + "px";
+            };
+
+            document.onmouseup = function() {                            
+                userCanSelect($("body"));
+                userCanSelect(dialog);
+
+                document.onselectstart = null;         
+                document.onmousemove = null;
+            };
+
+            dialogHeader.touchDraggable = function() {
+                var offset = null;
+                var start  = function(e) {
+                    var orig = e.originalEvent; 
+                    var pos  = $(this).parent().position();
+
+                    offset = {
+                        x : orig.changedTouches[0].pageX - pos.left,
+                        y : orig.changedTouches[0].pageY - pos.top
+                    };
+                };
+
+                var move = function(e) {
+                    e.preventDefault();
+                    var orig = e.originalEvent;
+
+                    $(this).parent().css({
+                        top  : orig.changedTouches[0].pageY - offset.y,
+                        left : orig.changedTouches[0].pageX - offset.x
+                    });
+                };
+
+                this.bind("touchstart", start).bind("touchmove", move);
+            };
+
+            dialogHeader.touchDraggable();
+        }
+
+        editormd.dialogZindex += 2;
+
+        return dialog;
+    };
+    
+    /**
+     * 鼠标和触摸事件的判断/选择方法
+     * MouseEvent or TouchEvent type switch
+     * 
+     * @param   {String} [mouseEventType="click"]    供选择的鼠标事件
+     * @param   {String} [touchEventType="touchend"] 供选择的触摸事件
+     * @returns {String} EventType                   返回事件类型名称
+     */
+    
+    editormd.mouseOrTouch = function(mouseEventType, touchEventType) {
+        mouseEventType = mouseEventType || "click";
+        touchEventType = touchEventType || "touchend";
+        
+        var eventType  = mouseEventType;
+
+        try {
+            document.createEvent("TouchEvent");
+            eventType = touchEventType;
+        } catch(e) {}
+
+        return eventType;
+    };
+    
+    /**
+     * 日期时间的格式化方法
+     * Datetime format method
+     * 
+     * @param   {String}   [format=""]  日期时间的格式,类似PHP的格式
+     * @returns {String}   datefmt      返回格式化后的日期时间字符串
+     */
+    
+    editormd.dateFormat = function(format) {                
+        format      = format || "";
+
+        var addZero = function(d) {
+            return (d < 10) ? "0" + d : d;
+        };
+
+        var date    = new Date(); 
+        var year    = date.getFullYear();
+        var year2   = year.toString().slice(2, 4);
+        var month   = addZero(date.getMonth() + 1);
+        var day     = addZero(date.getDate());
+        var weekDay = date.getDay();
+        var hour    = addZero(date.getHours());
+        var min     = addZero(date.getMinutes());
+        var second  = addZero(date.getSeconds());
+        var ms      = addZero(date.getMilliseconds()); 
+        var datefmt = "";
+
+        var ymd     = year2 + "-" + month + "-" + day;
+        var fymd    = year  + "-" + month + "-" + day;
+        var hms     = hour  + ":" + min   + ":" + second;
+
+        switch (format) 
+        {
+            case "UNIX Time" :
+                    datefmt = date.getTime();
+                break;
+
+            case "UTC" :
+                    datefmt = date.toUTCString();
+                break;	
+
+            case "yy" :
+                    datefmt = year2;
+                break;	
+
+            case "year" :
+            case "yyyy" :
+                    datefmt = year;
+                break;
+
+            case "month" :
+            case "mm" :
+                    datefmt = month;
+                break;                        
+
+            case "cn-week-day" :
+            case "cn-wd" :
+                    var cnWeekDays = ["日", "一", "二", "三", "四", "五", "六"];
+                    datefmt = "星期" + cnWeekDays[weekDay];
+                break;
+
+            case "week-day" :
+            case "wd" :
+                    var weekDays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+                    datefmt = weekDays[weekDay];
+                break;
+
+            case "day" :
+            case "dd" :
+                    datefmt = day;
+                break;
+
+            case "hour" :
+            case "hh" :
+                    datefmt = hour;
+                break;
+
+            case "min" :
+            case "ii" :
+                    datefmt = min;
+                break;
+
+            case "second" :
+            case "ss" :
+                    datefmt = second;
+                break;
+
+            case "ms" :
+                    datefmt = ms;
+                break;
+
+            case "yy-mm-dd" :
+                    datefmt = ymd;
+                break;
+
+            case "yyyy-mm-dd" :
+                    datefmt = fymd;
+                break;
+
+            case "yyyy-mm-dd h:i:s ms" :
+            case "full + ms" : 
+                    datefmt = fymd + " " + hms + " " + ms;
+                break;
+
+            case "full" :
+            case "yyyy-mm-dd h:i:s" :
+                default:
+                    datefmt = fymd + " " + hms;
+                break;
+        }
+
+        return datefmt;
+    };
+
+    return editormd;
+
+}));
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.3.0 on Mon Jun 08 2015 01:07:40 GMT+0800 (中国标准时间) +
+ + + + + diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.eot b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.eot new file mode 100644 index 000000000..5d20d9163 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.eot differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.svg b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.svg new file mode 100644 index 000000000..3ed7be4bc --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.svg @@ -0,0 +1,1830 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.woff b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.woff new file mode 100644 index 000000000..1205787b0 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Bold-webfont.woff differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.eot b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.eot new file mode 100644 index 000000000..1f639a15f Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.eot differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.svg b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.svg new file mode 100644 index 000000000..6a2607b9d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.svg @@ -0,0 +1,1830 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.woff b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.woff new file mode 100644 index 000000000..ed760c062 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-BoldItalic-webfont.woff differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.eot b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.eot new file mode 100644 index 000000000..0c8a0ae06 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.eot differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.svg b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.svg new file mode 100644 index 000000000..e1075dcc2 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.svg @@ -0,0 +1,1830 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.woff b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.woff new file mode 100644 index 000000000..ff652e643 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Italic-webfont.woff differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.eot b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.eot new file mode 100644 index 000000000..14868406a Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.eot differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.svg b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.svg new file mode 100644 index 000000000..11a472ca8 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.svg @@ -0,0 +1,1831 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.woff b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.woff new file mode 100644 index 000000000..e78607481 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Light-webfont.woff differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.eot b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.eot new file mode 100644 index 000000000..8f445929f Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.eot differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.svg b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.svg new file mode 100644 index 000000000..431d7e354 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.svg @@ -0,0 +1,1835 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.woff b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.woff new file mode 100644 index 000000000..43e8b9e6c Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-LightItalic-webfont.woff differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.eot b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.eot new file mode 100644 index 000000000..6bbc3cf58 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.eot differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.svg b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.svg new file mode 100644 index 000000000..25a395234 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.svg @@ -0,0 +1,1831 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.woff b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.woff new file mode 100644 index 000000000..e231183dc Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/docs/fonts/OpenSans-Regular-webfont.woff differ diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/index.html b/paicoding-ui/src/main/resources/static/editormd/docs/index.html new file mode 100644 index 000000000..6c67f6d77 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/index.html @@ -0,0 +1,65 @@ + + + + + JSDoc: Home + + + + + + + + + + +
+ +

Home

+ + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.3.0 on Mon Jun 08 2015 01:07:40 GMT+0800 (中国标准时间) +
+ + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/scripts/linenumber.js b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/linenumber.js new file mode 100644 index 000000000..8d52f7eaf --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/linenumber.js @@ -0,0 +1,25 @@ +/*global document */ +(function() { + var source = document.getElementsByClassName('prettyprint source linenums'); + var i = 0; + var lineNumber = 0; + var lineId; + var lines; + var totalLines; + var anchorHash; + + if (source && source[0]) { + anchorHash = document.location.hash.substring(1); + lines = source[0].getElementsByTagName('li'); + totalLines = lines.length; + + for (; i < totalLines; i++) { + lineNumber++; + lineId = 'line' + lineNumber; + lines[i].id = lineId; + if (lineId === anchorHash) { + lines[i].className += ' selected'; + } + } + } +})(); diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/Apache-License-2.0.txt b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/Apache-License-2.0.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/Apache-License-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/lang-css.js b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/lang-css.js new file mode 100644 index 000000000..041e1f590 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/lang-css.js @@ -0,0 +1,2 @@ +PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n "]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]*)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["com", +/^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]); diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/prettify.js b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/prettify.js new file mode 100644 index 000000000..eef5ad7e6 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/scripts/prettify/prettify.js @@ -0,0 +1,28 @@ +var q=null;window.PR_SHOULD_USE_CONTINUATION=!0; +(function(){function L(a){function m(a){var f=a.charCodeAt(0);if(f!==92)return f;var b=a.charAt(1);return(f=r[b])?f:"0"<=b&&b<="7"?parseInt(a.substring(1),8):b==="u"||b==="x"?parseInt(a.substring(2),16):a.charCodeAt(1)}function e(a){if(a<32)return(a<16?"\\x0":"\\x")+a.toString(16);a=String.fromCharCode(a);if(a==="\\"||a==="-"||a==="["||a==="]")a="\\"+a;return a}function h(a){for(var f=a.substring(1,a.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),a= +[],b=[],o=f[0]==="^",c=o?1:0,i=f.length;c122||(d<65||j>90||b.push([Math.max(65,j)|32,Math.min(d,90)|32]),d<97||j>122||b.push([Math.max(97,j)&-33,Math.min(d,122)&-33]))}}b.sort(function(a,f){return a[0]-f[0]||f[1]-a[1]});f=[];j=[NaN,NaN];for(c=0;ci[0]&&(i[1]+1>i[0]&&b.push("-"),b.push(e(i[1])));b.push("]");return b.join("")}function y(a){for(var f=a.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),b=f.length,d=[],c=0,i=0;c=2&&a==="["?f[c]=h(j):a!=="\\"&&(f[c]=j.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return f.join("")}for(var t=0,s=!1,l=!1,p=0,d=a.length;p=5&&"lang-"===b.substring(0,5))&&!(o&&typeof o[1]==="string"))c=!1,b="src";c||(r[f]=b)}i=d;d+=f.length;if(c){c=o[1];var j=f.indexOf(c),k=j+c.length;o[2]&&(k=f.length-o[2].length,j=k-c.length);b=b.substring(5);B(l+i,f.substring(0,j),e,p);B(l+i+j,c,C(b,c),p);B(l+i+k,f.substring(k),e,p)}else p.push(l+i,b)}a.e=p}var h={},y;(function(){for(var e=a.concat(m), +l=[],p={},d=0,g=e.length;d=0;)h[n.charAt(k)]=r;r=r[1];n=""+r;p.hasOwnProperty(n)||(l.push(r),p[n]=q)}l.push(/[\S\s]/);y=L(l)})();var t=m.length;return e}function u(a){var m=[],e=[];a.tripleQuotedStrings?m.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?m.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/, +q,"'\"`"]):m.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&e.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var h=a.hashComments;h&&(a.cStyleComments?(h>1?m.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):m.push(["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),e.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,q])):m.push(["com",/^#[^\n\r]*/, +q,"#"]));a.cStyleComments&&(e.push(["com",/^\/\/[^\n\r]*/,q]),e.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));a.regexLiterals&&e.push(["lang-regex",/^(?:^^\.?|[!+-]|!=|!==|#|%|%=|&|&&|&&=|&=|\(|\*|\*=|\+=|,|-=|->|\/|\/=|:|::|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|[?@[^]|\^=|\^\^|\^\^=|{|\||\|=|\|\||\|\|=|~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\s*(\/(?=[^*/])(?:[^/[\\]|\\[\S\s]|\[(?:[^\\\]]|\\[\S\s])*(?:]|$))+\/)/]);(h=a.types)&&e.push(["typ",h]);a=(""+a.keywords).replace(/^ | $/g, +"");a.length&&e.push(["kwd",RegExp("^(?:"+a.replace(/[\s,]+/g,"|")+")\\b"),q]);m.push(["pln",/^\s+/,q," \r\n\t\xa0"]);e.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,q],["pun",/^.[^\s\w"-$'./@\\`]*/,q]);return x(m,e)}function D(a,m){function e(a){switch(a.nodeType){case 1:if(k.test(a.className))break;if("BR"===a.nodeName)h(a), +a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)e(a);break;case 3:case 4:if(p){var b=a.nodeValue,d=b.match(t);if(d){var c=b.substring(0,d.index);a.nodeValue=c;(b=b.substring(d.index+d[0].length))&&a.parentNode.insertBefore(s.createTextNode(b),a.nextSibling);h(a);c||a.parentNode.removeChild(a)}}}}function h(a){function b(a,d){var e=d?a.cloneNode(!1):a,f=a.parentNode;if(f){var f=b(f,1),g=a.nextSibling;f.appendChild(e);for(var h=g;h;h=g)g=h.nextSibling,f.appendChild(h)}return e} +for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),e;(e=a.parentNode)&&e.nodeType===1;)a=e;d.push(a)}var k=/(?:^|\s)nocode(?:\s|$)/,t=/\r\n?|\n/,s=a.ownerDocument,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=s.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);for(l=s.createElement("LI");a.firstChild;)l.appendChild(a.firstChild);for(var d=[l],g=0;g=0;){var h=m[e];A.hasOwnProperty(h)?window.console&&console.warn("cannot override language handler %s",h):A[h]=a}}function C(a,m){if(!a||!A.hasOwnProperty(a))a=/^\s*=o&&(h+=2);e>=c&&(a+=2)}}catch(w){"console"in window&&console.log(w&&w.stack?w.stack:w)}}var v=["break,continue,do,else,for,if,return,while"],w=[[v,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"], +"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],F=[w,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],G=[w,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"], +H=[G,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"],w=[w,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],I=[v,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"], +J=[v,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],v=[v,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],K=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/,N=/\S/,O=u({keywords:[F,H,w,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END"+ +I,J,v],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),A={};k(O,["default-code"]);k(x([],[["pln",/^[^]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]), +["default-markup","htm","html","mxml","xhtml","xml","xsl"]);k(x([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css", +/^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);k(x([],[["atv",/^[\S\s]+/]]),["uq.val"]);k(u({keywords:F,hashComments:!0,cStyleComments:!0,types:K}),["c","cc","cpp","cxx","cyc","m"]);k(u({keywords:"null,true,false"}),["json"]);k(u({keywords:H,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:K}),["cs"]);k(u({keywords:G,cStyleComments:!0}),["java"]);k(u({keywords:v,hashComments:!0,multiLineStrings:!0}),["bsh","csh","sh"]);k(u({keywords:I,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}), +["cv","py"]);k(u({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["perl","pl","pm"]);k(u({keywords:J,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb"]);k(u({keywords:w,cStyleComments:!0,regexLiterals:!0}),["js"]);k(u({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes", +hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);k(x([],[["str",/^[\S\s]+/]]),["regex"]);window.prettyPrintOne=function(a,m,e){var h=document.createElement("PRE");h.innerHTML=a;e&&D(h,e);E({g:m,i:e,h:h});return h.innerHTML};window.prettyPrint=function(a){function m(){for(var e=window.PR_SHOULD_USE_CONTINUATION?l.now()+250:Infinity;p=0){var k=k.match(g),f,b;if(b= +!k){b=n;for(var o=void 0,c=b.firstChild;c;c=c.nextSibling)var i=c.nodeType,o=i===1?o?b:c:i===3?N.test(c.nodeValue)?b:o:o;b=(f=o===b?void 0:o)&&"CODE"===f.tagName}b&&(k=f.className.match(g));k&&(k=k[1]);b=!1;for(o=n.parentNode;o;o=o.parentNode)if((o.tagName==="pre"||o.tagName==="code"||o.tagName==="xmp")&&o.className&&o.className.indexOf("prettyprint")>=0){b=!0;break}b||((b=(b=n.className.match(/\blinenums\b(?::(\d+))?/))?b[1]&&b[1].length?+b[1]:!0:!1)&&D(n,b),d={g:k,h:n,i:b},E(d))}}p p:first-child, +.props td.description > p:first-child +{ + margin-top: 0; + padding-top: 0; +} + +.params td.description > p:last-child, +.props td.description > p:last-child +{ + margin-bottom: 0; + padding-bottom: 0; +} + +.disabled { + color: #454545; +} diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/styles/prettify-jsdoc.css b/paicoding-ui/src/main/resources/static/editormd/docs/styles/prettify-jsdoc.css new file mode 100644 index 000000000..5a2526e37 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/styles/prettify-jsdoc.css @@ -0,0 +1,111 @@ +/* JSDoc prettify.js theme */ + +/* plain text */ +.pln { + color: #000000; + font-weight: normal; + font-style: normal; +} + +/* string content */ +.str { + color: #006400; + font-weight: normal; + font-style: normal; +} + +/* a keyword */ +.kwd { + color: #000000; + font-weight: bold; + font-style: normal; +} + +/* a comment */ +.com { + font-weight: normal; + font-style: italic; +} + +/* a type name */ +.typ { + color: #000000; + font-weight: normal; + font-style: normal; +} + +/* a literal value */ +.lit { + color: #006400; + font-weight: normal; + font-style: normal; +} + +/* punctuation */ +.pun { + color: #000000; + font-weight: bold; + font-style: normal; +} + +/* lisp open bracket */ +.opn { + color: #000000; + font-weight: bold; + font-style: normal; +} + +/* lisp close bracket */ +.clo { + color: #000000; + font-weight: bold; + font-style: normal; +} + +/* a markup tag name */ +.tag { + color: #006400; + font-weight: normal; + font-style: normal; +} + +/* a markup attribute name */ +.atn { + color: #006400; + font-weight: normal; + font-style: normal; +} + +/* a markup attribute value */ +.atv { + color: #006400; + font-weight: normal; + font-style: normal; +} + +/* a declaration */ +.dec { + color: #000000; + font-weight: bold; + font-style: normal; +} + +/* a variable name */ +.var { + color: #000000; + font-weight: normal; + font-style: normal; +} + +/* a function name */ +.fun { + color: #000000; + font-weight: bold; + font-style: normal; +} + +/* Specify class=linenums on a pre to get line numbering */ +ol.linenums { + margin-top: 0; + margin-bottom: 0; +} diff --git a/paicoding-ui/src/main/resources/static/editormd/docs/styles/prettify-tomorrow.css b/paicoding-ui/src/main/resources/static/editormd/docs/styles/prettify-tomorrow.css new file mode 100644 index 000000000..b6f92a78d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/docs/styles/prettify-tomorrow.css @@ -0,0 +1,132 @@ +/* Tomorrow Theme */ +/* Original theme - https://github.com/chriskempson/tomorrow-theme */ +/* Pretty printing styles. Used with prettify.js. */ +/* SPAN elements with the classes below are added by prettyprint. */ +/* plain text */ +.pln { + color: #4d4d4c; } + +@media screen { + /* string content */ + .str { + color: #718c00; } + + /* a keyword */ + .kwd { + color: #8959a8; } + + /* a comment */ + .com { + color: #8e908c; } + + /* a type name */ + .typ { + color: #4271ae; } + + /* a literal value */ + .lit { + color: #f5871f; } + + /* punctuation */ + .pun { + color: #4d4d4c; } + + /* lisp open bracket */ + .opn { + color: #4d4d4c; } + + /* lisp close bracket */ + .clo { + color: #4d4d4c; } + + /* a markup tag name */ + .tag { + color: #c82829; } + + /* a markup attribute name */ + .atn { + color: #f5871f; } + + /* a markup attribute value */ + .atv { + color: #3e999f; } + + /* a declaration */ + .dec { + color: #f5871f; } + + /* a variable name */ + .var { + color: #c82829; } + + /* a function name */ + .fun { + color: #4271ae; } } +/* Use higher contrast and text-weight for printable form. */ +@media print, projection { + .str { + color: #060; } + + .kwd { + color: #006; + font-weight: bold; } + + .com { + color: #600; + font-style: italic; } + + .typ { + color: #404; + font-weight: bold; } + + .lit { + color: #044; } + + .pun, .opn, .clo { + color: #440; } + + .tag { + color: #006; + font-weight: bold; } + + .atn { + color: #404; } + + .atv { + color: #060; } } +/* Style */ +/* +pre.prettyprint { + background: white; + font-family: Consolas, Monaco, 'Andale Mono', monospace; + font-size: 12px; + line-height: 1.5; + border: 1px solid #ccc; + padding: 10px; } +*/ + +/* Specify class=linenums on a pre to get line numbering */ +ol.linenums { + margin-top: 0; + margin-bottom: 0; } + +/* IE indents via margin-left */ +li.L0, +li.L1, +li.L2, +li.L3, +li.L4, +li.L5, +li.L6, +li.L7, +li.L8, +li.L9 { + /* */ } + +/* Alternate shading for lines */ +li.L1, +li.L3, +li.L5, +li.L7, +li.L9 { + /* */ } diff --git a/paicoding-ui/src/main/resources/static/editormd/editormd.amd.js b/paicoding-ui/src/main/resources/static/editormd/editormd.amd.js new file mode 100644 index 000000000..14fc94e4d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/editormd.amd.js @@ -0,0 +1,4667 @@ +/* + * Editor.md + * + * @file editormd.amd.js + * @version v1.5.0 + * @description Open source online markdown editor. + * @license MIT License + * @author Pandao + * {@link https://github.com/pandao/editor.md} + * @updateTime 2015-06-09 + */ + +;(function(factory) { + "use strict"; + + // CommonJS/Node.js + if (typeof require === "function" && typeof exports === "object" && typeof module === "object") + { + module.exports = factory; + } + else if (typeof define === "function") // AMD/CMD/Sea.js + { + if (define.amd) // for Require.js + { + var cmModePath = "./lib/codemirror/mode/"; + var cmAddonPath = "./lib/codemirror/addon/"; + + var codeMirrorModules = [ + "jquery", "marked", "prettify", + "katex", "raphael", "underscore", "flowchart", "jqueryflowchart", "sequenceDiagram", + + "./lib/codemirror/lib/codemirror", + cmModePath + "css/css", + cmModePath + "sass/sass", + cmModePath + "shell/shell", + cmModePath + "sql/sql", + cmModePath + "clike/clike", + cmModePath + "php/php", + cmModePath + "xml/xml", + cmModePath + "markdown/markdown", + cmModePath + "javascript/javascript", + cmModePath + "htmlmixed/htmlmixed", + cmModePath + "gfm/gfm", + cmModePath + "http/http", + cmModePath + "go/go", + cmModePath + "dart/dart", + cmModePath + "coffeescript/coffeescript", + cmModePath + "nginx/nginx", + cmModePath + "python/python", + cmModePath + "perl/perl", + cmModePath + "lua/lua", + cmModePath + "r/r", + cmModePath + "ruby/ruby", + cmModePath + "rst/rst", + cmModePath + "smartymixed/smartymixed", + cmModePath + "vb/vb", + cmModePath + "vbscript/vbscript", + cmModePath + "velocity/velocity", + cmModePath + "xquery/xquery", + cmModePath + "yaml/yaml", + cmModePath + "erlang/erlang", + cmModePath + "jade/jade", + + cmAddonPath + "edit/trailingspace", + cmAddonPath + "dialog/dialog", + cmAddonPath + "search/searchcursor", + cmAddonPath + "search/search", + cmAddonPath + "scroll/annotatescrollbar", + cmAddonPath + "search/matchesonscrollbar", + cmAddonPath + "display/placeholder", + cmAddonPath + "edit/closetag", + cmAddonPath + "fold/foldcode", + cmAddonPath + "fold/foldgutter", + cmAddonPath + "fold/indent-fold", + cmAddonPath + "fold/brace-fold", + cmAddonPath + "fold/xml-fold", + cmAddonPath + "fold/markdown-fold", + cmAddonPath + "fold/comment-fold", + cmAddonPath + "mode/overlay", + cmAddonPath + "selection/active-line", + cmAddonPath + "edit/closebrackets", + cmAddonPath + "display/fullscreen", + cmAddonPath + "search/match-highlighter" + ]; + + define(codeMirrorModules, factory); + } + else + { + define(["jquery"], factory); // for Sea.js + } + } + else + { + window.editormd = factory(); + } + +}(function() { + + if (typeof define == "function" && define.amd) { + $ = arguments[0]; + marked = arguments[1]; + prettify = arguments[2]; + katex = arguments[3]; + Raphael = arguments[4]; + _ = arguments[5]; + flowchart = arguments[6]; + CodeMirror = arguments[9]; + } + + "use strict"; + + var $ = (typeof (jQuery) !== "undefined") ? jQuery : Zepto; + + if (typeof ($) === "undefined") { + return ; + } + + /** + * editormd + * + * @param {String} id 编辑器的ID + * @param {Object} options 配置选项 Key/Value + * @returns {Object} editormd 返回editormd对象 + */ + + var editormd = function (id, options) { + return new editormd.fn.init(id, options); + }; + + editormd.title = editormd.$name = "Editor.md"; + editormd.version = "1.5.0"; + editormd.homePage = "https://pandao.github.io/editor.md/"; + editormd.classPrefix = "editormd-"; + + editormd.toolbarModes = { + full : [ + "undo", "redo", "|", + "bold", "del", "italic", "quote", "ucwords", "uppercase", "lowercase", "|", + "h1", "h2", "h3", "h4", "h5", "h6", "|", + "list-ul", "list-ol", "hr", "|", + "link", "reference-link", "image", "code", "preformatted-text", "code-block", "table", "datetime", "emoji", "html-entities", "pagebreak", "|", + "goto-line", "watch", "preview", "fullscreen", "clear", "search", "|", + "help", "info" + ], + simple : [ + "undo", "redo", "|", + "bold", "del", "italic", "quote", "uppercase", "lowercase", "|", + "h1", "h2", "h3", "h4", "h5", "h6", "|", + "list-ul", "list-ol", "hr", "|", + "watch", "preview", "fullscreen", "|", + "help", "info" + ], + mini : [ + "undo", "redo", "|", + "watch", "preview", "|", + "help", "info" + ] + }; + + editormd.defaults = { + mode : "gfm", //gfm or markdown + name : "", // Form element name + value : "", // value for CodeMirror, if mode not gfm/markdown + theme : "", // Editor.md self themes, before v1.5.0 is CodeMirror theme, default empty + editorTheme : "default", // Editor area, this is CodeMirror theme at v1.5.0 + previewTheme : "", // Preview area theme, default empty + markdown : "", // Markdown source code + appendMarkdown : "", // if in init textarea value not empty, append markdown to textarea + width : "100%", + height : "100%", + path : "./lib/", // Dependents module file directory + pluginPath : "", // If this empty, default use settings.path + "../plugins/" + delay : 300, // Delay parse markdown to html, Uint : ms + autoLoadModules : true, // Automatic load dependent module files + watch : true, + placeholder : "Enjoy Markdown! coding now...", + gotoLine : true, + codeFold : false, + autoHeight : false, + autoFocus : true, + autoCloseTags : true, + searchReplace : true, + syncScrolling : true, // true | false | "single", default true + readOnly : false, + tabSize : 4, + indentUnit : 4, + lineNumbers : true, + lineWrapping : true, + autoCloseBrackets : true, + showTrailingSpace : true, + matchBrackets : true, + indentWithTabs : true, + styleSelectedText : true, + matchWordHighlight : true, // options: true, false, "onselected" + styleActiveLine : true, // Highlight the current line + dialogLockScreen : true, + dialogShowMask : true, + dialogDraggable : true, + dialogMaskBgColor : "#fff", + dialogMaskOpacity : 0.1, + fontSize : "13px", + saveHTMLToTextarea : false, + disabledKeyMaps : [], + + onload : function() {}, + onresize : function() {}, + onchange : function() {}, + onwatch : null, + onunwatch : null, + onpreviewing : function() {}, + onpreviewed : function() {}, + onfullscreen : function() {}, + onfullscreenExit : function() {}, + onscroll : function() {}, + onpreviewscroll : function() {}, + + imageUpload : false, + imageFormats : ["jpg", "jpeg", "gif", "png", "bmp", "webp"], + imageUploadURL : "", + crossDomainUpload : false, + uploadCallbackURL : "", + + toc : true, // Table of contents + tocm : false, // Using [TOCM], auto create ToC dropdown menu + tocTitle : "", // for ToC dropdown menu btn + tocDropdown : false, + tocContainer : "", + tocStartLevel : 1, // Said from H1 to create ToC + htmlDecode : false, // Open the HTML tag identification + pageBreak : true, // Enable parse page break [========] + atLink : true, // for @link + emailLink : true, // for email address auto link + taskList : false, // Enable Github Flavored Markdown task lists + emoji : false, // :emoji: , Support Github emoji, Twitter Emoji (Twemoji); + // Support FontAwesome icon emoji :fa-xxx: > Using fontAwesome icon web fonts; + // Support Editor.md logo icon emoji :editormd-logo: :editormd-logo-1x: > 1~8x; + tex : false, // TeX(LaTeX), based on KaTeX + flowChart : false, // flowChart.js only support IE9+ + sequenceDiagram : false, // sequenceDiagram.js only support IE9+ + previewCodeHighlight : true, + + toolbar : true, // show/hide toolbar + toolbarAutoFixed : true, // on window scroll auto fixed position + toolbarIcons : "full", + toolbarTitles : {}, + toolbarHandlers : { + ucwords : function() { + return editormd.toolbarHandlers.ucwords; + }, + lowercase : function() { + return editormd.toolbarHandlers.lowercase; + } + }, + toolbarCustomIcons : { // using html tag create toolbar icon, unused default tag. + lowercase : "a", + "ucwords" : "Aa" + }, + toolbarIconsClass : { + undo : "fa-undo", + redo : "fa-repeat", + bold : "fa-bold", + del : "fa-strikethrough", + italic : "fa-italic", + quote : "fa-quote-left", + uppercase : "fa-font", + h1 : editormd.classPrefix + "bold", + h2 : editormd.classPrefix + "bold", + h3 : editormd.classPrefix + "bold", + h4 : editormd.classPrefix + "bold", + h5 : editormd.classPrefix + "bold", + h6 : editormd.classPrefix + "bold", + "list-ul" : "fa-list-ul", + "list-ol" : "fa-list-ol", + hr : "fa-minus", + link : "fa-link", + "reference-link" : "fa-anchor", + image : "fa-picture-o", + code : "fa-code", + "preformatted-text" : "fa-file-code-o", + "code-block" : "fa-file-code-o", + table : "fa-table", + datetime : "fa-clock-o", + emoji : "fa-smile-o", + "html-entities" : "fa-copyright", + pagebreak : "fa-newspaper-o", + "goto-line" : "fa-terminal", // fa-crosshairs + watch : "fa-eye-slash", + unwatch : "fa-eye", + preview : "fa-desktop", + search : "fa-search", + fullscreen : "fa-arrows-alt", + clear : "fa-eraser", + help : "fa-question-circle", + info : "fa-info-circle" + }, + toolbarIconTexts : {}, + + lang : { + name : "zh-cn", + description : "开源在线Markdown编辑器
Open source online Markdown editor.", + tocTitle : "目录", + toolbar : { + undo : "撤销(Ctrl+Z)", + redo : "重做(Ctrl+Y)", + bold : "粗体", + del : "删除线", + italic : "斜体", + quote : "引用", + ucwords : "将每个单词首字母转成大写", + uppercase : "将所选转换成大写", + lowercase : "将所选转换成小写", + h1 : "标题1", + h2 : "标题2", + h3 : "标题3", + h4 : "标题4", + h5 : "标题5", + h6 : "标题6", + "list-ul" : "无序列表", + "list-ol" : "有序列表", + hr : "横线", + link : "链接", + "reference-link" : "引用链接", + image : "添加图片", + code : "行内代码", + "preformatted-text" : "预格式文本 / 代码块(缩进风格)", + "code-block" : "代码块(多语言风格)", + table : "添加表格", + datetime : "日期时间", + emoji : "Emoji表情", + "html-entities" : "HTML实体字符", + pagebreak : "插入分页符", + "goto-line" : "跳转到行", + watch : "关闭实时预览", + unwatch : "开启实时预览", + preview : "全窗口预览HTML(按 Shift + ESC还原)", + fullscreen : "全屏(按ESC还原)", + clear : "清空", + search : "搜索", + help : "使用帮助", + info : "关于" + editormd.title + }, + buttons : { + enter : "确定", + cancel : "取消", + close : "关闭" + }, + dialog : { + link : { + title : "添加链接", + url : "链接地址", + urlTitle : "链接标题", + urlEmpty : "错误:请填写链接地址。" + }, + referenceLink : { + title : "添加引用链接", + name : "引用名称", + url : "链接地址", + urlId : "链接ID", + urlTitle : "链接标题", + nameEmpty: "错误:引用链接的名称不能为空。", + idEmpty : "错误:请填写引用链接的ID。", + urlEmpty : "错误:请填写引用链接的URL地址。" + }, + image : { + title : "添加图片", + url : "图片地址", + link : "图片链接", + alt : "图片描述", + uploadButton : "本地上传", + imageURLEmpty : "错误:图片地址不能为空。", + uploadFileEmpty : "错误:上传的图片不能为空。", + formatNotAllowed : "错误:只允许上传图片文件,允许上传的图片文件格式有:" + }, + preformattedText : { + title : "添加预格式文本或代码块", + emptyAlert : "错误:请填写预格式文本或代码的内容。" + }, + codeBlock : { + title : "添加代码块", + selectLabel : "代码语言:", + selectDefaultText : "请选择代码语言", + otherLanguage : "其他语言", + unselectedLanguageAlert : "错误:请选择代码所属的语言类型。", + codeEmptyAlert : "错误:请填写代码内容。" + }, + htmlEntities : { + title : "HTML 实体字符" + }, + help : { + title : "使用帮助" + } + } + } + }; + + editormd.classNames = { + tex : editormd.classPrefix + "tex" + }; + + editormd.dialogZindex = 99999; + + editormd.$katex = null; + editormd.$marked = null; + editormd.$CodeMirror = null; + editormd.$prettyPrint = null; + + var timer, flowchartTimer; + + editormd.prototype = editormd.fn = { + state : { + watching : false, + loaded : false, + preview : false, + fullscreen : false + }, + + /** + * 构造函数/实例初始化 + * Constructor / instance initialization + * + * @param {String} id 编辑器的ID + * @param {Object} [options={}] 配置选项 Key/Value + * @returns {editormd} 返回editormd的实例对象 + */ + + init : function (id, options) { + + options = options || {}; + + if (typeof id === "object") + { + options = id; + } + + var _this = this; + var classPrefix = this.classPrefix = editormd.classPrefix; + var settings = this.settings = $.extend(true, editormd.defaults, options); + + id = (typeof id === "object") ? settings.id : id; + + var editor = this.editor = $("#" + id); + + this.id = id; + this.lang = settings.lang; + + var classNames = this.classNames = { + textarea : { + html : classPrefix + "html-textarea", + markdown : classPrefix + "markdown-textarea" + } + }; + + settings.pluginPath = (settings.pluginPath === "") ? settings.path + "../plugins/" : settings.pluginPath; + + this.state.watching = (settings.watch) ? true : false; + + if ( !editor.hasClass("editormd") ) { + editor.addClass("editormd"); + } + + editor.css({ + width : (typeof settings.width === "number") ? settings.width + "px" : settings.width, + height : (typeof settings.height === "number") ? settings.height + "px" : settings.height + }); + + if (settings.autoHeight) + { + editor.css("height", "auto"); + } + + var markdownTextarea = this.markdownTextarea = editor.children("textarea"); + + if (markdownTextarea.length < 1) + { + editor.append(""); + markdownTextarea = this.markdownTextarea = editor.children("textarea"); + } + + markdownTextarea.addClass(classNames.textarea.markdown).attr("placeholder", settings.placeholder); + + if (typeof markdownTextarea.attr("name") === "undefined" || markdownTextarea.attr("name") === "") + { + markdownTextarea.attr("name", (settings.name !== "") ? settings.name : id + "-markdown-doc"); + } + + var appendElements = [ + (!settings.readOnly) ? "" : "", + ( (settings.saveHTMLToTextarea) ? "" : "" ), + "
", + "
", + "
" + ].join("\n"); + + editor.append(appendElements).addClass(classPrefix + "vertical"); + + if (settings.theme !== "") + { + editor.addClass(classPrefix + "theme-" + settings.theme); + } + + this.mask = editor.children("." + classPrefix + "mask"); + this.containerMask = editor.children("." + classPrefix + "container-mask"); + + if (settings.markdown !== "") + { + markdownTextarea.val(settings.markdown); + } + + if (settings.appendMarkdown !== "") + { + markdownTextarea.val(markdownTextarea.val() + settings.appendMarkdown); + } + + this.htmlTextarea = editor.children("." + classNames.textarea.html); + this.preview = editor.children("." + classPrefix + "preview"); + this.previewContainer = this.preview.children("." + classPrefix + "preview-container"); + + if (settings.previewTheme !== "") + { + this.preview.addClass(classPrefix + "preview-theme-" + settings.previewTheme); + } + + if (typeof define === "function" && define.amd) + { + if (typeof katex !== "undefined") + { + editormd.$katex = katex; + } + + if (settings.searchReplace && !settings.readOnly) + { + editormd.loadCSS(settings.path + "codemirror/addon/dialog/dialog"); + editormd.loadCSS(settings.path + "codemirror/addon/search/matchesonscrollbar"); + } + } + + if ((typeof define === "function" && define.amd) || !settings.autoLoadModules) + { + if (typeof CodeMirror !== "undefined") { + editormd.$CodeMirror = CodeMirror; + } + + if (typeof marked !== "undefined") { + editormd.$marked = marked; + } + + this.setCodeMirror().setToolbar().loadedDisplay(); + } + else + { + this.loadQueues(); + } + + return this; + }, + + /** + * 所需组件加载队列 + * Required components loading queue + * + * @returns {editormd} 返回editormd的实例对象 + */ + + loadQueues : function() { + var _this = this; + var settings = this.settings; + var loadPath = settings.path; + + var loadFlowChartOrSequenceDiagram = function() { + + if (editormd.isIE8) + { + _this.loadedDisplay(); + + return ; + } + + if (settings.flowChart || settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "raphael.min", function() { + + editormd.loadScript(loadPath + "underscore.min", function() { + + if (!settings.flowChart && settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "sequence-diagram.min", function() { + _this.loadedDisplay(); + }); + } + else if (settings.flowChart && !settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "flowchart.min", function() { + editormd.loadScript(loadPath + "jquery.flowchart.min", function() { + _this.loadedDisplay(); + }); + }); + } + else if (settings.flowChart && settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "flowchart.min", function() { + editormd.loadScript(loadPath + "jquery.flowchart.min", function() { + editormd.loadScript(loadPath + "sequence-diagram.min", function() { + _this.loadedDisplay(); + }); + }); + }); + } + }); + + }); + } + else + { + _this.loadedDisplay(); + } + }; + + editormd.loadCSS(loadPath + "codemirror/codemirror.min"); + + if (settings.searchReplace && !settings.readOnly) + { + editormd.loadCSS(loadPath + "codemirror/addon/dialog/dialog"); + editormd.loadCSS(loadPath + "codemirror/addon/search/matchesonscrollbar"); + } + + if (settings.codeFold) + { + editormd.loadCSS(loadPath + "codemirror/addon/fold/foldgutter"); + } + + editormd.loadScript(loadPath + "codemirror/codemirror.min", function() { + editormd.$CodeMirror = CodeMirror; + + editormd.loadScript(loadPath + "codemirror/modes.min", function() { + + editormd.loadScript(loadPath + "codemirror/addons.min", function() { + + _this.setCodeMirror(); + + if (settings.mode !== "gfm" && settings.mode !== "markdown") + { + _this.loadedDisplay(); + + return false; + } + + _this.setToolbar(); + + editormd.loadScript(loadPath + "marked.min", function() { + + editormd.$marked = marked; + + if (settings.previewCodeHighlight) + { + editormd.loadScript(loadPath + "prettify.min", function() { + loadFlowChartOrSequenceDiagram(); + }); + } + else + { + loadFlowChartOrSequenceDiagram(); + } + }); + + }); + + }); + + }); + + return this; + }, + + /** + * 设置 Editor.md 的整体主题,主要是工具栏 + * Setting Editor.md theme + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setTheme : function(theme) { + var editor = this.editor; + var oldTheme = this.settings.theme; + var themePrefix = this.classPrefix + "theme-"; + + editor.removeClass(themePrefix + oldTheme).addClass(themePrefix + theme); + + this.settings.theme = theme; + + return this; + }, + + /** + * 设置 CodeMirror(编辑区)的主题 + * Setting CodeMirror (Editor area) theme + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setEditorTheme : function(theme) { + var settings = this.settings; + settings.editorTheme = theme; + + if (theme !== "default") + { + editormd.loadCSS(settings.path + "codemirror/theme/" + settings.editorTheme); + } + + this.cm.setOption("theme", theme); + + return this; + }, + + /** + * setEditorTheme() 的别名 + * setEditorTheme() alias + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setCodeMirrorTheme : function (theme) { + this.setEditorTheme(theme); + + return this; + }, + + /** + * 设置 Editor.md 的主题 + * Setting Editor.md theme + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setPreviewTheme : function(theme) { + var preview = this.preview; + var oldTheme = this.settings.previewTheme; + var themePrefix = this.classPrefix + "preview-theme-"; + + preview.removeClass(themePrefix + oldTheme).addClass(themePrefix + theme); + + this.settings.previewTheme = theme; + + return this; + }, + + /** + * 配置和初始化CodeMirror组件 + * CodeMirror initialization + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setCodeMirror : function() { + var settings = this.settings; + var editor = this.editor; + + if (settings.editorTheme !== "default") + { + editormd.loadCSS(settings.path + "codemirror/theme/" + settings.editorTheme); + } + + var codeMirrorConfig = { + mode : settings.mode, + theme : settings.editorTheme, + tabSize : settings.tabSize, + dragDrop : false, + autofocus : settings.autoFocus, + autoCloseTags : settings.autoCloseTags, + readOnly : (settings.readOnly) ? "nocursor" : false, + indentUnit : settings.indentUnit, + lineNumbers : settings.lineNumbers, + lineWrapping : settings.lineWrapping, + extraKeys : { + "Ctrl-Q": function(cm) { + cm.foldCode(cm.getCursor()); + } + }, + foldGutter : settings.codeFold, + gutters : ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], + matchBrackets : settings.matchBrackets, + indentWithTabs : settings.indentWithTabs, + styleActiveLine : settings.styleActiveLine, + styleSelectedText : settings.styleSelectedText, + autoCloseBrackets : settings.autoCloseBrackets, + showTrailingSpace : settings.showTrailingSpace, + highlightSelectionMatches : ( (!settings.matchWordHighlight) ? false : { showToken: (settings.matchWordHighlight === "onselected") ? false : /\w/ } ) + }; + + this.codeEditor = this.cm = editormd.$CodeMirror.fromTextArea(this.markdownTextarea[0], codeMirrorConfig); + this.codeMirror = this.cmElement = editor.children(".CodeMirror"); + + if (settings.value !== "") + { + this.cm.setValue(settings.value); + } + + this.codeMirror.css({ + fontSize : settings.fontSize, + width : (!settings.watch) ? "100%" : "50%" + }); + + if (settings.autoHeight) + { + this.codeMirror.css("height", "auto"); + this.cm.setOption("viewportMargin", Infinity); + } + + if (!settings.lineNumbers) + { + this.codeMirror.find(".CodeMirror-gutters").css("border-right", "none"); + } + + return this; + }, + + /** + * 获取CodeMirror的配置选项 + * Get CodeMirror setting options + * + * @returns {Mixed} return CodeMirror setting option value + */ + + getCodeMirrorOption : function(key) { + return this.cm.getOption(key); + }, + + /** + * 配置和重配置CodeMirror的选项 + * CodeMirror setting options / resettings + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setCodeMirrorOption : function(key, value) { + + this.cm.setOption(key, value); + + return this; + }, + + /** + * 添加 CodeMirror 键盘快捷键 + * Add CodeMirror keyboard shortcuts key map + * + * @returns {editormd} 返回editormd的实例对象 + */ + + addKeyMap : function(map, bottom) { + this.cm.addKeyMap(map, bottom); + + return this; + }, + + /** + * 移除 CodeMirror 键盘快捷键 + * Remove CodeMirror keyboard shortcuts key map + * + * @returns {editormd} 返回editormd的实例对象 + */ + + removeKeyMap : function(map) { + this.cm.removeKeyMap(map); + + return this; + }, + + /** + * 跳转到指定的行 + * Goto CodeMirror line + * + * @param {String|Intiger} line line number or "first"|"last" + * @returns {editormd} 返回editormd的实例对象 + */ + + gotoLine : function (line) { + + var settings = this.settings; + + if (!settings.gotoLine) + { + return this; + } + + var cm = this.cm; + var editor = this.editor; + var count = cm.lineCount(); + var preview = this.preview; + + if (typeof line === "string") + { + if(line === "last") + { + line = count; + } + + if (line === "first") + { + line = 1; + } + } + + if (typeof line !== "number") + { + alert("Error: The line number must be an integer."); + return this; + } + + line = parseInt(line) - 1; + + if (line > count) + { + alert("Error: The line number range 1-" + count); + + return this; + } + + cm.setCursor( {line : line, ch : 0} ); + + var scrollInfo = cm.getScrollInfo(); + var clientHeight = scrollInfo.clientHeight; + var coords = cm.charCoords({line : line, ch : 0}, "local"); + + cm.scrollTo(null, (coords.top + coords.bottom - clientHeight) / 2); + + if (settings.watch) + { + var cmScroll = this.codeMirror.find(".CodeMirror-scroll")[0]; + var height = $(cmScroll).height(); + var scrollTop = cmScroll.scrollTop; + var percent = (scrollTop / cmScroll.scrollHeight); + + if (scrollTop === 0) + { + preview.scrollTop(0); + } + else if (scrollTop + height >= cmScroll.scrollHeight - 16) + { + preview.scrollTop(preview[0].scrollHeight); + } + else + { + preview.scrollTop(preview[0].scrollHeight * percent); + } + } + + cm.focus(); + + return this; + }, + + /** + * 扩展当前实例对象,可同时设置多个或者只设置一个 + * Extend editormd instance object, can mutil setting. + * + * @returns {editormd} this(editormd instance object.) + */ + + extend : function() { + if (typeof arguments[1] !== "undefined") + { + if (typeof arguments[1] === "function") + { + arguments[1] = $.proxy(arguments[1], this); + } + + this[arguments[0]] = arguments[1]; + } + + if (typeof arguments[0] === "object" && typeof arguments[0].length === "undefined") + { + $.extend(true, this, arguments[0]); + } + + return this; + }, + + /** + * 设置或扩展当前实例对象,单个设置 + * Extend editormd instance object, one by one + * + * @param {String|Object} key option key + * @param {String|Object} value option value + * @returns {editormd} this(editormd instance object.) + */ + + set : function (key, value) { + + if (typeof value !== "undefined" && typeof value === "function") + { + value = $.proxy(value, this); + } + + this[key] = value; + + return this; + }, + + /** + * 重新配置 + * Resetting editor options + * + * @param {String|Object} key option key + * @param {String|Object} value option value + * @returns {editormd} this(editormd instance object.) + */ + + config : function(key, value) { + var settings = this.settings; + + if (typeof key === "object") + { + settings = $.extend(true, settings, key); + } + + if (typeof key === "string") + { + settings[key] = value; + } + + this.settings = settings; + this.recreate(); + + return this; + }, + + /** + * 注册事件处理方法 + * Bind editor event handle + * + * @param {String} eventType event type + * @param {Function} callback 回调函数 + * @returns {editormd} this(editormd instance object.) + */ + + on : function(eventType, callback) { + var settings = this.settings; + + if (typeof settings["on" + eventType] !== "undefined") + { + settings["on" + eventType] = $.proxy(callback, this); + } + + return this; + }, + + /** + * 解除事件处理方法 + * Unbind editor event handle + * + * @param {String} eventType event type + * @returns {editormd} this(editormd instance object.) + */ + + off : function(eventType) { + var settings = this.settings; + + if (typeof settings["on" + eventType] !== "undefined") + { + settings["on" + eventType] = function(){}; + } + + return this; + }, + + /** + * 显示工具栏 + * Display toolbar + * + * @param {Function} [callback=function(){}] 回调函数 + * @returns {editormd} 返回editormd的实例对象 + */ + + showToolbar : function(callback) { + var settings = this.settings; + + if(settings.readOnly) { + return this; + } + + if (settings.toolbar && (this.toolbar.length < 1 || this.toolbar.find("." + this.classPrefix + "menu").html() === "") ) + { + this.setToolbar(); + } + + settings.toolbar = true; + + this.toolbar.show(); + this.resize(); + + $.proxy(callback || function(){}, this)(); + + return this; + }, + + /** + * 隐藏工具栏 + * Hide toolbar + * + * @param {Function} [callback=function(){}] 回调函数 + * @returns {editormd} this(editormd instance object.) + */ + + hideToolbar : function(callback) { + var settings = this.settings; + + settings.toolbar = false; + this.toolbar.hide(); + this.resize(); + + $.proxy(callback || function(){}, this)(); + + return this; + }, + + /** + * 页面滚动时工具栏的固定定位 + * Set toolbar in window scroll auto fixed position + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setToolbarAutoFixed : function(fixed) { + + var state = this.state; + var editor = this.editor; + var toolbar = this.toolbar; + var settings = this.settings; + + if (typeof fixed !== "undefined") + { + settings.toolbarAutoFixed = fixed; + } + + var autoFixedHandle = function(){ + var $window = $(window); + var top = $window.scrollTop(); + + if (!settings.toolbarAutoFixed) + { + return false; + } + + if (top - editor.offset().top > 10 && top < editor.height()) + { + toolbar.css({ + position : "fixed", + width : editor.width() + "px", + left : ($window.width() - editor.width()) / 2 + "px" + }); + } + else + { + toolbar.css({ + position : "absolute", + width : "100%", + left : 0 + }); + } + }; + + if (!state.fullscreen && !state.preview && settings.toolbar && settings.toolbarAutoFixed) + { + $(window).bind("scroll", autoFixedHandle); + } + + return this; + }, + + /** + * 配置和初始化工具栏 + * Set toolbar and Initialization + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setToolbar : function() { + var settings = this.settings; + + if(settings.readOnly) { + return this; + } + + var editor = this.editor; + var preview = this.preview; + var classPrefix = this.classPrefix; + + var toolbar = this.toolbar = editor.children("." + classPrefix + "toolbar"); + + if (settings.toolbar && toolbar.length < 1) + { + var toolbarHTML = "
    "; + + editor.append(toolbarHTML); + toolbar = this.toolbar = editor.children("." + classPrefix + "toolbar"); + } + + if (!settings.toolbar) + { + toolbar.hide(); + + return this; + } + + toolbar.show(); + + var icons = (typeof settings.toolbarIcons === "function") ? settings.toolbarIcons() + : ((typeof settings.toolbarIcons === "string") ? editormd.toolbarModes[settings.toolbarIcons] : settings.toolbarIcons); + + var toolbarMenu = toolbar.find("." + this.classPrefix + "menu"), menu = ""; + var pullRight = false; + + for (var i = 0, len = icons.length; i < len; i++) + { + var name = icons[i]; + + if (name === "||") + { + pullRight = true; + } + else if (name === "|") + { + menu += "
  • |
  • "; + } + else + { + var isHeader = (/h(\d)/.test(name)); + var index = name; + + if (name === "watch" && !settings.watch) { + index = "unwatch"; + } + + var title = settings.lang.toolbar[index]; + var iconTexts = settings.toolbarIconTexts[index]; + var iconClass = settings.toolbarIconsClass[index]; + + title = (typeof title === "undefined") ? "" : title; + iconTexts = (typeof iconTexts === "undefined") ? "" : iconTexts; + iconClass = (typeof iconClass === "undefined") ? "" : iconClass; + + var menuItem = pullRight ? "
  • " : "
  • "; + + if (typeof settings.toolbarCustomIcons[name] !== "undefined" && typeof settings.toolbarCustomIcons[name] !== "function") + { + menuItem += settings.toolbarCustomIcons[name]; + } + else + { + menuItem += ""; + menuItem += ""+((isHeader) ? name.toUpperCase() : ( (iconClass === "") ? iconTexts : "") ) + ""; + menuItem += ""; + } + + menuItem += "
  • "; + + menu = pullRight ? menuItem + menu : menu + menuItem; + } + } + + toolbarMenu.html(menu); + + toolbarMenu.find("[title=\"Lowercase\"]").attr("title", settings.lang.toolbar.lowercase); + toolbarMenu.find("[title=\"ucwords\"]").attr("title", settings.lang.toolbar.ucwords); + + this.setToolbarHandler(); + this.setToolbarAutoFixed(); + + return this; + }, + + /** + * 工具栏图标事件处理对象序列 + * Get toolbar icons event handlers + * + * @param {Object} cm CodeMirror的实例对象 + * @param {String} name 要获取的事件处理器名称 + * @returns {Object} 返回处理对象序列 + */ + + dialogLockScreen : function() { + $.proxy(editormd.dialogLockScreen, this)(); + + return this; + }, + + dialogShowMask : function(dialog) { + $.proxy(editormd.dialogShowMask, this)(dialog); + + return this; + }, + + getToolbarHandles : function(name) { + var toolbarHandlers = this.toolbarHandlers = editormd.toolbarHandlers; + + return (name && typeof toolbarIconHandlers[name] !== "undefined") ? toolbarHandlers[name] : toolbarHandlers; + }, + + /** + * 工具栏图标事件处理器 + * Bind toolbar icons event handle + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setToolbarHandler : function() { + var _this = this; + var settings = this.settings; + + if (!settings.toolbar || settings.readOnly) { + return this; + } + + var toolbar = this.toolbar; + var cm = this.cm; + var classPrefix = this.classPrefix; + var toolbarIcons = this.toolbarIcons = toolbar.find("." + classPrefix + "menu > li > a"); + var toolbarIconHandlers = this.getToolbarHandles(); + + toolbarIcons.bind(editormd.mouseOrTouch("click", "touchend"), function(event) { + + var icon = $(this).children(".fa"); + var name = icon.attr("name"); + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (name === "") { + return ; + } + + _this.activeIcon = icon; + + if (typeof toolbarIconHandlers[name] !== "undefined") + { + $.proxy(toolbarIconHandlers[name], _this)(cm); + } + else + { + if (typeof settings.toolbarHandlers[name] !== "undefined") + { + $.proxy(settings.toolbarHandlers[name], _this)(cm, icon, cursor, selection); + } + } + + if (name !== "link" && name !== "reference-link" && name !== "image" && name !== "code-block" && + name !== "preformatted-text" && name !== "watch" && name !== "preview" && name !== "search" && name !== "fullscreen" && name !== "info") + { + cm.focus(); + } + + return false; + + }); + + return this; + }, + + /** + * 动态创建对话框 + * Creating custom dialogs + * + * @param {Object} options 配置项键值对 Key/Value + * @returns {dialog} 返回创建的dialog的jQuery实例对象 + */ + + createDialog : function(options) { + return $.proxy(editormd.createDialog, this)(options); + }, + + /** + * 创建关于Editor.md的对话框 + * Create about Editor.md dialog + * + * @returns {editormd} 返回editormd的实例对象 + */ + + createInfoDialog : function() { + var _this = this; + var editor = this.editor; + var classPrefix = this.classPrefix; + + var infoDialogHTML = [ + "
    ", + "
    ", + "

    " + editormd.title + "v" + editormd.version + "

    ", + "

    " + this.lang.description + "

    ", + "

    " + editormd.homePage + "

    ", + "

    Copyright © 2015 Pandao, The MIT License.

    ", + "
    ", + "", + "
    " + ].join("\n"); + + editor.append(infoDialogHTML); + + var infoDialog = this.infoDialog = editor.children("." + classPrefix + "dialog-info"); + + infoDialog.find("." + classPrefix + "dialog-close").bind(editormd.mouseOrTouch("click", "touchend"), function() { + _this.hideInfoDialog(); + }); + + infoDialog.css("border", (editormd.isIE8) ? "1px solid #ddd" : "").css("z-index", editormd.dialogZindex).show(); + + this.infoDialogPosition(); + + return this; + }, + + /** + * 关于Editor.md对话居中定位 + * Editor.md dialog position handle + * + * @returns {editormd} 返回editormd的实例对象 + */ + + infoDialogPosition : function() { + var infoDialog = this.infoDialog; + + var _infoDialogPosition = function() { + infoDialog.css({ + top : ($(window).height() - infoDialog.height()) / 2 + "px", + left : ($(window).width() - infoDialog.width()) / 2 + "px" + }); + }; + + _infoDialogPosition(); + + $(window).resize(_infoDialogPosition); + + return this; + }, + + /** + * 显示关于Editor.md + * Display about Editor.md dialog + * + * @returns {editormd} 返回editormd的实例对象 + */ + + showInfoDialog : function() { + + $("html,body").css("overflow-x", "hidden"); + + var _this = this; + var editor = this.editor; + var settings = this.settings; + var infoDialog = this.infoDialog = editor.children("." + this.classPrefix + "dialog-info"); + + if (infoDialog.length < 1) + { + this.createInfoDialog(); + } + + this.lockScreen(true); + + this.mask.css({ + opacity : settings.dialogMaskOpacity, + backgroundColor : settings.dialogMaskBgColor + }).show(); + + infoDialog.css("z-index", editormd.dialogZindex).show(); + + this.infoDialogPosition(); + + return this; + }, + + /** + * 隐藏关于Editor.md + * Hide about Editor.md dialog + * + * @returns {editormd} 返回editormd的实例对象 + */ + + hideInfoDialog : function() { + $("html,body").css("overflow-x", ""); + this.infoDialog.hide(); + this.mask.hide(); + this.lockScreen(false); + + return this; + }, + + /** + * 锁屏 + * lock screen + * + * @param {Boolean} lock Boolean 布尔值,是否锁屏 + * @returns {editormd} 返回editormd的实例对象 + */ + + lockScreen : function(lock) { + editormd.lockScreen(lock); + this.resize(); + + return this; + }, + + /** + * 编辑器界面重建,用于动态语言包或模块加载等 + * Recreate editor + * + * @returns {editormd} 返回editormd的实例对象 + */ + + recreate : function() { + var _this = this; + var editor = this.editor; + var settings = this.settings; + + this.codeMirror.remove(); + + this.setCodeMirror(); + + if (!settings.readOnly) + { + if (editor.find(".editormd-dialog").length > 0) { + editor.find(".editormd-dialog").remove(); + } + + if (settings.toolbar) + { + this.getToolbarHandles(); + this.setToolbar(); + } + } + + this.loadedDisplay(true); + + return this; + }, + + /** + * 高亮预览HTML的pre代码部分 + * highlight of preview codes + * + * @returns {editormd} 返回editormd的实例对象 + */ + + previewCodeHighlight : function() { + var settings = this.settings; + var previewContainer = this.previewContainer; + + if (settings.previewCodeHighlight) + { + previewContainer.find("pre").addClass("prettyprint linenums"); + + if (typeof prettyPrint !== "undefined") + { + prettyPrint(); + } + } + + return this; + }, + + /** + * 解析TeX(KaTeX)科学公式 + * TeX(KaTeX) Renderer + * + * @returns {editormd} 返回editormd的实例对象 + */ + + katexRender : function() { + + if (timer === null) + { + return this; + } + + this.previewContainer.find("." + editormd.classNames.tex).each(function(){ + var tex = $(this); + editormd.$katex.render(tex.text(), tex[0]); + + tex.find(".katex").css("font-size", "1.6em"); + }); + + return this; + }, + + /** + * 解析和渲染流程图及时序图 + * FlowChart and SequenceDiagram Renderer + * + * @returns {editormd} 返回editormd的实例对象 + */ + + flowChartAndSequenceDiagramRender : function() { + var $this = this; + var settings = this.settings; + var previewContainer = this.previewContainer; + + if (editormd.isIE8) { + return this; + } + + if (settings.flowChart) { + if (flowchartTimer === null) { + return this; + } + + previewContainer.find(".flowchart").flowChart(); + } + + if (settings.sequenceDiagram) { + previewContainer.find(".sequence-diagram").sequenceDiagram({theme: "simple"}); + } + + var preview = $this.preview; + var codeMirror = $this.codeMirror; + var codeView = codeMirror.find(".CodeMirror-scroll"); + + var height = codeView.height(); + var scrollTop = codeView.scrollTop(); + var percent = (scrollTop / codeView[0].scrollHeight); + var tocHeight = 0; + + preview.find(".markdown-toc-list").each(function(){ + tocHeight += $(this).height(); + }); + + var tocMenuHeight = preview.find(".editormd-toc-menu").height(); + tocMenuHeight = (!tocMenuHeight) ? 0 : tocMenuHeight; + + if (scrollTop === 0) + { + preview.scrollTop(0); + } + else if (scrollTop + height >= codeView[0].scrollHeight - 16) + { + preview.scrollTop(preview[0].scrollHeight); + } + else + { + preview.scrollTop((preview[0].scrollHeight + tocHeight + tocMenuHeight) * percent); + } + + return this; + }, + + /** + * 注册键盘快捷键处理 + * Register CodeMirror keyMaps (keyboard shortcuts). + * + * @param {Object} keyMap KeyMap key/value {"(Ctrl/Shift/Alt)-Key" : function(){}} + * @returns {editormd} return this + */ + + registerKeyMaps : function(keyMap) { + + var _this = this; + var cm = this.cm; + var settings = this.settings; + var toolbarHandlers = editormd.toolbarHandlers; + var disabledKeyMaps = settings.disabledKeyMaps; + + keyMap = keyMap || null; + + if (keyMap) + { + for (var i in keyMap) + { + if ($.inArray(i, disabledKeyMaps) < 0) + { + var map = {}; + map[i] = keyMap[i]; + + cm.addKeyMap(keyMap); + } + } + } + else + { + for (var k in editormd.keyMaps) + { + var _keyMap = editormd.keyMaps[k]; + var handle = (typeof _keyMap === "string") ? $.proxy(toolbarHandlers[_keyMap], _this) : $.proxy(_keyMap, _this); + + if ($.inArray(k, ["F9", "F10", "F11"]) < 0 && $.inArray(k, disabledKeyMaps) < 0) + { + var _map = {}; + _map[k] = handle; + + cm.addKeyMap(_map); + } + } + + $(window).keydown(function(event) { + + var keymaps = { + "120" : "F9", + "121" : "F10", + "122" : "F11" + }; + + if ( $.inArray(keymaps[event.keyCode], disabledKeyMaps) < 0 ) + { + switch (event.keyCode) + { + case 120: + $.proxy(toolbarHandlers["watch"], _this)(); + return false; + break; + + case 121: + $.proxy(toolbarHandlers["preview"], _this)(); + return false; + break; + + case 122: + $.proxy(toolbarHandlers["fullscreen"], _this)(); + return false; + break; + + default: + break; + } + } + }); + } + + return this; + }, + + /** + * 绑定同步滚动 + * + * @returns {editormd} return this + */ + + bindScrollEvent : function() { + + var _this = this; + var preview = this.preview; + var settings = this.settings; + var codeMirror = this.codeMirror; + var mouseOrTouch = editormd.mouseOrTouch; + + if (!settings.syncScrolling) { + return this; + } + + var cmBindScroll = function() { + codeMirror.find(".CodeMirror-scroll").bind(mouseOrTouch("scroll", "touchmove"), function(event) { + var height = $(this).height(); + var scrollTop = $(this).scrollTop(); + var percent = (scrollTop / $(this)[0].scrollHeight); + + var tocHeight = 0; + + preview.find(".markdown-toc-list").each(function(){ + tocHeight += $(this).height(); + }); + + var tocMenuHeight = preview.find(".editormd-toc-menu").height(); + tocMenuHeight = (!tocMenuHeight) ? 0 : tocMenuHeight; + + if (scrollTop === 0) + { + preview.scrollTop(0); + } + else if (scrollTop + height >= $(this)[0].scrollHeight - 16) + { + preview.scrollTop(preview[0].scrollHeight); + } + else + { + preview.scrollTop((preview[0].scrollHeight + tocHeight + tocMenuHeight) * percent); + } + + $.proxy(settings.onscroll, _this)(event); + }); + }; + + var cmUnbindScroll = function() { + codeMirror.find(".CodeMirror-scroll").unbind(mouseOrTouch("scroll", "touchmove")); + }; + + var previewBindScroll = function() { + + preview.bind(mouseOrTouch("scroll", "touchmove"), function(event) { + var height = $(this).height(); + var scrollTop = $(this).scrollTop(); + var percent = (scrollTop / $(this)[0].scrollHeight); + var codeView = codeMirror.find(".CodeMirror-scroll"); + + if(scrollTop === 0) + { + codeView.scrollTop(0); + } + else if (scrollTop + height >= $(this)[0].scrollHeight) + { + codeView.scrollTop(codeView[0].scrollHeight); + } + else + { + codeView.scrollTop(codeView[0].scrollHeight * percent); + } + + $.proxy(settings.onpreviewscroll, _this)(event); + }); + + }; + + var previewUnbindScroll = function() { + preview.unbind(mouseOrTouch("scroll", "touchmove")); + }; + + codeMirror.bind({ + mouseover : cmBindScroll, + mouseout : cmUnbindScroll, + touchstart : cmBindScroll, + touchend : cmUnbindScroll + }); + + if (settings.syncScrolling === "single") { + return this; + } + + preview.bind({ + mouseover : previewBindScroll, + mouseout : previewUnbindScroll, + touchstart : previewBindScroll, + touchend : previewUnbindScroll + }); + + return this; + }, + + bindChangeEvent : function() { + + var _this = this; + var cm = this.cm; + var settings = this.settings; + + if (!settings.syncScrolling) { + return this; + } + + cm.on("change", function(_cm, changeObj) { + + if (settings.watch) + { + _this.previewContainer.css("padding", settings.autoHeight ? "20px 20px 50px 40px" : "20px"); + } + + timer = setTimeout(function() { + clearTimeout(timer); + _this.save(); + timer = null; + }, settings.delay); + }); + + return this; + }, + + /** + * 加载队列完成之后的显示处理 + * Display handle of the module queues loaded after. + * + * @param {Boolean} recreate 是否为重建编辑器 + * @returns {editormd} 返回editormd的实例对象 + */ + + loadedDisplay : function(recreate) { + + recreate = recreate || false; + + var _this = this; + var editor = this.editor; + var preview = this.preview; + var settings = this.settings; + + this.containerMask.hide(); + + this.save(); + + if (settings.watch) { + preview.show(); + } + + editor.data("oldWidth", editor.width()).data("oldHeight", editor.height()); // 为了兼容Zepto + + this.resize(); + this.registerKeyMaps(); + + $(window).resize(function(){ + _this.resize(); + }); + + this.bindScrollEvent().bindChangeEvent(); + + if (!recreate) + { + $.proxy(settings.onload, this)(); + } + + this.state.loaded = true; + + return this; + }, + + /** + * 设置编辑器的宽度 + * Set editor width + * + * @param {Number|String} width 编辑器宽度值 + * @returns {editormd} 返回editormd的实例对象 + */ + + width : function(width) { + + this.editor.css("width", (typeof width === "number") ? width + "px" : width); + this.resize(); + + return this; + }, + + /** + * 设置编辑器的高度 + * Set editor height + * + * @param {Number|String} height 编辑器高度值 + * @returns {editormd} 返回editormd的实例对象 + */ + + height : function(height) { + + this.editor.css("height", (typeof height === "number") ? height + "px" : height); + this.resize(); + + return this; + }, + + /** + * 调整编辑器的尺寸和布局 + * Resize editor layout + * + * @param {Number|String} [width=null] 编辑器宽度值 + * @param {Number|String} [height=null] 编辑器高度值 + * @returns {editormd} 返回editormd的实例对象 + */ + + resize : function(width, height) { + + width = width || null; + height = height || null; + + var state = this.state; + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var codeMirror = this.codeMirror; + + if (width) + { + editor.css("width", (typeof width === "number") ? width + "px" : width); + } + + if (settings.autoHeight && !state.fullscreen && !state.preview) + { + editor.css("height", "auto"); + codeMirror.css("height", "auto"); + } + else + { + if (height) + { + editor.css("height", (typeof height === "number") ? height + "px" : height); + } + + if (state.fullscreen) + { + editor.height($(window).height()); + } + + if (settings.toolbar && !settings.readOnly) + { + codeMirror.css("margin-top", toolbar.height() + 1).height(editor.height() - toolbar.height()); + } + else + { + codeMirror.css("margin-top", 0).height(editor.height()); + } + } + + if(settings.watch) + { + codeMirror.width(editor.width() / 2); + preview.width((!state.preview) ? editor.width() / 2 : editor.width()); + + this.previewContainer.css("padding", settings.autoHeight ? "20px 20px 50px 40px" : "20px"); + + if (settings.toolbar && !settings.readOnly) + { + preview.css("top", toolbar.height() + 1); + } + else + { + preview.css("top", 0); + } + + if (settings.autoHeight && !state.fullscreen && !state.preview) + { + preview.height(""); + } + else + { + var previewHeight = (settings.toolbar && !settings.readOnly) ? editor.height() - toolbar.height() : editor.height(); + + preview.height(previewHeight); + } + } + else + { + codeMirror.width(editor.width()); + preview.hide(); + } + + if (state.loaded) + { + $.proxy(settings.onresize, this)(); + } + + return this; + }, + + /** + * 解析和保存Markdown代码 + * Parse & Saving Markdown source code + * + * @returns {editormd} 返回editormd的实例对象 + */ + + save : function() { + + if (timer === null) + { + return this; + } + + var _this = this; + var state = this.state; + var settings = this.settings; + var cm = this.cm; + var cmValue = cm.getValue(); + var previewContainer = this.previewContainer; + + if (settings.mode !== "gfm" && settings.mode !== "markdown") + { + this.markdownTextarea.val(cmValue); + + return this; + } + + var marked = editormd.$marked; + var markdownToC = this.markdownToC = []; + var rendererOptions = this.markedRendererOptions = { + toc : settings.toc, + tocm : settings.tocm, + tocStartLevel : settings.tocStartLevel, + pageBreak : settings.pageBreak, + taskList : settings.taskList, + emoji : settings.emoji, + tex : settings.tex, + atLink : settings.atLink, // for @link + emailLink : settings.emailLink, // for mail address auto link + flowChart : settings.flowChart, + sequenceDiagram : settings.sequenceDiagram, + previewCodeHighlight : settings.previewCodeHighlight, + }; + + var markedOptions = this.markedOptions = { + renderer : editormd.markedRenderer(markdownToC, rendererOptions), + gfm : true, + tables : true, + breaks : true, + pedantic : false, + sanitize : (settings.htmlDecode) ? false : true, // 关闭忽略HTML标签,即开启识别HTML标签,默认为false + smartLists : true, + smartypants : true + }; + + marked.setOptions(markedOptions); + + var newMarkdownDoc = editormd.$marked(cmValue, markedOptions); + + //console.info("cmValue", cmValue, newMarkdownDoc); + + newMarkdownDoc = editormd.filterHTMLTags(newMarkdownDoc, settings.htmlDecode); + + //console.error("cmValue", cmValue, newMarkdownDoc); + + this.markdownTextarea.text(cmValue); + + cm.save(); + + if (settings.saveHTMLToTextarea) + { + this.htmlTextarea.text(newMarkdownDoc); + } + + if(settings.watch || (!settings.watch && state.preview)) + { + previewContainer.html(newMarkdownDoc); + + this.previewCodeHighlight(); + + if (settings.toc) + { + var tocContainer = (settings.tocContainer === "") ? previewContainer : $(settings.tocContainer); + var tocMenu = tocContainer.find("." + this.classPrefix + "toc-menu"); + + tocContainer.attr("previewContainer", (settings.tocContainer === "") ? "true" : "false"); + + if (settings.tocContainer !== "" && tocMenu.length > 0) + { + tocMenu.remove(); + } + + editormd.markdownToCRenderer(markdownToC, tocContainer, settings.tocDropdown, settings.tocStartLevel); + + if (settings.tocDropdown || tocContainer.find("." + this.classPrefix + "toc-menu").length > 0) + { + editormd.tocDropdownMenu(tocContainer, (settings.tocTitle !== "") ? settings.tocTitle : this.lang.tocTitle); + } + + if (settings.tocContainer !== "") + { + previewContainer.find(".markdown-toc").css("border", "none"); + } + } + + if (settings.tex) + { + if (!editormd.kaTeXLoaded && settings.autoLoadModules) + { + editormd.loadKaTeX(function() { + editormd.$katex = katex; + editormd.kaTeXLoaded = true; + _this.katexRender(); + }); + } + else + { + editormd.$katex = katex; + this.katexRender(); + } + } + + if (settings.flowChart || settings.sequenceDiagram) + { + flowchartTimer = setTimeout(function(){ + clearTimeout(flowchartTimer); + _this.flowChartAndSequenceDiagramRender(); + flowchartTimer = null; + }, 10); + } + + if (state.loaded) + { + $.proxy(settings.onchange, this)(); + } + } + + return this; + }, + + /** + * 聚焦光标位置 + * Focusing the cursor position + * + * @returns {editormd} 返回editormd的实例对象 + */ + + focus : function() { + this.cm.focus(); + + return this; + }, + + /** + * 设置光标的位置 + * Set cursor position + * + * @param {Object} cursor 要设置的光标位置键值对象,例:{line:1, ch:0} + * @returns {editormd} 返回editormd的实例对象 + */ + + setCursor : function(cursor) { + this.cm.setCursor(cursor); + + return this; + }, + + /** + * 获取当前光标的位置 + * Get the current position of the cursor + * + * @returns {Cursor} 返回一个光标Cursor对象 + */ + + getCursor : function() { + return this.cm.getCursor(); + }, + + /** + * 设置光标选中的范围 + * Set cursor selected ranges + * + * @param {Object} from 开始位置的光标键值对象,例:{line:1, ch:0} + * @param {Object} to 结束位置的光标键值对象,例:{line:1, ch:0} + * @returns {editormd} 返回editormd的实例对象 + */ + + setSelection : function(from, to) { + + this.cm.setSelection(from, to); + + return this; + }, + + /** + * 获取光标选中的文本 + * Get the texts from cursor selected + * + * @returns {String} 返回选中文本的字符串形式 + */ + + getSelection : function() { + return this.cm.getSelection(); + }, + + /** + * 设置光标选中的文本范围 + * Set the cursor selection ranges + * + * @param {Array} ranges cursor selection ranges array + * @returns {Array} return this + */ + + setSelections : function(ranges) { + this.cm.setSelections(ranges); + + return this; + }, + + /** + * 获取光标选中的文本范围 + * Get the cursor selection ranges + * + * @returns {Array} return selection ranges array + */ + + getSelections : function() { + return this.cm.getSelections(); + }, + + /** + * 替换当前光标选中的文本或在当前光标处插入新字符 + * Replace the text at the current cursor selected or insert a new character at the current cursor position + * + * @param {String} value 要插入的字符值 + * @returns {editormd} 返回editormd的实例对象 + */ + + replaceSelection : function(value) { + this.cm.replaceSelection(value); + + return this; + }, + + /** + * 在当前光标处插入新字符 + * Insert a new character at the current cursor position + * + * 同replaceSelection()方法 + * With the replaceSelection() method + * + * @param {String} value 要插入的字符值 + * @returns {editormd} 返回editormd的实例对象 + */ + + insertValue : function(value) { + this.replaceSelection(value); + + return this; + }, + + /** + * 追加markdown + * append Markdown to editor + * + * @param {String} md 要追加的markdown源文档 + * @returns {editormd} 返回editormd的实例对象 + */ + + appendMarkdown : function(md) { + var settings = this.settings; + var cm = this.cm; + + cm.setValue(cm.getValue() + md); + + return this; + }, + + /** + * 设置和传入编辑器的markdown源文档 + * Set Markdown source document + * + * @param {String} md 要传入的markdown源文档 + * @returns {editormd} 返回editormd的实例对象 + */ + + setMarkdown : function(md) { + this.cm.setValue(md || this.settings.markdown); + + return this; + }, + + /** + * 获取编辑器的markdown源文档 + * Set Editor.md markdown/CodeMirror value + * + * @returns {editormd} 返回editormd的实例对象 + */ + + getMarkdown : function() { + return this.cm.getValue(); + }, + + /** + * 获取编辑器的源文档 + * Get CodeMirror value + * + * @returns {editormd} 返回editormd的实例对象 + */ + + getValue : function() { + return this.cm.getValue(); + }, + + /** + * 设置编辑器的源文档 + * Set CodeMirror value + * + * @param {String} value set code/value/string/text + * @returns {editormd} 返回editormd的实例对象 + */ + + setValue : function(value) { + this.cm.setValue(value); + + return this; + }, + + /** + * 清空编辑器 + * Empty CodeMirror editor container + * + * @returns {editormd} 返回editormd的实例对象 + */ + + clear : function() { + this.cm.setValue(""); + + return this; + }, + + /** + * 获取解析后存放在Textarea的HTML源码 + * Get parsed html code from Textarea + * + * @returns {String} 返回HTML源码 + */ + + getHTML : function() { + if (!this.settings.saveHTMLToTextarea) + { + alert("Error: settings.saveHTMLToTextarea == false"); + + return false; + } + + return this.htmlTextarea.val(); + }, + + /** + * getHTML()的别名 + * getHTML (alias) + * + * @returns {String} Return html code 返回HTML源码 + */ + + getTextareaSavedHTML : function() { + return this.getHTML(); + }, + + /** + * 获取预览窗口的HTML源码 + * Get html from preview container + * + * @returns {editormd} 返回editormd的实例对象 + */ + + getPreviewedHTML : function() { + if (!this.settings.watch) + { + alert("Error: settings.watch == false"); + + return false; + } + + return this.previewContainer.html(); + }, + + /** + * 开启实时预览 + * Enable real-time watching + * + * @returns {editormd} 返回editormd的实例对象 + */ + + watch : function(callback) { + var settings = this.settings; + + if ($.inArray(settings.mode, ["gfm", "markdown"]) < 0) + { + return this; + } + + this.state.watching = settings.watch = true; + this.preview.show(); + + if (this.toolbar) + { + var watchIcon = settings.toolbarIconsClass.watch; + var unWatchIcon = settings.toolbarIconsClass.unwatch; + + var icon = this.toolbar.find(".fa[name=watch]"); + icon.parent().attr("title", settings.lang.toolbar.watch); + icon.removeClass(unWatchIcon).addClass(watchIcon); + } + + this.codeMirror.css("border-right", "1px solid #ddd").width(this.editor.width() / 2); + + timer = 0; + + this.save().resize(); + + if (!settings.onwatch) + { + settings.onwatch = callback || function() {}; + } + + $.proxy(settings.onwatch, this)(); + + return this; + }, + + /** + * 关闭实时预览 + * Disable real-time watching + * + * @returns {editormd} 返回editormd的实例对象 + */ + + unwatch : function(callback) { + var settings = this.settings; + this.state.watching = settings.watch = false; + this.preview.hide(); + + if (this.toolbar) + { + var watchIcon = settings.toolbarIconsClass.watch; + var unWatchIcon = settings.toolbarIconsClass.unwatch; + + var icon = this.toolbar.find(".fa[name=watch]"); + icon.parent().attr("title", settings.lang.toolbar.unwatch); + icon.removeClass(watchIcon).addClass(unWatchIcon); + } + + this.codeMirror.css("border-right", "none").width(this.editor.width()); + + this.resize(); + + if (!settings.onunwatch) + { + settings.onunwatch = callback || function() {}; + } + + $.proxy(settings.onunwatch, this)(); + + return this; + }, + + /** + * 显示编辑器 + * Show editor + * + * @param {Function} [callback=function()] 回调函数 + * @returns {editormd} 返回editormd的实例对象 + */ + + show : function(callback) { + callback = callback || function() {}; + + var _this = this; + this.editor.show(0, function() { + $.proxy(callback, _this)(); + }); + + return this; + }, + + /** + * 隐藏编辑器 + * Hide editor + * + * @param {Function} [callback=function()] 回调函数 + * @returns {editormd} 返回editormd的实例对象 + */ + + hide : function(callback) { + callback = callback || function() {}; + + var _this = this; + this.editor.hide(0, function() { + $.proxy(callback, _this)(); + }); + + return this; + }, + + /** + * 隐藏编辑器部分,只预览HTML + * Enter preview html state + * + * @returns {editormd} 返回editormd的实例对象 + */ + + previewing : function() { + + var _this = this; + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var codeMirror = this.codeMirror; + var previewContainer = this.previewContainer; + + if ($.inArray(settings.mode, ["gfm", "markdown"]) < 0) { + return this; + } + + if (settings.toolbar && toolbar) { + toolbar.toggle(); + toolbar.find(".fa[name=preview]").toggleClass("active"); + } + + codeMirror.toggle(); + + var escHandle = function(event) { + if (event.shiftKey && event.keyCode === 27) { + _this.previewed(); + } + }; + + if (codeMirror.css("display") === "none") // 为了兼容Zepto,而不使用codeMirror.is(":hidden") + { + this.state.preview = true; + + if (this.state.fullscreen) { + preview.css("background", "#fff"); + } + + editor.find("." + this.classPrefix + "preview-close-btn").show().bind(editormd.mouseOrTouch("click", "touchend"), function(){ + _this.previewed(); + }); + + if (!settings.watch) + { + this.save(); + } + else + { + previewContainer.css("padding", ""); + } + + previewContainer.addClass(this.classPrefix + "preview-active"); + + preview.show().css({ + position : "", + top : 0, + width : editor.width(), + height : (settings.autoHeight && !this.state.fullscreen) ? "auto" : editor.height() + }); + + if (this.state.loaded) + { + $.proxy(settings.onpreviewing, this)(); + } + + $(window).bind("keyup", escHandle); + } + else + { + $(window).unbind("keyup", escHandle); + this.previewed(); + } + }, + + /** + * 显示编辑器部分,退出只预览HTML + * Exit preview html state + * + * @returns {editormd} 返回editormd的实例对象 + */ + + previewed : function() { + + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var previewContainer = this.previewContainer; + var previewCloseBtn = editor.find("." + this.classPrefix + "preview-close-btn"); + + this.state.preview = false; + + this.codeMirror.show(); + + if (settings.toolbar) { + toolbar.show(); + } + + preview[(settings.watch) ? "show" : "hide"](); + + previewCloseBtn.hide().unbind(editormd.mouseOrTouch("click", "touchend")); + + previewContainer.removeClass(this.classPrefix + "preview-active"); + + if (settings.watch) + { + previewContainer.css("padding", "20px"); + } + + preview.css({ + background : null, + position : "absolute", + width : editor.width() / 2, + height : (settings.autoHeight && !this.state.fullscreen) ? "auto" : editor.height() - toolbar.height(), + top : (settings.toolbar) ? toolbar.height() : 0 + }); + + if (this.state.loaded) + { + $.proxy(settings.onpreviewed, this)(); + } + + return this; + }, + + /** + * 编辑器全屏显示 + * Fullscreen show + * + * @returns {editormd} 返回editormd的实例对象 + */ + + fullscreen : function() { + + var _this = this; + var state = this.state; + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var fullscreenClass = this.classPrefix + "fullscreen"; + + if (toolbar) { + toolbar.find(".fa[name=fullscreen]").parent().toggleClass("active"); + } + + var escHandle = function(event) { + if (!event.shiftKey && event.keyCode === 27) + { + if (state.fullscreen) + { + _this.fullscreenExit(); + } + } + }; + + if (!editor.hasClass(fullscreenClass)) + { + state.fullscreen = true; + + $("html,body").css("overflow", "hidden"); + + editor.css({ + width : $(window).width(), + height : $(window).height() + }).addClass(fullscreenClass); + + this.resize(); + + $.proxy(settings.onfullscreen, this)(); + + $(window).bind("keyup", escHandle); + } + else + { + $(window).unbind("keyup", escHandle); + this.fullscreenExit(); + } + + return this; + }, + + /** + * 编辑器退出全屏显示 + * Exit fullscreen state + * + * @returns {editormd} 返回editormd的实例对象 + */ + + fullscreenExit : function() { + + var editor = this.editor; + var settings = this.settings; + var toolbar = this.toolbar; + var fullscreenClass = this.classPrefix + "fullscreen"; + + this.state.fullscreen = false; + + if (toolbar) { + toolbar.find(".fa[name=fullscreen]").parent().removeClass("active"); + } + + $("html,body").css("overflow", ""); + + editor.css({ + width : editor.data("oldWidth"), + height : editor.data("oldHeight") + }).removeClass(fullscreenClass); + + this.resize(); + + $.proxy(settings.onfullscreenExit, this)(); + + return this; + }, + + /** + * 加载并执行插件 + * Load and execute the plugin + * + * @param {String} name plugin name / function name + * @param {String} path plugin load path + * @returns {editormd} 返回editormd的实例对象 + */ + + executePlugin : function(name, path) { + + var _this = this; + var cm = this.cm; + var settings = this.settings; + + path = settings.pluginPath + path; + + if (typeof define === "function") + { + if (typeof this[name] === "undefined") + { + alert("Error: " + name + " plugin is not found, you are not load this plugin."); + + return this; + } + + this[name](cm); + + return this; + } + + if ($.inArray(path, editormd.loadFiles.plugin) < 0) + { + editormd.loadPlugin(path, function() { + editormd.loadPlugins[name] = _this[name]; + _this[name](cm); + }); + } + else + { + $.proxy(editormd.loadPlugins[name], this)(cm); + } + + return this; + }, + + /** + * 搜索替换 + * Search & replace + * + * @param {String} command CodeMirror serach commands, "find, fintNext, fintPrev, clearSearch, replace, replaceAll" + * @returns {editormd} return this + */ + + search : function(command) { + var settings = this.settings; + + if (!settings.searchReplace) + { + alert("Error: settings.searchReplace == false"); + return this; + } + + if (!settings.readOnly) + { + this.cm.execCommand(command || "find"); + } + + return this; + }, + + searchReplace : function() { + this.search("replace"); + + return this; + }, + + searchReplaceAll : function() { + this.search("replaceAll"); + + return this; + } + }; + + editormd.fn.init.prototype = editormd.fn; + + /** + * 锁屏 + * lock screen when dialog opening + * + * @returns {void} + */ + + editormd.dialogLockScreen = function() { + var settings = this.settings || {dialogLockScreen : true}; + + if (settings.dialogLockScreen) + { + $("html,body").css("overflow", "hidden"); + this.resize(); + } + }; + + /** + * 显示透明背景层 + * Display mask layer when dialog opening + * + * @param {Object} dialog dialog jQuery object + * @returns {void} + */ + + editormd.dialogShowMask = function(dialog) { + var editor = this.editor; + var settings = this.settings || {dialogShowMask : true}; + + dialog.css({ + top : ($(window).height() - dialog.height()) / 2 + "px", + left : ($(window).width() - dialog.width()) / 2 + "px" + }); + + if (settings.dialogShowMask) { + editor.children("." + this.classPrefix + "mask").css("z-index", parseInt(dialog.css("z-index")) - 1).show(); + } + }; + + editormd.toolbarHandlers = { + undo : function() { + this.cm.undo(); + }, + + redo : function() { + this.cm.redo(); + }, + + bold : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("**" + selection + "**"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 2); + } + }, + + del : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("~~" + selection + "~~"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 2); + } + }, + + italic : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("*" + selection + "*"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + + quote : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("> " + selection); + cm.setCursor(cursor.line, cursor.ch + 2); + } + else + { + cm.replaceSelection("> " + selection); + } + + //cm.replaceSelection("> " + selection); + //cm.setCursor(cursor.line, (selection === "") ? cursor.ch + 2 : cursor.ch + selection.length + 2); + }, + + ucfirst : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(editormd.firstUpperCase(selection)); + cm.setSelections(selections); + }, + + ucwords : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(editormd.wordsFirstUpperCase(selection)); + cm.setSelections(selections); + }, + + uppercase : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(selection.toUpperCase()); + cm.setSelections(selections); + }, + + lowercase : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(selection.toLowerCase()); + cm.setSelections(selections); + }, + + h1 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("# " + selection); + cm.setCursor(cursor.line, cursor.ch + 2); + } + else + { + cm.replaceSelection("# " + selection); + } + }, + + h2 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("## " + selection); + cm.setCursor(cursor.line, cursor.ch + 3); + } + else + { + cm.replaceSelection("## " + selection); + } + }, + + h3 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("### " + selection); + cm.setCursor(cursor.line, cursor.ch + 4); + } + else + { + cm.replaceSelection("### " + selection); + } + }, + + h4 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("#### " + selection); + cm.setCursor(cursor.line, cursor.ch + 5); + } + else + { + cm.replaceSelection("#### " + selection); + } + }, + + h5 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("##### " + selection); + cm.setCursor(cursor.line, cursor.ch + 6); + } + else + { + cm.replaceSelection("##### " + selection); + } + }, + + h6 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("###### " + selection); + cm.setCursor(cursor.line, cursor.ch + 7); + } + else + { + cm.replaceSelection("###### " + selection); + } + }, + + "list-ul" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (selection === "") + { + cm.replaceSelection("- " + selection); + } + else + { + var selectionText = selection.split("\n"); + + for (var i = 0, len = selectionText.length; i < len; i++) + { + selectionText[i] = (selectionText[i] === "") ? "" : "- " + selectionText[i]; + } + + cm.replaceSelection(selectionText.join("\n")); + } + }, + + "list-ol" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if(selection === "") + { + cm.replaceSelection("1. " + selection); + } + else + { + var selectionText = selection.split("\n"); + + for (var i = 0, len = selectionText.length; i < len; i++) + { + selectionText[i] = (selectionText[i] === "") ? "" : (i+1) + ". " + selectionText[i]; + } + + cm.replaceSelection(selectionText.join("\n")); + } + }, + + hr : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection(((cursor.ch !== 0) ? "\n\n" : "\n") + "------------\n\n"); + }, + + tex : function() { + if (!this.settings.tex) + { + alert("settings.tex === false"); + return this; + } + + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("$$" + selection + "$$"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 2); + } + }, + + link : function() { + this.executePlugin("linkDialog", "link-dialog/link-dialog"); + }, + + "reference-link" : function() { + this.executePlugin("referenceLinkDialog", "reference-link-dialog/reference-link-dialog"); + }, + + pagebreak : function() { + if (!this.settings.pageBreak) + { + alert("settings.pageBreak === false"); + return this; + } + + var cm = this.cm; + var selection = cm.getSelection(); + + cm.replaceSelection("\r\n[========]\r\n"); + }, + + image : function() { + this.executePlugin("imageDialog", "image-dialog/image-dialog"); + }, + + code : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("`" + selection + "`"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + + "code-block" : function() { + this.executePlugin("codeBlockDialog", "code-block-dialog/code-block-dialog"); + }, + + "preformatted-text" : function() { + this.executePlugin("preformattedTextDialog", "preformatted-text-dialog/preformatted-text-dialog"); + }, + + table : function() { + this.executePlugin("tableDialog", "table-dialog/table-dialog"); + }, + + datetime : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var date = new Date(); + var langName = this.settings.lang.name; + var datefmt = editormd.dateFormat() + " " + editormd.dateFormat((langName === "zh-cn" || langName === "zh-tw") ? "cn-week-day" : "week-day"); + + cm.replaceSelection(datefmt); + }, + + emoji : function() { + this.executePlugin("emojiDialog", "emoji-dialog/emoji-dialog"); + }, + + "html-entities" : function() { + this.executePlugin("htmlEntitiesDialog", "html-entities-dialog/html-entities-dialog"); + }, + + "goto-line" : function() { + this.executePlugin("gotoLineDialog", "goto-line-dialog/goto-line-dialog"); + }, + + watch : function() { + this[this.settings.watch ? "unwatch" : "watch"](); + }, + + preview : function() { + this.previewing(); + }, + + fullscreen : function() { + this.fullscreen(); + }, + + clear : function() { + this.clear(); + }, + + search : function() { + this.search(); + }, + + help : function() { + this.executePlugin("helpDialog", "help-dialog/help-dialog"); + }, + + info : function() { + this.showInfoDialog(); + } + }; + + editormd.keyMaps = { + "Ctrl-1" : "h1", + "Ctrl-2" : "h2", + "Ctrl-3" : "h3", + "Ctrl-4" : "h4", + "Ctrl-5" : "h5", + "Ctrl-6" : "h6", + "Ctrl-B" : "bold", // if this is string == editormd.toolbarHandlers.xxxx + "Ctrl-D" : "datetime", + + "Ctrl-E" : function() { // emoji + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (!this.settings.emoji) + { + alert("Error: settings.emoji == false"); + return ; + } + + cm.replaceSelection(":" + selection + ":"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + "Ctrl-Alt-G" : "goto-line", + "Ctrl-H" : "hr", + "Ctrl-I" : "italic", + "Ctrl-K" : "code", + + "Ctrl-L" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + var title = (selection === "") ? "" : " \""+selection+"\""; + + cm.replaceSelection("[" + selection + "]("+title+")"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + "Ctrl-U" : "list-ul", + + "Shift-Ctrl-A" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (!this.settings.atLink) + { + alert("Error: settings.atLink == false"); + return ; + } + + cm.replaceSelection("@" + selection); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + + "Shift-Ctrl-C" : "code", + "Shift-Ctrl-Q" : "quote", + "Shift-Ctrl-S" : "del", + "Shift-Ctrl-K" : "tex", // KaTeX + + "Shift-Alt-C" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection(["```", selection, "```"].join("\n")); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 3); + } + }, + + "Shift-Ctrl-Alt-C" : "code-block", + "Shift-Ctrl-H" : "html-entities", + "Shift-Alt-H" : "help", + "Shift-Ctrl-E" : "emoji", + "Shift-Ctrl-U" : "uppercase", + "Shift-Alt-U" : "ucwords", + "Shift-Ctrl-Alt-U" : "ucfirst", + "Shift-Alt-L" : "lowercase", + + "Shift-Ctrl-I" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + var title = (selection === "") ? "" : " \""+selection+"\""; + + cm.replaceSelection("![" + selection + "]("+title+")"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 4); + } + }, + + "Shift-Ctrl-Alt-I" : "image", + "Shift-Ctrl-L" : "link", + "Shift-Ctrl-O" : "list-ol", + "Shift-Ctrl-P" : "preformatted-text", + "Shift-Ctrl-T" : "table", + "Shift-Alt-P" : "pagebreak", + "F9" : "watch", + "F10" : "preview", + "F11" : "fullscreen", + }; + + /** + * 清除字符串两边的空格 + * Clear the space of strings both sides. + * + * @param {String} str string + * @returns {String} trimed string + */ + + var trim = function(str) { + return (!String.prototype.trim) ? str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "") : str.trim(); + }; + + editormd.trim = trim; + + /** + * 所有单词首字母大写 + * Words first to uppercase + * + * @param {String} str string + * @returns {String} string + */ + + var ucwords = function (str) { + return str.toLowerCase().replace(/\b(\w)|\s(\w)/g, function($1) { + return $1.toUpperCase(); + }); + }; + + editormd.ucwords = editormd.wordsFirstUpperCase = ucwords; + + /** + * 字符串首字母大写 + * Only string first char to uppercase + * + * @param {String} str string + * @returns {String} string + */ + + var firstUpperCase = function(str) { + return str.toLowerCase().replace(/\b(\w)/, function($1){ + return $1.toUpperCase(); + }); + }; + + var ucfirst = firstUpperCase; + + editormd.firstUpperCase = editormd.ucfirst = firstUpperCase; + + editormd.urls = { + atLinkBase : "https://github.com/" + }; + + editormd.regexs = { + atLink : /@(\w+)/g, + email : /(\w+)@(\w+)\.(\w+)\.?(\w+)?/g, + emailLink : /(mailto:)?([\w\.\_]+)@(\w+)\.(\w+)\.?(\w+)?/g, + emoji : /:([\w\+-]+):/g, + emojiDatetime : /(\d{2}:\d{2}:\d{2})/g, + twemoji : /:(tw-([\w]+)-?(\w+)?):/g, + fontAwesome : /:(fa-([\w]+)(-(\w+)){0,}):/g, + editormdLogo : /:(editormd-logo-?(\w+)?):/g, + pageBreak : /^\[[=]{8,}\]$/ + }; + + // Emoji graphics files url path + editormd.emoji = { + path : "http://www.emoji-cheat-sheet.com/graphics/emojis/", + ext : ".png" + }; + + // Twitter Emoji (Twemoji) graphics files url path + editormd.twemoji = { + path : "http://twemoji.maxcdn.com/36x36/", + ext : ".png" + }; + + /** + * 自定义marked的解析器 + * Custom Marked renderer rules + * + * @param {Array} markdownToC 传入用于接收TOC的数组 + * @returns {Renderer} markedRenderer 返回marked的Renderer自定义对象 + */ + + editormd.markedRenderer = function(markdownToC, options) { + var defaults = { + toc : true, // Table of contents + tocm : false, + tocStartLevel : 1, // Said from H1 to create ToC + pageBreak : true, + atLink : true, // for @link + emailLink : true, // for mail address auto link + taskList : false, // Enable Github Flavored Markdown task lists + emoji : false, // :emoji: , Support Twemoji, fontAwesome, Editor.md logo emojis. + tex : false, // TeX(LaTeX), based on KaTeX + flowChart : false, // flowChart.js only support IE9+ + sequenceDiagram : false, // sequenceDiagram.js only support IE9+ + }; + + var settings = $.extend(defaults, options || {}); + var marked = editormd.$marked; + var markedRenderer = new marked.Renderer(); + markdownToC = markdownToC || []; + + var regexs = editormd.regexs; + var atLinkReg = regexs.atLink; + var emojiReg = regexs.emoji; + var emailReg = regexs.email; + var emailLinkReg = regexs.emailLink; + var twemojiReg = regexs.twemoji; + var faIconReg = regexs.fontAwesome; + var editormdLogoReg = regexs.editormdLogo; + var pageBreakReg = regexs.pageBreak; + + markedRenderer.emoji = function(text) { + + text = text.replace(editormd.regexs.emojiDatetime, function($1) { + return $1.replace(/:/g, ":"); + }); + + var matchs = text.match(emojiReg); + + if (!matchs || !settings.emoji) { + return text; + } + + for (var i = 0, len = matchs.length; i < len; i++) + { + if (matchs[i] === ":+1:") { + matchs[i] = ":\\+1:"; + } + + text = text.replace(new RegExp(matchs[i]), function($1, $2){ + var faMatchs = $1.match(faIconReg); + var name = $1.replace(/:/g, ""); + + if (faMatchs) + { + for (var fa = 0, len1 = faMatchs.length; fa < len1; fa++) + { + var faName = faMatchs[fa].replace(/:/g, ""); + + return ""; + } + } + else + { + var emdlogoMathcs = $1.match(editormdLogoReg); + var twemojiMatchs = $1.match(twemojiReg); + + if (emdlogoMathcs) + { + for (var x = 0, len2 = emdlogoMathcs.length; x < len2; x++) + { + var logoName = emdlogoMathcs[x].replace(/:/g, ""); + return ""; + } + } + else if (twemojiMatchs) + { + for (var t = 0, len3 = twemojiMatchs.length; t < len3; t++) + { + var twe = twemojiMatchs[t].replace(/:/g, "").replace("tw-", ""); + return "\"twemoji-""; + } + } + else + { + var src = (name === "+1") ? "plus1" : name; + src = (src === "black_large_square") ? "black_square" : src; + src = (src === "moon") ? "waxing_gibbous_moon" : src; + + return "\":""; + } + } + }); + } + + return text; + }; + + markedRenderer.atLink = function(text) { + + if (atLinkReg.test(text)) + { + if (settings.atLink) + { + text = text.replace(emailReg, function($1, $2, $3, $4) { + return $1.replace(/@/g, "_#_@_#_"); + }); + + text = text.replace(atLinkReg, function($1, $2) { + return "" + $1 + ""; + }).replace(/_#_@_#_/g, "@"); + } + + if (settings.emailLink) + { + text = text.replace(emailLinkReg, function($1, $2, $3, $4, $5) { + return (!$2 && $.inArray($5, "jpg|jpeg|png|gif|webp|ico|icon|pdf".split("|")) < 0) ? ""+$1+"" : $1; + }); + } + + return text; + } + + return text; + }; + + markedRenderer.link = function (href, title, text) { + + if (this.options.sanitize) { + try { + var prot = decodeURIComponent(unescape(href)).replace(/[^\w:]/g,"").toLowerCase(); + } catch(e) { + return ""; + } + + if (prot.indexOf("javascript:") === 0) { + return ""; + } + } + + var out = "" + text.replace(/@/g, "@") + ""; + } + + if (title) { + out += " title=\"" + title + "\""; + } + + out += ">" + text + ""; + + return out; + }; + + markedRenderer.heading = function(text, level, raw) { + + var linkText = text; + var hasLinkReg = /\s*\]*)\>(.*)\<\/a\>\s*/; + var getLinkTextReg = /\s*\]+)\>([^\>]*)\<\/a\>\s*/g; + + if (hasLinkReg.test(text)) + { + var tempText = []; + text = text.split(/\]+)\>([^\>]*)\<\/a\>/); + + for (var i = 0, len = text.length; i < len; i++) + { + tempText.push(text[i].replace(/\s*href\=\"(.*)\"\s*/g, "")); + } + + text = tempText.join(" "); + } + + text = trim(text); + + var escapedText = text.toLowerCase().replace(/[^\w]+/g, "-"); + var toc = { + text : text, + level : level, + slug : escapedText + }; + + var isChinese = /^[\u4e00-\u9fa5]+$/.test(text); + var id = (isChinese) ? escape(text).replace(/\%/g, "") : text.toLowerCase().replace(/[^\w]+/g, "-"); + + markdownToC.push(toc); + + var headingHTML = ""; + + headingHTML += ""; + headingHTML += ""; + headingHTML += (hasLinkReg) ? this.atLink(this.emoji(linkText)) : this.atLink(this.emoji(text)); + headingHTML += ""; + + return headingHTML; + }; + + markedRenderer.pageBreak = function(text) { + if (pageBreakReg.test(text) && settings.pageBreak) + { + text = "
    "; + } + + return text; + }; + + markedRenderer.paragraph = function(text) { + var isTeXInline = /\$\$(.*)\$\$/g.test(text); + var isTeXLine = /^\$\$(.*)\$\$$/.test(text); + var isTeXAddClass = (isTeXLine) ? " class=\"" + editormd.classNames.tex + "\"" : ""; + var isToC = (settings.tocm) ? /^(\[TOC\]|\[TOCM\])$/.test(text) : /^\[TOC\]$/.test(text); + var isToCMenu = /^\[TOCM\]$/.test(text); + + if (!isTeXLine && isTeXInline) + { + text = text.replace(/(\$\$([^\$]*)\$\$)+/g, function($1, $2) { + return "" + $2.replace(/\$/g, "") + ""; + }); + } + else + { + text = (isTeXLine) ? text.replace(/\$/g, "") : text; + } + + var tocHTML = "
    " + text + "
    "; + + return (isToC) ? ( (isToCMenu) ? "
    " + tocHTML + "

    " : tocHTML ) + : ( (pageBreakReg.test(text)) ? this.pageBreak(text) : "" + this.atLink(this.emoji(text)) + "

    \n" ); + }; + + markedRenderer.code = function (code, lang, escaped) { + + if (lang === "seq" || lang === "sequence") + { + return "
    " + code + "
    "; + } + else if ( lang === "flow") + { + return "
    " + code + "
    "; + } + else if ( lang === "math" || lang === "latex" || lang === "katex") + { + return "

    " + code + "

    "; + } + else + { + + return marked.Renderer.prototype.code.apply(this, arguments); + } + }; + + markedRenderer.tablecell = function(content, flags) { + var type = (flags.header) ? "th" : "td"; + var tag = (flags.align) ? "<" + type +" style=\"text-align:" + flags.align + "\">" : "<" + type + ">"; + + return tag + this.atLink(this.emoji(content)) + "\n"; + }; + + markedRenderer.listitem = function(text) { + if (settings.taskList && /^\s*\[[x\s]\]\s*/.test(text)) + { + text = text.replace(/^\s*\[\s\]\s*/, " ") + .replace(/^\s*\[x\]\s*/, " "); + + return "
  • " + this.atLink(this.emoji(text)) + "
  • "; + } + else + { + return "
  • " + this.atLink(this.emoji(text)) + "
  • "; + } + }; + + return markedRenderer; + }; + + /** + * + * 生成TOC(Table of Contents) + * Creating ToC (Table of Contents) + * + * @param {Array} toc 从marked获取的TOC数组列表 + * @param {Element} container 插入TOC的容器元素 + * @param {Integer} startLevel Hx 起始层级 + * @returns {Object} tocContainer 返回ToC列表容器层的jQuery对象元素 + */ + + editormd.markdownToCRenderer = function(toc, container, tocDropdown, startLevel) { + + var html = ""; + var lastLevel = 0; + var classPrefix = this.classPrefix; + + startLevel = startLevel || 1; + + for (var i = 0, len = toc.length; i < len; i++) + { + var text = toc[i].text; + var level = toc[i].level; + + if (level < startLevel) { + continue; + } + + if (level > lastLevel) + { + html += ""; + } + else if (level < lastLevel) + { + html += (new Array(lastLevel - level + 2)).join(""); + } + else + { + html += ""; + } + + html += "
  • " + text + "
      "; + lastLevel = level; + } + + var tocContainer = container.find(".markdown-toc"); + + if ((tocContainer.length < 1 && container.attr("previewContainer") === "false")) + { + var tocHTML = "
      "; + + tocHTML = (tocDropdown) ? "
      " + tocHTML + "
      " : tocHTML; + + container.html(tocHTML); + + tocContainer = container.find(".markdown-toc"); + } + + if (tocDropdown) + { + tocContainer.wrap("

      "); + } + + tocContainer.html("
        ").children(".markdown-toc-list").html(html.replace(/\r?\n?\\<\/ul\>/g, "")); + + return tocContainer; + }; + + /** + * + * 生成TOC下拉菜单 + * Creating ToC dropdown menu + * + * @param {Object} container 插入TOC的容器jQuery对象元素 + * @param {String} tocTitle ToC title + * @returns {Object} return toc-menu object + */ + + editormd.tocDropdownMenu = function(container, tocTitle) { + + tocTitle = tocTitle || "Table of Contents"; + + var zindex = 400; + var tocMenus = container.find("." + this.classPrefix + "toc-menu"); + + tocMenus.each(function() { + var $this = $(this); + var toc = $this.children(".markdown-toc"); + var icon = ""; + var btn = "" + icon + tocTitle + ""; + var menu = toc.children("ul"); + var list = menu.find("li"); + + toc.append(btn); + + list.first().before("
      • " + tocTitle + " " + icon + "

      • "); + + $this.mouseover(function(){ + menu.show(); + + list.each(function(){ + var li = $(this); + var ul = li.children("ul"); + + if (ul.html() === "") + { + ul.remove(); + } + + if (ul.length > 0 && ul.html() !== "") + { + var firstA = li.children("a").first(); + + if (firstA.children(".fa").length < 1) + { + firstA.append( $(icon).css({ float:"right", paddingTop:"4px" }) ); + } + } + + li.mouseover(function(){ + ul.css("z-index", zindex).show(); + zindex += 1; + }).mouseleave(function(){ + ul.hide(); + }); + }); + }).mouseleave(function(){ + menu.hide(); + }); + }); + + return tocMenus; + }; + + /** + * 简单地过滤指定的HTML标签 + * Filter custom html tags + * + * @param {String} html 要过滤HTML + * @param {String} filters 要过滤的标签 + * @returns {String} html 返回过滤的HTML + */ + + editormd.filterHTMLTags = function(html, filters) { + + if (typeof html !== "string") { + html = new String(html); + } + + if (typeof filters !== "string") { + return html; + } + + var expression = filters.split("|"); + var filterTags = expression[0].split(","); + var attrs = expression[1]; + + for (var i = 0, len = filterTags.length; i < len; i++) + { + var tag = filterTags[i]; + + html = html.replace(new RegExp("\<\s*" + tag + "\s*([^\>]*)\>([^\>]*)\<\s*\/" + tag + "\s*\>", "igm"), ""); + } + + //return html; + + if (typeof attrs !== "undefined") + { + var htmlTagRegex = /\<(\w+)\s*([^\>]*)\>([^\>]*)\<\/(\w+)\>/ig; + + if (attrs === "*") + { + html = html.replace(htmlTagRegex, function($1, $2, $3, $4, $5) { + return "<" + $2 + ">" + $4 + ""; + }); + } + else if (attrs === "on*") + { + html = html.replace(htmlTagRegex, function($1, $2, $3, $4, $5) { + var el = $("<" + $2 + ">" + $4 + ""); + var _attrs = $($1)[0].attributes; + var $attrs = {}; + + $.each(_attrs, function(i, e) { + if (e.nodeName !== '"') $attrs[e.nodeName] = e.nodeValue; + }); + + $.each($attrs, function(i) { + if (i.indexOf("on") === 0) { + delete $attrs[i]; + } + }); + + el.attr($attrs); + + var text = (typeof el[1] !== "undefined") ? $(el[1]).text() : ""; + + return el[0].outerHTML + text; + }); + } + else + { + html = html.replace(htmlTagRegex, function($1, $2, $3, $4) { + var filterAttrs = attrs.split(","); + var el = $($1); + el.html($4); + + $.each(filterAttrs, function(i) { + el.attr(filterAttrs[i], null); + }); + + return el[0].outerHTML; + }); + } + } + + return html; + }; + + /** + * 将Markdown文档解析为HTML用于前台显示 + * Parse Markdown to HTML for Font-end preview. + * + * @param {String} id 用于显示HTML的对象ID + * @param {Object} [options={}] 配置选项,可选 + * @returns {Object} div 返回jQuery对象元素 + */ + + editormd.markdownToHTML = function(id, options) { + var defaults = { + gfm : true, + toc : true, + tocm : false, + tocStartLevel : 1, + tocTitle : "目录", + tocDropdown : false, + tocContainer : "", + markdown : "", + markdownSourceCode : false, + htmlDecode : false, + autoLoadKaTeX : true, + pageBreak : true, + atLink : true, // for @link + emailLink : true, // for mail address auto link + tex : false, + taskList : false, // Github Flavored Markdown task lists + emoji : false, + flowChart : false, + sequenceDiagram : false, + previewCodeHighlight : true + }; + + editormd.$marked = marked; + + var div = $("#" + id); + var settings = div.settings = $.extend(true, defaults, options || {}); + var saveTo = div.find("textarea"); + + if (saveTo.length < 1) + { + div.append(""); + saveTo = div.find("textarea"); + } + + var markdownDoc = (settings.markdown === "") ? saveTo.val() : settings.markdown; + var markdownToC = []; + + var rendererOptions = { + toc : settings.toc, + tocm : settings.tocm, + tocStartLevel : settings.tocStartLevel, + taskList : settings.taskList, + emoji : settings.emoji, + tex : settings.tex, + pageBreak : settings.pageBreak, + atLink : settings.atLink, // for @link + emailLink : settings.emailLink, // for mail address auto link + flowChart : settings.flowChart, + sequenceDiagram : settings.sequenceDiagram, + previewCodeHighlight : settings.previewCodeHighlight, + }; + + var markedOptions = { + renderer : editormd.markedRenderer(markdownToC, rendererOptions), + gfm : settings.gfm, + tables : true, + breaks : true, + pedantic : false, + sanitize : (settings.htmlDecode) ? false : true, // 是否忽略HTML标签,即是否开启HTML标签解析,为了安全性,默认不开启 + smartLists : true, + smartypants : true + }; + + markdownDoc = new String(markdownDoc); + + var markdownParsed = marked(markdownDoc, markedOptions); + + markdownParsed = editormd.filterHTMLTags(markdownParsed, settings.htmlDecode); + + if (settings.markdownSourceCode) { + saveTo.text(markdownDoc); + } else { + saveTo.remove(); + } + + div.addClass("markdown-body " + this.classPrefix + "html-preview").append(markdownParsed); + + var tocContainer = (settings.tocContainer !== "") ? $(settings.tocContainer) : div; + + if (settings.tocContainer !== "") + { + tocContainer.attr("previewContainer", false); + } + + if (settings.toc) + { + div.tocContainer = this.markdownToCRenderer(markdownToC, tocContainer, settings.tocDropdown, settings.tocStartLevel); + + if (settings.tocDropdown || div.find("." + this.classPrefix + "toc-menu").length > 0) + { + this.tocDropdownMenu(div, settings.tocTitle); + } + + if (settings.tocContainer !== "") + { + div.find(".editormd-toc-menu, .editormd-markdown-toc").remove(); + } + } + + if (settings.previewCodeHighlight) + { + div.find("pre").addClass("prettyprint linenums"); + prettyPrint(); + } + + if (!editormd.isIE8) + { + if (settings.flowChart) { + div.find(".flowchart").flowChart(); + } + + if (settings.sequenceDiagram) { + div.find(".sequence-diagram").sequenceDiagram({theme: "simple"}); + } + } + + if (settings.tex) + { + var katexHandle = function() { + div.find("." + editormd.classNames.tex).each(function(){ + var tex = $(this); + katex.render(tex.html().replace(/</g, "<").replace(/>/g, ">"), tex[0]); + tex.find(".katex").css("font-size", "1.6em"); + }); + }; + + if (settings.autoLoadKaTeX && !editormd.$katex && !editormd.kaTeXLoaded) + { + this.loadKaTeX(function() { + editormd.$katex = katex; + editormd.kaTeXLoaded = true; + katexHandle(); + }); + } + else + { + katexHandle(); + } + } + + div.getMarkdown = function() { + return saveTo.val(); + }; + + return div; + }; + + // Editor.md themes, change toolbar themes etc. + // added @1.5.0 + editormd.themes = ["default", "dark"]; + + // Preview area themes + // added @1.5.0 + editormd.previewThemes = ["default", "dark"]; + + // CodeMirror / editor area themes + // @1.5.0 rename -> editorThemes, old version -> themes + editormd.editorThemes = [ + "default", "3024-day", "3024-night", + "ambiance", "ambiance-mobile", + "base16-dark", "base16-light", "blackboard", + "cobalt", + "eclipse", "elegant", "erlang-dark", + "lesser-dark", + "mbo", "mdn-like", "midnight", "monokai", + "neat", "neo", "night", + "paraiso-dark", "paraiso-light", "pastel-on-dark", + "rubyblue", + "solarized", + "the-matrix", "tomorrow-night-eighties", "twilight", + "vibrant-ink", + "xq-dark", "xq-light" + ]; + + editormd.loadPlugins = {}; + + editormd.loadFiles = { + js : [], + css : [], + plugin : [] + }; + + /** + * 动态加载Editor.md插件,但不立即执行 + * Load editor.md plugins + * + * @param {String} fileName 插件文件路径 + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + * @param {String} [into="head"] 嵌入页面的位置 + */ + + editormd.loadPlugin = function(fileName, callback, into) { + callback = callback || function() {}; + + this.loadScript(fileName, function() { + editormd.loadFiles.plugin.push(fileName); + callback(); + }, into); + }; + + /** + * 动态加载CSS文件的方法 + * Load css file method + * + * @param {String} fileName CSS文件名 + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + * @param {String} [into="head"] 嵌入页面的位置 + */ + + editormd.loadCSS = function(fileName, callback, into) { + into = into || "head"; + callback = callback || function() {}; + + var css = document.createElement("link"); + css.type = "text/css"; + css.rel = "stylesheet"; + css.onload = css.onreadystatechange = function() { + editormd.loadFiles.css.push(fileName); + callback(); + }; + + css.href = fileName + ".css"; + + if(into === "head") { + document.getElementsByTagName("head")[0].appendChild(css); + } else { + document.body.appendChild(css); + } + }; + + editormd.isIE = (navigator.appName == "Microsoft Internet Explorer"); + editormd.isIE8 = (editormd.isIE && navigator.appVersion.match(/8./i) == "8."); + + /** + * 动态加载JS文件的方法 + * Load javascript file method + * + * @param {String} fileName JS文件名 + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + * @param {String} [into="head"] 嵌入页面的位置 + */ + + editormd.loadScript = function(fileName, callback, into) { + + into = into || "head"; + callback = callback || function() {}; + + var script = null; + script = document.createElement("script"); + script.id = fileName.replace(/[\./]+/g, "-"); + script.type = "text/javascript"; + script.src = fileName + ".js"; + + if (editormd.isIE8) + { + script.onreadystatechange = function() { + if(script.readyState) + { + if (script.readyState === "loaded" || script.readyState === "complete") + { + script.onreadystatechange = null; + editormd.loadFiles.js.push(fileName); + callback(); + } + } + }; + } + else + { + script.onload = function() { + editormd.loadFiles.js.push(fileName); + callback(); + }; + } + + if (into === "head") { + document.getElementsByTagName("head")[0].appendChild(script); + } else { + document.body.appendChild(script); + } + }; + + // 使用国外的CDN,加载速度有时会很慢,或者自定义URL + // You can custom KaTeX load url. + editormd.katexURL = { + css : "//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min", + js : "//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min" + }; + + editormd.kaTeXLoaded = false; + + /** + * 加载KaTeX文件 + * load KaTeX files + * + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + */ + + editormd.loadKaTeX = function (callback) { + editormd.loadCSS(editormd.katexURL.css, function(){ + editormd.loadScript(editormd.katexURL.js, callback || function(){}); + }); + }; + + /** + * 锁屏 + * lock screen + * + * @param {Boolean} lock Boolean 布尔值,是否锁屏 + * @returns {void} + */ + + editormd.lockScreen = function(lock) { + $("html,body").css("overflow", (lock) ? "hidden" : ""); + }; + + /** + * 动态创建对话框 + * Creating custom dialogs + * + * @param {Object} options 配置项键值对 Key/Value + * @returns {dialog} 返回创建的dialog的jQuery实例对象 + */ + + editormd.createDialog = function(options) { + var defaults = { + name : "", + width : 420, + height: 240, + title : "", + drag : true, + closed : true, + content : "", + mask : true, + maskStyle : { + backgroundColor : "#fff", + opacity : 0.1 + }, + lockScreen : true, + footer : true, + buttons : false + }; + + options = $.extend(true, defaults, options); + + var $this = this; + var editor = this.editor; + var classPrefix = editormd.classPrefix; + var guid = (new Date()).getTime(); + var dialogName = ( (options.name === "") ? classPrefix + "dialog-" + guid : options.name); + var mouseOrTouch = editormd.mouseOrTouch; + + var html = "
        "; + + if (options.title !== "") + { + html += "
        "; + html += "" + options.title + ""; + html += "
        "; + } + + if (options.closed) + { + html += ""; + } + + html += "
        " + options.content; + + if (options.footer || typeof options.footer === "string") + { + html += "
        " + ( (typeof options.footer === "boolean") ? "" : options.footer) + "
        "; + } + + html += "
        "; + + html += "
        "; + html += "
        "; + html += "
        "; + + editor.append(html); + + var dialog = editor.find("." + dialogName); + + dialog.lockScreen = function(lock) { + if (options.lockScreen) + { + $("html,body").css("overflow", (lock) ? "hidden" : ""); + $this.resize(); + } + + return dialog; + }; + + dialog.showMask = function() { + if (options.mask) + { + editor.find("." + classPrefix + "mask").css(options.maskStyle).css("z-index", editormd.dialogZindex - 1).show(); + } + return dialog; + }; + + dialog.hideMask = function() { + if (options.mask) + { + editor.find("." + classPrefix + "mask").hide(); + } + + return dialog; + }; + + dialog.loading = function(show) { + var loading = dialog.find("." + classPrefix + "dialog-mask"); + loading[(show) ? "show" : "hide"](); + + return dialog; + }; + + dialog.lockScreen(true).showMask(); + + dialog.show().css({ + zIndex : editormd.dialogZindex, + border : (editormd.isIE8) ? "1px solid #ddd" : "", + width : (typeof options.width === "number") ? options.width + "px" : options.width, + height : (typeof options.height === "number") ? options.height + "px" : options.height + }); + + var dialogPosition = function(){ + dialog.css({ + top : ($(window).height() - dialog.height()) / 2 + "px", + left : ($(window).width() - dialog.width()) / 2 + "px" + }); + }; + + dialogPosition(); + + $(window).resize(dialogPosition); + + dialog.children("." + classPrefix + "dialog-close").bind(mouseOrTouch("click", "touchend"), function() { + dialog.hide().lockScreen(false).hideMask(); + }); + + if (typeof options.buttons === "object") + { + var footer = dialog.footer = dialog.find("." + classPrefix + "dialog-footer"); + + for (var key in options.buttons) + { + var btn = options.buttons[key]; + var btnClassName = classPrefix + key + "-btn"; + + footer.append(""); + btn[1] = $.proxy(btn[1], dialog); + footer.children("." + btnClassName).bind(mouseOrTouch("click", "touchend"), btn[1]); + } + } + + if (options.title !== "" && options.drag) + { + var posX, posY; + var dialogHeader = dialog.children("." + classPrefix + "dialog-header"); + + if (!options.mask) { + dialogHeader.bind(mouseOrTouch("click", "touchend"), function(){ + editormd.dialogZindex += 2; + dialog.css("z-index", editormd.dialogZindex); + }); + } + + dialogHeader.mousedown(function(e) { + e = e || window.event; //IE + posX = e.clientX - parseInt(dialog[0].style.left); + posY = e.clientY - parseInt(dialog[0].style.top); + + document.onmousemove = moveAction; + }); + + var userCanSelect = function (obj) { + obj.removeClass(classPrefix + "user-unselect").off("selectstart"); + }; + + var userUnselect = function (obj) { + obj.addClass(classPrefix + "user-unselect").on("selectstart", function(event) { // selectstart for IE + return false; + }); + }; + + var moveAction = function (e) { + e = e || window.event; //IE + + var left, top, nowLeft = parseInt(dialog[0].style.left), nowTop = parseInt(dialog[0].style.top); + + if( nowLeft >= 0 ) { + if( nowLeft + dialog.width() <= $(window).width()) { + left = e.clientX - posX; + } else { + left = $(window).width() - dialog.width(); + document.onmousemove = null; + } + } else { + left = 0; + document.onmousemove = null; + } + + if( nowTop >= 0 ) { + top = e.clientY - posY; + } else { + top = 0; + document.onmousemove = null; + } + + + document.onselectstart = function() { + return false; + }; + + userUnselect($("body")); + userUnselect(dialog); + dialog[0].style.left = left + "px"; + dialog[0].style.top = top + "px"; + }; + + document.onmouseup = function() { + userCanSelect($("body")); + userCanSelect(dialog); + + document.onselectstart = null; + document.onmousemove = null; + }; + + dialogHeader.touchDraggable = function() { + var offset = null; + var start = function(e) { + var orig = e.originalEvent; + var pos = $(this).parent().position(); + + offset = { + x : orig.changedTouches[0].pageX - pos.left, + y : orig.changedTouches[0].pageY - pos.top + }; + }; + + var move = function(e) { + e.preventDefault(); + var orig = e.originalEvent; + + $(this).parent().css({ + top : orig.changedTouches[0].pageY - offset.y, + left : orig.changedTouches[0].pageX - offset.x + }); + }; + + this.bind("touchstart", start).bind("touchmove", move); + }; + + dialogHeader.touchDraggable(); + } + + editormd.dialogZindex += 2; + + return dialog; + }; + + /** + * 鼠标和触摸事件的判断/选择方法 + * MouseEvent or TouchEvent type switch + * + * @param {String} [mouseEventType="click"] 供选择的鼠标事件 + * @param {String} [touchEventType="touchend"] 供选择的触摸事件 + * @returns {String} EventType 返回事件类型名称 + */ + + editormd.mouseOrTouch = function(mouseEventType, touchEventType) { + mouseEventType = mouseEventType || "click"; + touchEventType = touchEventType || "touchend"; + + var eventType = mouseEventType; + + try { + document.createEvent("TouchEvent"); + eventType = touchEventType; + } catch(e) {} + + return eventType; + }; + + /** + * 日期时间的格式化方法 + * Datetime format method + * + * @param {String} [format=""] 日期时间的格式,类似PHP的格式 + * @returns {String} datefmt 返回格式化后的日期时间字符串 + */ + + editormd.dateFormat = function(format) { + format = format || ""; + + var addZero = function(d) { + return (d < 10) ? "0" + d : d; + }; + + var date = new Date(); + var year = date.getFullYear(); + var year2 = year.toString().slice(2, 4); + var month = addZero(date.getMonth() + 1); + var day = addZero(date.getDate()); + var weekDay = date.getDay(); + var hour = addZero(date.getHours()); + var min = addZero(date.getMinutes()); + var second = addZero(date.getSeconds()); + var ms = addZero(date.getMilliseconds()); + var datefmt = ""; + + var ymd = year2 + "-" + month + "-" + day; + var fymd = year + "-" + month + "-" + day; + var hms = hour + ":" + min + ":" + second; + + switch (format) + { + case "UNIX Time" : + datefmt = date.getTime(); + break; + + case "UTC" : + datefmt = date.toUTCString(); + break; + + case "yy" : + datefmt = year2; + break; + + case "year" : + case "yyyy" : + datefmt = year; + break; + + case "month" : + case "mm" : + datefmt = month; + break; + + case "cn-week-day" : + case "cn-wd" : + var cnWeekDays = ["日", "一", "二", "三", "四", "五", "六"]; + datefmt = "星期" + cnWeekDays[weekDay]; + break; + + case "week-day" : + case "wd" : + var weekDays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + datefmt = weekDays[weekDay]; + break; + + case "day" : + case "dd" : + datefmt = day; + break; + + case "hour" : + case "hh" : + datefmt = hour; + break; + + case "min" : + case "ii" : + datefmt = min; + break; + + case "second" : + case "ss" : + datefmt = second; + break; + + case "ms" : + datefmt = ms; + break; + + case "yy-mm-dd" : + datefmt = ymd; + break; + + case "yyyy-mm-dd" : + datefmt = fymd; + break; + + case "yyyy-mm-dd h:i:s ms" : + case "full + ms" : + datefmt = fymd + " " + hms + " " + ms; + break; + + case "full" : + case "yyyy-mm-dd h:i:s" : + default: + datefmt = fymd + " " + hms; + break; + } + + return datefmt; + }; + + return editormd; + +})); diff --git a/paicoding-ui/src/main/resources/static/editormd/editormd.amd.min.js b/paicoding-ui/src/main/resources/static/editormd/editormd.amd.min.js new file mode 100644 index 000000000..a5e0e19aa --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/editormd.amd.min.js @@ -0,0 +1,4 @@ +/*! Editor.md v1.5.0 | editormd.amd.min.js | Open source online markdown editor. | MIT License | By: Pandao | https://github.com/pandao/editor.md | 2015-06-09 */ +!function(e){"use strict";if("function"==typeof require&&"object"==typeof exports&&"object"==typeof module)module.exports=e;else if("function"==typeof define)if(define.amd){var t="codemirror/mode/",i="codemirror/addon/",o=["jquery","marked","prettify","katex","raphael","underscore","flowchart","jqueryflowchart","sequenceDiagram","codemirror/lib/codemirror",t+"css/css",t+"sass/sass",t+"shell/shell",t+"sql/sql",t+"clike/clike",t+"php/php",t+"xml/xml",t+"markdown/markdown",t+"javascript/javascript",t+"htmlmixed/htmlmixed",t+"gfm/gfm",t+"http/http",t+"go/go",t+"dart/dart",t+"coffeescript/coffeescript",t+"nginx/nginx",t+"python/python",t+"perl/perl",t+"lua/lua",t+"r/r",t+"ruby/ruby",t+"rst/rst",t+"smartymixed/smartymixed",t+"vb/vb",t+"vbscript/vbscript",t+"velocity/velocity",t+"xquery/xquery",t+"yaml/yaml",t+"erlang/erlang",t+"jade/jade",i+"edit/trailingspace",i+"dialog/dialog",i+"search/searchcursor",i+"search/search",i+"scroll/annotatescrollbar",i+"search/matchesonscrollbar",i+"display/placeholder",i+"edit/closetag",i+"fold/foldcode",i+"fold/foldgutter",i+"fold/indent-fold",i+"fold/brace-fold",i+"fold/xml-fold",i+"fold/markdown-fold",i+"fold/comment-fold",i+"mode/overlay",i+"selection/active-line",i+"edit/closebrackets",i+"display/fullscreen",i+"search/match-highlighter"];define(o,e)}else define(["jquery"],e);else window.editormd=e()}(function(){"function"==typeof define&&define.amd&&(e=arguments[0],marked=arguments[1],prettify=arguments[2],katex=arguments[3],Raphael=arguments[4],_=arguments[5],flowchart=arguments[6],CodeMirror=arguments[9]);var e="undefined"!=typeof jQuery?jQuery:Zepto;if("undefined"!=typeof e){var t=function(e,i){return new t.fn.init(e,i)};t.title=t.$name="Editor.md",t.version="1.5.0",t.homePage="https://pandao.github.io/editor.md/",t.classPrefix="editormd-",t.toolbarModes={full:["undo","redo","|","bold","del","italic","quote","ucwords","uppercase","lowercase","|","h1","h2","h3","h4","h5","h6","|","list-ul","list-ol","hr","|","link","reference-link","image","code","preformatted-text","code-block","table","datetime","emoji","html-entities","pagebreak","|","goto-line","watch","preview","fullscreen","clear","search","|","help","info"],simple:["undo","redo","|","bold","del","italic","quote","uppercase","lowercase","|","h1","h2","h3","h4","h5","h6","|","list-ul","list-ol","hr","|","watch","preview","fullscreen","|","help","info"],mini:["undo","redo","|","watch","preview","|","help","info"]},t.defaults={mode:"gfm",name:"",value:"",theme:"",editorTheme:"default",previewTheme:"",markdown:"",appendMarkdown:"",width:"100%",height:"100%",path:"./lib/",pluginPath:"",delay:300,autoLoadModules:!0,watch:!0,placeholder:"Enjoy Markdown! coding now...",gotoLine:!0,codeFold:!1,autoHeight:!1,autoFocus:!0,autoCloseTags:!0,searchReplace:!0,syncScrolling:!0,readOnly:!1,tabSize:4,indentUnit:4,lineNumbers:!0,lineWrapping:!0,autoCloseBrackets:!0,showTrailingSpace:!0,matchBrackets:!0,indentWithTabs:!0,styleSelectedText:!0,matchWordHighlight:!0,styleActiveLine:!0,dialogLockScreen:!0,dialogShowMask:!0,dialogDraggable:!0,dialogMaskBgColor:"#fff",dialogMaskOpacity:.1,fontSize:"13px",saveHTMLToTextarea:!1,disabledKeyMaps:[],onload:function(){},onresize:function(){},onchange:function(){},onwatch:null,onunwatch:null,onpreviewing:function(){},onpreviewed:function(){},onfullscreen:function(){},onfullscreenExit:function(){},onscroll:function(){},onpreviewscroll:function(){},imageUpload:!1,imageFormats:["jpg","jpeg","gif","png","bmp","webp"],imageUploadURL:"",crossDomainUpload:!1,uploadCallbackURL:"",toc:!0,tocm:!1,tocTitle:"",tocDropdown:!1,tocContainer:"",tocStartLevel:1,htmlDecode:!1,pageBreak:!0,atLink:!0,emailLink:!0,taskList:!1,emoji:!1,tex:!1,flowChart:!1,sequenceDiagram:!1,previewCodeHighlight:!0,toolbar:!0,toolbarAutoFixed:!0,toolbarIcons:"full",toolbarTitles:{},toolbarHandlers:{ucwords:function(){return t.toolbarHandlers.ucwords},lowercase:function(){return t.toolbarHandlers.lowercase}},toolbarCustomIcons:{lowercase:'a',ucwords:'Aa'},toolbarIconsClass:{undo:"fa-undo",redo:"fa-repeat",bold:"fa-bold",del:"fa-strikethrough",italic:"fa-italic",quote:"fa-quote-left",uppercase:"fa-font",h1:t.classPrefix+"bold",h2:t.classPrefix+"bold",h3:t.classPrefix+"bold",h4:t.classPrefix+"bold",h5:t.classPrefix+"bold",h6:t.classPrefix+"bold","list-ul":"fa-list-ul","list-ol":"fa-list-ol",hr:"fa-minus",link:"fa-link","reference-link":"fa-anchor",image:"fa-picture-o",code:"fa-code","preformatted-text":"fa-file-code-o","code-block":"fa-file-code-o",table:"fa-table",datetime:"fa-clock-o",emoji:"fa-smile-o","html-entities":"fa-copyright",pagebreak:"fa-newspaper-o","goto-line":"fa-terminal",watch:"fa-eye-slash",unwatch:"fa-eye",preview:"fa-desktop",search:"fa-search",fullscreen:"fa-arrows-alt",clear:"fa-eraser",help:"fa-question-circle",info:"fa-info-circle"},toolbarIconTexts:{},lang:{name:"zh-cn",description:"开源在线Markdown编辑器
        Open source online Markdown editor.",tocTitle:"目录",toolbar:{undo:"撤销(Ctrl+Z)",redo:"重做(Ctrl+Y)",bold:"粗体",del:"删除线",italic:"斜体",quote:"引用",ucwords:"将每个单词首字母转成大写",uppercase:"将所选转换成大写",lowercase:"将所选转换成小写",h1:"标题1",h2:"标题2",h3:"标题3",h4:"标题4",h5:"标题5",h6:"标题6","list-ul":"无序列表","list-ol":"有序列表",hr:"横线",link:"链接","reference-link":"引用链接",image:"添加图片",code:"行内代码","preformatted-text":"预格式文本 / 代码块(缩进风格)","code-block":"代码块(多语言风格)",table:"添加表格",datetime:"日期时间",emoji:"Emoji表情","html-entities":"HTML实体字符",pagebreak:"插入分页符","goto-line":"跳转到行",watch:"关闭实时预览",unwatch:"开启实时预览",preview:"全窗口预览HTML(按 Shift + ESC还原)",fullscreen:"全屏(按ESC还原)",clear:"清空",search:"搜索",help:"使用帮助",info:"关于"+t.title},buttons:{enter:"确定",cancel:"取消",close:"关闭"},dialog:{link:{title:"添加链接",url:"链接地址",urlTitle:"链接标题",urlEmpty:"错误:请填写链接地址。"},referenceLink:{title:"添加引用链接",name:"引用名称",url:"链接地址",urlId:"链接ID",urlTitle:"链接标题",nameEmpty:"错误:引用链接的名称不能为空。",idEmpty:"错误:请填写引用链接的ID。",urlEmpty:"错误:请填写引用链接的URL地址。"},image:{title:"添加图片",url:"图片地址",link:"图片链接",alt:"图片描述",uploadButton:"本地上传",imageURLEmpty:"错误:图片地址不能为空。",uploadFileEmpty:"错误:上传的图片不能为空。",formatNotAllowed:"错误:只允许上传图片文件,允许上传的图片文件格式有:"},preformattedText:{title:"添加预格式文本或代码块",emptyAlert:"错误:请填写预格式文本或代码的内容。"},codeBlock:{title:"添加代码块",selectLabel:"代码语言:",selectDefaultText:"请选择代码语言",otherLanguage:"其他语言",unselectedLanguageAlert:"错误:请选择代码所属的语言类型。",codeEmptyAlert:"错误:请填写代码内容。"},htmlEntities:{title:"HTML 实体字符"},help:{title:"使用帮助"}}}},t.classNames={tex:t.classPrefix+"tex"},t.dialogZindex=99999,t.$katex=null,t.$marked=null,t.$CodeMirror=null,t.$prettyPrint=null;var i,o;t.prototype=t.fn={state:{watching:!1,loaded:!1,preview:!1,fullscreen:!1},init:function(i,o){o=o||{},"object"==typeof i&&(o=i);var r=this.classPrefix=t.classPrefix,n=this.settings=e.extend(!0,t.defaults,o);i="object"==typeof i?n.id:i;var a=this.editor=e("#"+i);this.id=i,this.lang=n.lang;var s=this.classNames={textarea:{html:r+"html-textarea",markdown:r+"markdown-textarea"}};n.pluginPath=""===n.pluginPath?n.path+"../plugins/":n.pluginPath,this.state.watching=n.watch?!0:!1,a.hasClass("editormd")||a.addClass("editormd"),a.css({width:"number"==typeof n.width?n.width+"px":n.width,height:"number"==typeof n.height?n.height+"px":n.height}),n.autoHeight&&a.css("height","auto");var l=this.markdownTextarea=a.children("textarea");l.length<1&&(a.append(""),l=this.markdownTextarea=a.children("textarea")),l.addClass(s.textarea.markdown).attr("placeholder",n.placeholder),("undefined"==typeof l.attr("name")||""===l.attr("name"))&&l.attr("name",""!==n.name?n.name:i+"-markdown-doc");var c=[n.readOnly?"":'',n.saveHTMLToTextarea?'':"",'
        ','
        ','
        '].join("\n");return a.append(c).addClass(r+"vertical"),""!==n.theme&&a.addClass(r+"theme-"+n.theme),this.mask=a.children("."+r+"mask"),this.containerMask=a.children("."+r+"container-mask"),""!==n.markdown&&l.val(n.markdown),""!==n.appendMarkdown&&l.val(l.val()+n.appendMarkdown),this.htmlTextarea=a.children("."+s.textarea.html),this.preview=a.children("."+r+"preview"),this.previewContainer=this.preview.children("."+r+"preview-container"),""!==n.previewTheme&&this.preview.addClass(r+"preview-theme-"+n.previewTheme),"function"==typeof define&&define.amd&&("undefined"!=typeof katex&&(t.$katex=katex),n.searchReplace&&!n.readOnly&&(t.loadCSS(n.path+"codemirror/addon/dialog/dialog"),t.loadCSS(n.path+"codemirror/addon/search/matchesonscrollbar"))),"function"==typeof define&&define.amd||!n.autoLoadModules?("undefined"!=typeof CodeMirror&&(t.$CodeMirror=CodeMirror),"undefined"!=typeof marked&&(t.$marked=marked),this.setCodeMirror().setToolbar().loadedDisplay()):this.loadQueues(),this},loadQueues:function(){var e=this,i=this.settings,o=i.path,r=function(){return t.isIE8?void e.loadedDisplay():void(i.flowChart||i.sequenceDiagram?t.loadScript(o+"raphael.min",function(){t.loadScript(o+"underscore.min",function(){!i.flowChart&&i.sequenceDiagram?t.loadScript(o+"sequence-diagram.min",function(){e.loadedDisplay()}):i.flowChart&&!i.sequenceDiagram?t.loadScript(o+"flowchart.min",function(){t.loadScript(o+"jquery.flowchart.min",function(){e.loadedDisplay()})}):i.flowChart&&i.sequenceDiagram&&t.loadScript(o+"flowchart.min",function(){t.loadScript(o+"jquery.flowchart.min",function(){t.loadScript(o+"sequence-diagram.min",function(){e.loadedDisplay()})})})})}):e.loadedDisplay())};return t.loadCSS(o+"codemirror/codemirror.min"),i.searchReplace&&!i.readOnly&&(t.loadCSS(o+"codemirror/addon/dialog/dialog"),t.loadCSS(o+"codemirror/addon/search/matchesonscrollbar")),i.codeFold&&t.loadCSS(o+"codemirror/addon/fold/foldgutter"),t.loadScript(o+"codemirror/codemirror.min",function(){t.$CodeMirror=CodeMirror,t.loadScript(o+"codemirror/modes.min",function(){t.loadScript(o+"codemirror/addons.min",function(){return e.setCodeMirror(),"gfm"!==i.mode&&"markdown"!==i.mode?(e.loadedDisplay(),!1):(e.setToolbar(),void t.loadScript(o+"marked.min",function(){t.$marked=marked,i.previewCodeHighlight?t.loadScript(o+"prettify.min",function(){r()}):r()}))})})}),this},setTheme:function(e){var t=this.editor,i=this.settings.theme,o=this.classPrefix+"theme-";return t.removeClass(o+i).addClass(o+e),this.settings.theme=e,this},setEditorTheme:function(e){var i=this.settings;return i.editorTheme=e,"default"!==e&&t.loadCSS(i.path+"codemirror/theme/"+i.editorTheme),this.cm.setOption("theme",e),this},setCodeMirrorTheme:function(e){return this.setEditorTheme(e),this},setPreviewTheme:function(e){var t=this.preview,i=this.settings.previewTheme,o=this.classPrefix+"preview-theme-";return t.removeClass(o+i).addClass(o+e),this.settings.previewTheme=e,this},setCodeMirror:function(){var e=this.settings,i=this.editor;"default"!==e.editorTheme&&t.loadCSS(e.path+"codemirror/theme/"+e.editorTheme);var o={mode:e.mode,theme:e.editorTheme,tabSize:e.tabSize,dragDrop:!1,autofocus:e.autoFocus,autoCloseTags:e.autoCloseTags,readOnly:e.readOnly?"nocursor":!1,indentUnit:e.indentUnit,lineNumbers:e.lineNumbers,lineWrapping:e.lineWrapping,extraKeys:{"Ctrl-Q":function(e){e.foldCode(e.getCursor())}},foldGutter:e.codeFold,gutters:["CodeMirror-linenumbers","CodeMirror-foldgutter"],matchBrackets:e.matchBrackets,indentWithTabs:e.indentWithTabs,styleActiveLine:e.styleActiveLine,styleSelectedText:e.styleSelectedText,autoCloseBrackets:e.autoCloseBrackets,showTrailingSpace:e.showTrailingSpace,highlightSelectionMatches:e.matchWordHighlight?{showToken:"onselected"===e.matchWordHighlight?!1:/\w/}:!1};return this.codeEditor=this.cm=t.$CodeMirror.fromTextArea(this.markdownTextarea[0],o),this.codeMirror=this.cmElement=i.children(".CodeMirror"),""!==e.value&&this.cm.setValue(e.value),this.codeMirror.css({fontSize:e.fontSize,width:e.watch?"50%":"100%"}),e.autoHeight&&(this.codeMirror.css("height","auto"),this.cm.setOption("viewportMargin",1/0)),e.lineNumbers||this.codeMirror.find(".CodeMirror-gutters").css("border-right","none"),this},getCodeMirrorOption:function(e){return this.cm.getOption(e)},setCodeMirrorOption:function(e,t){return this.cm.setOption(e,t),this},addKeyMap:function(e,t){return this.cm.addKeyMap(e,t),this},removeKeyMap:function(e){return this.cm.removeKeyMap(e),this},gotoLine:function(t){var i=this.settings;if(!i.gotoLine)return this;var o=this.cm,r=(this.editor,o.lineCount()),n=this.preview;if("string"==typeof t&&("last"===t&&(t=r),"first"===t&&(t=1)),"number"!=typeof t)return alert("Error: The line number must be an integer."),this;if(t=parseInt(t)-1,t>r)return alert("Error: The line number range 1-"+r),this;o.setCursor({line:t,ch:0});var a=o.getScrollInfo(),s=a.clientHeight,l=o.charCoords({line:t,ch:0},"local");if(o.scrollTo(null,(l.top+l.bottom-s)/2),i.watch){var c=this.codeMirror.find(".CodeMirror-scroll")[0],h=e(c).height(),d=c.scrollTop,u=d/c.scrollHeight;n.scrollTop(0===d?0:d+h>=c.scrollHeight-16?n[0].scrollHeight:n[0].scrollHeight*u)}return o.focus(),this},extend:function(){return"undefined"!=typeof arguments[1]&&("function"==typeof arguments[1]&&(arguments[1]=e.proxy(arguments[1],this)),this[arguments[0]]=arguments[1]),"object"==typeof arguments[0]&&"undefined"==typeof arguments[0].length&&e.extend(!0,this,arguments[0]),this},set:function(t,i){return"undefined"!=typeof i&&"function"==typeof i&&(i=e.proxy(i,this)),this[t]=i,this},config:function(t,i){var o=this.settings;return"object"==typeof t&&(o=e.extend(!0,o,t)),"string"==typeof t&&(o[t]=i),this.settings=o,this.recreate(),this},on:function(t,i){var o=this.settings;return"undefined"!=typeof o["on"+t]&&(o["on"+t]=e.proxy(i,this)),this},off:function(e){var t=this.settings;return"undefined"!=typeof t["on"+e]&&(t["on"+e]=function(){}),this},showToolbar:function(t){var i=this.settings;return i.readOnly?this:(i.toolbar&&(this.toolbar.length<1||""===this.toolbar.find("."+this.classPrefix+"menu").html())&&this.setToolbar(),i.toolbar=!0,this.toolbar.show(),this.resize(),e.proxy(t||function(){},this)(),this)},hideToolbar:function(t){var i=this.settings;return i.toolbar=!1,this.toolbar.hide(),this.resize(),e.proxy(t||function(){},this)(),this},setToolbarAutoFixed:function(t){var i=this.state,o=this.editor,r=this.toolbar,n=this.settings;"undefined"!=typeof t&&(n.toolbarAutoFixed=t);var a=function(){var t=e(window),i=t.scrollTop();return n.toolbarAutoFixed?void r.css(i-o.offset().top>10&&i
          ';i.append(n),r=this.toolbar=i.children("."+o+"toolbar")}if(!e.toolbar)return r.hide(),this;r.show();for(var a="function"==typeof e.toolbarIcons?e.toolbarIcons():"string"==typeof e.toolbarIcons?t.toolbarModes[e.toolbarIcons]:e.toolbarIcons,s=r.find("."+this.classPrefix+"menu"),l="",c=!1,h=0,d=a.length;d>h;h++){var u=a[h];if("||"===u)c=!0;else if("|"===u)l+='
        • |
        • ';else{var f=/h(\d)/.test(u),g=u;"watch"!==u||e.watch||(g="unwatch");var p=e.lang.toolbar[g],m=e.toolbarIconTexts[g],w=e.toolbarIconsClass[g];p="undefined"==typeof p?"":p,m="undefined"==typeof m?"":m,w="undefined"==typeof w?"":w;var v=c?'
        • ':"
        • ";"undefined"!=typeof e.toolbarCustomIcons[u]&&"function"!=typeof e.toolbarCustomIcons[u]?v+=e.toolbarCustomIcons[u]:(v+='',v+=''+(f?u.toUpperCase():""===w?m:"")+"",v+=""),v+="
        • ",l=c?v+l:l+v}}return s.html(l),s.find('[title="Lowercase"]').attr("title",e.lang.toolbar.lowercase),s.find('[title="ucwords"]').attr("title",e.lang.toolbar.ucwords),this.setToolbarHandler(),this.setToolbarAutoFixed(),this},dialogLockScreen:function(){return e.proxy(t.dialogLockScreen,this)(),this},dialogShowMask:function(i){return e.proxy(t.dialogShowMask,this)(i),this},getToolbarHandles:function(e){var i=this.toolbarHandlers=t.toolbarHandlers;return e&&"undefined"!=typeof toolbarIconHandlers[e]?i[e]:i},setToolbarHandler:function(){var i=this,o=this.settings;if(!o.toolbar||o.readOnly)return this;var r=this.toolbar,n=this.cm,a=this.classPrefix,s=this.toolbarIcons=r.find("."+a+"menu > li > a"),l=this.getToolbarHandles();return s.bind(t.mouseOrTouch("click","touchend"),function(t){var r=e(this).children(".fa"),a=r.attr("name"),s=n.getCursor(),c=n.getSelection();return""!==a?(i.activeIcon=r,"undefined"!=typeof l[a]?e.proxy(l[a],i)(n):"undefined"!=typeof o.toolbarHandlers[a]&&e.proxy(o.toolbarHandlers[a],i)(n,r,s,c),"link"!==a&&"reference-link"!==a&&"image"!==a&&"code-block"!==a&&"preformatted-text"!==a&&"watch"!==a&&"preview"!==a&&"search"!==a&&"fullscreen"!==a&&"info"!==a&&n.focus(),!1):void 0}),this},createDialog:function(i){return e.proxy(t.createDialog,this)(i)},createInfoDialog:function(){var e=this,i=this.editor,o=this.classPrefix,r=['
          ','
          ','

          '+t.title+"v"+t.version+"

          ","

          "+this.lang.description+"

          ",'

          '+t.homePage+'

          ','

          Copyright © 2015 Pandao, The MIT License.

          ',"
          ",'',"
          "].join("\n");i.append(r);var n=this.infoDialog=i.children("."+o+"dialog-info");return n.find("."+o+"dialog-close").bind(t.mouseOrTouch("click","touchend"),function(){e.hideInfoDialog()}),n.css("border",t.isIE8?"1px solid #ddd":"").css("z-index",t.dialogZindex).show(),this.infoDialogPosition(),this},infoDialogPosition:function(){var t=this.infoDialog,i=function(){t.css({top:(e(window).height()-t.height())/2+"px",left:(e(window).width()-t.width())/2+"px"})};return i(),e(window).resize(i),this},showInfoDialog:function(){e("html,body").css("overflow-x","hidden");var i=this.editor,o=this.settings,r=this.infoDialog=i.children("."+this.classPrefix+"dialog-info");return r.length<1&&this.createInfoDialog(),this.lockScreen(!0),this.mask.css({opacity:o.dialogMaskOpacity,backgroundColor:o.dialogMaskBgColor}).show(),r.css("z-index",t.dialogZindex).show(),this.infoDialogPosition(),this},hideInfoDialog:function(){return e("html,body").css("overflow-x",""),this.infoDialog.hide(),this.mask.hide(),this.lockScreen(!1),this},lockScreen:function(e){return t.lockScreen(e),this.resize(),this},recreate:function(){var e=this.editor,t=this.settings;return this.codeMirror.remove(),this.setCodeMirror(),t.readOnly||(e.find(".editormd-dialog").length>0&&e.find(".editormd-dialog").remove(),t.toolbar&&(this.getToolbarHandles(),this.setToolbar())),this.loadedDisplay(!0),this},previewCodeHighlight:function(){var e=this.settings,t=this.previewContainer;return e.previewCodeHighlight&&(t.find("pre").addClass("prettyprint linenums"),"undefined"!=typeof prettyPrint&&prettyPrint()),this},katexRender:function(){return null===i?this:(this.previewContainer.find("."+t.classNames.tex).each(function(){var i=e(this);t.$katex.render(i.text(),i[0]),i.find(".katex").css("font-size","1.6em")}),this)},flowChartAndSequenceDiagramRender:function(){var i=this,r=this.settings,n=this.previewContainer;if(t.isIE8)return this;if(r.flowChart){if(null===o)return this;n.find(".flowchart").flowChart()}r.sequenceDiagram&&n.find(".sequence-diagram").sequenceDiagram({theme:"simple"});var a=i.preview,s=i.codeMirror,l=s.find(".CodeMirror-scroll"),c=l.height(),h=l.scrollTop(),d=h/l[0].scrollHeight,u=0;a.find(".markdown-toc-list").each(function(){u+=e(this).height()});var f=a.find(".editormd-toc-menu").height();return f=f?f:0,a.scrollTop(0===h?0:h+c>=l[0].scrollHeight-16?a[0].scrollHeight:(a[0].scrollHeight+u+f)*d),this},registerKeyMaps:function(i){var o=this,r=this.cm,n=this.settings,a=t.toolbarHandlers,s=n.disabledKeyMaps;if(i=i||null){for(var l in i)if(e.inArray(l,s)<0){var c={};c[l]=i[l],r.addKeyMap(i)}}else{for(var h in t.keyMaps){var d=t.keyMaps[h],u="string"==typeof d?e.proxy(a[d],o):e.proxy(d,o);if(e.inArray(h,["F9","F10","F11"])<0&&e.inArray(h,s)<0){var f={};f[h]=u,r.addKeyMap(f)}}e(window).keydown(function(t){var i={120:"F9",121:"F10",122:"F11"};if(e.inArray(i[t.keyCode],s)<0)switch(t.keyCode){case 120:return e.proxy(a.watch,o)(),!1;case 121:return e.proxy(a.preview,o)(),!1;case 122:return e.proxy(a.fullscreen,o)(),!1}})}return this},bindScrollEvent:function(){var i=this,o=this.preview,r=this.settings,n=this.codeMirror,a=t.mouseOrTouch;if(!r.syncScrolling)return this;var s=function(){n.find(".CodeMirror-scroll").bind(a("scroll","touchmove"),function(t){var n=e(this).height(),a=e(this).scrollTop(),s=a/e(this)[0].scrollHeight,l=0;o.find(".markdown-toc-list").each(function(){l+=e(this).height()});var c=o.find(".editormd-toc-menu").height();c=c?c:0,o.scrollTop(0===a?0:a+n>=e(this)[0].scrollHeight-16?o[0].scrollHeight:(o[0].scrollHeight+l+c)*s),e.proxy(r.onscroll,i)(t)})},l=function(){n.find(".CodeMirror-scroll").unbind(a("scroll","touchmove"))},c=function(){o.bind(a("scroll","touchmove"),function(t){var o=e(this).height(),a=e(this).scrollTop(),s=a/e(this)[0].scrollHeight,l=n.find(".CodeMirror-scroll");l.scrollTop(0===a?0:a+o>=e(this)[0].scrollHeight?l[0].scrollHeight:l[0].scrollHeight*s),e.proxy(r.onpreviewscroll,i)(t)})},h=function(){o.unbind(a("scroll","touchmove"))};return n.bind({mouseover:s,mouseout:l,touchstart:s,touchend:l}),"single"===r.syncScrolling?this:(o.bind({mouseover:c,mouseout:h,touchstart:c,touchend:h}),this)},bindChangeEvent:function(){var e=this,t=this.cm,o=this.settings;return o.syncScrolling?(t.on("change",function(t,r){o.watch&&e.previewContainer.css("padding",o.autoHeight?"20px 20px 50px 40px":"20px"),i=setTimeout(function(){clearTimeout(i),e.save(),i=null},o.delay)}),this):this},loadedDisplay:function(t){t=t||!1;var i=this,o=this.editor,r=this.preview,n=this.settings;return this.containerMask.hide(),this.save(),n.watch&&r.show(),o.data("oldWidth",o.width()).data("oldHeight",o.height()),this.resize(),this.registerKeyMaps(),e(window).resize(function(){i.resize()}),this.bindScrollEvent().bindChangeEvent(),t||e.proxy(n.onload,this)(),this.state.loaded=!0,this},width:function(e){return this.editor.css("width","number"==typeof e?e+"px":e),this.resize(),this},height:function(e){return this.editor.css("height","number"==typeof e?e+"px":e),this.resize(),this},resize:function(t,i){t=t||null,i=i||null;var o=this.state,r=this.editor,n=this.preview,a=this.toolbar,s=this.settings,l=this.codeMirror;if(t&&r.css("width","number"==typeof t?t+"px":t),!s.autoHeight||o.fullscreen||o.preview?(i&&r.css("height","number"==typeof i?i+"px":i),o.fullscreen&&r.height(e(window).height()),s.toolbar&&!s.readOnly?l.css("margin-top",a.height()+1).height(r.height()-a.height()):l.css("margin-top",0).height(r.height())):(r.css("height","auto"),l.css("height","auto")),s.watch)if(l.width(r.width()/2),n.width(o.preview?r.width():r.width()/2),this.previewContainer.css("padding",s.autoHeight?"20px 20px 50px 40px":"20px"),s.toolbar&&!s.readOnly?n.css("top",a.height()+1):n.css("top",0),!s.autoHeight||o.fullscreen||o.preview){var c=s.toolbar&&!s.readOnly?r.height()-a.height():r.height();n.height(c)}else n.height("");else l.width(r.width()),n.hide();return o.loaded&&e.proxy(s.onresize,this)(),this},save:function(){if(null===i)return this;var r=this,n=this.state,a=this.settings,s=this.cm,l=s.getValue(),c=this.previewContainer;if("gfm"!==a.mode&&"markdown"!==a.mode)return this.markdownTextarea.val(l),this;var h=t.$marked,d=this.markdownToC=[],u=this.markedRendererOptions={toc:a.toc,tocm:a.tocm,tocStartLevel:a.tocStartLevel,pageBreak:a.pageBreak,taskList:a.taskList,emoji:a.emoji,tex:a.tex,atLink:a.atLink,emailLink:a.emailLink,flowChart:a.flowChart,sequenceDiagram:a.sequenceDiagram,previewCodeHighlight:a.previewCodeHighlight},f=this.markedOptions={renderer:t.markedRenderer(d,u),gfm:!0,tables:!0,breaks:!0,pedantic:!1,sanitize:a.htmlDecode?!1:!0,smartLists:!0,smartypants:!0};h.setOptions(f);var g=t.$marked(l,f);if(g=t.filterHTMLTags(g,a.htmlDecode),this.markdownTextarea.text(l),s.save(),a.saveHTMLToTextarea&&this.htmlTextarea.text(g),a.watch||!a.watch&&n.preview){if(c.html(g),this.previewCodeHighlight(),a.toc){var p=""===a.tocContainer?c:e(a.tocContainer),m=p.find("."+this.classPrefix+"toc-menu");p.attr("previewContainer",""===a.tocContainer?"true":"false"),""!==a.tocContainer&&m.length>0&&m.remove(),t.markdownToCRenderer(d,p,a.tocDropdown,a.tocStartLevel),(a.tocDropdown||p.find("."+this.classPrefix+"toc-menu").length>0)&&t.tocDropdownMenu(p,""!==a.tocTitle?a.tocTitle:this.lang.tocTitle),""!==a.tocContainer&&c.find(".markdown-toc").css("border","none")}a.tex&&(!t.kaTeXLoaded&&a.autoLoadModules?t.loadKaTeX(function(){t.$katex=katex,t.kaTeXLoaded=!0,r.katexRender()}):(t.$katex=katex,this.katexRender())),(a.flowChart||a.sequenceDiagram)&&(o=setTimeout(function(){clearTimeout(o),r.flowChartAndSequenceDiagramRender(),o=null},10)),n.loaded&&e.proxy(a.onchange,this)()}return this},focus:function(){return this.cm.focus(),this},setCursor:function(e){return this.cm.setCursor(e),this},getCursor:function(){return this.cm.getCursor()},setSelection:function(e,t){return this.cm.setSelection(e,t),this},getSelection:function(){return this.cm.getSelection()},setSelections:function(e){return this.cm.setSelections(e),this},getSelections:function(){return this.cm.getSelections()},replaceSelection:function(e){return this.cm.replaceSelection(e),this},insertValue:function(e){return this.replaceSelection(e),this},appendMarkdown:function(e){var t=(this.settings,this.cm);return t.setValue(t.getValue()+e),this},setMarkdown:function(e){return this.cm.setValue(e||this.settings.markdown),this},getMarkdown:function(){return this.cm.getValue()},getValue:function(){return this.cm.getValue()},setValue:function(e){return this.cm.setValue(e),this},clear:function(){return this.cm.setValue(""),this},getHTML:function(){return this.settings.saveHTMLToTextarea?this.htmlTextarea.val():(alert("Error: settings.saveHTMLToTextarea == false"),!1)},getTextareaSavedHTML:function(){return this.getHTML()},getPreviewedHTML:function(){return this.settings.watch?this.previewContainer.html():(alert("Error: settings.watch == false"),!1)},watch:function(t){var o=this.settings;if(e.inArray(o.mode,["gfm","markdown"])<0)return this;if(this.state.watching=o.watch=!0,this.preview.show(),this.toolbar){var r=o.toolbarIconsClass.watch,n=o.toolbarIconsClass.unwatch,a=this.toolbar.find(".fa[name=watch]");a.parent().attr("title",o.lang.toolbar.watch),a.removeClass(n).addClass(r)}return this.codeMirror.css("border-right","1px solid #ddd").width(this.editor.width()/2),i=0,this.save().resize(),o.onwatch||(o.onwatch=t||function(){}),e.proxy(o.onwatch,this)(),this},unwatch:function(t){var i=this.settings;if(this.state.watching=i.watch=!1,this.preview.hide(),this.toolbar){var o=i.toolbarIconsClass.watch,r=i.toolbarIconsClass.unwatch,n=this.toolbar.find(".fa[name=watch]");n.parent().attr("title",i.lang.toolbar.unwatch),n.removeClass(o).addClass(r)}return this.codeMirror.css("border-right","none").width(this.editor.width()),this.resize(),i.onunwatch||(i.onunwatch=t||function(){}),e.proxy(i.onunwatch,this)(),this},show:function(t){t=t||function(){};var i=this;return this.editor.show(0,function(){e.proxy(t,i)()}),this},hide:function(t){t=t||function(){};var i=this;return this.editor.hide(0,function(){e.proxy(t,i)()}),this},previewing:function(){var i=this,o=this.editor,r=this.preview,n=this.toolbar,a=this.settings,s=this.codeMirror,l=this.previewContainer;if(e.inArray(a.mode,["gfm","markdown"])<0)return this;a.toolbar&&n&&(n.toggle(),n.find(".fa[name=preview]").toggleClass("active")),s.toggle();var c=function(e){e.shiftKey&&27===e.keyCode&&i.previewed()};"none"===s.css("display")?(this.state.preview=!0,this.state.fullscreen&&r.css("background","#fff"),o.find("."+this.classPrefix+"preview-close-btn").show().bind(t.mouseOrTouch("click","touchend"),function(){i.previewed()}),a.watch?l.css("padding",""):this.save(),l.addClass(this.classPrefix+"preview-active"),r.show().css({position:"",top:0,width:o.width(),height:a.autoHeight&&!this.state.fullscreen?"auto":o.height()}),this.state.loaded&&e.proxy(a.onpreviewing,this)(),e(window).bind("keyup",c)):(e(window).unbind("keyup",c),this.previewed())},previewed:function(){var i=this.editor,o=this.preview,r=this.toolbar,n=this.settings,a=this.previewContainer,s=i.find("."+this.classPrefix+"preview-close-btn");return this.state.preview=!1,this.codeMirror.show(),n.toolbar&&r.show(),o[n.watch?"show":"hide"](),s.hide().unbind(t.mouseOrTouch("click","touchend")),a.removeClass(this.classPrefix+"preview-active"),n.watch&&a.css("padding","20px"),o.css({background:null,position:"absolute",width:i.width()/2,height:n.autoHeight&&!this.state.fullscreen?"auto":i.height()-r.height(),top:n.toolbar?r.height():0}),this.state.loaded&&e.proxy(n.onpreviewed,this)(),this},fullscreen:function(){var t=this,i=this.state,o=this.editor,r=(this.preview,this.toolbar),n=this.settings,a=this.classPrefix+"fullscreen";r&&r.find(".fa[name=fullscreen]").parent().toggleClass("active");var s=function(e){e.shiftKey||27!==e.keyCode||i.fullscreen&&t.fullscreenExit()};return o.hasClass(a)?(e(window).unbind("keyup",s),this.fullscreenExit()):(i.fullscreen=!0,e("html,body").css("overflow","hidden"),o.css({width:e(window).width(),height:e(window).height()}).addClass(a),this.resize(),e.proxy(n.onfullscreen,this)(),e(window).bind("keyup",s)),this},fullscreenExit:function(){var t=this.editor,i=this.settings,o=this.toolbar,r=this.classPrefix+"fullscreen";return this.state.fullscreen=!1,o&&o.find(".fa[name=fullscreen]").parent().removeClass("active"),e("html,body").css("overflow",""),t.css({width:t.data("oldWidth"),height:t.data("oldHeight")}).removeClass(r),this.resize(),e.proxy(i.onfullscreenExit,this)(),this},executePlugin:function(i,o){var r=this,n=this.cm,a=this.settings;return o=a.pluginPath+o,"function"==typeof define?"undefined"==typeof this[i]?(alert("Error: "+i+" plugin is not found, you are not load this plugin."),this):(this[i](n),this):(e.inArray(o,t.loadFiles.plugin)<0?t.loadPlugin(o,function(){t.loadPlugins[i]=r[i],r[i](n)}):e.proxy(t.loadPlugins[i],this)(n),this)},search:function(e){var t=this.settings;return t.searchReplace?(t.readOnly||this.cm.execCommand(e||"find"),this):(alert("Error: settings.searchReplace == false"),this)},searchReplace:function(){return this.search("replace"),this},searchReplaceAll:function(){return this.search("replaceAll"),this}},t.fn.init.prototype=t.fn,t.dialogLockScreen=function(){var t=this.settings||{dialogLockScreen:!0};t.dialogLockScreen&&(e("html,body").css("overflow","hidden"),this.resize())},t.dialogShowMask=function(t){var i=this.editor,o=this.settings||{dialogShowMask:!0};t.css({top:(e(window).height()-t.height())/2+"px",left:(e(window).width()-t.width())/2+"px"}),o.dialogShowMask&&i.children("."+this.classPrefix+"mask").css("z-index",parseInt(t.css("z-index"))-1).show()},t.toolbarHandlers={undo:function(){this.cm.undo()},redo:function(){this.cm.redo()},bold:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection(); + +e.replaceSelection("**"+i+"**"),""===i&&e.setCursor(t.line,t.ch+2)},del:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("~~"+i+"~~"),""===i&&e.setCursor(t.line,t.ch+2)},italic:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("*"+i+"*"),""===i&&e.setCursor(t.line,t.ch+1)},quote:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("> "+i),e.setCursor(t.line,t.ch+2)):e.replaceSelection("> "+i)},ucfirst:function(){var e=this.cm,i=e.getSelection(),o=e.listSelections();e.replaceSelection(t.firstUpperCase(i)),e.setSelections(o)},ucwords:function(){var e=this.cm,i=e.getSelection(),o=e.listSelections();e.replaceSelection(t.wordsFirstUpperCase(i)),e.setSelections(o)},uppercase:function(){var e=this.cm,t=e.getSelection(),i=e.listSelections();e.replaceSelection(t.toUpperCase()),e.setSelections(i)},lowercase:function(){var e=this.cm,t=(e.getCursor(),e.getSelection()),i=e.listSelections();e.replaceSelection(t.toLowerCase()),e.setSelections(i)},h1:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("# "+i),e.setCursor(t.line,t.ch+2)):e.replaceSelection("# "+i)},h2:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("## "+i),e.setCursor(t.line,t.ch+3)):e.replaceSelection("## "+i)},h3:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("### "+i),e.setCursor(t.line,t.ch+4)):e.replaceSelection("### "+i)},h4:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("#### "+i),e.setCursor(t.line,t.ch+5)):e.replaceSelection("#### "+i)},h5:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("##### "+i),e.setCursor(t.line,t.ch+6)):e.replaceSelection("##### "+i)},h6:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("###### "+i),e.setCursor(t.line,t.ch+7)):e.replaceSelection("###### "+i)},"list-ul":function(){var e=this.cm,t=(e.getCursor(),e.getSelection());if(""===t)e.replaceSelection("- "+t);else{for(var i=t.split("\n"),o=0,r=i.length;r>o;o++)i[o]=""===i[o]?"":"- "+i[o];e.replaceSelection(i.join("\n"))}},"list-ol":function(){var e=this.cm,t=(e.getCursor(),e.getSelection());if(""===t)e.replaceSelection("1. "+t);else{for(var i=t.split("\n"),o=0,r=i.length;r>o;o++)i[o]=""===i[o]?"":o+1+". "+i[o];e.replaceSelection(i.join("\n"))}},hr:function(){{var e=this.cm,t=e.getCursor();e.getSelection()}e.replaceSelection((0!==t.ch?"\n\n":"\n")+"------------\n\n")},tex:function(){if(!this.settings.tex)return alert("settings.tex === false"),this;var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("$$"+i+"$$"),""===i&&e.setCursor(t.line,t.ch+2)},link:function(){this.executePlugin("linkDialog","link-dialog/link-dialog")},"reference-link":function(){this.executePlugin("referenceLinkDialog","reference-link-dialog/reference-link-dialog")},pagebreak:function(){if(!this.settings.pageBreak)return alert("settings.pageBreak === false"),this;{var e=this.cm;e.getSelection()}e.replaceSelection("\r\n[========]\r\n")},image:function(){this.executePlugin("imageDialog","image-dialog/image-dialog")},code:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("`"+i+"`"),""===i&&e.setCursor(t.line,t.ch+1)},"code-block":function(){this.executePlugin("codeBlockDialog","code-block-dialog/code-block-dialog")},"preformatted-text":function(){this.executePlugin("preformattedTextDialog","preformatted-text-dialog/preformatted-text-dialog")},table:function(){this.executePlugin("tableDialog","table-dialog/table-dialog")},datetime:function(){var e=this.cm,i=(e.getSelection(),new Date,this.settings.lang.name),o=t.dateFormat()+" "+t.dateFormat("zh-cn"===i||"zh-tw"===i?"cn-week-day":"week-day");e.replaceSelection(o)},emoji:function(){this.executePlugin("emojiDialog","emoji-dialog/emoji-dialog")},"html-entities":function(){this.executePlugin("htmlEntitiesDialog","html-entities-dialog/html-entities-dialog")},"goto-line":function(){this.executePlugin("gotoLineDialog","goto-line-dialog/goto-line-dialog")},watch:function(){this[this.settings.watch?"unwatch":"watch"]()},preview:function(){this.previewing()},fullscreen:function(){this.fullscreen()},clear:function(){this.clear()},search:function(){this.search()},help:function(){this.executePlugin("helpDialog","help-dialog/help-dialog")},info:function(){this.showInfoDialog()}},t.keyMaps={"Ctrl-1":"h1","Ctrl-2":"h2","Ctrl-3":"h3","Ctrl-4":"h4","Ctrl-5":"h5","Ctrl-6":"h6","Ctrl-B":"bold","Ctrl-D":"datetime","Ctrl-E":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();return this.settings.emoji?(e.replaceSelection(":"+i+":"),void(""===i&&e.setCursor(t.line,t.ch+1))):void alert("Error: settings.emoji == false")},"Ctrl-Alt-G":"goto-line","Ctrl-H":"hr","Ctrl-I":"italic","Ctrl-K":"code","Ctrl-L":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection(),o=""===i?"":' "'+i+'"';e.replaceSelection("["+i+"]("+o+")"),""===i&&e.setCursor(t.line,t.ch+1)},"Ctrl-U":"list-ul","Shift-Ctrl-A":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();return this.settings.atLink?(e.replaceSelection("@"+i),void(""===i&&e.setCursor(t.line,t.ch+1))):void alert("Error: settings.atLink == false")},"Shift-Ctrl-C":"code","Shift-Ctrl-Q":"quote","Shift-Ctrl-S":"del","Shift-Ctrl-K":"tex","Shift-Alt-C":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection(["```",i,"```"].join("\n")),""===i&&e.setCursor(t.line,t.ch+3)},"Shift-Ctrl-Alt-C":"code-block","Shift-Ctrl-H":"html-entities","Shift-Alt-H":"help","Shift-Ctrl-E":"emoji","Shift-Ctrl-U":"uppercase","Shift-Alt-U":"ucwords","Shift-Ctrl-Alt-U":"ucfirst","Shift-Alt-L":"lowercase","Shift-Ctrl-I":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection(),o=""===i?"":' "'+i+'"';e.replaceSelection("!["+i+"]("+o+")"),""===i&&e.setCursor(t.line,t.ch+4)},"Shift-Ctrl-Alt-I":"image","Shift-Ctrl-L":"link","Shift-Ctrl-O":"list-ol","Shift-Ctrl-P":"preformatted-text","Shift-Ctrl-T":"table","Shift-Alt-P":"pagebreak",F9:"watch",F10:"preview",F11:"fullscreen"};var r=function(e){return String.prototype.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")};t.trim=r;var n=function(e){return e.toLowerCase().replace(/\b(\w)|\s(\w)/g,function(e){return e.toUpperCase()})};t.ucwords=t.wordsFirstUpperCase=n;var a=function(e){return e.toLowerCase().replace(/\b(\w)/,function(e){return e.toUpperCase()})};return t.firstUpperCase=t.ucfirst=a,t.urls={atLinkBase:"https://github.com/"},t.regexs={atLink:/@(\w+)/g,email:/(\w+)@(\w+)\.(\w+)\.?(\w+)?/g,emailLink:/(mailto:)?([\w\.\_]+)@(\w+)\.(\w+)\.?(\w+)?/g,emoji:/:([\w\+-]+):/g,emojiDatetime:/(\d{2}:\d{2}:\d{2})/g,twemoji:/:(tw-([\w]+)-?(\w+)?):/g,fontAwesome:/:(fa-([\w]+)(-(\w+)){0,}):/g,editormdLogo:/:(editormd-logo-?(\w+)?):/g,pageBreak:/^\[[=]{8,}\]$/},t.emoji={path:"http://www.emoji-cheat-sheet.com/graphics/emojis/",ext:".png"},t.twemoji={path:"http://twemoji.maxcdn.com/36x36/",ext:".png"},t.markedRenderer=function(i,o){var n={toc:!0,tocm:!1,tocStartLevel:1,pageBreak:!0,atLink:!0,emailLink:!0,taskList:!1,emoji:!1,tex:!1,flowChart:!1,sequenceDiagram:!1},a=e.extend(n,o||{}),s=t.$marked,l=new s.Renderer;i=i||[];var c=t.regexs,h=c.atLink,d=c.emoji,u=c.email,f=c.emailLink,g=c.twemoji,p=c.fontAwesome,m=c.editormdLogo,w=c.pageBreak;return l.emoji=function(e){e=e.replace(t.regexs.emojiDatetime,function(e){return e.replace(/:/g,":")});var i=e.match(d);if(!i||!a.emoji)return e;for(var o=0,r=i.length;r>o;o++)":+1:"===i[o]&&(i[o]=":\\+1:"),e=e.replace(new RegExp(i[o]),function(e,i){var o=e.match(p),r=e.replace(/:/g,"");if(o)for(var n=0,a=o.length;a>n;n++){var s=o[n].replace(/:/g,"");return''}else{var l=e.match(m),c=e.match(g);if(l)for(var h=0,d=l.length;d>h;h++){var u=l[h].replace(/:/g,"");return''}else{if(!c){var f="+1"===r?"plus1":r;return f="black_large_square"===f?"black_square":f,f="moon"===f?"waxing_gibbous_moon":f,':'+r+':'}for(var w=0,v=c.length;v>w;w++){var k=c[w].replace(/:/g,"").replace("tw-","");return'twemoji-'+k+''}}}});return e},l.atLink=function(i){return h.test(i)?(a.atLink&&(i=i.replace(u,function(e,t,i,o){return e.replace(/@/g,"_#_@_#_")}),i=i.replace(h,function(e,i){return''+e+""}).replace(/_#_@_#_/g,"@")),a.emailLink&&(i=i.replace(f,function(t,i,o,r,n){return!i&&e.inArray(n,"jpg|jpeg|png|gif|webp|ico|icon|pdf".split("|"))<0?''+t+"":t})),i):i},l.link=function(e,t,i){if(this.options.sanitize){try{var o=decodeURIComponent(unescape(e)).replace(/[^\w:]/g,"").toLowerCase()}catch(r){return""}if(0===o.indexOf("javascript:"))return""}var n=''+i.replace(/@/g,"@")+""):(t&&(n+=' title="'+t+'"'),n+=">"+i+"")},l.heading=function(e,t,o){var n=e,a=/\s*\]*)\>(.*)\<\/a\>\s*/;if(a.test(e)){var s=[];e=e.split(/\]+)\>([^\>]*)\<\/a\>/);for(var l=0,c=e.length;c>l;l++)s.push(e[l].replace(/\s*href\=\"(.*)\"\s*/g,""));e=s.join(" ")}e=r(e);var h=e.toLowerCase().replace(/[^\w]+/g,"-"),d={text:e,level:t,slug:h},u=/^[\u4e00-\u9fa5]+$/.test(e),f=u?escape(e).replace(/\%/g,""):e.toLowerCase().replace(/[^\w]+/g,"-");i.push(d);var g="';return g+='',g+='',g+=this.atLink(a?this.emoji(n):this.emoji(e)),g+=""},l.pageBreak=function(e){return w.test(e)&&a.pageBreak&&(e='
          '),e},l.paragraph=function(e){var i=/\$\$(.*)\$\$/g.test(e),o=/^\$\$(.*)\$\$$/.test(e),r=o?' class="'+t.classNames.tex+'"':"",n=a.tocm?/^(\[TOC\]|\[TOCM\])$/.test(e):/^\[TOC\]$/.test(e),s=/^\[TOCM\]$/.test(e);e=!o&&i?e.replace(/(\$\$([^\$]*)\$\$)+/g,function(e,i){return''+i.replace(/\$/g,"")+""}):o?e.replace(/\$/g,""):e;var l='
          '+e+"
          ";return n?s?'
          '+l+"

          ":l:w.test(e)?this.pageBreak(e):""+this.atLink(this.emoji(e))+"

          \n"},l.code=function(e,i,o){return"seq"===i||"sequence"===i?'
          '+e+"
          ":"flow"===i?'
          '+e+"
          ":"math"===i||"latex"===i||"katex"===i?'

          '+e+"

          ":s.Renderer.prototype.code.apply(this,arguments)},l.tablecell=function(e,t){var i=t.header?"th":"td",o=t.align?"<"+i+' style="text-align:'+t.align+'">':"<"+i+">";return o+this.atLink(this.emoji(e))+"\n"},l.listitem=function(e){return a.taskList&&/^\s*\[[x\s]\]\s*/.test(e)?(e=e.replace(/^\s*\[\s\]\s*/,' ').replace(/^\s*\[x\]\s*/,' '),'
        • '+this.atLink(this.emoji(e))+"
        • "):"
        • "+this.atLink(this.emoji(e))+"
        • "},l},t.markdownToCRenderer=function(e,t,i,o){var r="",n=0,a=this.classPrefix;o=o||1;for(var s=0,l=e.length;l>s;s++){var c=e[s].text,h=e[s].level;o>h||(r+=h>n?"":n>h?new Array(n-h+2).join("
      • "):"",r+='
      • '+c+"
          ",n=h)}var d=t.find(".markdown-toc");if(d.length<1&&"false"===t.attr("previewContainer")){var u='
          ';u=i?'
          '+u+"
          ":u,t.html(u),d=t.find(".markdown-toc")}return i&&d.wrap('

          '),d.html('
            ').children(".markdown-toc-list").html(r.replace(/\r?\n?\\<\/ul\>/g,"")),d},t.tocDropdownMenu=function(t,i){i=i||"Table of Contents";var o=400,r=t.find("."+this.classPrefix+"toc-menu");return r.each(function(){var t=e(this),r=t.children(".markdown-toc"),n='',a=''+n+i+"",s=r.children("ul"),l=s.find("li");r.append(a),l.first().before("
          • "+i+" "+n+"

          • "),t.mouseover(function(){s.show(),l.each(function(){var t=e(this),i=t.children("ul");if(""===i.html()&&i.remove(),i.length>0&&""!==i.html()){var r=t.children("a").first();r.children(".fa").length<1&&r.append(e(n).css({"float":"right",paddingTop:"4px"}))}t.mouseover(function(){i.css("z-index",o).show(),o+=1}).mouseleave(function(){i.hide()})})}).mouseleave(function(){s.hide()})}),r},t.filterHTMLTags=function(t,i){if("string"!=typeof t&&(t=new String(t)),"string"!=typeof i)return t;for(var o=i.split("|"),r=o[0].split(","),n=o[1],a=0,s=r.length;s>a;a++){var l=r[a];t=t.replace(new RegExp("]*)>([^>]*)","igm"),"")}if("undefined"!=typeof n){var c=/\<(\w+)\s*([^\>]*)\>([^\>]*)\<\/(\w+)\>/gi;t="*"===n?t.replace(c,function(e,t,i,o,r){return"<"+t+">"+o+""}):"on*"===n?t.replace(c,function(t,i,o,r,n){var a=e("<"+i+">"+r+""),s=e(t)[0].attributes,l={};e.each(s,function(e,t){'"'!==t.nodeName&&(l[t.nodeName]=t.nodeValue)}),e.each(l,function(e){0===e.indexOf("on")&&delete l[e]}),a.attr(l);var c="undefined"!=typeof a[1]?e(a[1]).text():"";return a[0].outerHTML+c}):t.replace(c,function(t,i,o,r){var a=n.split(","),s=e(t);return s.html(r),e.each(a,function(e){s.attr(a[e],null)}),s[0].outerHTML})}return t},t.markdownToHTML=function(i,o){var r={gfm:!0,toc:!0,tocm:!1,tocStartLevel:1,tocTitle:"目录",tocDropdown:!1,tocContainer:"",markdown:"",markdownSourceCode:!1,htmlDecode:!1,autoLoadKaTeX:!0,pageBreak:!0,atLink:!0,emailLink:!0,tex:!1,taskList:!1,emoji:!1,flowChart:!1,sequenceDiagram:!1,previewCodeHighlight:!0};t.$marked=marked;var n=e("#"+i),a=n.settings=e.extend(!0,r,o||{}),s=n.find("textarea");s.length<1&&(n.append(""),s=n.find("textarea"));var l=""===a.markdown?s.val():a.markdown,c=[],h={toc:a.toc,tocm:a.tocm,tocStartLevel:a.tocStartLevel,taskList:a.taskList,emoji:a.emoji,tex:a.tex,pageBreak:a.pageBreak,atLink:a.atLink,emailLink:a.emailLink,flowChart:a.flowChart,sequenceDiagram:a.sequenceDiagram,previewCodeHighlight:a.previewCodeHighlight},d={renderer:t.markedRenderer(c,h),gfm:a.gfm,tables:!0,breaks:!0,pedantic:!1,sanitize:a.htmlDecode?!1:!0,smartLists:!0,smartypants:!0};l=new String(l);var u=marked(l,d);u=t.filterHTMLTags(u,a.htmlDecode),a.markdownSourceCode?s.text(l):s.remove(),n.addClass("markdown-body "+this.classPrefix+"html-preview").append(u);var f=""!==a.tocContainer?e(a.tocContainer):n;if(""!==a.tocContainer&&f.attr("previewContainer",!1),a.toc&&(n.tocContainer=this.markdownToCRenderer(c,f,a.tocDropdown,a.tocStartLevel),(a.tocDropdown||n.find("."+this.classPrefix+"toc-menu").length>0)&&this.tocDropdownMenu(n,a.tocTitle),""!==a.tocContainer&&n.find(".editormd-toc-menu, .editormd-markdown-toc").remove()),a.previewCodeHighlight&&(n.find("pre").addClass("prettyprint linenums"),prettyPrint()),t.isIE8||(a.flowChart&&n.find(".flowchart").flowChart(),a.sequenceDiagram&&n.find(".sequence-diagram").sequenceDiagram({theme:"simple"})),a.tex){var g=function(){n.find("."+t.classNames.tex).each(function(){var t=e(this);katex.render(t.html().replace(/</g,"<").replace(/>/g,">"),t[0]),t.find(".katex").css("font-size","1.6em")})};!a.autoLoadKaTeX||t.$katex||t.kaTeXLoaded?g():this.loadKaTeX(function(){t.$katex=katex,t.kaTeXLoaded=!0,g()})}return n.getMarkdown=function(){return s.val()},n},t.themes=["default","dark"],t.previewThemes=["default","dark"],t.editorThemes=["default","3024-day","3024-night","ambiance","ambiance-mobile","base16-dark","base16-light","blackboard","cobalt","eclipse","elegant","erlang-dark","lesser-dark","mbo","mdn-like","midnight","monokai","neat","neo","night","paraiso-dark","paraiso-light","pastel-on-dark","rubyblue","solarized","the-matrix","tomorrow-night-eighties","twilight","vibrant-ink","xq-dark","xq-light"],t.loadPlugins={},t.loadFiles={js:[],css:[],plugin:[]},t.loadPlugin=function(e,i,o){i=i||function(){},this.loadScript(e,function(){t.loadFiles.plugin.push(e),i()},o)},t.loadCSS=function(e,i,o){o=o||"head",i=i||function(){};var r=document.createElement("link");r.type="text/css",r.rel="stylesheet",r.onload=r.onreadystatechange=function(){t.loadFiles.css.push(e),i()},r.href=e+".css","head"===o?document.getElementsByTagName("head")[0].appendChild(r):document.body.appendChild(r)},t.isIE="Microsoft Internet Explorer"==navigator.appName,t.isIE8=t.isIE&&"8."==navigator.appVersion.match(/8./i),t.loadScript=function(e,i,o){o=o||"head",i=i||function(){};var r=null;r=document.createElement("script"),r.id=e.replace(/[\./]+/g,"-"),r.type="text/javascript",r.src=e+".js",t.isIE8?r.onreadystatechange=function(){r.readyState&&("loaded"===r.readyState||"complete"===r.readyState)&&(r.onreadystatechange=null,t.loadFiles.js.push(e),i())}:r.onload=function(){t.loadFiles.js.push(e),i()},"head"===o?document.getElementsByTagName("head")[0].appendChild(r):document.body.appendChild(r)},t.katexURL={css:"//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min",js:"//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min"},t.kaTeXLoaded=!1,t.loadKaTeX=function(e){t.loadCSS(t.katexURL.css,function(){t.loadScript(t.katexURL.js,e||function(){})})},t.lockScreen=function(t){e("html,body").css("overflow",t?"hidden":"")},t.createDialog=function(i){var o={name:"",width:420,height:240,title:"",drag:!0,closed:!0,content:"",mask:!0,maskStyle:{backgroundColor:"#fff",opacity:.1},lockScreen:!0,footer:!0,buttons:!1};i=e.extend(!0,o,i);var r=this,n=this.editor,a=t.classPrefix,s=(new Date).getTime(),l=""===i.name?a+"dialog-"+s:i.name,c=t.mouseOrTouch,h='
            ';""!==i.title&&(h+='
            ",h+=''+i.title+"",h+="
            "),i.closed&&(h+=''),h+='
            '+i.content,(i.footer||"string"==typeof i.footer)&&(h+='"),h+="
            ",h+='
            ',h+='
            ',h+="
            ",n.append(h);var d=n.find("."+l);d.lockScreen=function(t){return i.lockScreen&&(e("html,body").css("overflow",t?"hidden":""),r.resize()),d},d.showMask=function(){return i.mask&&n.find("."+a+"mask").css(i.maskStyle).css("z-index",t.dialogZindex-1).show(),d},d.hideMask=function(){return i.mask&&n.find("."+a+"mask").hide(),d},d.loading=function(e){var t=d.find("."+a+"dialog-mask");return t[e?"show":"hide"](),d},d.lockScreen(!0).showMask(),d.show().css({zIndex:t.dialogZindex,border:t.isIE8?"1px solid #ddd":"",width:"number"==typeof i.width?i.width+"px":i.width,height:"number"==typeof i.height?i.height+"px":i.height});var u=function(){d.css({top:(e(window).height()-d.height())/2+"px",left:(e(window).width()-d.width())/2+"px"})};if(u(),e(window).resize(u),d.children("."+a+"dialog-close").bind(c("click","touchend"),function(){d.hide().lockScreen(!1).hideMask()}),"object"==typeof i.buttons){var f=d.footer=d.find("."+a+"dialog-footer");for(var g in i.buttons){var p=i.buttons[g],m=a+g+"-btn";f.append('"),p[1]=e.proxy(p[1],d),f.children("."+m).bind(c("click","touchend"),p[1])}}if(""!==i.title&&i.drag){var w,v,k=d.children("."+a+"dialog-header");i.mask||k.bind(c("click","touchend"),function(){t.dialogZindex+=2,d.css("z-index",t.dialogZindex)}),k.mousedown(function(e){e=e||window.event,w=e.clientX-parseInt(d[0].style.left),v=e.clientY-parseInt(d[0].style.top),document.onmousemove=y});var b=function(e){e.removeClass(a+"user-unselect").off("selectstart")},x=function(e){e.addClass(a+"user-unselect").on("selectstart",function(e){return!1})},y=function(t){t=t||window.event;var i,o,r=parseInt(d[0].style.left),n=parseInt(d[0].style.top);r>=0?r+d.width()<=e(window).width()?i=t.clientX-w:(i=e(window).width()-d.width(),document.onmousemove=null):(i=0,document.onmousemove=null),n>=0?o=t.clientY-v:(o=0,document.onmousemove=null),document.onselectstart=function(){return!1},x(e("body")),x(d),d[0].style.left=i+"px",d[0].style.top=o+"px"};document.onmouseup=function(){b(e("body")),b(d),document.onselectstart=null,document.onmousemove=null},k.touchDraggable=function(){var t=null,i=function(i){var o=i.originalEvent,r=e(this).parent().position();t={x:o.changedTouches[0].pageX-r.left,y:o.changedTouches[0].pageY-r.top}},o=function(i){i.preventDefault();var o=i.originalEvent;e(this).parent().css({top:o.changedTouches[0].pageY-t.y,left:o.changedTouches[0].pageX-t.x})};this.bind("touchstart",i).bind("touchmove",o)},k.touchDraggable()}return t.dialogZindex+=2,d},t.mouseOrTouch=function(e,t){e=e||"click",t=t||"touchend";var i=e;try{document.createEvent("TouchEvent"),i=t}catch(o){}return i},t.dateFormat=function(e){e=e||"";var t=function(e){return 10>e?"0"+e:e},i=new Date,o=i.getFullYear(),r=o.toString().slice(2,4),n=t(i.getMonth()+1),a=t(i.getDate()),s=i.getDay(),l=t(i.getHours()),c=t(i.getMinutes()),h=t(i.getSeconds()),d=t(i.getMilliseconds()),u="",f=r+"-"+n+"-"+a,g=o+"-"+n+"-"+a,p=l+":"+c+":"+h;switch(e){case"UNIX Time":u=i.getTime();break;case"UTC":u=i.toUTCString();break;case"yy":u=r;break;case"year":case"yyyy":u=o;break;case"month":case"mm":u=n;break;case"cn-week-day":case"cn-wd":var m=["日","一","二","三","四","五","六"];u="星期"+m[s];break;case"week-day":case"wd":var w=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];u=w[s];break;case"day":case"dd":u=a;break;case"hour":case"hh":u=l;break;case"min":case"ii":u=c;break;case"second":case"ss":u=h;break;case"ms":u=d;break;case"yy-mm-dd":u=f;break;case"yyyy-mm-dd":u=g;break;case"yyyy-mm-dd h:i:s ms":case"full + ms":u=g+" "+p+" "+d;break;case"full":case"yyyy-mm-dd h:i:s":default:u=g+" "+p}return u},t}}); \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/editormd.js b/paicoding-ui/src/main/resources/static/editormd/editormd.js new file mode 100644 index 000000000..427bc91dd --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/editormd.js @@ -0,0 +1,4594 @@ +/* + * Editor.md + * + * @file editormd.js + * @version v1.5.0 + * @description Open source online markdown editor. + * @license MIT License + * @author Pandao + * {@link https://github.com/pandao/editor.md} + * @updateTime 2015-06-09 + */ + +;(function(factory) { + "use strict"; + + // CommonJS/Node.js + if (typeof require === "function" && typeof exports === "object" && typeof module === "object") + { + module.exports = factory; + } + else if (typeof define === "function") // AMD/CMD/Sea.js + { + if (define.amd) // for Require.js + { + /* Require.js define replace */ + } + else + { + define(["jquery"], factory); // for Sea.js + } + } + else + { + window.editormd = factory(); + } + +}(function() { + + /* Require.js assignment replace */ + + "use strict"; + + var $ = (typeof (jQuery) !== "undefined") ? jQuery : Zepto; + + if (typeof ($) === "undefined") { + return ; + } + + /** + * editormd + * + * @param {String} id 编辑器的ID + * @param {Object} options 配置选项 Key/Value + * @returns {Object} editormd 返回editormd对象 + */ + + var editormd = function (id, options) { + return new editormd.fn.init(id, options); + }; + + editormd.title = editormd.$name = "Editor.md"; + editormd.version = "1.5.0"; + editormd.homePage = "https://pandao.github.io/editor.md/"; + editormd.classPrefix = "editormd-"; + + editormd.toolbarModes = { + full : [ + "undo", "redo", "|", + "bold", "del", "italic", "quote", "ucwords", "uppercase", "lowercase", "|", + "h1", "h2", "h3", "h4", "h5", "h6", "|", + "list-ul", "list-ol", "hr", "|", + "link", "reference-link", "image", "code", "preformatted-text", "code-block", "table", "datetime", "emoji", "html-entities", "pagebreak", "|", + "goto-line", "watch", "preview", "fullscreen", "clear", "search", "|", + "help", "info" + ], + simple : [ + "undo", "redo", "|", + "bold", "del", "italic", "quote", "uppercase", "lowercase", "|", + "h1", "h2", "h3", "h4", "h5", "h6", "|", + "list-ul", "list-ol", "hr", "|", + "watch", "preview", "fullscreen", "|", + "help", "info" + ], + mini : [ + "undo", "redo", "|", + "watch", "preview", "|", + "help", "info" + ] + }; + + editormd.defaults = { + mode : "gfm", //gfm or markdown + name : "", // Form element name + value : "", // value for CodeMirror, if mode not gfm/markdown + theme : "", // Editor.md self themes, before v1.5.0 is CodeMirror theme, default empty + editorTheme : "default", // Editor area, this is CodeMirror theme at v1.5.0 + previewTheme : "", // Preview area theme, default empty + markdown : "", // Markdown source code + appendMarkdown : "", // if in init textarea value not empty, append markdown to textarea + width : "100%", + height : "100%", + path : "./lib/", // Dependents module file directory + katexURL : {}, + pluginPath : "", // If this empty, default use settings.path + "../plugins/" + delay : 300, // Delay parse markdown to html, Uint : ms + autoLoadModules : true, // Automatic load dependent module files + watch : true, + placeholder : "Enjoy Markdown! coding now...", + gotoLine : true, + codeFold : false, + autoHeight : false, + autoFocus : true, + autoCloseTags : true, + searchReplace : true, + syncScrolling : true, // true | false | "single", default true + readOnly : false, + tabSize : 4, + indentUnit : 4, + lineNumbers : true, + lineWrapping : true, + autoCloseBrackets : true, + showTrailingSpace : true, + matchBrackets : true, + indentWithTabs : true, + styleSelectedText : true, + matchWordHighlight : true, // options: true, false, "onselected" + styleActiveLine : true, // Highlight the current line + dialogLockScreen : true, + dialogShowMask : true, + dialogDraggable : true, + dialogMaskBgColor : "#fff", + dialogMaskOpacity : 0.1, + fontSize : "13px", + saveHTMLToTextarea : false, + disabledKeyMaps : [], + + onload : function() {}, + onresize : function() {}, + onchange : function() {}, + onwatch : null, + onunwatch : null, + onpreviewing : function() {}, + onpreviewed : function() {}, + onfullscreen : function() {}, + onfullscreenExit : function() {}, + onscroll : function() {}, + onpreviewscroll : function() {}, + + imageUpload : false, + imageFormats : ["jpg", "jpeg", "gif", "png", "bmp", "webp"], + imageUploadURL : "", + crossDomainUpload : false, + uploadCallbackURL : "", + + toc : true, // Table of contents + tocm : false, // Using [TOCM], auto create ToC dropdown menu + tocTitle : "", // for ToC dropdown menu btn + tocDropdown : false, + tocContainer : "", + tocStartLevel : 1, // Said from H1 to create ToC + htmlDecode : false, // Open the HTML tag identification + pageBreak : true, // Enable parse page break [========] + atLink : true, // for @link + emailLink : true, // for email address auto link + taskList : false, // Enable Github Flavored Markdown task lists + emoji : false, // :emoji: , Support Github emoji, Twitter Emoji (Twemoji); + // Support FontAwesome icon emoji :fa-xxx: > Using fontAwesome icon web fonts; + // Support Editor.md logo icon emoji :editormd-logo: :editormd-logo-1x: > 1~8x; + tex : false, // TeX(LaTeX), based on KaTeX + flowChart : false, // flowChart.js only support IE9+ + sequenceDiagram : false, // sequenceDiagram.js only support IE9+ + previewCodeHighlight : true, + + toolbar : true, // show/hide toolbar + toolbarAutoFixed : true, // on window scroll auto fixed position + toolbarIcons : "full", + toolbarTitles : {}, + toolbarHandlers : { + ucwords : function() { + return editormd.toolbarHandlers.ucwords; + }, + lowercase : function() { + return editormd.toolbarHandlers.lowercase; + } + }, + toolbarCustomIcons : { // using html tag create toolbar icon, unused default tag. + lowercase : "a", + "ucwords" : "Aa" + }, + toolbarIconsClass : { + undo : "fa-undo", + redo : "fa-repeat", + bold : "fa-bold", + del : "fa-strikethrough", + italic : "fa-italic", + quote : "fa-quote-left", + uppercase : "fa-font", + h1 : editormd.classPrefix + "bold", + h2 : editormd.classPrefix + "bold", + h3 : editormd.classPrefix + "bold", + h4 : editormd.classPrefix + "bold", + h5 : editormd.classPrefix + "bold", + h6 : editormd.classPrefix + "bold", + "list-ul" : "fa-list-ul", + "list-ol" : "fa-list-ol", + hr : "fa-minus", + link : "fa-link", + "reference-link" : "fa-anchor", + image : "fa-picture-o", + code : "fa-code", + "preformatted-text" : "fa-file-code-o", + "code-block" : "fa-file-code-o", + table : "fa-table", + datetime : "fa-clock-o", + emoji : "fa-smile-o", + "html-entities" : "fa-copyright", + pagebreak : "fa-newspaper-o", + "goto-line" : "fa-terminal", // fa-crosshairs + watch : "fa-eye-slash", + unwatch : "fa-eye", + preview : "fa-desktop", + search : "fa-search", + fullscreen : "fa-arrows-alt", + clear : "fa-eraser", + help : "fa-question-circle", + info : "fa-info-circle" + }, + toolbarIconTexts : {}, + + lang : { + name : "zh-cn", + description : "开源在线Markdown编辑器
            Open source online Markdown editor.", + tocTitle : "目录", + toolbar : { + undo : "撤销(Ctrl+Z)", + redo : "重做(Ctrl+Y)", + bold : "粗体", + del : "删除线", + italic : "斜体", + quote : "引用", + ucwords : "将每个单词首字母转成大写", + uppercase : "将所选转换成大写", + lowercase : "将所选转换成小写", + h1 : "标题1", + h2 : "标题2", + h3 : "标题3", + h4 : "标题4", + h5 : "标题5", + h6 : "标题6", + "list-ul" : "无序列表", + "list-ol" : "有序列表", + hr : "横线", + link : "链接", + "reference-link" : "引用链接", + image : "添加图片", + code : "行内代码", + "preformatted-text" : "预格式文本 / 代码块(缩进风格)", + "code-block" : "代码块(多语言风格)", + table : "添加表格", + datetime : "日期时间", + emoji : "Emoji表情", + "html-entities" : "HTML实体字符", + pagebreak : "插入分页符", + "goto-line" : "跳转到行", + watch : "关闭实时预览", + unwatch : "开启实时预览", + preview : "全窗口预览HTML(按 Shift + ESC还原)", + fullscreen : "全屏(按ESC还原)", + clear : "清空", + search : "搜索", + help : "使用帮助", + info : "关于" + editormd.title + }, + buttons : { + enter : "确定", + cancel : "取消", + close : "关闭" + }, + dialog : { + link : { + title : "添加链接", + url : "链接地址", + urlTitle : "链接标题", + urlEmpty : "错误:请填写链接地址。" + }, + referenceLink : { + title : "添加引用链接", + name : "引用名称", + url : "链接地址", + urlId : "链接ID", + urlTitle : "链接标题", + nameEmpty: "错误:引用链接的名称不能为空。", + idEmpty : "错误:请填写引用链接的ID。", + urlEmpty : "错误:请填写引用链接的URL地址。" + }, + image : { + title : "添加图片", + url : "图片地址", + link : "图片链接", + alt : "图片描述", + uploadButton : "本地上传", + imageURLEmpty : "错误:图片地址不能为空。", + uploadFileEmpty : "错误:上传的图片不能为空。", + formatNotAllowed : "错误:只允许上传图片文件,允许上传的图片文件格式有:" + }, + preformattedText : { + title : "添加预格式文本或代码块", + emptyAlert : "错误:请填写预格式文本或代码的内容。" + }, + codeBlock : { + title : "添加代码块", + selectLabel : "代码语言:", + selectDefaultText : "请选择代码语言", + otherLanguage : "其他语言", + unselectedLanguageAlert : "错误:请选择代码所属的语言类型。", + codeEmptyAlert : "错误:请填写代码内容。" + }, + htmlEntities : { + title : "HTML 实体字符" + }, + help : { + title : "使用帮助" + } + } + } + }; + + editormd.classNames = { + tex : editormd.classPrefix + "tex" + }; + + editormd.dialogZindex = 99999; + + editormd.$katex = null; + editormd.$marked = null; + editormd.$CodeMirror = null; + editormd.$prettyPrint = null; + + var timer, flowchartTimer; + + editormd.prototype = editormd.fn = { + state : { + watching : false, + loaded : false, + preview : false, + fullscreen : false + }, + + /** + * 构造函数/实例初始化 + * Constructor / instance initialization + * + * @param {String} id 编辑器的ID + * @param {Object} [options={}] 配置选项 Key/Value + * @returns {editormd} 返回editormd的实例对象 + */ + + init : function (id, options) { + + options = options || {}; + + if (typeof id === "object") + { + options = id; + } + + var _this = this; + var classPrefix = this.classPrefix = editormd.classPrefix; + var settings = this.settings = $.extend(true, {}, editormd.defaults, options); + + id = (typeof id === "object") ? settings.id : id; + + var editor = this.editor = $("#" + id); + + this.id = id; + this.lang = settings.lang; + + var classNames = this.classNames = { + textarea : { + html : classPrefix + "html-textarea", + markdown : classPrefix + "markdown-textarea" + } + }; + + settings.pluginPath = (settings.pluginPath === "") ? settings.path + "../plugins/" : settings.pluginPath; + + this.state.watching = (settings.watch) ? true : false; + + if ( !editor.hasClass("editormd") ) { + editor.addClass("editormd"); + } + + editor.css({ + width : (typeof settings.width === "number") ? settings.width + "px" : settings.width, + height : (typeof settings.height === "number") ? settings.height + "px" : settings.height + }); + + if (settings.autoHeight) + { + editor.css("height", "auto"); + } + + var markdownTextarea = this.markdownTextarea = editor.children("textarea"); + + if (markdownTextarea.length < 1) + { + editor.append(""); + markdownTextarea = this.markdownTextarea = editor.children("textarea"); + } + + markdownTextarea.addClass(classNames.textarea.markdown).attr("placeholder", settings.placeholder); + + if (typeof markdownTextarea.attr("name") === "undefined" || markdownTextarea.attr("name") === "") + { + markdownTextarea.attr("name", (settings.name !== "") ? settings.name : id + "-markdown-doc"); + } + + var appendElements = [ + (!settings.readOnly) ? "" : "", + ( (settings.saveHTMLToTextarea) ? "" : "" ), + "
            ", + "
            ", + "
            " + ].join("\n"); + + editor.append(appendElements).addClass(classPrefix + "vertical"); + + if (settings.theme !== "") + { + editor.addClass(classPrefix + "theme-" + settings.theme); + } + + this.mask = editor.children("." + classPrefix + "mask"); + this.containerMask = editor.children("." + classPrefix + "container-mask"); + + if (settings.markdown !== "") + { + markdownTextarea.val(settings.markdown); + } + + if (settings.appendMarkdown !== "") + { + markdownTextarea.val(markdownTextarea.val() + settings.appendMarkdown); + } + + this.htmlTextarea = editor.children("." + classNames.textarea.html); + this.preview = editor.children("." + classPrefix + "preview"); + this.previewContainer = this.preview.children("." + classPrefix + "preview-container"); + + if (settings.previewTheme !== "") + { + this.preview.addClass(classPrefix + "preview-theme-" + settings.previewTheme); + } + + if (typeof define === "function" && define.amd) + { + if (typeof katex !== "undefined") + { + editormd.$katex = katex; + } + + if (settings.searchReplace && !settings.readOnly) + { + editormd.loadCSS(settings.path + "codemirror/addon/dialog/dialog"); + editormd.loadCSS(settings.path + "codemirror/addon/search/matchesonscrollbar"); + } + } + + if ((typeof define === "function" && define.amd) || !settings.autoLoadModules) + { + if (typeof CodeMirror !== "undefined") { + editormd.$CodeMirror = CodeMirror; + } + + if (typeof marked !== "undefined") { + editormd.$marked = marked; + } + + this.setCodeMirror().setToolbar().loadedDisplay(); + } + else + { + this.loadQueues(); + } + + return this; + }, + + /** + * 所需组件加载队列 + * Required components loading queue + * + * @returns {editormd} 返回editormd的实例对象 + */ + + loadQueues : function() { + var _this = this; + var settings = this.settings; + var loadPath = settings.path; + + var loadFlowChartOrSequenceDiagram = function() { + + if (editormd.isIE8) + { + _this.loadedDisplay(); + + return ; + } + + if (settings.flowChart || settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "raphael.min", function() { + + editormd.loadScript(loadPath + "underscore.min", function() { + + if (!settings.flowChart && settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "sequence-diagram.min", function() { + _this.loadedDisplay(); + }); + } + else if (settings.flowChart && !settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "flowchart.min", function() { + editormd.loadScript(loadPath + "jquery.flowchart.min", function() { + _this.loadedDisplay(); + }); + }); + } + else if (settings.flowChart && settings.sequenceDiagram) + { + editormd.loadScript(loadPath + "flowchart.min", function() { + editormd.loadScript(loadPath + "jquery.flowchart.min", function() { + editormd.loadScript(loadPath + "sequence-diagram.min", function() { + _this.loadedDisplay(); + }); + }); + }); + } + }); + + }); + } + else + { + _this.loadedDisplay(); + } + }; + + editormd.loadCSS(loadPath + "codemirror/codemirror.min"); + + if (settings.searchReplace && !settings.readOnly) + { + editormd.loadCSS(loadPath + "codemirror/addon/dialog/dialog"); + editormd.loadCSS(loadPath + "codemirror/addon/search/matchesonscrollbar"); + } + + if (settings.codeFold) + { + editormd.loadCSS(loadPath + "codemirror/addon/fold/foldgutter"); + } + + editormd.loadScript(loadPath + "codemirror/codemirror.min", function() { + editormd.$CodeMirror = CodeMirror; + + editormd.loadScript(loadPath + "codemirror/modes.min", function() { + + editormd.loadScript(loadPath + "codemirror/addons.min", function() { + + _this.setCodeMirror(); + + if (settings.mode !== "gfm" && settings.mode !== "markdown") + { + _this.loadedDisplay(); + + return false; + } + + _this.setToolbar(); + + editormd.loadScript(loadPath + "marked.min", function() { + + editormd.$marked = marked; + + if (settings.previewCodeHighlight) + { + editormd.loadScript(loadPath + "prettify.min", function() { + loadFlowChartOrSequenceDiagram(); + }); + } + else + { + loadFlowChartOrSequenceDiagram(); + } + }); + + }); + + }); + + }); + + return this; + }, + + /** + * 设置 Editor.md 的整体主题,主要是工具栏 + * Setting Editor.md theme + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setTheme : function(theme) { + var editor = this.editor; + var oldTheme = this.settings.theme; + var themePrefix = this.classPrefix + "theme-"; + + editor.removeClass(themePrefix + oldTheme).addClass(themePrefix + theme); + + this.settings.theme = theme; + + return this; + }, + + /** + * 设置 CodeMirror(编辑区)的主题 + * Setting CodeMirror (Editor area) theme + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setEditorTheme : function(theme) { + var settings = this.settings; + settings.editorTheme = theme; + + if (theme !== "default") + { + editormd.loadCSS(settings.path + "codemirror/theme/" + settings.editorTheme); + } + + this.cm.setOption("theme", theme); + + return this; + }, + + /** + * setEditorTheme() 的别名 + * setEditorTheme() alias + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setCodeMirrorTheme : function (theme) { + this.setEditorTheme(theme); + + return this; + }, + + /** + * 设置 Editor.md 的主题 + * Setting Editor.md theme + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setPreviewTheme : function(theme) { + var preview = this.preview; + var oldTheme = this.settings.previewTheme; + var themePrefix = this.classPrefix + "preview-theme-"; + + preview.removeClass(themePrefix + oldTheme).addClass(themePrefix + theme); + + this.settings.previewTheme = theme; + + return this; + }, + + /** + * 配置和初始化CodeMirror组件 + * CodeMirror initialization + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setCodeMirror : function() { + var settings = this.settings; + var editor = this.editor; + + if (settings.editorTheme !== "default") + { + editormd.loadCSS(settings.path + "codemirror/theme/" + settings.editorTheme); + } + + var codeMirrorConfig = { + mode : settings.mode, + theme : settings.editorTheme, + tabSize : settings.tabSize, + dragDrop : false, + autofocus : settings.autoFocus, + autoCloseTags : settings.autoCloseTags, + readOnly : (settings.readOnly) ? "nocursor" : false, + indentUnit : settings.indentUnit, + lineNumbers : settings.lineNumbers, + lineWrapping : settings.lineWrapping, + extraKeys : { + "Ctrl-Q": function(cm) { + cm.foldCode(cm.getCursor()); + } + }, + foldGutter : settings.codeFold, + gutters : ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], + matchBrackets : settings.matchBrackets, + indentWithTabs : settings.indentWithTabs, + styleActiveLine : settings.styleActiveLine, + styleSelectedText : settings.styleSelectedText, + autoCloseBrackets : settings.autoCloseBrackets, + showTrailingSpace : settings.showTrailingSpace, + highlightSelectionMatches : ( (!settings.matchWordHighlight) ? false : { showToken: (settings.matchWordHighlight === "onselected") ? false : /\w/ } ) + }; + + this.codeEditor = this.cm = editormd.$CodeMirror.fromTextArea(this.markdownTextarea[0], codeMirrorConfig); + this.codeMirror = this.cmElement = editor.children(".CodeMirror"); + + if (settings.value !== "") + { + this.cm.setValue(settings.value); + } + + this.codeMirror.css({ + fontSize : settings.fontSize, + width : (!settings.watch) ? "100%" : "50%" + }); + + if (settings.autoHeight) + { + this.codeMirror.css("height", "auto"); + this.cm.setOption("viewportMargin", Infinity); + } + + if (!settings.lineNumbers) + { + this.codeMirror.find(".CodeMirror-gutters").css("border-right", "none"); + } + + return this; + }, + + /** + * 获取CodeMirror的配置选项 + * Get CodeMirror setting options + * + * @returns {Mixed} return CodeMirror setting option value + */ + + getCodeMirrorOption : function(key) { + return this.cm.getOption(key); + }, + + /** + * 配置和重配置CodeMirror的选项 + * CodeMirror setting options / resettings + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setCodeMirrorOption : function(key, value) { + + this.cm.setOption(key, value); + + return this; + }, + + /** + * 添加 CodeMirror 键盘快捷键 + * Add CodeMirror keyboard shortcuts key map + * + * @returns {editormd} 返回editormd的实例对象 + */ + + addKeyMap : function(map, bottom) { + this.cm.addKeyMap(map, bottom); + + return this; + }, + + /** + * 移除 CodeMirror 键盘快捷键 + * Remove CodeMirror keyboard shortcuts key map + * + * @returns {editormd} 返回editormd的实例对象 + */ + + removeKeyMap : function(map) { + this.cm.removeKeyMap(map); + + return this; + }, + + /** + * 跳转到指定的行 + * Goto CodeMirror line + * + * @param {String|Intiger} line line number or "first"|"last" + * @returns {editormd} 返回editormd的实例对象 + */ + + gotoLine : function (line) { + + var settings = this.settings; + + if (!settings.gotoLine) + { + return this; + } + + var cm = this.cm; + var editor = this.editor; + var count = cm.lineCount(); + var preview = this.preview; + + if (typeof line === "string") + { + if(line === "last") + { + line = count; + } + + if (line === "first") + { + line = 1; + } + } + + if (typeof line !== "number") + { + alert("Error: The line number must be an integer."); + return this; + } + + line = parseInt(line) - 1; + + if (line > count) + { + alert("Error: The line number range 1-" + count); + + return this; + } + + cm.setCursor( {line : line, ch : 0} ); + + var scrollInfo = cm.getScrollInfo(); + var clientHeight = scrollInfo.clientHeight; + var coords = cm.charCoords({line : line, ch : 0}, "local"); + + cm.scrollTo(null, (coords.top + coords.bottom - clientHeight) / 2); + + if (settings.watch) + { + var cmScroll = this.codeMirror.find(".CodeMirror-scroll")[0]; + var height = $(cmScroll).height(); + var scrollTop = cmScroll.scrollTop; + var percent = (scrollTop / cmScroll.scrollHeight); + + if (scrollTop === 0) + { + preview.scrollTop(0); + } + else if (scrollTop + height >= cmScroll.scrollHeight - 16) + { + preview.scrollTop(preview[0].scrollHeight); + } + else + { + preview.scrollTop(preview[0].scrollHeight * percent); + } + } + + cm.focus(); + + return this; + }, + + /** + * 扩展当前实例对象,可同时设置多个或者只设置一个 + * Extend editormd instance object, can mutil setting. + * + * @returns {editormd} this(editormd instance object.) + */ + + extend : function() { + if (typeof arguments[1] !== "undefined") + { + if (typeof arguments[1] === "function") + { + arguments[1] = $.proxy(arguments[1], this); + } + + this[arguments[0]] = arguments[1]; + } + + if (typeof arguments[0] === "object" && typeof arguments[0].length === "undefined") + { + $.extend(true, this, arguments[0]); + } + + return this; + }, + + /** + * 设置或扩展当前实例对象,单个设置 + * Extend editormd instance object, one by one + * + * @param {String|Object} key option key + * @param {String|Object} value option value + * @returns {editormd} this(editormd instance object.) + */ + + set : function (key, value) { + + if (typeof value !== "undefined" && typeof value === "function") + { + value = $.proxy(value, this); + } + + this[key] = value; + + return this; + }, + + /** + * 重新配置 + * Resetting editor options + * + * @param {String|Object} key option key + * @param {String|Object} value option value + * @returns {editormd} this(editormd instance object.) + */ + + config : function(key, value) { + var settings = this.settings; + + if (typeof key === "object") + { + settings = $.extend(true, settings, key); + } + + if (typeof key === "string") + { + settings[key] = value; + } + + this.settings = settings; + this.recreate(); + + return this; + }, + + /** + * 注册事件处理方法 + * Bind editor event handle + * + * @param {String} eventType event type + * @param {Function} callback 回调函数 + * @returns {editormd} this(editormd instance object.) + */ + + on : function(eventType, callback) { + var settings = this.settings; + + if (typeof settings["on" + eventType] !== "undefined") + { + settings["on" + eventType] = $.proxy(callback, this); + } + + return this; + }, + + /** + * 解除事件处理方法 + * Unbind editor event handle + * + * @param {String} eventType event type + * @returns {editormd} this(editormd instance object.) + */ + + off : function(eventType) { + var settings = this.settings; + + if (typeof settings["on" + eventType] !== "undefined") + { + settings["on" + eventType] = function(){}; + } + + return this; + }, + + /** + * 显示工具栏 + * Display toolbar + * + * @param {Function} [callback=function(){}] 回调函数 + * @returns {editormd} 返回editormd的实例对象 + */ + + showToolbar : function(callback) { + var settings = this.settings; + + if(settings.readOnly) { + return this; + } + + if (settings.toolbar && (this.toolbar.length < 1 || this.toolbar.find("." + this.classPrefix + "menu").html() === "") ) + { + this.setToolbar(); + } + + settings.toolbar = true; + + this.toolbar.show(); + this.resize(); + + $.proxy(callback || function(){}, this)(); + + return this; + }, + + /** + * 隐藏工具栏 + * Hide toolbar + * + * @param {Function} [callback=function(){}] 回调函数 + * @returns {editormd} this(editormd instance object.) + */ + + hideToolbar : function(callback) { + var settings = this.settings; + + settings.toolbar = false; + this.toolbar.hide(); + this.resize(); + + $.proxy(callback || function(){}, this)(); + + return this; + }, + + /** + * 页面滚动时工具栏的固定定位 + * Set toolbar in window scroll auto fixed position + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setToolbarAutoFixed : function(fixed) { + + var state = this.state; + var editor = this.editor; + var toolbar = this.toolbar; + var settings = this.settings; + + if (typeof fixed !== "undefined") + { + settings.toolbarAutoFixed = fixed; + } + + var autoFixedHandle = function(){ + var $window = $(window); + var top = $window.scrollTop(); + + if (!settings.toolbarAutoFixed) + { + return false; + } + + if (top - editor.offset().top > 10 && top < editor.height()) + { + toolbar.css({ + position : "fixed", + width : editor.width() + "px", + left : ($window.width() - editor.width()) / 2 + "px" + }); + } + else + { + toolbar.css({ + position : "absolute", + width : "100%", + left : 0 + }); + } + }; + + if (!state.fullscreen && !state.preview && settings.toolbar && settings.toolbarAutoFixed) + { + $(window).bind("scroll", autoFixedHandle); + } + + return this; + }, + + /** + * 配置和初始化工具栏 + * Set toolbar and Initialization + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setToolbar : function() { + var settings = this.settings; + + if(settings.readOnly) { + return this; + } + + var editor = this.editor; + var preview = this.preview; + var classPrefix = this.classPrefix; + + var toolbar = this.toolbar = editor.children("." + classPrefix + "toolbar"); + + if (settings.toolbar && toolbar.length < 1) + { + var toolbarHTML = "
              "; + + editor.append(toolbarHTML); + toolbar = this.toolbar = editor.children("." + classPrefix + "toolbar"); + } + + if (!settings.toolbar) + { + toolbar.hide(); + + return this; + } + + toolbar.show(); + + var icons = (typeof settings.toolbarIcons === "function") ? settings.toolbarIcons() + : ((typeof settings.toolbarIcons === "string") ? editormd.toolbarModes[settings.toolbarIcons] : settings.toolbarIcons); + + var toolbarMenu = toolbar.find("." + this.classPrefix + "menu"), menu = ""; + var pullRight = false; + + for (var i = 0, len = icons.length; i < len; i++) + { + var name = icons[i]; + + if (name === "||") + { + pullRight = true; + } + else if (name === "|") + { + menu += "
            • |
            • "; + } + else + { + var isHeader = (/h(\d)/.test(name)); + var index = name; + + if (name === "watch" && !settings.watch) { + index = "unwatch"; + } + + var title = settings.lang.toolbar[index]; + var iconTexts = settings.toolbarIconTexts[index]; + var iconClass = settings.toolbarIconsClass[index]; + + title = (typeof title === "undefined") ? "" : title; + iconTexts = (typeof iconTexts === "undefined") ? "" : iconTexts; + iconClass = (typeof iconClass === "undefined") ? "" : iconClass; + + var menuItem = pullRight ? "
            • " : "
            • "; + + if (typeof settings.toolbarCustomIcons[name] !== "undefined" && typeof settings.toolbarCustomIcons[name] !== "function") + { + menuItem += settings.toolbarCustomIcons[name]; + } + else + { + menuItem += ""; + menuItem += ""+((isHeader) ? name.toUpperCase() : ( (iconClass === "") ? iconTexts : "") ) + ""; + menuItem += ""; + } + + menuItem += "
            • "; + + menu = pullRight ? menuItem + menu : menu + menuItem; + } + } + + toolbarMenu.html(menu); + + toolbarMenu.find("[title=\"Lowercase\"]").attr("title", settings.lang.toolbar.lowercase); + toolbarMenu.find("[title=\"ucwords\"]").attr("title", settings.lang.toolbar.ucwords); + + this.setToolbarHandler(); + this.setToolbarAutoFixed(); + + return this; + }, + + /** + * 工具栏图标事件处理对象序列 + * Get toolbar icons event handlers + * + * @param {Object} cm CodeMirror的实例对象 + * @param {String} name 要获取的事件处理器名称 + * @returns {Object} 返回处理对象序列 + */ + + dialogLockScreen : function() { + $.proxy(editormd.dialogLockScreen, this)(); + + return this; + }, + + dialogShowMask : function(dialog) { + $.proxy(editormd.dialogShowMask, this)(dialog); + + return this; + }, + + getToolbarHandles : function(name) { + var toolbarHandlers = this.toolbarHandlers = editormd.toolbarHandlers; + + return (name && typeof toolbarIconHandlers[name] !== "undefined") ? toolbarHandlers[name] : toolbarHandlers; + }, + + /** + * 工具栏图标事件处理器 + * Bind toolbar icons event handle + * + * @returns {editormd} 返回editormd的实例对象 + */ + + setToolbarHandler : function() { + var _this = this; + var settings = this.settings; + + if (!settings.toolbar || settings.readOnly) { + return this; + } + + var toolbar = this.toolbar; + var cm = this.cm; + var classPrefix = this.classPrefix; + var toolbarIcons = this.toolbarIcons = toolbar.find("." + classPrefix + "menu > li > a"); + var toolbarIconHandlers = this.getToolbarHandles(); + + toolbarIcons.bind(editormd.mouseOrTouch("click", "touchend"), function(event) { + + var icon = $(this).children(".fa"); + var name = icon.attr("name"); + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (name === "") { + return ; + } + + _this.activeIcon = icon; + + if (typeof toolbarIconHandlers[name] !== "undefined") + { + $.proxy(toolbarIconHandlers[name], _this)(cm); + } + else + { + if (typeof settings.toolbarHandlers[name] !== "undefined") + { + $.proxy(settings.toolbarHandlers[name], _this)(cm, icon, cursor, selection); + } + } + + if (name !== "link" && name !== "reference-link" && name !== "image" && name !== "code-block" && + name !== "preformatted-text" && name !== "watch" && name !== "preview" && name !== "search" && name !== "fullscreen" && name !== "info") + { + cm.focus(); + } + + return false; + + }); + + return this; + }, + + /** + * 动态创建对话框 + * Creating custom dialogs + * + * @param {Object} options 配置项键值对 Key/Value + * @returns {dialog} 返回创建的dialog的jQuery实例对象 + */ + + createDialog : function(options) { + return $.proxy(editormd.createDialog, this)(options); + }, + + /** + * 创建关于Editor.md的对话框 + * Create about Editor.md dialog + * + * @returns {editormd} 返回editormd的实例对象 + */ + + createInfoDialog : function() { + var _this = this; + var editor = this.editor; + var classPrefix = this.classPrefix; + + var infoDialogHTML = [ + "
              ", + "
              ", + "

              " + editormd.title + "v" + editormd.version + "

              ", + "

              " + this.lang.description + "

              ", + "

              " + editormd.homePage + "

              ", + "

              Copyright © 2015 Pandao, The MIT License.

              ", + "
              ", + "", + "
              " + ].join("\n"); + + editor.append(infoDialogHTML); + + var infoDialog = this.infoDialog = editor.children("." + classPrefix + "dialog-info"); + + infoDialog.find("." + classPrefix + "dialog-close").bind(editormd.mouseOrTouch("click", "touchend"), function() { + _this.hideInfoDialog(); + }); + + infoDialog.css("border", (editormd.isIE8) ? "1px solid #ddd" : "").css("z-index", editormd.dialogZindex).show(); + + this.infoDialogPosition(); + + return this; + }, + + /** + * 关于Editor.md对话居中定位 + * Editor.md dialog position handle + * + * @returns {editormd} 返回editormd的实例对象 + */ + + infoDialogPosition : function() { + var infoDialog = this.infoDialog; + + var _infoDialogPosition = function() { + infoDialog.css({ + top : ($(window).height() - infoDialog.height()) / 2 + "px", + left : ($(window).width() - infoDialog.width()) / 2 + "px" + }); + }; + + _infoDialogPosition(); + + $(window).resize(_infoDialogPosition); + + return this; + }, + + /** + * 显示关于Editor.md + * Display about Editor.md dialog + * + * @returns {editormd} 返回editormd的实例对象 + */ + + showInfoDialog : function() { + + $("html,body").css("overflow-x", "hidden"); + + var _this = this; + var editor = this.editor; + var settings = this.settings; + var infoDialog = this.infoDialog = editor.children("." + this.classPrefix + "dialog-info"); + + if (infoDialog.length < 1) + { + this.createInfoDialog(); + } + + this.lockScreen(true); + + this.mask.css({ + opacity : settings.dialogMaskOpacity, + backgroundColor : settings.dialogMaskBgColor + }).show(); + + infoDialog.css("z-index", editormd.dialogZindex).show(); + + this.infoDialogPosition(); + + return this; + }, + + /** + * 隐藏关于Editor.md + * Hide about Editor.md dialog + * + * @returns {editormd} 返回editormd的实例对象 + */ + + hideInfoDialog : function() { + $("html,body").css("overflow-x", ""); + this.infoDialog.hide(); + this.mask.hide(); + this.lockScreen(false); + + return this; + }, + + /** + * 锁屏 + * lock screen + * + * @param {Boolean} lock Boolean 布尔值,是否锁屏 + * @returns {editormd} 返回editormd的实例对象 + */ + + lockScreen : function(lock) { + editormd.lockScreen(lock); + this.resize(); + + return this; + }, + + /** + * 编辑器界面重建,用于动态语言包或模块加载等 + * Recreate editor + * + * @returns {editormd} 返回editormd的实例对象 + */ + + recreate : function() { + var _this = this; + var editor = this.editor; + var settings = this.settings; + + this.codeMirror.remove(); + + this.setCodeMirror(); + + if (!settings.readOnly) + { + if (editor.find(".editormd-dialog").length > 0) { + editor.find(".editormd-dialog").remove(); + } + + if (settings.toolbar) + { + this.getToolbarHandles(); + this.setToolbar(); + } + } + + this.loadedDisplay(true); + + return this; + }, + + /** + * 高亮预览HTML的pre代码部分 + * highlight of preview codes + * + * @returns {editormd} 返回editormd的实例对象 + */ + + previewCodeHighlight : function() { + var settings = this.settings; + var previewContainer = this.previewContainer; + + if (settings.previewCodeHighlight) + { + previewContainer.find("pre").addClass("prettyprint linenums"); + + if (typeof prettyPrint !== "undefined") + { + prettyPrint(); + } + } + + return this; + }, + + /** + * 解析TeX(KaTeX)科学公式 + * TeX(KaTeX) Renderer + * + * @returns {editormd} 返回editormd的实例对象 + */ + + katexRender : function() { + + this.previewContainer.find("." + editormd.classNames.tex).each(function(){ + var tex = $(this); + editormd.$katex.render(tex.text(), tex[0]); + }); + + // 块内的已经渲染了 CSS 所以可以找到,但是行内的则不行 + // 行内的需要特殊处理 + katexRender(this.previewContainer[0]); + + return this; + }, + + /** + * 解析和渲染流程图及时序图 + * FlowChart and SequenceDiagram Renderer + * + * @returns {editormd} 返回editormd的实例对象 + */ + + flowChartAndSequenceDiagramRender : function() { + var $this = this; + var settings = this.settings; + var previewContainer = this.previewContainer; + + if (editormd.isIE8) { + return this; + } + + if (settings.flowChart) { + if (flowchartTimer === null) { + return this; + } + + previewContainer.find(".flowchart").flowChart(); + } + + if (settings.sequenceDiagram) { + previewContainer.find(".sequence-diagram").sequenceDiagram({theme: "simple"}); + } + + var preview = $this.preview; + var codeMirror = $this.codeMirror; + var codeView = codeMirror.find(".CodeMirror-scroll"); + + var height = codeView.height(); + var scrollTop = codeView.scrollTop(); + var percent = (scrollTop / codeView[0].scrollHeight); + var tocHeight = 0; + + preview.find(".markdown-toc-list").each(function(){ + tocHeight += $(this).height(); + }); + + var tocMenuHeight = preview.find(".editormd-toc-menu").height(); + tocMenuHeight = (!tocMenuHeight) ? 0 : tocMenuHeight; + + if (scrollTop === 0) + { + preview.scrollTop(0); + } + else if (scrollTop + height >= codeView[0].scrollHeight - 16) + { + preview.scrollTop(preview[0].scrollHeight); + } + else + { + preview.scrollTop((preview[0].scrollHeight + tocHeight + tocMenuHeight) * percent); + } + + return this; + }, + + /** + * 注册键盘快捷键处理 + * Register CodeMirror keyMaps (keyboard shortcuts). + * + * @param {Object} keyMap KeyMap key/value {"(Ctrl/Shift/Alt)-Key" : function(){}} + * @returns {editormd} return this + */ + + registerKeyMaps : function(keyMap) { + + var _this = this; + var cm = this.cm; + var settings = this.settings; + var toolbarHandlers = editormd.toolbarHandlers; + var disabledKeyMaps = settings.disabledKeyMaps; + + keyMap = keyMap || null; + + if (keyMap) + { + for (var i in keyMap) + { + if ($.inArray(i, disabledKeyMaps) < 0) + { + var map = {}; + map[i] = keyMap[i]; + + cm.addKeyMap(keyMap); + } + } + } + else + { + for (var k in editormd.keyMaps) + { + var _keyMap = editormd.keyMaps[k]; + var handle = (typeof _keyMap === "string") ? $.proxy(toolbarHandlers[_keyMap], _this) : $.proxy(_keyMap, _this); + + if ($.inArray(k, ["F9", "F10", "F11"]) < 0 && $.inArray(k, disabledKeyMaps) < 0) + { + var _map = {}; + _map[k] = handle; + + cm.addKeyMap(_map); + } + } + + $(window).keydown(function(event) { + + var keymaps = { + "120" : "F9", + "121" : "F10", + "122" : "F11" + }; + + if ( $.inArray(keymaps[event.keyCode], disabledKeyMaps) < 0 ) + { + switch (event.keyCode) + { + case 120: + $.proxy(toolbarHandlers["watch"], _this)(); + return false; + break; + + case 121: + $.proxy(toolbarHandlers["preview"], _this)(); + return false; + break; + + case 122: + $.proxy(toolbarHandlers["fullscreen"], _this)(); + return false; + break; + + default: + break; + } + } + }); + } + + return this; + }, + + /** + * 绑定同步滚动 + * + * @returns {editormd} return this + */ + + bindScrollEvent : function() { + + var _this = this; + var preview = this.preview; + var settings = this.settings; + var codeMirror = this.codeMirror; + var mouseOrTouch = editormd.mouseOrTouch; + + if (!settings.syncScrolling) { + return this; + } + + var cmBindScroll = function() { + codeMirror.find(".CodeMirror-scroll").bind(mouseOrTouch("scroll", "touchmove"), function(event) { + var height = $(this).height(); + var scrollTop = $(this).scrollTop(); + var percent = (scrollTop / $(this)[0].scrollHeight); + + var tocHeight = 0; + + preview.find(".markdown-toc-list").each(function(){ + tocHeight += $(this).height(); + }); + + var tocMenuHeight = preview.find(".editormd-toc-menu").height(); + tocMenuHeight = (!tocMenuHeight) ? 0 : tocMenuHeight; + + if (scrollTop === 0) + { + preview.scrollTop(0); + } + else if (scrollTop + height >= $(this)[0].scrollHeight - 16) + { + preview.scrollTop(preview[0].scrollHeight); + } + else + { + preview.scrollTop((preview[0].scrollHeight + tocHeight + tocMenuHeight) * percent); + } + + $.proxy(settings.onscroll, _this)(event); + }); + }; + + var cmUnbindScroll = function() { + codeMirror.find(".CodeMirror-scroll").unbind(mouseOrTouch("scroll", "touchmove")); + }; + + var previewBindScroll = function() { + + preview.bind(mouseOrTouch("scroll", "touchmove"), function(event) { + var height = $(this).height(); + var scrollTop = $(this).scrollTop(); + var percent = (scrollTop / $(this)[0].scrollHeight); + var codeView = codeMirror.find(".CodeMirror-scroll"); + + if(scrollTop === 0) + { + codeView.scrollTop(0); + } + else if (scrollTop + height >= $(this)[0].scrollHeight) + { + codeView.scrollTop(codeView[0].scrollHeight); + } + else + { + codeView.scrollTop(codeView[0].scrollHeight * percent); + } + + $.proxy(settings.onpreviewscroll, _this)(event); + }); + + }; + + var previewUnbindScroll = function() { + preview.unbind(mouseOrTouch("scroll", "touchmove")); + }; + + codeMirror.bind({ + mouseover : cmBindScroll, + mouseout : cmUnbindScroll, + touchstart : cmBindScroll, + touchend : cmUnbindScroll + }); + + if (settings.syncScrolling === "single") { + return this; + } + + preview.bind({ + mouseover : previewBindScroll, + mouseout : previewUnbindScroll, + touchstart : previewBindScroll, + touchend : previewUnbindScroll + }); + + return this; + }, + + bindChangeEvent : function() { + + var _this = this; + var cm = this.cm; + var settings = this.settings; + + if (!settings.syncScrolling) { + return this; + } + + cm.on("change", function(_cm, changeObj) { + + if (settings.watch) + { + _this.previewContainer.css("padding", settings.autoHeight ? "20px 20px 50px 40px" : "20px"); + } + + timer = setTimeout(function() { + clearTimeout(timer); + _this.save(); + timer = null; + }, settings.delay); + }); + + return this; + }, + + /** + * 加载队列完成之后的显示处理 + * Display handle of the module queues loaded after. + * + * @param {Boolean} recreate 是否为重建编辑器 + * @returns {editormd} 返回editormd的实例对象 + */ + + loadedDisplay : function(recreate) { + + recreate = recreate || false; + + var _this = this; + var editor = this.editor; + var preview = this.preview; + var settings = this.settings; + + this.containerMask.hide(); + + this.save(); + + if (settings.watch) { + preview.show(); + } + + editor.data("oldWidth", editor.width()).data("oldHeight", editor.height()); // 为了兼容Zepto + + this.resize(); + this.registerKeyMaps(); + + $(window).resize(function(){ + _this.resize(); + }); + + this.bindScrollEvent().bindChangeEvent(); + + if (!recreate) + { + $.proxy(settings.onload, this)(); + } + + this.state.loaded = true; + + return this; + }, + + /** + * 设置编辑器的宽度 + * Set editor width + * + * @param {Number|String} width 编辑器宽度值 + * @returns {editormd} 返回editormd的实例对象 + */ + + width : function(width) { + + this.editor.css("width", (typeof width === "number") ? width + "px" : width); + this.resize(); + + return this; + }, + + /** + * 设置编辑器的高度 + * Set editor height + * + * @param {Number|String} height 编辑器高度值 + * @returns {editormd} 返回editormd的实例对象 + */ + + height : function(height) { + + this.editor.css("height", (typeof height === "number") ? height + "px" : height); + this.resize(); + + return this; + }, + + /** + * 调整编辑器的尺寸和布局 + * Resize editor layout + * + * @param {Number|String} [width=null] 编辑器宽度值 + * @param {Number|String} [height=null] 编辑器高度值 + * @returns {editormd} 返回editormd的实例对象 + */ + + resize : function(width, height) { + + width = width || null; + height = height || null; + + var state = this.state; + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var codeMirror = this.codeMirror; + + if (width) + { + editor.css("width", (typeof width === "number") ? width + "px" : width); + } + + if (settings.autoHeight && !state.fullscreen && !state.preview) + { + editor.css("height", "auto"); + codeMirror.css("height", "auto"); + } + else + { + if (height) + { + editor.css("height", (typeof height === "number") ? height + "px" : height); + } + + if (state.fullscreen) + { + editor.height($(window).height()); + } + + if (settings.toolbar && !settings.readOnly) + { + codeMirror.css("margin-top", toolbar.height() + 1).height(editor.height() - toolbar.height()); + } + else + { + codeMirror.css("margin-top", 0).height(editor.height()); + } + } + + if(settings.watch) + { + codeMirror.width(editor.width() / 2); + preview.width((!state.preview) ? editor.width() / 2 : editor.width()); + + this.previewContainer.css("padding", settings.autoHeight ? "20px 20px 50px 40px" : "20px"); + + if (settings.toolbar && !settings.readOnly) + { + preview.css("top", toolbar.height() + 1); + } + else + { + preview.css("top", 0); + } + + if (settings.autoHeight && !state.fullscreen && !state.preview) + { + preview.height(""); + } + else + { + var previewHeight = (settings.toolbar && !settings.readOnly) ? editor.height() - toolbar.height() : editor.height(); + + preview.height(previewHeight); + } + } + else + { + codeMirror.width(editor.width()); + preview.hide(); + } + + if (state.loaded) + { + $.proxy(settings.onresize, this)(); + } + + return this; + }, + + /** + * 解析和保存Markdown代码 + * Parse & Saving Markdown source code + * + * @returns {editormd} 返回editormd的实例对象 + */ + + save : function() { + + var _this = this; + var state = this.state; + var settings = this.settings; + + if (timer === null && !(!settings.watch && state.preview)) + { + return this; + } + + var cm = this.cm; + var cmValue = cm.getValue(); + var previewContainer = this.previewContainer; + + if (settings.mode !== "gfm" && settings.mode !== "markdown") + { + this.markdownTextarea.val(cmValue); + + return this; + } + + var marked = editormd.$marked; + var markdownToC = this.markdownToC = []; + var rendererOptions = this.markedRendererOptions = { + toc : settings.toc, + tocm : settings.tocm, + tocStartLevel : settings.tocStartLevel, + pageBreak : settings.pageBreak, + taskList : settings.taskList, + emoji : settings.emoji, + tex : settings.tex, + atLink : settings.atLink, // for @link + emailLink : settings.emailLink, // for mail address auto link + flowChart : settings.flowChart, + sequenceDiagram : settings.sequenceDiagram, + previewCodeHighlight : settings.previewCodeHighlight, + }; + + var markedOptions = this.markedOptions = { + renderer : editormd.markedRenderer(markdownToC, rendererOptions), + gfm : true, + tables : true, + breaks : true, + pedantic : false, + sanitize : (settings.htmlDecode) ? false : true, // 关闭忽略HTML标签,即开启识别HTML标签,默认为false + smartLists : true, + smartypants : true + }; + + marked.setOptions(markedOptions); + + var newMarkdownDoc = editormd.$marked(cmValue, markedOptions); + + //console.info("cmValue", cmValue, newMarkdownDoc); + + newMarkdownDoc = editormd.filterHTMLTags(newMarkdownDoc, settings.htmlDecode); + + //console.error("cmValue", cmValue, newMarkdownDoc); + + this.markdownTextarea.text(cmValue); + + cm.save(); + + if (settings.saveHTMLToTextarea) + { + this.htmlTextarea.text(newMarkdownDoc); + } + + if(settings.watch || (!settings.watch && state.preview)) + { + previewContainer.html(newMarkdownDoc); + + this.previewCodeHighlight(); + + if (settings.toc) + { + var tocContainer = (settings.tocContainer === "") ? previewContainer : $(settings.tocContainer); + var tocMenu = tocContainer.find("." + this.classPrefix + "toc-menu"); + + tocContainer.attr("previewContainer", (settings.tocContainer === "") ? "true" : "false"); + + if (settings.tocContainer !== "" && tocMenu.length > 0) + { + tocMenu.remove(); + } + + editormd.markdownToCRenderer(markdownToC, tocContainer, settings.tocDropdown, settings.tocStartLevel); + + if (settings.tocDropdown || tocContainer.find("." + this.classPrefix + "toc-menu").length > 0) + { + editormd.tocDropdownMenu(tocContainer, (settings.tocTitle !== "") ? settings.tocTitle : this.lang.tocTitle); + } + + if (settings.tocContainer !== "") + { + previewContainer.find(".markdown-toc").css("border", "none"); + } + } + + if (settings.tex) + { + if (!editormd.kaTeXLoaded && settings.autoLoadModules) + { + editormd.loadKaTeX(function() { + editormd.$katex = katex; + editormd.kaTeXLoaded = true; + _this.katexRender(); + }); + } + else + { + editormd.$katex = katex; + this.katexRender(); + } + } + + if (settings.flowChart || settings.sequenceDiagram) + { + flowchartTimer = setTimeout(function(){ + clearTimeout(flowchartTimer); + _this.flowChartAndSequenceDiagramRender(); + flowchartTimer = null; + }, 10); + } + + if (state.loaded) + { + $.proxy(settings.onchange, this)(); + } + } + + return this; + }, + + /** + * 聚焦光标位置 + * Focusing the cursor position + * + * @returns {editormd} 返回editormd的实例对象 + */ + + focus : function() { + this.cm.focus(); + + return this; + }, + + /** + * 设置光标的位置 + * Set cursor position + * + * @param {Object} cursor 要设置的光标位置键值对象,例:{line:1, ch:0} + * @returns {editormd} 返回editormd的实例对象 + */ + + setCursor : function(cursor) { + this.cm.setCursor(cursor); + + return this; + }, + + /** + * 获取当前光标的位置 + * Get the current position of the cursor + * + * @returns {Cursor} 返回一个光标Cursor对象 + */ + + getCursor : function() { + return this.cm.getCursor(); + }, + + /** + * 设置光标选中的范围 + * Set cursor selected ranges + * + * @param {Object} from 开始位置的光标键值对象,例:{line:1, ch:0} + * @param {Object} to 结束位置的光标键值对象,例:{line:1, ch:0} + * @returns {editormd} 返回editormd的实例对象 + */ + + setSelection : function(from, to) { + + this.cm.setSelection(from, to); + + return this; + }, + + /** + * 获取光标选中的文本 + * Get the texts from cursor selected + * + * @returns {String} 返回选中文本的字符串形式 + */ + + getSelection : function() { + return this.cm.getSelection(); + }, + + /** + * 设置光标选中的文本范围 + * Set the cursor selection ranges + * + * @param {Array} ranges cursor selection ranges array + * @returns {Array} return this + */ + + setSelections : function(ranges) { + this.cm.setSelections(ranges); + + return this; + }, + + /** + * 获取光标选中的文本范围 + * Get the cursor selection ranges + * + * @returns {Array} return selection ranges array + */ + + getSelections : function() { + return this.cm.getSelections(); + }, + + /** + * 替换当前光标选中的文本或在当前光标处插入新字符 + * Replace the text at the current cursor selected or insert a new character at the current cursor position + * + * @param {String} value 要插入的字符值 + * @returns {editormd} 返回editormd的实例对象 + */ + + replaceSelection : function(value) { + this.cm.replaceSelection(value); + + return this; + }, + + /** + * 在当前光标处插入新字符 + * Insert a new character at the current cursor position + * + * 同replaceSelection()方法 + * With the replaceSelection() method + * + * @param {String} value 要插入的字符值 + * @returns {editormd} 返回editormd的实例对象 + */ + + insertValue : function(value) { + this.replaceSelection(value); + + return this; + }, + + /** + * 追加markdown + * append Markdown to editor + * + * @param {String} md 要追加的markdown源文档 + * @returns {editormd} 返回editormd的实例对象 + */ + + appendMarkdown : function(md) { + var settings = this.settings; + var cm = this.cm; + + cm.setValue(cm.getValue() + md); + + return this; + }, + + /** + * 设置和传入编辑器的markdown源文档 + * Set Markdown source document + * + * @param {String} md 要传入的markdown源文档 + * @returns {editormd} 返回editormd的实例对象 + */ + + setMarkdown : function(md) { + this.cm.setValue(md || this.settings.markdown); + + return this; + }, + + /** + * 获取编辑器的markdown源文档 + * Set Editor.md markdown/CodeMirror value + * + * @returns {editormd} 返回editormd的实例对象 + */ + + getMarkdown : function() { + return this.cm.getValue(); + }, + + /** + * 获取编辑器的源文档 + * Get CodeMirror value + * + * @returns {editormd} 返回editormd的实例对象 + */ + + getValue : function() { + return this.cm.getValue(); + }, + + /** + * 设置编辑器的源文档 + * Set CodeMirror value + * + * @param {String} value set code/value/string/text + * @returns {editormd} 返回editormd的实例对象 + */ + + setValue : function(value) { + this.cm.setValue(value); + + return this; + }, + + /** + * 清空编辑器 + * Empty CodeMirror editor container + * + * @returns {editormd} 返回editormd的实例对象 + */ + + clear : function() { + this.cm.setValue(""); + + return this; + }, + + /** + * 获取解析后存放在Textarea的HTML源码 + * Get parsed html code from Textarea + * + * @returns {String} 返回HTML源码 + */ + + getHTML : function() { + if (!this.settings.saveHTMLToTextarea) + { + alert("Error: settings.saveHTMLToTextarea == false"); + + return false; + } + + return this.htmlTextarea.val(); + }, + + /** + * getHTML()的别名 + * getHTML (alias) + * + * @returns {String} Return html code 返回HTML源码 + */ + + getTextareaSavedHTML : function() { + return this.getHTML(); + }, + + /** + * 获取预览窗口的HTML源码 + * Get html from preview container + * + * @returns {editormd} 返回editormd的实例对象 + */ + + getPreviewedHTML : function() { + if (!this.settings.watch) + { + alert("Error: settings.watch == false"); + + return false; + } + + return this.previewContainer.html(); + }, + + /** + * 开启实时预览 + * Enable real-time watching + * + * @returns {editormd} 返回editormd的实例对象 + */ + + watch : function(callback) { + var settings = this.settings; + + if ($.inArray(settings.mode, ["gfm", "markdown"]) < 0) + { + return this; + } + + this.state.watching = settings.watch = true; + this.preview.show(); + + if (this.toolbar) + { + var watchIcon = settings.toolbarIconsClass.watch; + var unWatchIcon = settings.toolbarIconsClass.unwatch; + + var icon = this.toolbar.find(".fa[name=watch]"); + icon.parent().attr("title", settings.lang.toolbar.watch); + icon.removeClass(unWatchIcon).addClass(watchIcon); + } + + this.codeMirror.css("border-right", "1px solid #ddd").width(this.editor.width() / 2); + + timer = 0; + + this.save().resize(); + + if (!settings.onwatch) + { + settings.onwatch = callback || function() {}; + } + + $.proxy(settings.onwatch, this)(); + + return this; + }, + + /** + * 关闭实时预览 + * Disable real-time watching + * + * @returns {editormd} 返回editormd的实例对象 + */ + + unwatch : function(callback) { + var settings = this.settings; + this.state.watching = settings.watch = false; + this.preview.hide(); + + if (this.toolbar) + { + var watchIcon = settings.toolbarIconsClass.watch; + var unWatchIcon = settings.toolbarIconsClass.unwatch; + + var icon = this.toolbar.find(".fa[name=watch]"); + icon.parent().attr("title", settings.lang.toolbar.unwatch); + icon.removeClass(watchIcon).addClass(unWatchIcon); + } + + this.codeMirror.css("border-right", "none").width(this.editor.width()); + + this.resize(); + + if (!settings.onunwatch) + { + settings.onunwatch = callback || function() {}; + } + + $.proxy(settings.onunwatch, this)(); + + return this; + }, + + /** + * 显示编辑器 + * Show editor + * + * @param {Function} [callback=function()] 回调函数 + * @returns {editormd} 返回editormd的实例对象 + */ + + show : function(callback) { + callback = callback || function() {}; + + var _this = this; + this.editor.show(0, function() { + $.proxy(callback, _this)(); + }); + + return this; + }, + + /** + * 隐藏编辑器 + * Hide editor + * + * @param {Function} [callback=function()] 回调函数 + * @returns {editormd} 返回editormd的实例对象 + */ + + hide : function(callback) { + callback = callback || function() {}; + + var _this = this; + this.editor.hide(0, function() { + $.proxy(callback, _this)(); + }); + + return this; + }, + + /** + * 隐藏编辑器部分,只预览HTML + * Enter preview html state + * + * @returns {editormd} 返回editormd的实例对象 + */ + + previewing : function() { + + var _this = this; + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var codeMirror = this.codeMirror; + var previewContainer = this.previewContainer; + + if ($.inArray(settings.mode, ["gfm", "markdown"]) < 0) { + return this; + } + + if (settings.toolbar && toolbar) { + toolbar.toggle(); + toolbar.find(".fa[name=preview]").toggleClass("active"); + } + + codeMirror.toggle(); + + var escHandle = function(event) { + if (event.shiftKey && event.keyCode === 27) { + _this.previewed(); + } + }; + + if (codeMirror.css("display") === "none") // 为了兼容Zepto,而不使用codeMirror.is(":hidden") + { + this.state.preview = true; + + if (this.state.fullscreen) { + preview.css("background", "#fff"); + } + + editor.find("." + this.classPrefix + "preview-close-btn").show().bind(editormd.mouseOrTouch("click", "touchend"), function(){ + _this.previewed(); + }); + + if (!settings.watch) + { + this.save(); + } + else + { + previewContainer.css("padding", ""); + } + + previewContainer.addClass(this.classPrefix + "preview-active"); + + preview.show().css({ + position : "", + top : 0, + width : editor.width(), + height : (settings.autoHeight && !this.state.fullscreen) ? "auto" : editor.height() + }); + + if (this.state.loaded) + { + $.proxy(settings.onpreviewing, this)(); + } + + $(window).bind("keyup", escHandle); + } + else + { + $(window).unbind("keyup", escHandle); + this.previewed(); + } + }, + + /** + * 显示编辑器部分,退出只预览HTML + * Exit preview html state + * + * @returns {editormd} 返回editormd的实例对象 + */ + + previewed : function() { + + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var previewContainer = this.previewContainer; + var previewCloseBtn = editor.find("." + this.classPrefix + "preview-close-btn"); + + this.state.preview = false; + + this.codeMirror.show(); + + if (settings.toolbar) { + toolbar.show(); + } + + preview[(settings.watch) ? "show" : "hide"](); + + previewCloseBtn.hide().unbind(editormd.mouseOrTouch("click", "touchend")); + + previewContainer.removeClass(this.classPrefix + "preview-active"); + + if (settings.watch) + { + previewContainer.css("padding", "20px"); + } + + preview.css({ + background : null, + position : "absolute", + width : editor.width() / 2, + height : (settings.autoHeight && !this.state.fullscreen) ? "auto" : editor.height() - toolbar.height(), + top : (settings.toolbar) ? toolbar.height() : 0 + }); + + if (this.state.loaded) + { + $.proxy(settings.onpreviewed, this)(); + } + + return this; + }, + + /** + * 编辑器全屏显示 + * Fullscreen show + * + * @returns {editormd} 返回editormd的实例对象 + */ + + fullscreen : function() { + + var _this = this; + var state = this.state; + var editor = this.editor; + var preview = this.preview; + var toolbar = this.toolbar; + var settings = this.settings; + var fullscreenClass = this.classPrefix + "fullscreen"; + + if (toolbar) { + toolbar.find(".fa[name=fullscreen]").parent().toggleClass("active"); + } + + var escHandle = function(event) { + if (!event.shiftKey && event.keyCode === 27) + { + if (state.fullscreen) + { + _this.fullscreenExit(); + } + } + }; + + if (!editor.hasClass(fullscreenClass)) + { + state.fullscreen = true; + + $("html,body").css("overflow", "hidden"); + + editor.css({ + width : $(window).width(), + height : $(window).height() + }).addClass(fullscreenClass); + + this.resize(); + + $.proxy(settings.onfullscreen, this)(); + + $(window).bind("keyup", escHandle); + } + else + { + $(window).unbind("keyup", escHandle); + this.fullscreenExit(); + } + + return this; + }, + + /** + * 编辑器退出全屏显示 + * Exit fullscreen state + * + * @returns {editormd} 返回editormd的实例对象 + */ + + fullscreenExit : function() { + + var editor = this.editor; + var settings = this.settings; + var toolbar = this.toolbar; + var fullscreenClass = this.classPrefix + "fullscreen"; + + this.state.fullscreen = false; + + if (toolbar) { + toolbar.find(".fa[name=fullscreen]").parent().removeClass("active"); + } + + $("html,body").css("overflow", ""); + + editor.css({ + width : editor.data("oldWidth"), + height : editor.data("oldHeight") + }).removeClass(fullscreenClass); + + this.resize(); + + $.proxy(settings.onfullscreenExit, this)(); + + return this; + }, + + /** + * 加载并执行插件 + * Load and execute the plugin + * + * @param {String} name plugin name / function name + * @param {String} path plugin load path + * @returns {editormd} 返回editormd的实例对象 + */ + + executePlugin : function(name, path) { + + var _this = this; + var cm = this.cm; + var settings = this.settings; + + path = settings.pluginPath + path; + + if (typeof define === "function") + { + if (typeof this[name] === "undefined") + { + alert("Error: " + name + " plugin is not found, you are not load this plugin."); + + return this; + } + + this[name](cm); + + return this; + } + + if ($.inArray(path, editormd.loadFiles.plugin) < 0) + { + editormd.loadPlugin(path, function() { + editormd.loadPlugins[name] = _this[name]; + _this[name](cm); + }); + } + else + { + $.proxy(editormd.loadPlugins[name], this)(cm); + } + + return this; + }, + + /** + * 搜索替换 + * Search & replace + * + * @param {String} command CodeMirror serach commands, "find, fintNext, fintPrev, clearSearch, replace, replaceAll" + * @returns {editormd} return this + */ + + search : function(command) { + var settings = this.settings; + + if (!settings.searchReplace) + { + alert("Error: settings.searchReplace == false"); + return this; + } + + if (!settings.readOnly) + { + this.cm.execCommand(command || "find"); + } + + return this; + }, + + searchReplace : function() { + this.search("replace"); + + return this; + }, + + searchReplaceAll : function() { + this.search("replaceAll"); + + return this; + } + }; + + editormd.fn.init.prototype = editormd.fn; + + /** + * 锁屏 + * lock screen when dialog opening + * + * @returns {void} + */ + + editormd.dialogLockScreen = function() { + var settings = this.settings || {dialogLockScreen : true}; + + if (settings.dialogLockScreen) + { + $("html,body").css("overflow", "hidden"); + this.resize(); + } + }; + + /** + * 显示透明背景层 + * Display mask layer when dialog opening + * + * @param {Object} dialog dialog jQuery object + * @returns {void} + */ + + editormd.dialogShowMask = function(dialog) { + var editor = this.editor; + var settings = this.settings || {dialogShowMask : true}; + + dialog.css({ + top : ($(window).height() - dialog.height()) / 2 + "px", + left : ($(window).width() - dialog.width()) / 2 + "px" + }); + + if (settings.dialogShowMask) { + editor.children("." + this.classPrefix + "mask").css("z-index", parseInt(dialog.css("z-index")) - 1).show(); + } + }; + + editormd.toolbarHandlers = { + undo : function() { + this.cm.undo(); + }, + + redo : function() { + this.cm.redo(); + }, + + bold : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("**" + selection + "**"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 2); + } + }, + + del : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("~~" + selection + "~~"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 2); + } + }, + + italic : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("*" + selection + "*"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + + quote : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("> " + selection); + cm.setCursor(cursor.line, cursor.ch + 2); + } + else + { + cm.replaceSelection("> " + selection); + } + + //cm.replaceSelection("> " + selection); + //cm.setCursor(cursor.line, (selection === "") ? cursor.ch + 2 : cursor.ch + selection.length + 2); + }, + + ucfirst : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(editormd.firstUpperCase(selection)); + cm.setSelections(selections); + }, + + ucwords : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(editormd.wordsFirstUpperCase(selection)); + cm.setSelections(selections); + }, + + uppercase : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(selection.toUpperCase()); + cm.setSelections(selections); + }, + + lowercase : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + var selections = cm.listSelections(); + + cm.replaceSelection(selection.toLowerCase()); + cm.setSelections(selections); + }, + + h1 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("# " + selection); + cm.setCursor(cursor.line, cursor.ch + 2); + } + else + { + cm.replaceSelection("# " + selection); + } + }, + + h2 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("## " + selection); + cm.setCursor(cursor.line, cursor.ch + 3); + } + else + { + cm.replaceSelection("## " + selection); + } + }, + + h3 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("### " + selection); + cm.setCursor(cursor.line, cursor.ch + 4); + } + else + { + cm.replaceSelection("### " + selection); + } + }, + + h4 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("#### " + selection); + cm.setCursor(cursor.line, cursor.ch + 5); + } + else + { + cm.replaceSelection("#### " + selection); + } + }, + + h5 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("##### " + selection); + cm.setCursor(cursor.line, cursor.ch + 6); + } + else + { + cm.replaceSelection("##### " + selection); + } + }, + + h6 : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (cursor.ch !== 0) + { + cm.setCursor(cursor.line, 0); + cm.replaceSelection("###### " + selection); + cm.setCursor(cursor.line, cursor.ch + 7); + } + else + { + cm.replaceSelection("###### " + selection); + } + }, + + "list-ul" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (selection === "") + { + cm.replaceSelection("- " + selection); + } + else + { + var selectionText = selection.split("\n"); + + for (var i = 0, len = selectionText.length; i < len; i++) + { + selectionText[i] = (selectionText[i] === "") ? "" : "- " + selectionText[i]; + } + + cm.replaceSelection(selectionText.join("\n")); + } + }, + + "list-ol" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if(selection === "") + { + cm.replaceSelection("1. " + selection); + } + else + { + var selectionText = selection.split("\n"); + + for (var i = 0, len = selectionText.length; i < len; i++) + { + selectionText[i] = (selectionText[i] === "") ? "" : (i+1) + ". " + selectionText[i]; + } + + cm.replaceSelection(selectionText.join("\n")); + } + }, + + hr : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection(((cursor.ch !== 0) ? "\n\n" : "\n") + "------------\n\n"); + }, + + tex : function() { + if (!this.settings.tex) + { + alert("settings.tex === false"); + return this; + } + + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("$$" + selection + "$$"); + + if(selection === "") { + cm.setCursor(cursor.line, cursor.ch + 2); + } + }, + + link : function() { + this.executePlugin("linkDialog", "link-dialog/link-dialog"); + }, + + "reference-link" : function() { + this.executePlugin("referenceLinkDialog", "reference-link-dialog/reference-link-dialog"); + }, + + pagebreak : function() { + if (!this.settings.pageBreak) + { + alert("settings.pageBreak === false"); + return this; + } + + var cm = this.cm; + var selection = cm.getSelection(); + + cm.replaceSelection("\r\n[========]\r\n"); + }, + + image : function() { + this.executePlugin("imageDialog", "image-dialog/image-dialog"); + }, + + code : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection("`" + selection + "`"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + + "code-block" : function() { + this.executePlugin("codeBlockDialog", "code-block-dialog/code-block-dialog"); + }, + + "preformatted-text" : function() { + this.executePlugin("preformattedTextDialog", "preformatted-text-dialog/preformatted-text-dialog"); + }, + + table : function() { + this.executePlugin("tableDialog", "table-dialog/table-dialog"); + }, + + datetime : function() { + var cm = this.cm; + var selection = cm.getSelection(); + var date = new Date(); + var langName = this.settings.lang.name; + var datefmt = editormd.dateFormat() + " " + editormd.dateFormat((langName === "zh-cn" || langName === "zh-tw") ? "cn-week-day" : "week-day"); + + cm.replaceSelection(datefmt); + }, + + emoji : function() { + this.executePlugin("emojiDialog", "emoji-dialog/emoji-dialog"); + }, + + "html-entities" : function() { + this.executePlugin("htmlEntitiesDialog", "html-entities-dialog/html-entities-dialog"); + }, + + "goto-line" : function() { + this.executePlugin("gotoLineDialog", "goto-line-dialog/goto-line-dialog"); + }, + + watch : function() { + this[this.settings.watch ? "unwatch" : "watch"](); + }, + + preview : function() { + this.previewing(); + }, + + fullscreen : function() { + this.fullscreen(); + }, + + clear : function() { + this.clear(); + }, + + search : function() { + this.search(); + }, + + help : function() { + this.executePlugin("helpDialog", "help-dialog/help-dialog"); + }, + + info : function() { + this.showInfoDialog(); + } + }; + + editormd.keyMaps = { + "Ctrl-1" : "h1", + "Ctrl-2" : "h2", + "Ctrl-3" : "h3", + "Ctrl-4" : "h4", + "Ctrl-5" : "h5", + "Ctrl-6" : "h6", + "Ctrl-B" : "bold", // if this is string == editormd.toolbarHandlers.xxxx + "Ctrl-D" : "datetime", + + "Ctrl-E" : function() { // emoji + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (!this.settings.emoji) + { + alert("Error: settings.emoji == false"); + return ; + } + + cm.replaceSelection(":" + selection + ":"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + "Ctrl-Alt-G" : "goto-line", + "Ctrl-H" : "hr", + "Ctrl-I" : "italic", + "Ctrl-K" : "code", + + "Ctrl-L" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + var title = (selection === "") ? "" : " \""+selection+"\""; + + cm.replaceSelection("[" + selection + "]("+title+")"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + "Ctrl-U" : "list-ul", + + "Shift-Ctrl-A" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + if (!this.settings.atLink) + { + alert("Error: settings.atLink == false"); + return ; + } + + cm.replaceSelection("@" + selection); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 1); + } + }, + + "Shift-Ctrl-C" : "code", + "Shift-Ctrl-Q" : "quote", + "Shift-Ctrl-S" : "del", + "Shift-Ctrl-K" : "tex", // KaTeX + + "Shift-Alt-C" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + cm.replaceSelection(["```", selection, "```"].join("\n")); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 3); + } + }, + + "Shift-Ctrl-Alt-C" : "code-block", + "Shift-Ctrl-H" : "html-entities", + "Shift-Alt-H" : "help", + "Shift-Ctrl-E" : "emoji", + "Shift-Ctrl-U" : "uppercase", + "Shift-Alt-U" : "ucwords", + "Shift-Ctrl-Alt-U" : "ucfirst", + "Shift-Alt-L" : "lowercase", + + "Shift-Ctrl-I" : function() { + var cm = this.cm; + var cursor = cm.getCursor(); + var selection = cm.getSelection(); + + var title = (selection === "") ? "" : " \""+selection+"\""; + + cm.replaceSelection("![" + selection + "]("+title+")"); + + if (selection === "") { + cm.setCursor(cursor.line, cursor.ch + 4); + } + }, + + "Shift-Ctrl-Alt-I" : "image", + "Shift-Ctrl-L" : "link", + "Shift-Ctrl-O" : "list-ol", + "Shift-Ctrl-P" : "preformatted-text", + "Shift-Ctrl-T" : "table", + "Shift-Alt-P" : "pagebreak", + "F9" : "watch", + "F10" : "preview", + "F11" : "fullscreen", + }; + + /** + * 清除字符串两边的空格 + * Clear the space of strings both sides. + * + * @param {String} str string + * @returns {String} trimed string + */ + + var trim = function(str) { + return (!String.prototype.trim) ? str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "") : str.trim(); + }; + + editormd.trim = trim; + + /** + * 所有单词首字母大写 + * Words first to uppercase + * + * @param {String} str string + * @returns {String} string + */ + + var ucwords = function (str) { + return str.toLowerCase().replace(/\b(\w)|\s(\w)/g, function($1) { + return $1.toUpperCase(); + }); + }; + + editormd.ucwords = editormd.wordsFirstUpperCase = ucwords; + + /** + * 字符串首字母大写 + * Only string first char to uppercase + * + * @param {String} str string + * @returns {String} string + */ + + var firstUpperCase = function(str) { + return str.toLowerCase().replace(/\b(\w)/, function($1){ + return $1.toUpperCase(); + }); + }; + + var ucfirst = firstUpperCase; + + editormd.firstUpperCase = editormd.ucfirst = firstUpperCase; + + editormd.urls = { + atLinkBase : "https://github.com/" + }; + + editormd.regexs = { + atLink : /@(\w+)/g, + email : /(\w+)@(\w+)\.(\w+)\.?(\w+)?/g, + emailLink : /(mailto:)?([\w\.\_]+)@(\w+)\.(\w+)\.?(\w+)?/g, + emoji : /:([\w\+-]+):/g, + emojiDatetime : /(\d{2}:\d{2}:\d{2})/g, + twemoji : /:(tw-([\w]+)-?(\w+)?):/g, + fontAwesome : /:(fa-([\w]+)(-(\w+)){0,}):/g, + editormdLogo : /:(editormd-logo-?(\w+)?):/g, + pageBreak : /^\[[=]{8,}\]$/ + }; + + // Emoji graphics files url path + editormd.emoji = { + path : "https://www.webpagefx.com/tools/emoji-cheat-sheet/graphics/emojis/", + ext : ".png" + }; + + // Twitter Emoji (Twemoji) graphics files url path + editormd.twemoji = { + path : "http://twemoji.maxcdn.com/36x36/", + ext : ".png" + }; + + /** + * 自定义marked的解析器 + * Custom Marked renderer rules + * + * @param {Array} markdownToC 传入用于接收TOC的数组 + * @returns {Renderer} markedRenderer 返回marked的Renderer自定义对象 + */ + + editormd.markedRenderer = function(markdownToC, options) { + var defaults = { + toc : true, // Table of contents + tocm : false, + tocStartLevel : 1, // Said from H1 to create ToC + pageBreak : true, + atLink : true, // for @link + emailLink : true, // for mail address auto link + taskList : false, // Enable Github Flavored Markdown task lists + emoji : false, // :emoji: , Support Twemoji, fontAwesome, Editor.md logo emojis. + tex : false, // TeX(LaTeX), based on KaTeX + flowChart : false, // flowChart.js only support IE9+ + sequenceDiagram : false, // sequenceDiagram.js only support IE9+ + }; + + var settings = $.extend(defaults, options || {}); + var marked = editormd.$marked; + var markedRenderer = new marked.Renderer(); + markdownToC = markdownToC || []; + + var regexs = editormd.regexs; + var atLinkReg = regexs.atLink; + var emojiReg = regexs.emoji; + var emailReg = regexs.email; + var emailLinkReg = regexs.emailLink; + var twemojiReg = regexs.twemoji; + var faIconReg = regexs.fontAwesome; + var editormdLogoReg = regexs.editormdLogo; + var pageBreakReg = regexs.pageBreak; + + markedRenderer.emoji = function(text) { + + text = text.replace(editormd.regexs.emojiDatetime, function($1) { + return $1.replace(/:/g, ":"); + }); + + var matchs = text.match(emojiReg); + + if (!matchs || !settings.emoji) { + return text; + } + + for (var i = 0, len = matchs.length; i < len; i++) + { + if (matchs[i] === ":+1:") { + matchs[i] = ":\\+1:"; + } + + text = text.replace(new RegExp(matchs[i]), function($1, $2){ + var faMatchs = $1.match(faIconReg); + var name = $1.replace(/:/g, ""); + + if (faMatchs) + { + for (var fa = 0, len1 = faMatchs.length; fa < len1; fa++) + { + var faName = faMatchs[fa].replace(/:/g, ""); + + return ""; + } + } + else + { + var emdlogoMathcs = $1.match(editormdLogoReg); + var twemojiMatchs = $1.match(twemojiReg); + + if (emdlogoMathcs) + { + for (var x = 0, len2 = emdlogoMathcs.length; x < len2; x++) + { + var logoName = emdlogoMathcs[x].replace(/:/g, ""); + return ""; + } + } + else if (twemojiMatchs) + { + for (var t = 0, len3 = twemojiMatchs.length; t < len3; t++) + { + var twe = twemojiMatchs[t].replace(/:/g, "").replace("tw-", ""); + return "\"twemoji-""; + } + } + else + { + var src = (name === "+1") ? "plus1" : name; + src = (src === "black_large_square") ? "black_square" : src; + src = (src === "moon") ? "waxing_gibbous_moon" : src; + + return "\":""; + } + } + }); + } + + return text; + }; + + markedRenderer.atLink = function(text) { + + if (atLinkReg.test(text)) + { + if (settings.atLink) + { + text = text.replace(emailReg, function($1, $2, $3, $4) { + return $1.replace(/@/g, "_#_@_#_"); + }); + + text = text.replace(atLinkReg, function($1, $2) { + return "" + $1 + ""; + }).replace(/_#_@_#_/g, "@"); + } + + if (settings.emailLink) + { + text = text.replace(emailLinkReg, function($1, $2, $3, $4, $5) { + return (!$2 && $.inArray($5, "jpg|jpeg|png|gif|webp|ico|icon|pdf".split("|")) < 0) ? ""+$1+"" : $1; + }); + } + + return text; + } + + return text; + }; + + markedRenderer.link = function (href, title, text) { + + if (this.options.sanitize) { + try { + var prot = decodeURIComponent(unescape(href)).replace(/[^\w:]/g,"").toLowerCase(); + } catch(e) { + return ""; + } + + if (prot.indexOf("javascript:") === 0) { + return ""; + } + } + + var out = "" + text.replace(/@/g, "@") + ""; + } + + if (title) { + out += " title=\"" + title + "\""; + } + + out += ">" + text + ""; + + return out; + }; + + markedRenderer.heading = function(text, level, raw) { + + var linkText = text; + var hasLinkReg = /\s*\]*)\>(.*)\<\/a\>\s*/; + var getLinkTextReg = /\s*\]+)\>([^\>]*)\<\/a\>\s*/g; + + if (hasLinkReg.test(text)) + { + var tempText = []; + text = text.split(/\]+)\>([^\>]*)\<\/a\>/); + + for (var i = 0, len = text.length; i < len; i++) + { + tempText.push(text[i].replace(/\s*href\=\"(.*)\"\s*/g, "")); + } + + text = tempText.join(" "); + } + + text = trim(text); + + var escapedText = text.toLowerCase().replace(/[^\w]+/g, "-"); + var toc = { + text : text, + level : level, + slug : escapedText + }; + + var isChinese = /^[\u4e00-\u9fa5]+$/.test(text); + var id = (isChinese) ? escape(text).replace(/\%/g, "") : text.toLowerCase().replace(/[^\w]+/g, "-"); + + markdownToC.push(toc); + + var headingHTML = ""; + + headingHTML += ""; + headingHTML += ""; + headingHTML += (hasLinkReg) ? this.atLink(this.emoji(linkText)) : this.atLink(this.emoji(text)); + headingHTML += ""; + + return headingHTML; + }; + + markedRenderer.pageBreak = function(text) { + if (pageBreakReg.test(text) && settings.pageBreak) + { + text = "
              "; + } + + return text; + }; + + markedRenderer.paragraph = function(text) { + var isTeXInline = /\$\$(.*)\$\$/g.test(text); + var isTeXLine = /^\$\$(.*)\$\$$/.test(text); + var isTeXAddClass = (isTeXLine) ? " class=\"" + editormd.classNames.tex + "\"" : ""; + var isToC = (settings.tocm) ? /^(\[TOC\]|\[TOCM\])$/.test(text) : /^\[TOC\]$/.test(text); + var isToCMenu = /^\[TOCM\]$/.test(text); + + if (!isTeXLine && isTeXInline) + { + text = text.replace(/(\$\$([^\$]*)\$\$)+/g, function($1, $2) { + return "" + $2.replace(/\$/g, "") + ""; + }); + } + else + { + text = (isTeXLine) ? text.replace(/\$/g, "") : text; + } + + var tocHTML = "
              " + text + "
              "; + + return (isToC) ? ( (isToCMenu) ? "
              " + tocHTML + "

              " : tocHTML ) + : ( (pageBreakReg.test(text)) ? this.pageBreak(text) : "" + this.atLink(this.emoji(text)) + "

              \n" ); + }; + + markedRenderer.code = function (code, lang, escaped) { + + if (lang === "seq" || lang === "sequence") + { + return "
              " + code + "
              "; + } + else if ( lang === "flow") + { + return "
              " + code + "
              "; + } + else if ( lang === "math" || lang === "latex" || lang === "katex") + { + return "

              " + code + "

              "; + } + else + { + + return marked.Renderer.prototype.code.apply(this, arguments); + } + }; + + markedRenderer.tablecell = function(content, flags) { + var type = (flags.header) ? "th" : "td"; + var tag = (flags.align) ? "<" + type +" style=\"text-align:" + flags.align + "\">" : "<" + type + ">"; + + return tag + this.atLink(this.emoji(content)) + "\n"; + }; + + markedRenderer.listitem = function(text) { + if (settings.taskList && /^\s*\[[x\s]\]\s*/.test(text)) + { + text = text.replace(/^\s*\[\s\]\s*/, " ") + .replace(/^\s*\[x\]\s*/, " "); + + return "
            • " + this.atLink(this.emoji(text)) + "
            • "; + } + else + { + return "
            • " + this.atLink(this.emoji(text)) + "
            • "; + } + }; + + return markedRenderer; + }; + + /** + * + * 生成TOC(Table of Contents) + * Creating ToC (Table of Contents) + * + * @param {Array} toc 从marked获取的TOC数组列表 + * @param {Element} container 插入TOC的容器元素 + * @param {Integer} startLevel Hx 起始层级 + * @returns {Object} tocContainer 返回ToC列表容器层的jQuery对象元素 + */ + + editormd.markdownToCRenderer = function(toc, container, tocDropdown, startLevel) { + + var html = ""; + var lastLevel = 0; + var classPrefix = this.classPrefix; + + startLevel = startLevel || 1; + + for (var i = 0, len = toc.length; i < len; i++) + { + var text = toc[i].text; + var level = toc[i].level; + + if (level < startLevel) { + continue; + } + + if (level > lastLevel) + { + html += ""; + } + else if (level < lastLevel) + { + html += (new Array(lastLevel - level + 2)).join("
          • "); + } + else + { + html += ""; + } + + html += "
          • " + text + "
              "; + lastLevel = level; + } + + var tocContainer = container.find(".markdown-toc"); + + if ((tocContainer.length < 1 && container.attr("previewContainer") === "false")) + { + var tocHTML = "
              "; + + tocHTML = (tocDropdown) ? "
              " + tocHTML + "
              " : tocHTML; + + container.html(tocHTML); + + tocContainer = container.find(".markdown-toc"); + } + + if (tocDropdown) + { + tocContainer.wrap("

              "); + } + + tocContainer.html("
                ").children(".markdown-toc-list").html(html.replace(/\r?\n?\\<\/ul\>/g, "")); + + return tocContainer; + }; + + /** + * + * 生成TOC下拉菜单 + * Creating ToC dropdown menu + * + * @param {Object} container 插入TOC的容器jQuery对象元素 + * @param {String} tocTitle ToC title + * @returns {Object} return toc-menu object + */ + + editormd.tocDropdownMenu = function(container, tocTitle) { + + tocTitle = tocTitle || "Table of Contents"; + + var zindex = 400; + var tocMenus = container.find("." + this.classPrefix + "toc-menu"); + + tocMenus.each(function() { + var $this = $(this); + var toc = $this.children(".markdown-toc"); + var icon = ""; + var btn = "" + icon + tocTitle + ""; + var menu = toc.children("ul"); + var list = menu.find("li"); + + toc.append(btn); + + list.first().before("
              • " + tocTitle + " " + icon + "

              • "); + + $this.mouseover(function(){ + menu.show(); + + list.each(function(){ + var li = $(this); + var ul = li.children("ul"); + + if (ul.html() === "") + { + ul.remove(); + } + + if (ul.length > 0 && ul.html() !== "") + { + var firstA = li.children("a").first(); + + if (firstA.children(".fa").length < 1) + { + firstA.append( $(icon).css({ float:"right", paddingTop:"4px" }) ); + } + } + + li.mouseover(function(){ + ul.css("z-index", zindex).show(); + zindex += 1; + }).mouseleave(function(){ + ul.hide(); + }); + }); + }).mouseleave(function(){ + menu.hide(); + }); + }); + + return tocMenus; + }; + + /** + * 简单地过滤指定的HTML标签 + * Filter custom html tags + * + * @param {String} html 要过滤HTML + * @param {String} filters 要过滤的标签 + * @returns {String} html 返回过滤的HTML + */ + + editormd.filterHTMLTags = function(html, filters) { + + if (typeof html !== "string") { + html = new String(html); + } + + if (typeof filters !== "string") { + return html; + } + + var expression = filters.split("|"); + var filterTags = expression[0].split(","); + var attrs = expression[1]; + + for (var i = 0, len = filterTags.length; i < len; i++) + { + var tag = filterTags[i]; + + html = html.replace(new RegExp("\<\s*" + tag + "\s*([^\>]*)\>([^\>]*)\<\s*\/" + tag + "\s*\>", "igm"), ""); + } + + //return html; + + if (typeof attrs !== "undefined") + { + var htmlTagRegex = /\<(\w+)\s*([^\>]*)\>([^\>]*)\<\/(\w+)\>/ig; + + if (attrs === "*") + { + html = html.replace(htmlTagRegex, function($1, $2, $3, $4, $5) { + return "<" + $2 + ">" + $4 + ""; + }); + } + else if (attrs === "on*") + { + html = html.replace(htmlTagRegex, function($1, $2, $3, $4, $5) { + var el = $("<" + $2 + ">" + $4 + ""); + var _attrs = $($1)[0].attributes; + var $attrs = {}; + + $.each(_attrs, function(i, e) { + if (e.nodeName !== '"') $attrs[e.nodeName] = e.nodeValue; + }); + + $.each($attrs, function(i) { + if (i.indexOf("on") === 0) { + delete $attrs[i]; + } + }); + + el.attr($attrs); + + var text = (typeof el[1] !== "undefined") ? $(el[1]).text() : ""; + + return el[0].outerHTML + text; + }); + } + else + { + html = html.replace(htmlTagRegex, function($1, $2, $3, $4) { + var filterAttrs = attrs.split(","); + var el = $($1); + el.html($4); + + $.each(filterAttrs, function(i) { + el.attr(filterAttrs[i], null); + }); + + return el[0].outerHTML; + }); + } + } + + return html; + }; + + /** + * 将Markdown文档解析为HTML用于前台显示 + * Parse Markdown to HTML for Font-end preview. + * + * @param {String} id 用于显示HTML的对象ID + * @param {Object} [options={}] 配置选项,可选 + * @returns {Object} div 返回jQuery对象元素 + */ + + editormd.markdownToHTML = function(id, options) { + var defaults = { + gfm : true, + toc : true, + tocm : false, + tocStartLevel : 1, + tocTitle : "目录", + tocDropdown : false, + tocContainer : "", + markdown : "", + markdownSourceCode : false, + htmlDecode : false, + autoLoadKaTeX : true, + pageBreak : true, + atLink : true, // for @link + emailLink : true, // for mail address auto link + tex : false, + taskList : false, // Github Flavored Markdown task lists + emoji : false, + flowChart : false, + sequenceDiagram : false, + previewCodeHighlight : true + }; + + editormd.$marked = marked; + + var div = $("#" + id); + var settings = div.settings = $.extend(true, defaults, options || {}); + var saveTo = div.find("textarea"); + + if (saveTo.length < 1) + { + div.append(""); + saveTo = div.find("textarea"); + } + + var markdownDoc = (settings.markdown === "") ? saveTo.val() : settings.markdown; + var markdownToC = []; + + var rendererOptions = { + toc : settings.toc, + tocm : settings.tocm, + tocStartLevel : settings.tocStartLevel, + taskList : settings.taskList, + emoji : settings.emoji, + tex : settings.tex, + pageBreak : settings.pageBreak, + atLink : settings.atLink, // for @link + emailLink : settings.emailLink, // for mail address auto link + flowChart : settings.flowChart, + sequenceDiagram : settings.sequenceDiagram, + previewCodeHighlight : settings.previewCodeHighlight, + }; + + var markedOptions = { + renderer : editormd.markedRenderer(markdownToC, rendererOptions), + gfm : settings.gfm, + tables : true, + breaks : true, + pedantic : false, + sanitize : (settings.htmlDecode) ? false : true, // 是否忽略HTML标签,即是否开启HTML标签解析,为了安全性,默认不开启 + smartLists : true, + smartypants : true + }; + + markdownDoc = new String(markdownDoc); + + var markdownParsed = marked(markdownDoc, markedOptions); + + markdownParsed = editormd.filterHTMLTags(markdownParsed, settings.htmlDecode); + + if (settings.markdownSourceCode) { + saveTo.text(markdownDoc); + } else { + saveTo.remove(); + } + + div.addClass("markdown-body " + this.classPrefix + "html-preview").append(markdownParsed); + + var tocContainer = (settings.tocContainer !== "") ? $(settings.tocContainer) : div; + + if (settings.tocContainer !== "") + { + tocContainer.attr("previewContainer", false); + } + + if (settings.toc) + { + div.tocContainer = this.markdownToCRenderer(markdownToC, tocContainer, settings.tocDropdown, settings.tocStartLevel); + + if (settings.tocDropdown || div.find("." + this.classPrefix + "toc-menu").length > 0) + { + this.tocDropdownMenu(div, settings.tocTitle); + } + + if (settings.tocContainer !== "") + { + div.find(".editormd-toc-menu, .editormd-markdown-toc").remove(); + } + } + + if (settings.previewCodeHighlight) + { + div.find("pre").addClass("prettyprint linenums"); + prettyPrint(); + } + + if (!editormd.isIE8) + { + if (settings.flowChart) { + div.find(".flowchart").flowChart(); + } + + if (settings.sequenceDiagram) { + div.find(".sequence-diagram").sequenceDiagram({theme: "simple"}); + } + } + + if (settings.tex) + { + var katexHandle = function() { + div.find("." + editormd.classNames.tex).each(function(){ + var tex = $(this); + katex.render(tex.html().replace(/</g, "<").replace(/>/g, ">"), tex[0]); + tex.find(".katex").css("font-size", "1.6em"); + }); + }; + + if (settings.autoLoadKaTeX && !editormd.$katex && !editormd.kaTeXLoaded) + { + this.loadKaTeX(function() { + editormd.$katex = katex; + editormd.kaTeXLoaded = true; + katexHandle(); + }); + } + else + { + katexHandle(); + } + } + + div.getMarkdown = function() { + return saveTo.val(); + }; + + return div; + }; + + // Editor.md themes, change toolbar themes etc. + // added @1.5.0 + editormd.themes = ["default", "dark"]; + + // Preview area themes + // added @1.5.0 + editormd.previewThemes = ["default", "dark"]; + + // CodeMirror / editor area themes + // @1.5.0 rename -> editorThemes, old version -> themes + editormd.editorThemes = [ + "default", "3024-day", "3024-night", + "ambiance", "ambiance-mobile", + "base16-dark", "base16-light", "blackboard", + "cobalt", + "eclipse", "elegant", "erlang-dark", + "lesser-dark", + "mbo", "mdn-like", "midnight", "monokai", + "neat", "neo", "night", + "paraiso-dark", "paraiso-light", "pastel-on-dark", + "rubyblue", + "solarized", + "the-matrix", "tomorrow-night-eighties", "twilight", + "vibrant-ink", + "xq-dark", "xq-light" + ]; + + editormd.loadPlugins = {}; + + editormd.loadFiles = { + js : [], + css : [], + plugin : [] + }; + + /** + * 动态加载Editor.md插件,但不立即执行 + * Load editor.md plugins + * + * @param {String} fileName 插件文件路径 + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + * @param {String} [into="head"] 嵌入页面的位置 + */ + + editormd.loadPlugin = function(fileName, callback, into) { + callback = callback || function() {}; + + this.loadScript(fileName, function() { + editormd.loadFiles.plugin.push(fileName); + callback(); + }, into); + }; + + /** + * 动态加载CSS文件的方法 + * Load css file method + * + * @param {String} fileName CSS文件名 + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + * @param {String} [into="head"] 嵌入页面的位置 + */ + + editormd.loadCSS = function(fileName, callback, into) { + into = into || "head"; + callback = callback || function() {}; + + var css = document.createElement("link"); + css.type = "text/css"; + css.rel = "stylesheet"; + css.onload = css.onreadystatechange = function() { + editormd.loadFiles.css.push(fileName); + callback(); + }; + + css.href = fileName + ".css"; + + if(into === "head") { + document.getElementsByTagName("head")[0].appendChild(css); + } else { + document.body.appendChild(css); + } + }; + + editormd.isIE = (navigator.appName == "Microsoft Internet Explorer"); + editormd.isIE8 = (editormd.isIE && navigator.appVersion.match(/8./i) == "8."); + + /** + * 动态加载JS文件的方法 + * Load javascript file method + * + * @param {String} fileName JS文件名 + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + * @param {String} [into="head"] 嵌入页面的位置 + */ + + editormd.loadScript = function(fileName, callback, into) { + + into = into || "head"; + callback = callback || function() {}; + + var script = null; + script = document.createElement("script"); + script.id = fileName.replace(/[\./]+/g, "-"); + script.type = "text/javascript"; + script.src = fileName + ".js"; + + if (editormd.isIE8) + { + script.onreadystatechange = function() { + if(script.readyState) + { + if (script.readyState === "loaded" || script.readyState === "complete") + { + script.onreadystatechange = null; + editormd.loadFiles.js.push(fileName); + callback(); + } + } + }; + } + else + { + script.onload = function() { + editormd.loadFiles.js.push(fileName); + callback(); + }; + } + + if (into === "head") { + document.getElementsByTagName("head")[0].appendChild(script); + } else { + document.body.appendChild(script); + } + }; + + // 使用国外的CDN,加载速度有时会很慢,或者自定义URL + // You can custom KaTeX load url. + + editormd.kaTeXLoaded = false; + editormd.katexURL = { + css : "//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min", + js : "//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min" + } + /** + * 加载KaTeX文件 + * load KaTeX files + * + * @param {Function} [callback=function()] 加载成功后执行的回调函数 + */ + + editormd.loadKaTeX = function (callback) { + callback = callback || function() {}; + callback(); + }; + + /** + * 锁屏 + * lock screen + * + * @param {Boolean} lock Boolean 布尔值,是否锁屏 + * @returns {void} + */ + + editormd.lockScreen = function(lock) { + $("html,body").css("overflow", (lock) ? "hidden" : ""); + }; + + /** + * 动态创建对话框 + * Creating custom dialogs + * + * @param {Object} options 配置项键值对 Key/Value + * @returns {dialog} 返回创建的dialog的jQuery实例对象 + */ + + editormd.createDialog = function(options) { + var defaults = { + name : "", + width : 420, + height: 240, + title : "", + drag : true, + closed : true, + content : "", + mask : true, + maskStyle : { + backgroundColor : "#fff", + opacity : 0.1 + }, + lockScreen : true, + footer : true, + buttons : false + }; + + options = $.extend(true, defaults, options); + + var $this = this; + var editor = this.editor; + var classPrefix = editormd.classPrefix; + var guid = (new Date()).getTime(); + var dialogName = ( (options.name === "") ? classPrefix + "dialog-" + guid : options.name); + var mouseOrTouch = editormd.mouseOrTouch; + + var html = "
                "; + + if (options.title !== "") + { + html += "
                "; + html += "" + options.title + ""; + html += "
                "; + } + + if (options.closed) + { + html += ""; + } + + html += "
                " + options.content; + + if (options.footer || typeof options.footer === "string") + { + html += "
                " + ( (typeof options.footer === "boolean") ? "" : options.footer) + "
                "; + } + + html += "
                "; + + html += "
                "; + html += "
                "; + html += "
                "; + + editor.append(html); + + var dialog = editor.find("." + dialogName); + + dialog.lockScreen = function(lock) { + if (options.lockScreen) + { + $("html,body").css("overflow", (lock) ? "hidden" : ""); + $this.resize(); + } + + return dialog; + }; + + dialog.showMask = function() { + if (options.mask) + { + editor.find("." + classPrefix + "mask").css(options.maskStyle).css("z-index", editormd.dialogZindex - 1).show(); + } + return dialog; + }; + + dialog.hideMask = function() { + if (options.mask) + { + editor.find("." + classPrefix + "mask").hide(); + } + + return dialog; + }; + + dialog.loading = function(show) { + var loading = dialog.find("." + classPrefix + "dialog-mask"); + loading[(show) ? "show" : "hide"](); + + return dialog; + }; + + dialog.lockScreen(true).showMask(); + + dialog.show().css({ + zIndex : editormd.dialogZindex, + border : (editormd.isIE8) ? "1px solid #ddd" : "", + width : (typeof options.width === "number") ? options.width + "px" : options.width, + height : (typeof options.height === "number") ? options.height + "px" : options.height + }); + + var dialogPosition = function(){ + dialog.css({ + top : ($(window).height() - dialog.height()) / 2 + "px", + left : ($(window).width() - dialog.width()) / 2 + "px" + }); + }; + + dialogPosition(); + + $(window).resize(dialogPosition); + + dialog.children("." + classPrefix + "dialog-close").bind(mouseOrTouch("click", "touchend"), function() { + dialog.hide().lockScreen(false).hideMask(); + }); + + if (typeof options.buttons === "object") + { + var footer = dialog.footer = dialog.find("." + classPrefix + "dialog-footer"); + + for (var key in options.buttons) + { + var btn = options.buttons[key]; + var btnClassName = classPrefix + key + "-btn"; + + footer.append(""); + btn[1] = $.proxy(btn[1], dialog); + footer.children("." + btnClassName).bind(mouseOrTouch("click", "touchend"), btn[1]); + } + } + + if (options.title !== "" && options.drag) + { + var posX, posY; + var dialogHeader = dialog.children("." + classPrefix + "dialog-header"); + + if (!options.mask) { + dialogHeader.bind(mouseOrTouch("click", "touchend"), function(){ + editormd.dialogZindex += 2; + dialog.css("z-index", editormd.dialogZindex); + }); + } + + dialogHeader.mousedown(function(e) { + e = e || window.event; //IE + posX = e.clientX - parseInt(dialog[0].style.left); + posY = e.clientY - parseInt(dialog[0].style.top); + + document.onmousemove = moveAction; + }); + + var userCanSelect = function (obj) { + obj.removeClass(classPrefix + "user-unselect").off("selectstart"); + }; + + var userUnselect = function (obj) { + obj.addClass(classPrefix + "user-unselect").on("selectstart", function(event) { // selectstart for IE + return false; + }); + }; + + var moveAction = function (e) { + e = e || window.event; //IE + + var left, top, nowLeft = parseInt(dialog[0].style.left), nowTop = parseInt(dialog[0].style.top); + + if( nowLeft >= 0 ) { + if( nowLeft + dialog.width() <= $(window).width()) { + left = e.clientX - posX; + } else { + left = $(window).width() - dialog.width(); + document.onmousemove = null; + } + } else { + left = 0; + document.onmousemove = null; + } + + if( nowTop >= 0 ) { + top = e.clientY - posY; + } else { + top = 0; + document.onmousemove = null; + } + + + document.onselectstart = function() { + return false; + }; + + userUnselect($("body")); + userUnselect(dialog); + dialog[0].style.left = left + "px"; + dialog[0].style.top = top + "px"; + }; + + document.onmouseup = function() { + userCanSelect($("body")); + userCanSelect(dialog); + + document.onselectstart = null; + document.onmousemove = null; + }; + + dialogHeader.touchDraggable = function() { + var offset = null; + var start = function(e) { + var orig = e.originalEvent; + var pos = $(this).parent().position(); + + offset = { + x : orig.changedTouches[0].pageX - pos.left, + y : orig.changedTouches[0].pageY - pos.top + }; + }; + + var move = function(e) { + e.preventDefault(); + var orig = e.originalEvent; + + $(this).parent().css({ + top : orig.changedTouches[0].pageY - offset.y, + left : orig.changedTouches[0].pageX - offset.x + }); + }; + + this.bind("touchstart", start).bind("touchmove", move); + }; + + dialogHeader.touchDraggable(); + } + + editormd.dialogZindex += 2; + + return dialog; + }; + + /** + * 鼠标和触摸事件的判断/选择方法 + * MouseEvent or TouchEvent type switch + * + * @param {String} [mouseEventType="click"] 供选择的鼠标事件 + * @param {String} [touchEventType="touchend"] 供选择的触摸事件 + * @returns {String} EventType 返回事件类型名称 + */ + + editormd.mouseOrTouch = function(mouseEventType, touchEventType) { + mouseEventType = mouseEventType || "click"; + touchEventType = touchEventType || "touchend"; + + var eventType = mouseEventType; + + try { + document.createEvent("TouchEvent"); + eventType = touchEventType; + } catch(e) {} + + return eventType; + }; + + /** + * 日期时间的格式化方法 + * Datetime format method + * + * @param {String} [format=""] 日期时间的格式,类似PHP的格式 + * @returns {String} datefmt 返回格式化后的日期时间字符串 + */ + + editormd.dateFormat = function(format) { + format = format || ""; + + var addZero = function(d) { + return (d < 10) ? "0" + d : d; + }; + + var date = new Date(); + var year = date.getFullYear(); + var year2 = year.toString().slice(2, 4); + var month = addZero(date.getMonth() + 1); + var day = addZero(date.getDate()); + var weekDay = date.getDay(); + var hour = addZero(date.getHours()); + var min = addZero(date.getMinutes()); + var second = addZero(date.getSeconds()); + var ms = addZero(date.getMilliseconds()); + var datefmt = ""; + + var ymd = year2 + "-" + month + "-" + day; + var fymd = year + "-" + month + "-" + day; + var hms = hour + ":" + min + ":" + second; + + switch (format) + { + case "UNIX Time" : + datefmt = date.getTime(); + break; + + case "UTC" : + datefmt = date.toUTCString(); + break; + + case "yy" : + datefmt = year2; + break; + + case "year" : + case "yyyy" : + datefmt = year; + break; + + case "month" : + case "mm" : + datefmt = month; + break; + + case "cn-week-day" : + case "cn-wd" : + var cnWeekDays = ["日", "一", "二", "三", "四", "五", "六"]; + datefmt = "星期" + cnWeekDays[weekDay]; + break; + + case "week-day" : + case "wd" : + var weekDays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + datefmt = weekDays[weekDay]; + break; + + case "day" : + case "dd" : + datefmt = day; + break; + + case "hour" : + case "hh" : + datefmt = hour; + break; + + case "min" : + case "ii" : + datefmt = min; + break; + + case "second" : + case "ss" : + datefmt = second; + break; + + case "ms" : + datefmt = ms; + break; + + case "yy-mm-dd" : + datefmt = ymd; + break; + + case "yyyy-mm-dd" : + datefmt = fymd; + break; + + case "yyyy-mm-dd h:i:s ms" : + case "full + ms" : + datefmt = fymd + " " + hms + " " + ms; + break; + + case "full" : + case "yyyy-mm-dd h:i:s" : + default: + datefmt = fymd + " " + hms; + break; + } + + return datefmt; + }; + + return editormd; + +})); diff --git a/paicoding-ui/src/main/resources/static/editormd/editormd.min.js b/paicoding-ui/src/main/resources/static/editormd/editormd.min.js new file mode 100644 index 000000000..f810e34ff --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/editormd.min.js @@ -0,0 +1,3 @@ +/*! Editor.md v1.5.0 | editormd.min.js | Open source online markdown editor. | MIT License | By: Pandao | https://github.com/pandao/editor.md | 2015-06-09 */ +!function(e){"use strict";"function"==typeof require&&"object"==typeof exports&&"object"==typeof module?module.exports=e:"function"==typeof define?define.amd||define(["jquery"],e):window.editormd=e()}(function(){"use strict";var e="undefined"!=typeof jQuery?jQuery:Zepto;if("undefined"!=typeof e){var t=function(e,i){return new t.fn.init(e,i)};t.title=t.$name="Editor.md",t.version="1.5.0",t.homePage="https://pandao.github.io/editor.md/",t.classPrefix="editormd-",t.toolbarModes={full:["undo","redo","|","bold","del","italic","quote","ucwords","uppercase","lowercase","|","h1","h2","h3","h4","h5","h6","|","list-ul","list-ol","hr","|","link","reference-link","image","code","preformatted-text","code-block","table","datetime","emoji","html-entities","pagebreak","|","goto-line","watch","preview","fullscreen","clear","search","|","help","info"],simple:["undo","redo","|","bold","del","italic","quote","uppercase","lowercase","|","h1","h2","h3","h4","h5","h6","|","list-ul","list-ol","hr","|","watch","preview","fullscreen","|","help","info"],mini:["undo","redo","|","watch","preview","|","help","info"]},t.defaults={mode:"gfm",name:"",value:"",theme:"",editorTheme:"default",previewTheme:"",markdown:"",appendMarkdown:"",width:"100%",height:"100%",path:"./lib/",pluginPath:"",delay:300,autoLoadModules:!0,watch:!0,placeholder:"Enjoy Markdown! coding now...",gotoLine:!0,codeFold:!1,autoHeight:!1,autoFocus:!0,autoCloseTags:!0,searchReplace:!0,syncScrolling:!0,readOnly:!1,tabSize:4,indentUnit:4,lineNumbers:!0,lineWrapping:!0,autoCloseBrackets:!0,showTrailingSpace:!0,matchBrackets:!0,indentWithTabs:!0,styleSelectedText:!0,matchWordHighlight:!0,styleActiveLine:!0,dialogLockScreen:!0,dialogShowMask:!0,dialogDraggable:!0,dialogMaskBgColor:"#fff",dialogMaskOpacity:.1,fontSize:"13px",saveHTMLToTextarea:!1,disabledKeyMaps:[],onload:function(){},onresize:function(){},onchange:function(){},onwatch:null,onunwatch:null,onpreviewing:function(){},onpreviewed:function(){},onfullscreen:function(){},onfullscreenExit:function(){},onscroll:function(){},onpreviewscroll:function(){},imageUpload:!1,imageFormats:["jpg","jpeg","gif","png","bmp","webp"],imageUploadURL:"",crossDomainUpload:!1,uploadCallbackURL:"",toc:!0,tocm:!1,tocTitle:"",tocDropdown:!1,tocContainer:"",tocStartLevel:1,htmlDecode:!1,pageBreak:!0,atLink:!0,emailLink:!0,taskList:!1,emoji:!1,tex:!1,flowChart:!1,sequenceDiagram:!1,previewCodeHighlight:!0,toolbar:!0,toolbarAutoFixed:!0,toolbarIcons:"full",toolbarTitles:{},toolbarHandlers:{ucwords:function(){return t.toolbarHandlers.ucwords},lowercase:function(){return t.toolbarHandlers.lowercase}},toolbarCustomIcons:{lowercase:'a',ucwords:'Aa'},toolbarIconsClass:{undo:"fa-undo",redo:"fa-repeat",bold:"fa-bold",del:"fa-strikethrough",italic:"fa-italic",quote:"fa-quote-left",uppercase:"fa-font",h1:t.classPrefix+"bold",h2:t.classPrefix+"bold",h3:t.classPrefix+"bold",h4:t.classPrefix+"bold",h5:t.classPrefix+"bold",h6:t.classPrefix+"bold","list-ul":"fa-list-ul","list-ol":"fa-list-ol",hr:"fa-minus",link:"fa-link","reference-link":"fa-anchor",image:"fa-picture-o",code:"fa-code","preformatted-text":"fa-file-code-o","code-block":"fa-file-code-o",table:"fa-table",datetime:"fa-clock-o",emoji:"fa-smile-o","html-entities":"fa-copyright",pagebreak:"fa-newspaper-o","goto-line":"fa-terminal",watch:"fa-eye-slash",unwatch:"fa-eye",preview:"fa-desktop",search:"fa-search",fullscreen:"fa-arrows-alt",clear:"fa-eraser",help:"fa-question-circle",info:"fa-info-circle"},toolbarIconTexts:{},lang:{name:"zh-cn",description:"开源在线Markdown编辑器
                Open source online Markdown editor.",tocTitle:"目录",toolbar:{undo:"撤销(Ctrl+Z)",redo:"重做(Ctrl+Y)",bold:"粗体",del:"删除线",italic:"斜体",quote:"引用",ucwords:"将每个单词首字母转成大写",uppercase:"将所选转换成大写",lowercase:"将所选转换成小写",h1:"标题1",h2:"标题2",h3:"标题3",h4:"标题4",h5:"标题5",h6:"标题6","list-ul":"无序列表","list-ol":"有序列表",hr:"横线",link:"链接","reference-link":"引用链接",image:"添加图片",code:"行内代码","preformatted-text":"预格式文本 / 代码块(缩进风格)","code-block":"代码块(多语言风格)",table:"添加表格",datetime:"日期时间",emoji:"Emoji表情","html-entities":"HTML实体字符",pagebreak:"插入分页符","goto-line":"跳转到行",watch:"关闭实时预览",unwatch:"开启实时预览",preview:"全窗口预览HTML(按 Shift + ESC还原)",fullscreen:"全屏(按ESC还原)",clear:"清空",search:"搜索",help:"使用帮助",info:"关于"+t.title},buttons:{enter:"确定",cancel:"取消",close:"关闭"},dialog:{link:{title:"添加链接",url:"链接地址",urlTitle:"链接标题",urlEmpty:"错误:请填写链接地址。"},referenceLink:{title:"添加引用链接",name:"引用名称",url:"链接地址",urlId:"链接ID",urlTitle:"链接标题",nameEmpty:"错误:引用链接的名称不能为空。",idEmpty:"错误:请填写引用链接的ID。",urlEmpty:"错误:请填写引用链接的URL地址。"},image:{title:"添加图片",url:"图片地址",link:"图片链接",alt:"图片描述",uploadButton:"本地上传",imageURLEmpty:"错误:图片地址不能为空。",uploadFileEmpty:"错误:上传的图片不能为空。",formatNotAllowed:"错误:只允许上传图片文件,允许上传的图片文件格式有:"},preformattedText:{title:"添加预格式文本或代码块",emptyAlert:"错误:请填写预格式文本或代码的内容。"},codeBlock:{title:"添加代码块",selectLabel:"代码语言:",selectDefaultText:"请选择代码语言",otherLanguage:"其他语言",unselectedLanguageAlert:"错误:请选择代码所属的语言类型。",codeEmptyAlert:"错误:请填写代码内容。"},htmlEntities:{title:"HTML 实体字符"},help:{title:"使用帮助"}}}},t.classNames={tex:t.classPrefix+"tex"},t.dialogZindex=99999,t.$katex=null,t.$marked=null,t.$CodeMirror=null,t.$prettyPrint=null;var i,o;t.prototype=t.fn={state:{watching:!1,loaded:!1,preview:!1,fullscreen:!1},init:function(i,o){o=o||{},"object"==typeof i&&(o=i);var r=this.classPrefix=t.classPrefix,n=this.settings=e.extend(!0,t.defaults,o);i="object"==typeof i?n.id:i;var a=this.editor=e("#"+i);this.id=i,this.lang=n.lang;var s=this.classNames={textarea:{html:r+"html-textarea",markdown:r+"markdown-textarea"}};n.pluginPath=""===n.pluginPath?n.path+"../plugins/":n.pluginPath,this.state.watching=n.watch?!0:!1,a.hasClass("editormd")||a.addClass("editormd"),a.css({width:"number"==typeof n.width?n.width+"px":n.width,height:"number"==typeof n.height?n.height+"px":n.height}),n.autoHeight&&a.css("height","auto");var l=this.markdownTextarea=a.children("textarea");l.length<1&&(a.append(""),l=this.markdownTextarea=a.children("textarea")),l.addClass(s.textarea.markdown).attr("placeholder",n.placeholder),("undefined"==typeof l.attr("name")||""===l.attr("name"))&&l.attr("name",""!==n.name?n.name:i+"-markdown-doc");var c=[n.readOnly?"":'',n.saveHTMLToTextarea?'':"",'
                ','
                ','
                '].join("\n");return a.append(c).addClass(r+"vertical"),""!==n.theme&&a.addClass(r+"theme-"+n.theme),this.mask=a.children("."+r+"mask"),this.containerMask=a.children("."+r+"container-mask"),""!==n.markdown&&l.val(n.markdown),""!==n.appendMarkdown&&l.val(l.val()+n.appendMarkdown),this.htmlTextarea=a.children("."+s.textarea.html),this.preview=a.children("."+r+"preview"),this.previewContainer=this.preview.children("."+r+"preview-container"),""!==n.previewTheme&&this.preview.addClass(r+"preview-theme-"+n.previewTheme),"function"==typeof define&&define.amd&&("undefined"!=typeof katex&&(t.$katex=katex),n.searchReplace&&!n.readOnly&&(t.loadCSS(n.path+"codemirror/addon/dialog/dialog"),t.loadCSS(n.path+"codemirror/addon/search/matchesonscrollbar"))),"function"==typeof define&&define.amd||!n.autoLoadModules?("undefined"!=typeof CodeMirror&&(t.$CodeMirror=CodeMirror),"undefined"!=typeof marked&&(t.$marked=marked),this.setCodeMirror().setToolbar().loadedDisplay()):this.loadQueues(),this},loadQueues:function(){var e=this,i=this.settings,o=i.path,r=function(){return t.isIE8?void e.loadedDisplay():void(i.flowChart||i.sequenceDiagram?t.loadScript(o+"raphael.min",function(){t.loadScript(o+"underscore.min",function(){!i.flowChart&&i.sequenceDiagram?t.loadScript(o+"sequence-diagram.min",function(){e.loadedDisplay()}):i.flowChart&&!i.sequenceDiagram?t.loadScript(o+"flowchart.min",function(){t.loadScript(o+"jquery.flowchart.min",function(){e.loadedDisplay()})}):i.flowChart&&i.sequenceDiagram&&t.loadScript(o+"flowchart.min",function(){t.loadScript(o+"jquery.flowchart.min",function(){t.loadScript(o+"sequence-diagram.min",function(){e.loadedDisplay()})})})})}):e.loadedDisplay())};return t.loadCSS(o+"codemirror/codemirror.min"),i.searchReplace&&!i.readOnly&&(t.loadCSS(o+"codemirror/addon/dialog/dialog"),t.loadCSS(o+"codemirror/addon/search/matchesonscrollbar")),i.codeFold&&t.loadCSS(o+"codemirror/addon/fold/foldgutter"),t.loadScript(o+"codemirror/codemirror.min",function(){t.$CodeMirror=CodeMirror,t.loadScript(o+"codemirror/modes.min",function(){t.loadScript(o+"codemirror/addons.min",function(){return e.setCodeMirror(),"gfm"!==i.mode&&"markdown"!==i.mode?(e.loadedDisplay(),!1):(e.setToolbar(),void t.loadScript(o+"marked.min",function(){t.$marked=marked,i.previewCodeHighlight?t.loadScript(o+"prettify.min",function(){r()}):r()}))})})}),this},setTheme:function(e){var t=this.editor,i=this.settings.theme,o=this.classPrefix+"theme-";return t.removeClass(o+i).addClass(o+e),this.settings.theme=e,this},setEditorTheme:function(e){var i=this.settings;return i.editorTheme=e,"default"!==e&&t.loadCSS(i.path+"codemirror/theme/"+i.editorTheme),this.cm.setOption("theme",e),this},setCodeMirrorTheme:function(e){return this.setEditorTheme(e),this},setPreviewTheme:function(e){var t=this.preview,i=this.settings.previewTheme,o=this.classPrefix+"preview-theme-";return t.removeClass(o+i).addClass(o+e),this.settings.previewTheme=e,this},setCodeMirror:function(){var e=this.settings,i=this.editor;"default"!==e.editorTheme&&t.loadCSS(e.path+"codemirror/theme/"+e.editorTheme);var o={mode:e.mode,theme:e.editorTheme,tabSize:e.tabSize,dragDrop:!1,autofocus:e.autoFocus,autoCloseTags:e.autoCloseTags,readOnly:e.readOnly?"nocursor":!1,indentUnit:e.indentUnit,lineNumbers:e.lineNumbers,lineWrapping:e.lineWrapping,extraKeys:{"Ctrl-Q":function(e){e.foldCode(e.getCursor())}},foldGutter:e.codeFold,gutters:["CodeMirror-linenumbers","CodeMirror-foldgutter"],matchBrackets:e.matchBrackets,indentWithTabs:e.indentWithTabs,styleActiveLine:e.styleActiveLine,styleSelectedText:e.styleSelectedText,autoCloseBrackets:e.autoCloseBrackets,showTrailingSpace:e.showTrailingSpace,highlightSelectionMatches:e.matchWordHighlight?{showToken:"onselected"===e.matchWordHighlight?!1:/\w/}:!1};return this.codeEditor=this.cm=t.$CodeMirror.fromTextArea(this.markdownTextarea[0],o),this.codeMirror=this.cmElement=i.children(".CodeMirror"),""!==e.value&&this.cm.setValue(e.value),this.codeMirror.css({fontSize:e.fontSize,width:e.watch?"50%":"100%"}),e.autoHeight&&(this.codeMirror.css("height","auto"),this.cm.setOption("viewportMargin",1/0)),e.lineNumbers||this.codeMirror.find(".CodeMirror-gutters").css("border-right","none"),this},getCodeMirrorOption:function(e){return this.cm.getOption(e)},setCodeMirrorOption:function(e,t){return this.cm.setOption(e,t),this},addKeyMap:function(e,t){return this.cm.addKeyMap(e,t),this},removeKeyMap:function(e){return this.cm.removeKeyMap(e),this},gotoLine:function(t){var i=this.settings;if(!i.gotoLine)return this;var o=this.cm,r=(this.editor,o.lineCount()),n=this.preview;if("string"==typeof t&&("last"===t&&(t=r),"first"===t&&(t=1)),"number"!=typeof t)return alert("Error: The line number must be an integer."),this;if(t=parseInt(t)-1,t>r)return alert("Error: The line number range 1-"+r),this;o.setCursor({line:t,ch:0});var a=o.getScrollInfo(),s=a.clientHeight,l=o.charCoords({line:t,ch:0},"local");if(o.scrollTo(null,(l.top+l.bottom-s)/2),i.watch){var c=this.codeMirror.find(".CodeMirror-scroll")[0],h=e(c).height(),d=c.scrollTop,u=d/c.scrollHeight;n.scrollTop(0===d?0:d+h>=c.scrollHeight-16?n[0].scrollHeight:n[0].scrollHeight*u)}return o.focus(),this},extend:function(){return"undefined"!=typeof arguments[1]&&("function"==typeof arguments[1]&&(arguments[1]=e.proxy(arguments[1],this)),this[arguments[0]]=arguments[1]),"object"==typeof arguments[0]&&"undefined"==typeof arguments[0].length&&e.extend(!0,this,arguments[0]),this},set:function(t,i){return"undefined"!=typeof i&&"function"==typeof i&&(i=e.proxy(i,this)),this[t]=i,this},config:function(t,i){var o=this.settings;return"object"==typeof t&&(o=e.extend(!0,o,t)),"string"==typeof t&&(o[t]=i),this.settings=o,this.recreate(),this},on:function(t,i){var o=this.settings;return"undefined"!=typeof o["on"+t]&&(o["on"+t]=e.proxy(i,this)),this},off:function(e){var t=this.settings;return"undefined"!=typeof t["on"+e]&&(t["on"+e]=function(){}),this},showToolbar:function(t){var i=this.settings;return i.readOnly?this:(i.toolbar&&(this.toolbar.length<1||""===this.toolbar.find("."+this.classPrefix+"menu").html())&&this.setToolbar(),i.toolbar=!0,this.toolbar.show(),this.resize(),e.proxy(t||function(){},this)(),this)},hideToolbar:function(t){var i=this.settings;return i.toolbar=!1,this.toolbar.hide(),this.resize(),e.proxy(t||function(){},this)(),this},setToolbarAutoFixed:function(t){var i=this.state,o=this.editor,r=this.toolbar,n=this.settings;"undefined"!=typeof t&&(n.toolbarAutoFixed=t);var a=function(){var t=e(window),i=t.scrollTop();return n.toolbarAutoFixed?void r.css(i-o.offset().top>10&&i
                  ';i.append(n),r=this.toolbar=i.children("."+o+"toolbar")}if(!e.toolbar)return r.hide(),this;r.show();for(var a="function"==typeof e.toolbarIcons?e.toolbarIcons():"string"==typeof e.toolbarIcons?t.toolbarModes[e.toolbarIcons]:e.toolbarIcons,s=r.find("."+this.classPrefix+"menu"),l="",c=!1,h=0,d=a.length;d>h;h++){var u=a[h];if("||"===u)c=!0;else if("|"===u)l+='
                • |
                • ';else{var f=/h(\d)/.test(u),g=u;"watch"!==u||e.watch||(g="unwatch");var p=e.lang.toolbar[g],m=e.toolbarIconTexts[g],w=e.toolbarIconsClass[g];p="undefined"==typeof p?"":p,m="undefined"==typeof m?"":m,w="undefined"==typeof w?"":w;var v=c?'
                • ':"
                • ";"undefined"!=typeof e.toolbarCustomIcons[u]&&"function"!=typeof e.toolbarCustomIcons[u]?v+=e.toolbarCustomIcons[u]:(v+='',v+=''+(f?u.toUpperCase():""===w?m:"")+"",v+=""),v+="
                • ",l=c?v+l:l+v}}return s.html(l),s.find('[title="Lowercase"]').attr("title",e.lang.toolbar.lowercase),s.find('[title="ucwords"]').attr("title",e.lang.toolbar.ucwords),this.setToolbarHandler(),this.setToolbarAutoFixed(),this},dialogLockScreen:function(){return e.proxy(t.dialogLockScreen,this)(),this},dialogShowMask:function(i){return e.proxy(t.dialogShowMask,this)(i),this},getToolbarHandles:function(e){var i=this.toolbarHandlers=t.toolbarHandlers;return e&&"undefined"!=typeof toolbarIconHandlers[e]?i[e]:i},setToolbarHandler:function(){var i=this,o=this.settings;if(!o.toolbar||o.readOnly)return this;var r=this.toolbar,n=this.cm,a=this.classPrefix,s=this.toolbarIcons=r.find("."+a+"menu > li > a"),l=this.getToolbarHandles();return s.bind(t.mouseOrTouch("click","touchend"),function(t){var r=e(this).children(".fa"),a=r.attr("name"),s=n.getCursor(),c=n.getSelection();return""!==a?(i.activeIcon=r,"undefined"!=typeof l[a]?e.proxy(l[a],i)(n):"undefined"!=typeof o.toolbarHandlers[a]&&e.proxy(o.toolbarHandlers[a],i)(n,r,s,c),"link"!==a&&"reference-link"!==a&&"image"!==a&&"code-block"!==a&&"preformatted-text"!==a&&"watch"!==a&&"preview"!==a&&"search"!==a&&"fullscreen"!==a&&"info"!==a&&n.focus(),!1):void 0}),this},createDialog:function(i){return e.proxy(t.createDialog,this)(i)},createInfoDialog:function(){var e=this,i=this.editor,o=this.classPrefix,r=['
                  ','
                  ','

                  '+t.title+"v"+t.version+"

                  ","

                  "+this.lang.description+"

                  ",'

                  '+t.homePage+'

                  ','

                  Copyright © 2015 Pandao, The MIT License.

                  ',"
                  ",'',"
                  "].join("\n");i.append(r);var n=this.infoDialog=i.children("."+o+"dialog-info");return n.find("."+o+"dialog-close").bind(t.mouseOrTouch("click","touchend"),function(){e.hideInfoDialog()}),n.css("border",t.isIE8?"1px solid #ddd":"").css("z-index",t.dialogZindex).show(),this.infoDialogPosition(),this},infoDialogPosition:function(){var t=this.infoDialog,i=function(){t.css({top:(e(window).height()-t.height())/2+"px",left:(e(window).width()-t.width())/2+"px"})};return i(),e(window).resize(i),this},showInfoDialog:function(){e("html,body").css("overflow-x","hidden");var i=this.editor,o=this.settings,r=this.infoDialog=i.children("."+this.classPrefix+"dialog-info");return r.length<1&&this.createInfoDialog(),this.lockScreen(!0),this.mask.css({opacity:o.dialogMaskOpacity,backgroundColor:o.dialogMaskBgColor}).show(),r.css("z-index",t.dialogZindex).show(),this.infoDialogPosition(),this},hideInfoDialog:function(){return e("html,body").css("overflow-x",""),this.infoDialog.hide(),this.mask.hide(),this.lockScreen(!1),this},lockScreen:function(e){return t.lockScreen(e),this.resize(),this},recreate:function(){var e=this.editor,t=this.settings;return this.codeMirror.remove(),this.setCodeMirror(),t.readOnly||(e.find(".editormd-dialog").length>0&&e.find(".editormd-dialog").remove(),t.toolbar&&(this.getToolbarHandles(),this.setToolbar())),this.loadedDisplay(!0),this},previewCodeHighlight:function(){var e=this.settings,t=this.previewContainer;return e.previewCodeHighlight&&(t.find("pre").addClass("prettyprint linenums"),"undefined"!=typeof prettyPrint&&prettyPrint()),this},katexRender:function(){return null===i?this:(this.previewContainer.find("."+t.classNames.tex).each(function(){var i=e(this);t.$katex.render(i.text(),i[0]),i.find(".katex").css("font-size","1.6em")}),this)},flowChartAndSequenceDiagramRender:function(){var i=this,r=this.settings,n=this.previewContainer;if(t.isIE8)return this;if(r.flowChart){if(null===o)return this;n.find(".flowchart").flowChart()}r.sequenceDiagram&&n.find(".sequence-diagram").sequenceDiagram({theme:"simple"});var a=i.preview,s=i.codeMirror,l=s.find(".CodeMirror-scroll"),c=l.height(),h=l.scrollTop(),d=h/l[0].scrollHeight,u=0;a.find(".markdown-toc-list").each(function(){u+=e(this).height()});var f=a.find(".editormd-toc-menu").height();return f=f?f:0,a.scrollTop(0===h?0:h+c>=l[0].scrollHeight-16?a[0].scrollHeight:(a[0].scrollHeight+u+f)*d),this},registerKeyMaps:function(i){var o=this,r=this.cm,n=this.settings,a=t.toolbarHandlers,s=n.disabledKeyMaps;if(i=i||null){for(var l in i)if(e.inArray(l,s)<0){var c={};c[l]=i[l],r.addKeyMap(i)}}else{for(var h in t.keyMaps){var d=t.keyMaps[h],u="string"==typeof d?e.proxy(a[d],o):e.proxy(d,o);if(e.inArray(h,["F9","F10","F11"])<0&&e.inArray(h,s)<0){var f={};f[h]=u,r.addKeyMap(f)}}e(window).keydown(function(t){var i={120:"F9",121:"F10",122:"F11"};if(e.inArray(i[t.keyCode],s)<0)switch(t.keyCode){case 120:return e.proxy(a.watch,o)(),!1;case 121:return e.proxy(a.preview,o)(),!1;case 122:return e.proxy(a.fullscreen,o)(),!1}})}return this},bindScrollEvent:function(){var i=this,o=this.preview,r=this.settings,n=this.codeMirror,a=t.mouseOrTouch;if(!r.syncScrolling)return this;var s=function(){n.find(".CodeMirror-scroll").bind(a("scroll","touchmove"),function(t){var n=e(this).height(),a=e(this).scrollTop(),s=a/e(this)[0].scrollHeight,l=0;o.find(".markdown-toc-list").each(function(){l+=e(this).height()});var c=o.find(".editormd-toc-menu").height();c=c?c:0,o.scrollTop(0===a?0:a+n>=e(this)[0].scrollHeight-16?o[0].scrollHeight:(o[0].scrollHeight+l+c)*s),e.proxy(r.onscroll,i)(t)})},l=function(){n.find(".CodeMirror-scroll").unbind(a("scroll","touchmove"))},c=function(){o.bind(a("scroll","touchmove"),function(t){var o=e(this).height(),a=e(this).scrollTop(),s=a/e(this)[0].scrollHeight,l=n.find(".CodeMirror-scroll");l.scrollTop(0===a?0:a+o>=e(this)[0].scrollHeight?l[0].scrollHeight:l[0].scrollHeight*s),e.proxy(r.onpreviewscroll,i)(t)})},h=function(){o.unbind(a("scroll","touchmove"))};return n.bind({mouseover:s,mouseout:l,touchstart:s,touchend:l}),"single"===r.syncScrolling?this:(o.bind({mouseover:c,mouseout:h,touchstart:c,touchend:h}),this)},bindChangeEvent:function(){var e=this,t=this.cm,o=this.settings;return o.syncScrolling?(t.on("change",function(t,r){o.watch&&e.previewContainer.css("padding",o.autoHeight?"20px 20px 50px 40px":"20px"),i=setTimeout(function(){clearTimeout(i),e.save(),i=null},o.delay)}),this):this},loadedDisplay:function(t){t=t||!1;var i=this,o=this.editor,r=this.preview,n=this.settings;return this.containerMask.hide(),this.save(),n.watch&&r.show(),o.data("oldWidth",o.width()).data("oldHeight",o.height()),this.resize(),this.registerKeyMaps(),e(window).resize(function(){i.resize()}),this.bindScrollEvent().bindChangeEvent(),t||e.proxy(n.onload,this)(),this.state.loaded=!0,this},width:function(e){return this.editor.css("width","number"==typeof e?e+"px":e),this.resize(),this},height:function(e){return this.editor.css("height","number"==typeof e?e+"px":e),this.resize(),this},resize:function(t,i){t=t||null,i=i||null;var o=this.state,r=this.editor,n=this.preview,a=this.toolbar,s=this.settings,l=this.codeMirror;if(t&&r.css("width","number"==typeof t?t+"px":t),!s.autoHeight||o.fullscreen||o.preview?(i&&r.css("height","number"==typeof i?i+"px":i),o.fullscreen&&r.height(e(window).height()),s.toolbar&&!s.readOnly?l.css("margin-top",a.height()+1).height(r.height()-a.height()):l.css("margin-top",0).height(r.height())):(r.css("height","auto"),l.css("height","auto")),s.watch)if(l.width(r.width()/2),n.width(o.preview?r.width():r.width()/2),this.previewContainer.css("padding",s.autoHeight?"20px 20px 50px 40px":"20px"),s.toolbar&&!s.readOnly?n.css("top",a.height()+1):n.css("top",0),!s.autoHeight||o.fullscreen||o.preview){var c=s.toolbar&&!s.readOnly?r.height()-a.height():r.height();n.height(c)}else n.height("");else l.width(r.width()),n.hide();return o.loaded&&e.proxy(s.onresize,this)(),this},save:function(){if(null===i)return this;var r=this,n=this.state,a=this.settings,s=this.cm,l=s.getValue(),c=this.previewContainer;if("gfm"!==a.mode&&"markdown"!==a.mode)return this.markdownTextarea.val(l),this;var h=t.$marked,d=this.markdownToC=[],u=this.markedRendererOptions={toc:a.toc,tocm:a.tocm,tocStartLevel:a.tocStartLevel,pageBreak:a.pageBreak,taskList:a.taskList,emoji:a.emoji,tex:a.tex,atLink:a.atLink,emailLink:a.emailLink,flowChart:a.flowChart,sequenceDiagram:a.sequenceDiagram,previewCodeHighlight:a.previewCodeHighlight},f=this.markedOptions={renderer:t.markedRenderer(d,u),gfm:!0,tables:!0,breaks:!0,pedantic:!1,sanitize:a.htmlDecode?!1:!0,smartLists:!0,smartypants:!0};h.setOptions(f);var g=t.$marked(l,f);if(g=t.filterHTMLTags(g,a.htmlDecode),this.markdownTextarea.text(l),s.save(),a.saveHTMLToTextarea&&this.htmlTextarea.text(g),a.watch||!a.watch&&n.preview){if(c.html(g),this.previewCodeHighlight(),a.toc){var p=""===a.tocContainer?c:e(a.tocContainer),m=p.find("."+this.classPrefix+"toc-menu");p.attr("previewContainer",""===a.tocContainer?"true":"false"),""!==a.tocContainer&&m.length>0&&m.remove(),t.markdownToCRenderer(d,p,a.tocDropdown,a.tocStartLevel),(a.tocDropdown||p.find("."+this.classPrefix+"toc-menu").length>0)&&t.tocDropdownMenu(p,""!==a.tocTitle?a.tocTitle:this.lang.tocTitle),""!==a.tocContainer&&c.find(".markdown-toc").css("border","none")}a.tex&&(!t.kaTeXLoaded&&a.autoLoadModules?t.loadKaTeX(function(){t.$katex=katex,t.kaTeXLoaded=!0,r.katexRender()}):(t.$katex=katex,this.katexRender())),(a.flowChart||a.sequenceDiagram)&&(o=setTimeout(function(){clearTimeout(o),r.flowChartAndSequenceDiagramRender(),o=null},10)),n.loaded&&e.proxy(a.onchange,this)()}return this},focus:function(){return this.cm.focus(),this},setCursor:function(e){return this.cm.setCursor(e),this},getCursor:function(){return this.cm.getCursor()},setSelection:function(e,t){return this.cm.setSelection(e,t),this},getSelection:function(){return this.cm.getSelection()},setSelections:function(e){return this.cm.setSelections(e),this},getSelections:function(){return this.cm.getSelections()},replaceSelection:function(e){return this.cm.replaceSelection(e),this},insertValue:function(e){return this.replaceSelection(e),this},appendMarkdown:function(e){var t=(this.settings,this.cm);return t.setValue(t.getValue()+e),this},setMarkdown:function(e){return this.cm.setValue(e||this.settings.markdown),this},getMarkdown:function(){return this.cm.getValue()},getValue:function(){return this.cm.getValue()},setValue:function(e){return this.cm.setValue(e),this},clear:function(){return this.cm.setValue(""),this},getHTML:function(){return this.settings.saveHTMLToTextarea?this.htmlTextarea.val():(alert("Error: settings.saveHTMLToTextarea == false"),!1)},getTextareaSavedHTML:function(){return this.getHTML()},getPreviewedHTML:function(){return this.settings.watch?this.previewContainer.html():(alert("Error: settings.watch == false"),!1)},watch:function(t){var o=this.settings;if(e.inArray(o.mode,["gfm","markdown"])<0)return this;if(this.state.watching=o.watch=!0,this.preview.show(),this.toolbar){var r=o.toolbarIconsClass.watch,n=o.toolbarIconsClass.unwatch,a=this.toolbar.find(".fa[name=watch]");a.parent().attr("title",o.lang.toolbar.watch),a.removeClass(n).addClass(r)}return this.codeMirror.css("border-right","1px solid #ddd").width(this.editor.width()/2),i=0,this.save().resize(),o.onwatch||(o.onwatch=t||function(){}),e.proxy(o.onwatch,this)(),this},unwatch:function(t){var i=this.settings;if(this.state.watching=i.watch=!1,this.preview.hide(),this.toolbar){var o=i.toolbarIconsClass.watch,r=i.toolbarIconsClass.unwatch,n=this.toolbar.find(".fa[name=watch]");n.parent().attr("title",i.lang.toolbar.unwatch),n.removeClass(o).addClass(r)}return this.codeMirror.css("border-right","none").width(this.editor.width()),this.resize(),i.onunwatch||(i.onunwatch=t||function(){}),e.proxy(i.onunwatch,this)(),this},show:function(t){t=t||function(){};var i=this;return this.editor.show(0,function(){e.proxy(t,i)()}),this},hide:function(t){t=t||function(){};var i=this;return this.editor.hide(0,function(){e.proxy(t,i)()}),this},previewing:function(){var i=this,o=this.editor,r=this.preview,n=this.toolbar,a=this.settings,s=this.codeMirror,l=this.previewContainer;if(e.inArray(a.mode,["gfm","markdown"])<0)return this;a.toolbar&&n&&(n.toggle(),n.find(".fa[name=preview]").toggleClass("active")),s.toggle();var c=function(e){e.shiftKey&&27===e.keyCode&&i.previewed()};"none"===s.css("display")?(this.state.preview=!0,this.state.fullscreen&&r.css("background","#fff"),o.find("."+this.classPrefix+"preview-close-btn").show().bind(t.mouseOrTouch("click","touchend"),function(){i.previewed()}),a.watch?l.css("padding",""):this.save(),l.addClass(this.classPrefix+"preview-active"),r.show().css({position:"",top:0,width:o.width(),height:a.autoHeight&&!this.state.fullscreen?"auto":o.height()}),this.state.loaded&&e.proxy(a.onpreviewing,this)(),e(window).bind("keyup",c)):(e(window).unbind("keyup",c),this.previewed())},previewed:function(){var i=this.editor,o=this.preview,r=this.toolbar,n=this.settings,a=this.previewContainer,s=i.find("."+this.classPrefix+"preview-close-btn");return this.state.preview=!1,this.codeMirror.show(),n.toolbar&&r.show(),o[n.watch?"show":"hide"](),s.hide().unbind(t.mouseOrTouch("click","touchend")),a.removeClass(this.classPrefix+"preview-active"),n.watch&&a.css("padding","20px"),o.css({background:null,position:"absolute",width:i.width()/2,height:n.autoHeight&&!this.state.fullscreen?"auto":i.height()-r.height(),top:n.toolbar?r.height():0}),this.state.loaded&&e.proxy(n.onpreviewed,this)(),this},fullscreen:function(){var t=this,i=this.state,o=this.editor,r=(this.preview,this.toolbar),n=this.settings,a=this.classPrefix+"fullscreen";r&&r.find(".fa[name=fullscreen]").parent().toggleClass("active");var s=function(e){e.shiftKey||27!==e.keyCode||i.fullscreen&&t.fullscreenExit()};return o.hasClass(a)?(e(window).unbind("keyup",s),this.fullscreenExit()):(i.fullscreen=!0,e("html,body").css("overflow","hidden"),o.css({width:e(window).width(),height:e(window).height()}).addClass(a),this.resize(),e.proxy(n.onfullscreen,this)(),e(window).bind("keyup",s)),this},fullscreenExit:function(){var t=this.editor,i=this.settings,o=this.toolbar,r=this.classPrefix+"fullscreen";return this.state.fullscreen=!1,o&&o.find(".fa[name=fullscreen]").parent().removeClass("active"),e("html,body").css("overflow",""),t.css({width:t.data("oldWidth"),height:t.data("oldHeight")}).removeClass(r),this.resize(),e.proxy(i.onfullscreenExit,this)(),this},executePlugin:function(i,o){var r=this,n=this.cm,a=this.settings;return o=a.pluginPath+o,"function"==typeof define?"undefined"==typeof this[i]?(alert("Error: "+i+" plugin is not found, you are not load this plugin."),this):(this[i](n),this):(e.inArray(o,t.loadFiles.plugin)<0?t.loadPlugin(o,function(){t.loadPlugins[i]=r[i],r[i](n)}):e.proxy(t.loadPlugins[i],this)(n),this)},search:function(e){var t=this.settings;return t.searchReplace?(t.readOnly||this.cm.execCommand(e||"find"),this):(alert("Error: settings.searchReplace == false"),this)},searchReplace:function(){return this.search("replace"),this},searchReplaceAll:function(){return this.search("replaceAll"),this}},t.fn.init.prototype=t.fn,t.dialogLockScreen=function(){var t=this.settings||{dialogLockScreen:!0};t.dialogLockScreen&&(e("html,body").css("overflow","hidden"),this.resize())},t.dialogShowMask=function(t){var i=this.editor,o=this.settings||{dialogShowMask:!0};t.css({top:(e(window).height()-t.height())/2+"px",left:(e(window).width()-t.width())/2+"px"}),o.dialogShowMask&&i.children("."+this.classPrefix+"mask").css("z-index",parseInt(t.css("z-index"))-1).show()},t.toolbarHandlers={undo:function(){this.cm.undo()},redo:function(){this.cm.redo()},bold:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("**"+i+"**"),""===i&&e.setCursor(t.line,t.ch+2)},del:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("~~"+i+"~~"),""===i&&e.setCursor(t.line,t.ch+2)},italic:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("*"+i+"*"),""===i&&e.setCursor(t.line,t.ch+1)},quote:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("> "+i),e.setCursor(t.line,t.ch+2)):e.replaceSelection("> "+i)},ucfirst:function(){var e=this.cm,i=e.getSelection(),o=e.listSelections();e.replaceSelection(t.firstUpperCase(i)),e.setSelections(o)},ucwords:function(){var e=this.cm,i=e.getSelection(),o=e.listSelections();e.replaceSelection(t.wordsFirstUpperCase(i)),e.setSelections(o)},uppercase:function(){var e=this.cm,t=e.getSelection(),i=e.listSelections();e.replaceSelection(t.toUpperCase()),e.setSelections(i)},lowercase:function(){var e=this.cm,t=(e.getCursor(),e.getSelection()),i=e.listSelections();e.replaceSelection(t.toLowerCase()),e.setSelections(i)},h1:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("# "+i),e.setCursor(t.line,t.ch+2)):e.replaceSelection("# "+i)},h2:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0), +e.replaceSelection("## "+i),e.setCursor(t.line,t.ch+3)):e.replaceSelection("## "+i)},h3:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("### "+i),e.setCursor(t.line,t.ch+4)):e.replaceSelection("### "+i)},h4:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("#### "+i),e.setCursor(t.line,t.ch+5)):e.replaceSelection("#### "+i)},h5:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("##### "+i),e.setCursor(t.line,t.ch+6)):e.replaceSelection("##### "+i)},h6:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();0!==t.ch?(e.setCursor(t.line,0),e.replaceSelection("###### "+i),e.setCursor(t.line,t.ch+7)):e.replaceSelection("###### "+i)},"list-ul":function(){var e=this.cm,t=(e.getCursor(),e.getSelection());if(""===t)e.replaceSelection("- "+t);else{for(var i=t.split("\n"),o=0,r=i.length;r>o;o++)i[o]=""===i[o]?"":"- "+i[o];e.replaceSelection(i.join("\n"))}},"list-ol":function(){var e=this.cm,t=(e.getCursor(),e.getSelection());if(""===t)e.replaceSelection("1. "+t);else{for(var i=t.split("\n"),o=0,r=i.length;r>o;o++)i[o]=""===i[o]?"":o+1+". "+i[o];e.replaceSelection(i.join("\n"))}},hr:function(){{var e=this.cm,t=e.getCursor();e.getSelection()}e.replaceSelection((0!==t.ch?"\n\n":"\n")+"------------\n\n")},tex:function(){if(!this.settings.tex)return alert("settings.tex === false"),this;var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("$$"+i+"$$"),""===i&&e.setCursor(t.line,t.ch+2)},link:function(){this.executePlugin("linkDialog","link-dialog/link-dialog")},"reference-link":function(){this.executePlugin("referenceLinkDialog","reference-link-dialog/reference-link-dialog")},pagebreak:function(){if(!this.settings.pageBreak)return alert("settings.pageBreak === false"),this;{var e=this.cm;e.getSelection()}e.replaceSelection("\r\n[========]\r\n")},image:function(){this.executePlugin("imageDialog","image-dialog/image-dialog")},code:function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection("`"+i+"`"),""===i&&e.setCursor(t.line,t.ch+1)},"code-block":function(){this.executePlugin("codeBlockDialog","code-block-dialog/code-block-dialog")},"preformatted-text":function(){this.executePlugin("preformattedTextDialog","preformatted-text-dialog/preformatted-text-dialog")},table:function(){this.executePlugin("tableDialog","table-dialog/table-dialog")},datetime:function(){var e=this.cm,i=(e.getSelection(),new Date,this.settings.lang.name),o=t.dateFormat()+" "+t.dateFormat("zh-cn"===i||"zh-tw"===i?"cn-week-day":"week-day");e.replaceSelection(o)},emoji:function(){this.executePlugin("emojiDialog","emoji-dialog/emoji-dialog")},"html-entities":function(){this.executePlugin("htmlEntitiesDialog","html-entities-dialog/html-entities-dialog")},"goto-line":function(){this.executePlugin("gotoLineDialog","goto-line-dialog/goto-line-dialog")},watch:function(){this[this.settings.watch?"unwatch":"watch"]()},preview:function(){this.previewing()},fullscreen:function(){this.fullscreen()},clear:function(){this.clear()},search:function(){this.search()},help:function(){this.executePlugin("helpDialog","help-dialog/help-dialog")},info:function(){this.showInfoDialog()}},t.keyMaps={"Ctrl-1":"h1","Ctrl-2":"h2","Ctrl-3":"h3","Ctrl-4":"h4","Ctrl-5":"h5","Ctrl-6":"h6","Ctrl-B":"bold","Ctrl-D":"datetime","Ctrl-E":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();return this.settings.emoji?(e.replaceSelection(":"+i+":"),void(""===i&&e.setCursor(t.line,t.ch+1))):void alert("Error: settings.emoji == false")},"Ctrl-Alt-G":"goto-line","Ctrl-H":"hr","Ctrl-I":"italic","Ctrl-K":"code","Ctrl-L":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection(),o=""===i?"":' "'+i+'"';e.replaceSelection("["+i+"]("+o+")"),""===i&&e.setCursor(t.line,t.ch+1)},"Ctrl-U":"list-ul","Shift-Ctrl-A":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();return this.settings.atLink?(e.replaceSelection("@"+i),void(""===i&&e.setCursor(t.line,t.ch+1))):void alert("Error: settings.atLink == false")},"Shift-Ctrl-C":"code","Shift-Ctrl-Q":"quote","Shift-Ctrl-S":"del","Shift-Ctrl-K":"tex","Shift-Alt-C":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection();e.replaceSelection(["```",i,"```"].join("\n")),""===i&&e.setCursor(t.line,t.ch+3)},"Shift-Ctrl-Alt-C":"code-block","Shift-Ctrl-H":"html-entities","Shift-Alt-H":"help","Shift-Ctrl-E":"emoji","Shift-Ctrl-U":"uppercase","Shift-Alt-U":"ucwords","Shift-Ctrl-Alt-U":"ucfirst","Shift-Alt-L":"lowercase","Shift-Ctrl-I":function(){var e=this.cm,t=e.getCursor(),i=e.getSelection(),o=""===i?"":' "'+i+'"';e.replaceSelection("!["+i+"]("+o+")"),""===i&&e.setCursor(t.line,t.ch+4)},"Shift-Ctrl-Alt-I":"image","Shift-Ctrl-L":"link","Shift-Ctrl-O":"list-ol","Shift-Ctrl-P":"preformatted-text","Shift-Ctrl-T":"table","Shift-Alt-P":"pagebreak",F9:"watch",F10:"preview",F11:"fullscreen"};var r=function(e){return String.prototype.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")};t.trim=r;var n=function(e){return e.toLowerCase().replace(/\b(\w)|\s(\w)/g,function(e){return e.toUpperCase()})};t.ucwords=t.wordsFirstUpperCase=n;var a=function(e){return e.toLowerCase().replace(/\b(\w)/,function(e){return e.toUpperCase()})};return t.firstUpperCase=t.ucfirst=a,t.urls={atLinkBase:"https://github.com/"},t.regexs={atLink:/@(\w+)/g,email:/(\w+)@(\w+)\.(\w+)\.?(\w+)?/g,emailLink:/(mailto:)?([\w\.\_]+)@(\w+)\.(\w+)\.?(\w+)?/g,emoji:/:([\w\+-]+):/g,emojiDatetime:/(\d{2}:\d{2}:\d{2})/g,twemoji:/:(tw-([\w]+)-?(\w+)?):/g,fontAwesome:/:(fa-([\w]+)(-(\w+)){0,}):/g,editormdLogo:/:(editormd-logo-?(\w+)?):/g,pageBreak:/^\[[=]{8,}\]$/},t.emoji={path:"http://www.emoji-cheat-sheet.com/graphics/emojis/",ext:".png"},t.twemoji={path:"http://twemoji.maxcdn.com/36x36/",ext:".png"},t.markedRenderer=function(i,o){var n={toc:!0,tocm:!1,tocStartLevel:1,pageBreak:!0,atLink:!0,emailLink:!0,taskList:!1,emoji:!1,tex:!1,flowChart:!1,sequenceDiagram:!1},a=e.extend(n,o||{}),s=t.$marked,l=new s.Renderer;i=i||[];var c=t.regexs,h=c.atLink,d=c.emoji,u=c.email,f=c.emailLink,g=c.twemoji,p=c.fontAwesome,m=c.editormdLogo,w=c.pageBreak;return l.emoji=function(e){e=e.replace(t.regexs.emojiDatetime,function(e){return e.replace(/:/g,":")});var i=e.match(d);if(!i||!a.emoji)return e;for(var o=0,r=i.length;r>o;o++)":+1:"===i[o]&&(i[o]=":\\+1:"),e=e.replace(new RegExp(i[o]),function(e,i){var o=e.match(p),r=e.replace(/:/g,"");if(o)for(var n=0,a=o.length;a>n;n++){var s=o[n].replace(/:/g,"");return''}else{var l=e.match(m),c=e.match(g);if(l)for(var h=0,d=l.length;d>h;h++){var u=l[h].replace(/:/g,"");return''}else{if(!c){var f="+1"===r?"plus1":r;return f="black_large_square"===f?"black_square":f,f="moon"===f?"waxing_gibbous_moon":f,':'+r+':'}for(var w=0,v=c.length;v>w;w++){var k=c[w].replace(/:/g,"").replace("tw-","");return'twemoji-'+k+''}}}});return e},l.atLink=function(i){return h.test(i)?(a.atLink&&(i=i.replace(u,function(e,t,i,o){return e.replace(/@/g,"_#_@_#_")}),i=i.replace(h,function(e,i){return''+e+""}).replace(/_#_@_#_/g,"@")),a.emailLink&&(i=i.replace(f,function(t,i,o,r,n){return!i&&e.inArray(n,"jpg|jpeg|png|gif|webp|ico|icon|pdf".split("|"))<0?''+t+"":t})),i):i},l.link=function(e,t,i){if(this.options.sanitize){try{var o=decodeURIComponent(unescape(e)).replace(/[^\w:]/g,"").toLowerCase()}catch(r){return""}if(0===o.indexOf("javascript:"))return""}var n=''+i.replace(/@/g,"@")+""):(t&&(n+=' title="'+t+'"'),n+=">"+i+"")},l.heading=function(e,t,o){var n=e,a=/\s*\]*)\>(.*)\<\/a\>\s*/;if(a.test(e)){var s=[];e=e.split(/\]+)\>([^\>]*)\<\/a\>/);for(var l=0,c=e.length;c>l;l++)s.push(e[l].replace(/\s*href\=\"(.*)\"\s*/g,""));e=s.join(" ")}e=r(e);var h=e.toLowerCase().replace(/[^\w]+/g,"-"),d={text:e,level:t,slug:h},u=/^[\u4e00-\u9fa5]+$/.test(e),f=u?escape(e).replace(/\%/g,""):e.toLowerCase().replace(/[^\w]+/g,"-");i.push(d);var g="';return g+='',g+='',g+=this.atLink(a?this.emoji(n):this.emoji(e)),g+=""},l.pageBreak=function(e){return w.test(e)&&a.pageBreak&&(e='
                  '),e},l.paragraph=function(e){var i=/\$\$(.*)\$\$/g.test(e),o=/^\$\$(.*)\$\$$/.test(e),r=o?' class="'+t.classNames.tex+'"':"",n=a.tocm?/^(\[TOC\]|\[TOCM\])$/.test(e):/^\[TOC\]$/.test(e),s=/^\[TOCM\]$/.test(e);e=!o&&i?e.replace(/(\$\$([^\$]*)\$\$)+/g,function(e,i){return''+i.replace(/\$/g,"")+""}):o?e.replace(/\$/g,""):e;var l='
                  '+e+"
                  ";return n?s?'
                  '+l+"

                  ":l:w.test(e)?this.pageBreak(e):""+this.atLink(this.emoji(e))+"

                  \n"},l.code=function(e,i,o){return"seq"===i||"sequence"===i?'
                  '+e+"
                  ":"flow"===i?'
                  '+e+"
                  ":"math"===i||"latex"===i||"katex"===i?'

                  '+e+"

                  ":s.Renderer.prototype.code.apply(this,arguments)},l.tablecell=function(e,t){var i=t.header?"th":"td",o=t.align?"<"+i+' style="text-align:'+t.align+'">':"<"+i+">";return o+this.atLink(this.emoji(e))+"\n"},l.listitem=function(e){return a.taskList&&/^\s*\[[x\s]\]\s*/.test(e)?(e=e.replace(/^\s*\[\s\]\s*/,' ').replace(/^\s*\[x\]\s*/,' '),'
                • '+this.atLink(this.emoji(e))+"
                • "):"
                • "+this.atLink(this.emoji(e))+"
                • "},l},t.markdownToCRenderer=function(e,t,i,o){var r="",n=0,a=this.classPrefix;o=o||1;for(var s=0,l=e.length;l>s;s++){var c=e[s].text,h=e[s].level;o>h||(r+=h>n?"":n>h?new Array(n-h+2).join("
              • "):"",r+='
              • '+c+"
                  ",n=h)}var d=t.find(".markdown-toc");if(d.length<1&&"false"===t.attr("previewContainer")){var u='
                  ';u=i?'
                  '+u+"
                  ":u,t.html(u),d=t.find(".markdown-toc")}return i&&d.wrap('

                  '),d.html('
                    ').children(".markdown-toc-list").html(r.replace(/\r?\n?\\<\/ul\>/g,"")),d},t.tocDropdownMenu=function(t,i){i=i||"Table of Contents";var o=400,r=t.find("."+this.classPrefix+"toc-menu");return r.each(function(){var t=e(this),r=t.children(".markdown-toc"),n='',a=''+n+i+"",s=r.children("ul"),l=s.find("li");r.append(a),l.first().before("
                  • "+i+" "+n+"

                  • "),t.mouseover(function(){s.show(),l.each(function(){var t=e(this),i=t.children("ul");if(""===i.html()&&i.remove(),i.length>0&&""!==i.html()){var r=t.children("a").first();r.children(".fa").length<1&&r.append(e(n).css({"float":"right",paddingTop:"4px"}))}t.mouseover(function(){i.css("z-index",o).show(),o+=1}).mouseleave(function(){i.hide()})})}).mouseleave(function(){s.hide()})}),r},t.filterHTMLTags=function(t,i){if("string"!=typeof t&&(t=new String(t)),"string"!=typeof i)return t;for(var o=i.split("|"),r=o[0].split(","),n=o[1],a=0,s=r.length;s>a;a++){var l=r[a];t=t.replace(new RegExp("]*)>([^>]*)","igm"),"")}if("undefined"!=typeof n){var c=/\<(\w+)\s*([^\>]*)\>([^\>]*)\<\/(\w+)\>/gi;t="*"===n?t.replace(c,function(e,t,i,o,r){return"<"+t+">"+o+""}):"on*"===n?t.replace(c,function(t,i,o,r,n){var a=e("<"+i+">"+r+""),s=e(t)[0].attributes,l={};e.each(s,function(e,t){'"'!==t.nodeName&&(l[t.nodeName]=t.nodeValue)}),e.each(l,function(e){0===e.indexOf("on")&&delete l[e]}),a.attr(l);var c="undefined"!=typeof a[1]?e(a[1]).text():"";return a[0].outerHTML+c}):t.replace(c,function(t,i,o,r){var a=n.split(","),s=e(t);return s.html(r),e.each(a,function(e){s.attr(a[e],null)}),s[0].outerHTML})}return t},t.markdownToHTML=function(i,o){var r={gfm:!0,toc:!0,tocm:!1,tocStartLevel:1,tocTitle:"目录",tocDropdown:!1,tocContainer:"",markdown:"",markdownSourceCode:!1,htmlDecode:!1,autoLoadKaTeX:!0,pageBreak:!0,atLink:!0,emailLink:!0,tex:!1,taskList:!1,emoji:!1,flowChart:!1,sequenceDiagram:!1,previewCodeHighlight:!0};t.$marked=marked;var n=e("#"+i),a=n.settings=e.extend(!0,r,o||{}),s=n.find("textarea");s.length<1&&(n.append(""),s=n.find("textarea"));var l=""===a.markdown?s.val():a.markdown,c=[],h={toc:a.toc,tocm:a.tocm,tocStartLevel:a.tocStartLevel,taskList:a.taskList,emoji:a.emoji,tex:a.tex,pageBreak:a.pageBreak,atLink:a.atLink,emailLink:a.emailLink,flowChart:a.flowChart,sequenceDiagram:a.sequenceDiagram,previewCodeHighlight:a.previewCodeHighlight},d={renderer:t.markedRenderer(c,h),gfm:a.gfm,tables:!0,breaks:!0,pedantic:!1,sanitize:a.htmlDecode?!1:!0,smartLists:!0,smartypants:!0};l=new String(l);var u=marked(l,d);u=t.filterHTMLTags(u,a.htmlDecode),a.markdownSourceCode?s.text(l):s.remove(),n.addClass("markdown-body "+this.classPrefix+"html-preview").append(u);var f=""!==a.tocContainer?e(a.tocContainer):n;if(""!==a.tocContainer&&f.attr("previewContainer",!1),a.toc&&(n.tocContainer=this.markdownToCRenderer(c,f,a.tocDropdown,a.tocStartLevel),(a.tocDropdown||n.find("."+this.classPrefix+"toc-menu").length>0)&&this.tocDropdownMenu(n,a.tocTitle),""!==a.tocContainer&&n.find(".editormd-toc-menu, .editormd-markdown-toc").remove()),a.previewCodeHighlight&&(n.find("pre").addClass("prettyprint linenums"),prettyPrint()),t.isIE8||(a.flowChart&&n.find(".flowchart").flowChart(),a.sequenceDiagram&&n.find(".sequence-diagram").sequenceDiagram({theme:"simple"})),a.tex){var g=function(){n.find("."+t.classNames.tex).each(function(){var t=e(this);katex.render(t.html().replace(/</g,"<").replace(/>/g,">"),t[0]),t.find(".katex").css("font-size","1.6em")})};!a.autoLoadKaTeX||t.$katex||t.kaTeXLoaded?g():this.loadKaTeX(function(){t.$katex=katex,t.kaTeXLoaded=!0,g()})}return n.getMarkdown=function(){return s.val()},n},t.themes=["default","dark"],t.previewThemes=["default","dark"],t.editorThemes=["default","3024-day","3024-night","ambiance","ambiance-mobile","base16-dark","base16-light","blackboard","cobalt","eclipse","elegant","erlang-dark","lesser-dark","mbo","mdn-like","midnight","monokai","neat","neo","night","paraiso-dark","paraiso-light","pastel-on-dark","rubyblue","solarized","the-matrix","tomorrow-night-eighties","twilight","vibrant-ink","xq-dark","xq-light"],t.loadPlugins={},t.loadFiles={js:[],css:[],plugin:[]},t.loadPlugin=function(e,i,o){i=i||function(){},this.loadScript(e,function(){t.loadFiles.plugin.push(e),i()},o)},t.loadCSS=function(e,i,o){o=o||"head",i=i||function(){};var r=document.createElement("link");r.type="text/css",r.rel="stylesheet",r.onload=r.onreadystatechange=function(){t.loadFiles.css.push(e),i()},r.href=e+".css","head"===o?document.getElementsByTagName("head")[0].appendChild(r):document.body.appendChild(r)},t.isIE="Microsoft Internet Explorer"==navigator.appName,t.isIE8=t.isIE&&"8."==navigator.appVersion.match(/8./i),t.loadScript=function(e,i,o){o=o||"head",i=i||function(){};var r=null;r=document.createElement("script"),r.id=e.replace(/[\./]+/g,"-"),r.type="text/javascript",r.src=e+".js",t.isIE8?r.onreadystatechange=function(){r.readyState&&("loaded"===r.readyState||"complete"===r.readyState)&&(r.onreadystatechange=null,t.loadFiles.js.push(e),i())}:r.onload=function(){t.loadFiles.js.push(e),i()},"head"===o?document.getElementsByTagName("head")[0].appendChild(r):document.body.appendChild(r)},t.katexURL={css:"//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min",js:"//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.3.0/katex.min"},t.kaTeXLoaded=!1,t.loadKaTeX=function(e){t.loadCSS(t.katexURL.css,function(){t.loadScript(t.katexURL.js,e||function(){})})},t.lockScreen=function(t){e("html,body").css("overflow",t?"hidden":"")},t.createDialog=function(i){var o={name:"",width:420,height:240,title:"",drag:!0,closed:!0,content:"",mask:!0,maskStyle:{backgroundColor:"#fff",opacity:.1},lockScreen:!0,footer:!0,buttons:!1};i=e.extend(!0,o,i);var r=this,n=this.editor,a=t.classPrefix,s=(new Date).getTime(),l=""===i.name?a+"dialog-"+s:i.name,c=t.mouseOrTouch,h='
                    ';""!==i.title&&(h+='
                    ",h+=''+i.title+"",h+="
                    "),i.closed&&(h+=''),h+='
                    '+i.content,(i.footer||"string"==typeof i.footer)&&(h+='"),h+="
                    ",h+='
                    ',h+='
                    ',h+="
                    ",n.append(h);var d=n.find("."+l);d.lockScreen=function(t){return i.lockScreen&&(e("html,body").css("overflow",t?"hidden":""),r.resize()),d},d.showMask=function(){return i.mask&&n.find("."+a+"mask").css(i.maskStyle).css("z-index",t.dialogZindex-1).show(),d},d.hideMask=function(){return i.mask&&n.find("."+a+"mask").hide(),d},d.loading=function(e){var t=d.find("."+a+"dialog-mask");return t[e?"show":"hide"](),d},d.lockScreen(!0).showMask(),d.show().css({zIndex:t.dialogZindex,border:t.isIE8?"1px solid #ddd":"",width:"number"==typeof i.width?i.width+"px":i.width,height:"number"==typeof i.height?i.height+"px":i.height});var u=function(){d.css({top:(e(window).height()-d.height())/2+"px",left:(e(window).width()-d.width())/2+"px"})};if(u(),e(window).resize(u),d.children("."+a+"dialog-close").bind(c("click","touchend"),function(){d.hide().lockScreen(!1).hideMask()}),"object"==typeof i.buttons){var f=d.footer=d.find("."+a+"dialog-footer");for(var g in i.buttons){var p=i.buttons[g],m=a+g+"-btn";f.append('"),p[1]=e.proxy(p[1],d),f.children("."+m).bind(c("click","touchend"),p[1])}}if(""!==i.title&&i.drag){var w,v,k=d.children("."+a+"dialog-header");i.mask||k.bind(c("click","touchend"),function(){t.dialogZindex+=2,d.css("z-index",t.dialogZindex)}),k.mousedown(function(e){e=e||window.event,w=e.clientX-parseInt(d[0].style.left),v=e.clientY-parseInt(d[0].style.top),document.onmousemove=y});var b=function(e){e.removeClass(a+"user-unselect").off("selectstart")},x=function(e){e.addClass(a+"user-unselect").on("selectstart",function(e){return!1})},y=function(t){t=t||window.event;var i,o,r=parseInt(d[0].style.left),n=parseInt(d[0].style.top);r>=0?r+d.width()<=e(window).width()?i=t.clientX-w:(i=e(window).width()-d.width(),document.onmousemove=null):(i=0,document.onmousemove=null),n>=0?o=t.clientY-v:(o=0,document.onmousemove=null),document.onselectstart=function(){return!1},x(e("body")),x(d),d[0].style.left=i+"px",d[0].style.top=o+"px"};document.onmouseup=function(){b(e("body")),b(d),document.onselectstart=null,document.onmousemove=null},k.touchDraggable=function(){var t=null,i=function(i){var o=i.originalEvent,r=e(this).parent().position();t={x:o.changedTouches[0].pageX-r.left,y:o.changedTouches[0].pageY-r.top}},o=function(i){i.preventDefault();var o=i.originalEvent;e(this).parent().css({top:o.changedTouches[0].pageY-t.y,left:o.changedTouches[0].pageX-t.x})};this.bind("touchstart",i).bind("touchmove",o)},k.touchDraggable()}return t.dialogZindex+=2,d},t.mouseOrTouch=function(e,t){e=e||"click",t=t||"touchend";var i=e;try{document.createEvent("TouchEvent"),i=t}catch(o){}return i},t.dateFormat=function(e){e=e||"";var t=function(e){return 10>e?"0"+e:e},i=new Date,o=i.getFullYear(),r=o.toString().slice(2,4),n=t(i.getMonth()+1),a=t(i.getDate()),s=i.getDay(),l=t(i.getHours()),c=t(i.getMinutes()),h=t(i.getSeconds()),d=t(i.getMilliseconds()),u="",f=r+"-"+n+"-"+a,g=o+"-"+n+"-"+a,p=l+":"+c+":"+h;switch(e){case"UNIX Time":u=i.getTime();break;case"UTC":u=i.toUTCString();break;case"yy":u=r;break;case"year":case"yyyy":u=o;break;case"month":case"mm":u=n;break;case"cn-week-day":case"cn-wd":var m=["日","一","二","三","四","五","六"];u="星期"+m[s];break;case"week-day":case"wd":var w=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];u=w[s];break;case"day":case"dd":u=a;break;case"hour":case"hh":u=l;break;case"min":case"ii":u=c;break;case"second":case"ss":u=h;break;case"ms":u=d;break;case"yy-mm-dd":u=f;break;case"yyyy-mm-dd":u=g;break;case"yyyy-mm-dd h:i:s ms":case"full + ms":u=g+" "+p+" "+d;break;case"full":case"yyyy-mm-dd h:i:s":default:u=g+" "+p}return u},t}}); \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/@links.html b/paicoding-ui/src/main/resources/static/editormd/examples/@links.html new file mode 100644 index 000000000..2cc6a1062 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/@links.html @@ -0,0 +1,135 @@ + + + + + @links - Editor.md examples + + + + + +
                    +
                    +

                    @links

                    +

                    Github Flavored Markdown extras syntax

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/auto-height.html b/paicoding-ui/src/main/resources/static/editormd/examples/auto-height.html new file mode 100644 index 000000000..4a37c4319 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/auto-height.html @@ -0,0 +1,55 @@ + + + + + Auto height - Editor.md examples + + + + + +
                    +
                    +

                    Auto height test

                    +
                    +
                    + +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/change-mode.html b/paicoding-ui/src/main/resources/static/editormd/examples/change-mode.html new file mode 100644 index 000000000..e25798af1 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/change-mode.html @@ -0,0 +1,508 @@ + + + + + Chnage mode - Editor.md examples + + + + + + +
                    +
                    +

                    Chnage mode

                    +

                    Become to the code editor

                    +

                    Modes :   Themes : + +

                    +
                    +
                    + + +
                    +
                    + + + + + + + + + + +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/code-fold.html b/paicoding-ui/src/main/resources/static/editormd/examples/code-fold.html new file mode 100644 index 000000000..e2774bcc8 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/code-fold.html @@ -0,0 +1,44 @@ + + + + + Code folding - Editor.md examples + + + + + +
                    +
                    +

                    Code folding

                    +

                    Switch code folding : Press Ctrl + Q / Command + Q

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/css/style.css b/paicoding-ui/src/main/resources/static/editormd/examples/css/style.css new file mode 100644 index 000000000..0150e3b9a --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/css/style.css @@ -0,0 +1,94 @@ +* { + padding: 0; + margin: 0; +} + +*, *:before, *:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea,p,blockquote,th,td,hr,button,article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{ + margin: 0; + padding: 0; +} + +article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary { + display: block; +} + +audio, canvas, video { + display: inline-block; +} + +img { + border: none; + vertical-align: middle; +} + +ul, ol { + /*list-style: none;*/ +} + +.clear { + *zoom: 1; /* for IE 6/7 */ +} + +.clear:before, .clear:after { + height: 0; + content: ""; + font-size: 0; + display: table; + line-height: 0; /* for Opera */ + visibility: hidden; +} + +.clear:after { + clear: both; +} + +body { + font-size: 14px; + color: #666; + font-family: "Microsoft YaHei", "微软雅黑", Helvetica, Tahoma, STXihei, "华文细黑", STHeiti, "Helvetica Neue", Helvetica, Tahoma, "Droid Sans", "wenquanyi micro hei", FreeSans, Arimo, Arial, SimSun, "宋体", Heiti, "黑体", sans-serif; + background: #fff; + text-align: center; +} + +#layout { + text-align: left; +} + +#layout > header, .btns { + padding: 15px 0; + width: 90%; + margin: 0 auto; +} + +.btns { + padding-top: 0; +} + +.btns button { + padding: 2px 8px; +} + +#layout > header > h1 { + font-size: 20px; + margin-bottom: 10px; +} + +.btns button, .btn { + padding: 8px 10px; + background: #fff; + border: 1px solid #ddd; + -webkit-border-radius: 3px; + border-radius: 3px; + cursor: pointer; + -webkit-transition: background 300ms ease-out; + transition: background 300ms ease-out; +} + +.btns button:hover, .btn:hover { + background: #f6f6f6; +} \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/custom-keyboard-shortcuts.html b/paicoding-ui/src/main/resources/static/editormd/examples/custom-keyboard-shortcuts.html new file mode 100644 index 000000000..3afc27ba4 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/custom-keyboard-shortcuts.html @@ -0,0 +1,118 @@ + + + + + Custom keyboard shortcuts - Editor.md examples + + + + + +
                    +
                    +

                    Custom keyboard shortcuts

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/custom-toolbar.html b/paicoding-ui/src/main/resources/static/editormd/examples/custom-toolbar.html new file mode 100644 index 000000000..89177dae8 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/custom-toolbar.html @@ -0,0 +1,178 @@ + + + + + 自定义工具栏 - Editor.md examples + + + + + +
                    +
                    +

                    自定义工具栏

                    +

                    Custom toolbar (icons handler)

                    +
                    +
                    + +
                    +
                    + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/define-plugin.html b/paicoding-ui/src/main/resources/static/editormd/examples/define-plugin.html new file mode 100644 index 000000000..8c867e72c --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/define-plugin.html @@ -0,0 +1,151 @@ + + + + + Define extention plugins for Editor.md - Editor.md examples + + + + + +
                    +
                    +

                    Define extention plugins for Editor.md

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/delay-renderer-preview.html b/paicoding-ui/src/main/resources/static/editormd/examples/delay-renderer-preview.html new file mode 100644 index 000000000..ad343a2a0 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/delay-renderer-preview.html @@ -0,0 +1,56 @@ + + + + + Delay Rerender & Preview - Editor.md examples + + + + + +
                    +
                    +

                    Delay Rerender & Preview

                    +

                    P.S. If you input the content too much and too fast, You can setting the delay value.

                    +

                    P.S. 适用于输入内容太多太快的情形,但要是一个合理的值,不然会显得预览太慢。打字慢会相对显得慢,打字快时则相对显得快。

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/dynamic-create-editormd.html b/paicoding-ui/src/main/resources/static/editormd/examples/dynamic-create-editormd.html new file mode 100644 index 000000000..5644e0982 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/dynamic-create-editormd.html @@ -0,0 +1,47 @@ + + + + + 动态创建 Editor.md - Editor.md examples + + + + + +
                    +
                    +

                    动态创建 Editor.md

                    +

                    Dynamic create Editor.md

                    +
                    +
                    + + +
                    +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/emoji.html b/paicoding-ui/src/main/resources/static/editormd/examples/emoji.html new file mode 100644 index 000000000..a5a6ea642 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/emoji.html @@ -0,0 +1,191 @@ + + + + + Emoji - Editor.md examples + + + + + + +
                    +
                    +

                    Emoji 表情

                    +

                    Supports:

                    + +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/extends.html b/paicoding-ui/src/main/resources/static/editormd/examples/extends.html new file mode 100644 index 000000000..96018603d --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/extends.html @@ -0,0 +1,153 @@ + + + + + Expanded Editor.md - Editor.md examples + + + + + +
                    +
                    +

                    Expanded Editor.md

                    +

                    Expanded of member methods and properties

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/external-use.html b/paicoding-ui/src/main/resources/static/editormd/examples/external-use.html new file mode 100644 index 000000000..32e02e26a --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/external-use.html @@ -0,0 +1,119 @@ + + + + + External use - Editor.md examples + + + + + +
                    +
                    +

                    External use

                    +

                    External use of toolbar handlers / modal dialog

                    +
                    +
                    + + + + + + + + +
                    +
                    + +
                    +
                    + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/flowchart.html b/paicoding-ui/src/main/resources/static/editormd/examples/flowchart.html new file mode 100644 index 000000000..5149cb7fb --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/flowchart.html @@ -0,0 +1,53 @@ + + + + + FlowChart - Editor.md examples + + + + + +
                    +
                    +

                    FlowChart 流程图

                    +

                    Based on flowchart.js:http://adrai.github.io/flowchart.js/

                    +
                    +
                    + +
                    +
                    + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/form-get-value.html b/paicoding-ui/src/main/resources/static/editormd/examples/form-get-value.html new file mode 100644 index 000000000..5433d4535 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/form-get-value.html @@ -0,0 +1,92 @@ + + + + + Form get textarea value - Editor.md examples + + + + + +
                    +
                    +

                    表单取值

                    +

                    Form get textarea value.

                    +
                    +
                    +
                    + + +
                    +
                    + +
                    +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/full.html b/paicoding-ui/src/main/resources/static/editormd/examples/full.html new file mode 100644 index 000000000..6fe08183a --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/full.html @@ -0,0 +1,231 @@ + + + + + Full example - Editor.md examples + + + + + + +
                    +
                    +

                    完整示例

                    +

                    Full example

                    +
                      +
                    • Enable HTML tags decode
                    • +
                    • Enable TeX, Flowchart, Sequence Diagram, Emoji, FontAwesome, Task lists
                    • +
                    • Enable Image upload
                    • +
                    • Enable [TOCM], Search Replace, Code fold
                    • +
                    +
                    +
                    + + + + + + + + + + + + + +
                    +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/goto-line.html b/paicoding-ui/src/main/resources/static/editormd/examples/goto-line.html new file mode 100644 index 000000000..7eba47b08 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/goto-line.html @@ -0,0 +1,84 @@ + + + + + Goto line - Editor.md examples + + + + + +
                    +
                    +

                    Goto line

                    +
                    +
                    + + + + + + + +
                    +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/html-preview-markdown-to-html-custom-toc-container.html b/paicoding-ui/src/main/resources/static/editormd/examples/html-preview-markdown-to-html-custom-toc-container.html new file mode 100644 index 000000000..bdf1bd6eb --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/html-preview-markdown-to-html-custom-toc-container.html @@ -0,0 +1,180 @@ + + + + + HTML Preview (markdown to html) - Editor.md examples + + + + + + +
                    +
                    +

                    Markdown转HTML的显示处理之自定义 ToC 容器

                    +

                    即:非编辑情况下的HTML预览

                    +

                    HTML Preview (markdown to html and custom ToC container)

                    +
                    +
                    + +
                    + +
                    + +
                    +
                    + +
                    +
                    + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/html-preview-markdown-to-html.html b/paicoding-ui/src/main/resources/static/editormd/examples/html-preview-markdown-to-html.html new file mode 100644 index 000000000..ad1cf590f --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/html-preview-markdown-to-html.html @@ -0,0 +1,142 @@ + + + + + HTML Preview(markdown to html) - Editor.md examples + + + + + + +
                    +
                    +

                    Markdown转HTML的显示处理

                    +

                    即:非编辑情况下的HTML预览

                    +

                    HTML Preview(markdown to html)

                    +
                    +
                    + +
                    +
                    + +
                    +
                    + +
                    +
                    + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/html-tags-decode.html b/paicoding-ui/src/main/resources/static/editormd/examples/html-tags-decode.html new file mode 100644 index 000000000..34de0d32c --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/html-tags-decode.html @@ -0,0 +1,119 @@ + + + + + 识别和解析 HTML 标签 - Editor.md examples + + + + + +
                    +
                    +

                    识别和解析HTML标签

                    +

                    HTML tags (filter) decode, You can increase safety by filtering the danger label.

                    +

                    注:虽然此功能能极大地扩展 Markdown 语法,但也面临着安全上的风险,所以默认是不开启的。

                    +

                    Update: 可以通过设置 `settings.htmlDecode = "style,script,iframe|on*"`来实现过滤指定标签及属性的解析,提高安全性;

                    +
                    +
                    + + + + +
                    +
                    + +
                    +
                    + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/image-cross-domain-upload.html b/paicoding-ui/src/main/resources/static/editormd/examples/image-cross-domain-upload.html new file mode 100644 index 000000000..5a635454e --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/image-cross-domain-upload.html @@ -0,0 +1,109 @@ + + + + + 图片跨域上传示例 - Editor.md examples + + + + + +
                    +
                    +

                    图片跨域上传示例

                    +

                    Image cross-domain upload example.

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/image-upload.html b/paicoding-ui/src/main/resources/static/editormd/examples/image-upload.html new file mode 100644 index 000000000..e6fa69bc6 --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/image-upload.html @@ -0,0 +1,68 @@ + + + + + 图片上传示例 - Editor.md examples + + + + + +
                    +
                    +

                    图片上传示例

                    +

                    Image upload example

                    +
                    +
                    + +
                    +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/images/4.jpg b/paicoding-ui/src/main/resources/static/editormd/examples/images/4.jpg new file mode 100644 index 000000000..948f88c0b Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/examples/images/4.jpg differ diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/images/7.jpg b/paicoding-ui/src/main/resources/static/editormd/examples/images/7.jpg new file mode 100644 index 000000000..c1806731c Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/examples/images/7.jpg differ diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/images/8.jpg b/paicoding-ui/src/main/resources/static/editormd/examples/images/8.jpg new file mode 100644 index 000000000..f56e66eb6 Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/examples/images/8.jpg differ diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/images/editormd-screenshot.png b/paicoding-ui/src/main/resources/static/editormd/examples/images/editormd-screenshot.png new file mode 100644 index 000000000..f63f633ba Binary files /dev/null and b/paicoding-ui/src/main/resources/static/editormd/examples/images/editormd-screenshot.png differ diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/index.html b/paicoding-ui/src/main/resources/static/editormd/examples/index.html new file mode 100644 index 000000000..1d717e9fe --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/index.html @@ -0,0 +1,356 @@ + + + + + Editor.md examples + + + + + + + +
                    + +

                    Basic

                    + +

                    + TOP + 自定义 Customs +

                    + +

                    + TOP + Markdown Extras +

                    + +

                    + TOP + Image Upload +

                    + +

                    + TOP + 事件处理 Events handle +

                    + +
                    + +
                    + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/static/editormd/examples/js/jquery.min.js b/paicoding-ui/src/main/resources/static/editormd/examples/js/jquery.min.js new file mode 100644 index 000000000..b36821bec --- /dev/null +++ b/paicoding-ui/src/main/resources/static/editormd/examples/js/jquery.min.js @@ -0,0 +1,4 @@ +/*! jQuery v1.11.1 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ +!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l="1.11.1",m=function(a,b){return new m.fn.init(a,b)},n=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,o=/^-ms-/,p=/-([\da-z])/gi,q=function(a,b){return b.toUpperCase()};m.fn=m.prototype={jquery:l,constructor:m,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=m.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return m.each(this,a,b)},map:function(a){return this.pushStack(m.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},m.extend=m.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||m.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(m.isPlainObject(c)||(b=m.isArray(c)))?(b?(b=!1,f=a&&m.isArray(a)?a:[]):f=a&&m.isPlainObject(a)?a:{},g[d]=m.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},m.extend({expando:"jQuery"+(l+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===m.type(a)},isArray:Array.isArray||function(a){return"array"===m.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return!m.isArray(a)&&a-parseFloat(a)>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==m.type(a)||a.nodeType||m.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(k.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&m.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(o,"ms-").replace(p,q)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=r(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(n,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(r(Object(a))?m.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=r(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),m.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||m.guid++,e):void 0},now:function(){return+new Date},support:k}),m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function r(a){var b=a.length,c=m.type(a);return"function"===c||m.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var s=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+-new Date,v=a.document,w=0,x=0,y=gb(),z=gb(),A=gb(),B=function(a,b){return a===b&&(l=!0),0},C="undefined",D=1<<31,E={}.hasOwnProperty,F=[],G=F.pop,H=F.push,I=F.push,J=F.slice,K=F.indexOf||function(a){for(var b=0,c=this.length;c>b;b++)if(this[b]===a)return b;return-1},L="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",N="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=N.replace("w","w#"),P="\\["+M+"*("+N+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+O+"))|)"+M+"*\\]",Q=":("+N+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+P+")*)|.*)\\)|)",R=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),S=new RegExp("^"+M+"*,"+M+"*"),T=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),V=new RegExp(Q),W=new RegExp("^"+O+"$"),X={ID:new RegExp("^#("+N+")"),CLASS:new RegExp("^\\.("+N+")"),TAG:new RegExp("^("+N.replace("w","w*")+")"),ATTR:new RegExp("^"+P),PSEUDO:new RegExp("^"+Q),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)};try{I.apply(F=J.call(v.childNodes),v.childNodes),F[v.childNodes.length].nodeType}catch(eb){I={apply:F.length?function(a,b){H.apply(a,J.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],!a||"string"!=typeof a)return d;if(1!==(k=b.nodeType)&&9!==k)return[];if(p&&!e){if(f=_.exec(a))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return I.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName&&b.getElementsByClassName)return I.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=9===k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+qb(o[l]);w=ab.test(a)&&ob(b.parentNode)||b,x=o.join(",")}if(x)try{return I.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function gb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function hb(a){return a[u]=!0,a}function ib(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function jb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function kb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||D)-(~a.sourceIndex||D);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function lb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function nb(a){return hb(function(b){return b=+b,hb(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function ob(a){return a&&typeof a.getElementsByTagName!==C&&a}c=fb.support={},f=fb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fb.setDocument=function(a){var b,e=a?a.ownerDocument||a:v,g=e.defaultView;return e!==n&&9===e.nodeType&&e.documentElement?(n=e,o=e.documentElement,p=!f(e),g&&g!==g.top&&(g.addEventListener?g.addEventListener("unload",function(){m()},!1):g.attachEvent&&g.attachEvent("onunload",function(){m()})),c.attributes=ib(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ib(function(a){return a.appendChild(e.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(e.getElementsByClassName)&&ib(function(a){return a.innerHTML="
                    ",a.firstChild.className="i",2===a.getElementsByClassName("i").length}),c.getById=ib(function(a){return o.appendChild(a).id=u,!e.getElementsByName||!e.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if(typeof b.getElementById!==C&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c=typeof a.getAttributeNode!==C&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return typeof b.getElementsByTagName!==C?b.getElementsByTagName(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return typeof b.getElementsByClassName!==C&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(e.querySelectorAll))&&(ib(function(a){a.innerHTML="",a.querySelectorAll("[msallowclip^='']").length&&q.push("[*^$]="+M+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+M+"*(?:value|"+L+")"),a.querySelectorAll(":checked").length||q.push(":checked")}),ib(function(a){var b=e.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+M+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ib(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",Q)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===e||a.ownerDocument===v&&t(v,a)?-1:b===e||b.ownerDocument===v&&t(v,b)?1:k?K.call(k,a)-K.call(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,f=a.parentNode,g=b.parentNode,h=[a],i=[b];if(!f||!g)return a===e?-1:b===e?1:f?-1:g?1:k?K.call(k,a)-K.call(k,b):0;if(f===g)return kb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?kb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},e):n},fb.matches=function(a,b){return fb(a,null,null,b)},fb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fb(b,n,null,[a]).length>0},fb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&E.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fb.selectors={cacheLength:50,createPseudo:hb,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+M+")"+a+"("+M+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||typeof a.getAttribute!==C&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?hb(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=K.call(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:hb(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?hb(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),!c.pop()}}),has:hb(function(a){return function(b){return fb(a,b).length>0}}),contains:hb(function(a){return function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:hb(function(a){return W.test(a||"")||fb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:nb(function(){return[0]}),last:nb(function(a,b){return[b-1]}),eq:nb(function(a,b,c){return[0>c?c+b:c]}),even:nb(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:nb(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:nb(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:nb(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function rb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function sb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function tb(a,b,c){for(var d=0,e=b.length;e>d;d++)fb(a,b[d],c);return c}function ub(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function vb(a,b,c,d,e,f){return d&&!d[u]&&(d=vb(d)),e&&!e[u]&&(e=vb(e,f)),hb(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||tb(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ub(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ub(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?K.call(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ub(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):I.apply(g,r)})}function wb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=rb(function(a){return a===b},h,!0),l=rb(function(a){return K.call(b,a)>-1},h,!0),m=[function(a,c,d){return!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d))}];f>i;i++)if(c=d.relative[a[i].type])m=[rb(sb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return vb(i>1&&sb(m),i>1&&qb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&wb(a.slice(i,e)),f>e&&wb(a=a.slice(e)),f>e&&qb(a))}m.push(c)}return sb(m)}function xb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=G.call(i));s=ub(s)}I.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&fb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?hb(f):f}return h=fb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xb(e,d)),f.selector=a}return f},i=fb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&ob(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qb(j),!a)return I.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&ob(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ib(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ib(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||jb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ib(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||jb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ib(function(a){return null==a.getAttribute("disabled")})||jb(L,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fb}(a);m.find=s,m.expr=s.selectors,m.expr[":"]=m.expr.pseudos,m.unique=s.uniqueSort,m.text=s.getText,m.isXMLDoc=s.isXML,m.contains=s.contains;var t=m.expr.match.needsContext,u=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/^.[^:#\[\.,]*$/;function w(a,b,c){if(m.isFunction(b))return m.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return m.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(v.test(b))return m.filter(b,a,c);b=m.filter(b,a)}return m.grep(a,function(a){return m.inArray(a,b)>=0!==c})}m.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?m.find.matchesSelector(d,a)?[d]:[]:m.find.matches(a,m.grep(b,function(a){return 1===a.nodeType}))},m.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(m(a).filter(function(){for(b=0;e>b;b++)if(m.contains(d[b],this))return!0}));for(b=0;e>b;b++)m.find(a,d[b],c);return c=this.pushStack(e>1?m.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(w(this,a||[],!1))},not:function(a){return this.pushStack(w(this,a||[],!0))},is:function(a){return!!w(this,"string"==typeof a&&t.test(a)?m(a):a||[],!1).length}});var x,y=a.document,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=m.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||x).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof m?b[0]:b,m.merge(this,m.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:y,!0)),u.test(c[1])&&m.isPlainObject(b))for(c in b)m.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=y.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return x.find(a);this.length=1,this[0]=d}return this.context=y,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):m.isFunction(a)?"undefined"!=typeof x.ready?x.ready(a):a(m):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),m.makeArray(a,this))};A.prototype=m.fn,x=m(y);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};m.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!m(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),m.fn.extend({has:function(a){var b,c=m(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(m.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=t.test(a)||"string"!=typeof a?m(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&m.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?m.unique(f):f)},index:function(a){return a?"string"==typeof a?m.inArray(this[0],m(a)):m.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(m.unique(m.merge(this.get(),m(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}m.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return m.dir(a,"parentNode")},parentsUntil:function(a,b,c){return m.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return m.dir(a,"nextSibling")},prevAll:function(a){return m.dir(a,"previousSibling")},nextUntil:function(a,b,c){return m.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return m.dir(a,"previousSibling",c)},siblings:function(a){return m.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return m.sibling(a.firstChild)},contents:function(a){return m.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:m.merge([],a.childNodes)}},function(a,b){m.fn[a]=function(c,d){var e=m.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=m.filter(d,e)),this.length>1&&(C[a]||(e=m.unique(e)),B.test(a)&&(e=e.reverse())),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return m.each(a.match(E)||[],function(a,c){b[c]=!0}),b}m.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):m.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){m.each(b,function(b,c){var d=m.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&m.each(arguments,function(a,c){var d;while((d=m.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?m.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},m.extend({Deferred:function(a){var b=[["resolve","done",m.Callbacks("once memory"),"resolved"],["reject","fail",m.Callbacks("once memory"),"rejected"],["notify","progress",m.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return m.Deferred(function(c){m.each(b,function(b,f){var g=m.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&m.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?m.extend(a,d):d}},e={};return d.pipe=d.then,m.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&m.isFunction(a.promise)?e:0,g=1===f?a:m.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&m.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;m.fn.ready=function(a){return m.ready.promise().done(a),this},m.extend({isReady:!1,readyWait:1,holdReady:function(a){a?m.readyWait++:m.ready(!0)},ready:function(a){if(a===!0?!--m.readyWait:!m.isReady){if(!y.body)return setTimeout(m.ready);m.isReady=!0,a!==!0&&--m.readyWait>0||(H.resolveWith(y,[m]),m.fn.triggerHandler&&(m(y).triggerHandler("ready"),m(y).off("ready")))}}});function I(){y.addEventListener?(y.removeEventListener("DOMContentLoaded",J,!1),a.removeEventListener("load",J,!1)):(y.detachEvent("onreadystatechange",J),a.detachEvent("onload",J))}function J(){(y.addEventListener||"load"===event.type||"complete"===y.readyState)&&(I(),m.ready())}m.ready.promise=function(b){if(!H)if(H=m.Deferred(),"complete"===y.readyState)setTimeout(m.ready);else if(y.addEventListener)y.addEventListener("DOMContentLoaded",J,!1),a.addEventListener("load",J,!1);else{y.attachEvent("onreadystatechange",J),a.attachEvent("onload",J);var c=!1;try{c=null==a.frameElement&&y.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!m.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}I(),m.ready()}}()}return H.promise(b)};var K="undefined",L;for(L in m(k))break;k.ownLast="0"!==L,k.inlineBlockNeedsLayout=!1,m(function(){var a,b,c,d;c=y.getElementsByTagName("body")[0],c&&c.style&&(b=y.createElement("div"),d=y.createElement("div"),d.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(d).appendChild(b),typeof b.style.zoom!==K&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",k.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(d))}),function(){var a=y.createElement("div");if(null==k.deleteExpando){k.deleteExpando=!0;try{delete a.test}catch(b){k.deleteExpando=!1}}a=null}(),m.acceptData=function(a){var b=m.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var M=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,N=/([A-Z])/g;function O(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(N,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:M.test(c)?m.parseJSON(c):c}catch(e){}m.data(a,b,c)}else c=void 0}return c}function P(a){var b;for(b in a)if(("data"!==b||!m.isEmptyObject(a[b]))&&"toJSON"!==b)return!1;return!0}function Q(a,b,d,e){if(m.acceptData(a)){var f,g,h=m.expando,i=a.nodeType,j=i?m.cache:a,k=i?a[h]:a[h]&&h; +if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||m.guid++:h),j[k]||(j[k]=i?{}:{toJSON:m.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=m.extend(j[k],b):j[k].data=m.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[m.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[m.camelCase(b)])):f=g,f}}function R(a,b,c){if(m.acceptData(a)){var d,e,f=a.nodeType,g=f?m.cache:a,h=f?a[m.expando]:m.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){m.isArray(b)?b=b.concat(m.map(b,m.camelCase)):b in d?b=[b]:(b=m.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!P(d):!m.isEmptyObject(d))return}(c||(delete g[h].data,P(g[h])))&&(f?m.cleanData([a],!0):k.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}m.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?m.cache[a[m.expando]]:a[m.expando],!!a&&!P(a)},data:function(a,b,c){return Q(a,b,c)},removeData:function(a,b){return R(a,b)},_data:function(a,b,c){return Q(a,b,c,!0)},_removeData:function(a,b){return R(a,b,!0)}}),m.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=m.data(f),1===f.nodeType&&!m._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=m.camelCase(d.slice(5)),O(f,d,e[d])));m._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){m.data(this,a)}):arguments.length>1?this.each(function(){m.data(this,a,b)}):f?O(f,a,m.data(f,a)):void 0},removeData:function(a){return this.each(function(){m.removeData(this,a)})}}),m.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=m._data(a,b),c&&(!d||m.isArray(c)?d=m._data(a,b,m.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=m.queue(a,b),d=c.length,e=c.shift(),f=m._queueHooks(a,b),g=function(){m.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return m._data(a,c)||m._data(a,c,{empty:m.Callbacks("once memory").add(function(){m._removeData(a,b+"queue"),m._removeData(a,c)})})}}),m.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},W=/^(?:checkbox|radio)$/i;!function(){var a=y.createElement("input"),b=y.createElement("div"),c=y.createDocumentFragment();if(b.innerHTML="
                    a",k.leadingWhitespace=3===b.firstChild.nodeType,k.tbody=!b.getElementsByTagName("tbody").length,k.htmlSerialize=!!b.getElementsByTagName("link").length,k.html5Clone="<:nav>"!==y.createElement("nav").cloneNode(!0).outerHTML,a.type="checkbox",a.checked=!0,c.appendChild(a),k.appendChecked=a.checked,b.innerHTML="",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,c.appendChild(b),b.innerHTML="",k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,k.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){k.noCloneEvent=!1}),b.cloneNode(!0).click()),null==k.deleteExpando){k.deleteExpando=!0;try{delete b.test}catch(d){k.deleteExpando=!1}}}(),function(){var b,c,d=y.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(k[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),k[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var X=/^(?:input|select|textarea)$/i,Y=/^key/,Z=/^(?:mouse|pointer|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=/^([^.]*)(?:\.(.+)|)$/;function ab(){return!0}function bb(){return!1}function cb(){try{return y.activeElement}catch(a){}}m.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=m.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof m===K||a&&m.event.triggered===a.type?void 0:m.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(E)||[""],h=b.length;while(h--)f=_.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=m.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=m.event.special[o]||{},l=m.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&m.expr.match.needsContext.test(e),namespace:p.join(".")},i),(n=g[o])||(n=g[o]=[],n.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?n.splice(n.delegateCount++,0,l):n.push(l),m.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m.hasData(a)&&m._data(a);if(r&&(k=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=_.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=m.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,n=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=n.length;while(f--)g=n[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(n.splice(f,1),g.selector&&n.delegateCount--,l.remove&&l.remove.call(a,g));i&&!n.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||m.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)m.event.remove(a,o+b[j],c,d,!0);m.isEmptyObject(k)&&(delete r.handle,m._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,n,o=[d||y],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||y,3!==d.nodeType&&8!==d.nodeType&&!$.test(p+m.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[m.expando]?b:new m.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:m.makeArray(c,[b]),k=m.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!m.isWindow(d)){for(i=k.delegateType||p,$.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||y)&&o.push(l.defaultView||l.parentWindow||a)}n=0;while((h=o[n++])&&!b.isPropagationStopped())b.type=n>1?i:k.bindType||p,f=(m._data(h,"events")||{})[b.type]&&m._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&m.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&m.acceptData(d)&&g&&d[p]&&!m.isWindow(d)){l=d[g],l&&(d[g]=null),m.event.triggered=p;try{d[p]()}catch(r){}m.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=m.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(m._data(this,"events")||{})[a.type]||[],k=m.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=m.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((m.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?m(c,this).index(i)>=0:m.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),hb=/^\s+/,ib=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,jb=/<([\w:]+)/,kb=/\s*$/g,rb={option:[1,""],legend:[1,"
                    ","
                    "],area:[1,"",""],param:[1,"",""],thead:[1,"","
                    "],tr:[2,"","
                    "],col:[2,"","
                    "],td:[3,"","
                    "],_default:k.htmlSerialize?[0,"",""]:[1,"X
                    ","
                    "]},sb=db(y),tb=sb.appendChild(y.createElement("div"));rb.optgroup=rb.option,rb.tbody=rb.tfoot=rb.colgroup=rb.caption=rb.thead,rb.th=rb.td;function ub(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==K?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==K?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||m.nodeName(d,b)?f.push(d):m.merge(f,ub(d,b));return void 0===b||b&&m.nodeName(a,b)?m.merge([a],f):f}function vb(a){W.test(a.type)&&(a.defaultChecked=a.checked)}function wb(a,b){return m.nodeName(a,"table")&&m.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function xb(a){return a.type=(null!==m.find.attr(a,"type"))+"/"+a.type,a}function yb(a){var b=pb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function zb(a,b){for(var c,d=0;null!=(c=a[d]);d++)m._data(c,"globalEval",!b||m._data(b[d],"globalEval"))}function Ab(a,b){if(1===b.nodeType&&m.hasData(a)){var c,d,e,f=m._data(a),g=m._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)m.event.add(b,c,h[c][d])}g.data&&(g.data=m.extend({},g.data))}}function Bb(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!k.noCloneEvent&&b[m.expando]){e=m._data(b);for(d in e.events)m.removeEvent(b,d,e.handle);b.removeAttribute(m.expando)}"script"===c&&b.text!==a.text?(xb(b).text=a.text,yb(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),k.html5Clone&&a.innerHTML&&!m.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&W.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}m.extend({clone:function(a,b,c){var d,e,f,g,h,i=m.contains(a.ownerDocument,a);if(k.html5Clone||m.isXMLDoc(a)||!gb.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(tb.innerHTML=a.outerHTML,tb.removeChild(f=tb.firstChild)),!(k.noCloneEvent&&k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||m.isXMLDoc(a)))for(d=ub(f),h=ub(a),g=0;null!=(e=h[g]);++g)d[g]&&Bb(e,d[g]);if(b)if(c)for(h=h||ub(a),d=d||ub(f),g=0;null!=(e=h[g]);g++)Ab(e,d[g]);else Ab(a,f);return d=ub(f,"script"),d.length>0&&zb(d,!i&&ub(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,l,n=a.length,o=db(b),p=[],q=0;n>q;q++)if(f=a[q],f||0===f)if("object"===m.type(f))m.merge(p,f.nodeType?[f]:f);else if(lb.test(f)){h=h||o.appendChild(b.createElement("div")),i=(jb.exec(f)||["",""])[1].toLowerCase(),l=rb[i]||rb._default,h.innerHTML=l[1]+f.replace(ib,"<$1>")+l[2],e=l[0];while(e--)h=h.lastChild;if(!k.leadingWhitespace&&hb.test(f)&&p.push(b.createTextNode(hb.exec(f)[0])),!k.tbody){f="table"!==i||kb.test(f)?""!==l[1]||kb.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)m.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}m.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),k.appendChecked||m.grep(ub(p,"input"),vb),q=0;while(f=p[q++])if((!d||-1===m.inArray(f,d))&&(g=m.contains(f.ownerDocument,f),h=ub(o.appendChild(f),"script"),g&&zb(h),c)){e=0;while(f=h[e++])ob.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=m.expando,j=m.cache,l=k.deleteExpando,n=m.event.special;null!=(d=a[h]);h++)if((b||m.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)n[e]?m.event.remove(d,e):m.removeEvent(d,e,g.handle);j[f]&&(delete j[f],l?delete d[i]:typeof d.removeAttribute!==K?d.removeAttribute(i):d[i]=null,c.push(f))}}}),m.fn.extend({text:function(a){return V(this,function(a){return void 0===a?m.text(this):this.empty().append((this[0]&&this[0].ownerDocument||y).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?m.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||m.cleanData(ub(c)),c.parentNode&&(b&&m.contains(c.ownerDocument,c)&&zb(ub(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&m.cleanData(ub(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&m.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return m.clone(this,a,b)})},html:function(a){return V(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(fb,""):void 0;if(!("string"!=typeof a||mb.test(a)||!k.htmlSerialize&&gb.test(a)||!k.leadingWhitespace&&hb.test(a)||rb[(jb.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(ib,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(m.cleanData(ub(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,m.cleanData(ub(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,n=this,o=l-1,p=a[0],q=m.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&nb.test(p))return this.each(function(c){var d=n.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(i=m.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=m.map(ub(i,"script"),xb),f=g.length;l>j;j++)d=i,j!==o&&(d=m.clone(d,!0,!0),f&&m.merge(g,ub(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,m.map(g,yb),j=0;f>j;j++)d=g[j],ob.test(d.type||"")&&!m._data(d,"globalEval")&&m.contains(h,d)&&(d.src?m._evalUrl&&m._evalUrl(d.src):m.globalEval((d.text||d.textContent||d.innerHTML||"").replace(qb,"")));i=c=null}return this}}),m.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){m.fn[a]=function(a){for(var c,d=0,e=[],g=m(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),m(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Cb,Db={};function Eb(b,c){var d,e=m(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:m.css(e[0],"display");return e.detach(),f}function Fb(a){var b=y,c=Db[a];return c||(c=Eb(a,b),"none"!==c&&c||(Cb=(Cb||m(" + + `; + }); + + // 也支持直接粘贴B站链接 + content = content.replace(/https?:\/\/www\.bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/g, function(match, bvid) { + return ` +
                    + +
                    + `; + }); + + // YouTube视频渲染 + content = content.replace(/@\[youtube\]\(([a-zA-Z0-9_-]+)\)/g, function(match, videoId) { + return ` +
                    + +
                    + `; + }); + + // 腾讯视频渲染 + content = content.replace(/@\[tencent\]\(([a-z0-9]+)\)/g, function(match, videoId) { + return ` +
                    + +
                    + `; + }); + + articleContent.innerHTML = content; + console.log('视频渲染完成'); + })(); + + // 初始化 Fancybox 图片预览功能 + (function() { + // 为所有文章图片添加 cursor 样式提示可点击 + const images = document.querySelectorAll('.article-content img'); + images.forEach(function(img) { + img.style.cursor = 'zoom-in'; + }); + + // 初始化 Fancybox + Fancybox.bind('.article-content img', { + groupAll: true, // 将所有图片作为一组,支持左右翻页 + Hash: false, // 不修改 URL hash + Toolbar: { + display: { + left: ["infobar"], + middle: [ + "zoomIn", + "zoomOut", + "toggle1to1", + "rotateCCW", + "rotateCW", + "flipX", + "flipY", + ], + right: ["slideshow", "download", "thumbs", "close"] + } + }, + Thumbs: { + autoStart: false, // 默认不显示缩略图 + }, + Images: { + zoom: true, // 启用缩放 + }, + on: { + reveal: (fancybox, slide) => { + // 图片打开时的回调,可以添加自定义逻辑 + console.log('Image opened:', slide.src); + } + } + }); + })(); + + + + diff --git a/paicoding-ui/src/main/resources/templates/components/article/praise.html b/paicoding-ui/src/main/resources/templates/components/article/praise.html new file mode 100644 index 000000000..95e25e4e1 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/article/praise.html @@ -0,0 +1,36 @@ + + + +
                    +
                    + + + + + + + + +
                    + +
                    +

                    + 真诚点赞 诚不我欺 +

                    +
                    + + + +
                    +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/components/column/column-card.html b/paicoding-ui/src/main/resources/templates/components/column/column-card.html new file mode 100644 index 000000000..52853d228 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/column/column-card.html @@ -0,0 +1,89 @@ + + + + + diff --git a/paicoding-ui/src/main/resources/templates/components/comment/comment-action.html b/paicoding-ui/src/main/resources/templates/components/comment/comment-action.html new file mode 100644 index 000000000..cc34d853c --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/comment/comment-action.html @@ -0,0 +1,76 @@ + + + +
                    +
                    + + + + + 点赞 + +
                    +
                    + + + + + 取消回复 +
                    +
                    + + +
                    +
                    + + + + + 点赞 + +
                    +
                    + + + + 回复 + 取消回复 +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/comment/comment-highlight.html b/paicoding-ui/src/main/resources/templates/components/comment/comment-highlight.html new file mode 100644 index 000000000..5ae78172d --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/comment/comment-highlight.html @@ -0,0 +1,71 @@ + + + +
                    +

                    引用评论

                    +
                    +
                    +
                    + +
                    +
                    +
                    + + + +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/comment/comment-item.html b/paicoding-ui/src/main/resources/templates/components/comment/comment-item.html new file mode 100644 index 000000000..b50fe12e1 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/comment/comment-item.html @@ -0,0 +1,113 @@ + + + +
                    +
                    +
                    + + + +
                    +
                    + + + 作者 + + + + 时间 + +
                    + + +
                    +
                    + +
                    + 内容 +
                    +
                    +
                    +
                    +
                    +
                    +
                    + + +
                    +
                    + + + +
                    +
                    + + + + + 回复时间 + +
                    +
                    + 回复内容 +
                    + + 回复的引用 + +
                    +
                    +
                    +
                    +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/comment/comment-list.html b/paicoding-ui/src/main/resources/templates/components/comment/comment-list.html new file mode 100644 index 000000000..db31e8395 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/comment/comment-list.html @@ -0,0 +1,148 @@ + + + +
                    +
                    + + + + + + + + +
                    +
                    + + +
                    +
                    + +
                    +
                    +
                    + +
                    +
                    杠精派
                    +
                    派聪明
                    +
                    +
                    +
                    +
                    + 0/512 + +
                    +
                    +
                    +
                    +
                    + + +
                    + +
                    +

                    + 热门评论 + + + +

                    + +
                    +
                    +
                    + + +
                    +

                    + + 条评论 +

                    +
                    +
                    +
                    +
                    +
                    + + + +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/follow/follow-card.html b/paicoding-ui/src/main/resources/templates/components/follow/follow-card.html new file mode 100644 index 000000000..cbc59a21a --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/follow/follow-card.html @@ -0,0 +1,28 @@ + + + +
                    + +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/follow/follow-list.html b/paicoding-ui/src/main/resources/templates/components/follow/follow-list.html new file mode 100644 index 000000000..4340a4ad7 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/follow/follow-list.html @@ -0,0 +1,8 @@ + +
                    + +
                    \ No newline at end of file diff --git a/forum-ui/src/main/resources/templates/layout/container.html b/paicoding-ui/src/main/resources/templates/components/layout/container.html similarity index 100% rename from forum-ui/src/main/resources/templates/layout/container.html rename to paicoding-ui/src/main/resources/templates/components/layout/container.html diff --git a/paicoding-ui/src/main/resources/templates/components/layout/footer.html b/paicoding-ui/src/main/resources/templates/components/layout/footer.html new file mode 100644 index 000000000..57b903d50 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/layout/footer.html @@ -0,0 +1,29 @@ + + + + diff --git a/paicoding-ui/src/main/resources/templates/components/layout/header.html b/paicoding-ui/src/main/resources/templates/components/layout/header.html new file mode 100644 index 000000000..b4602729a --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/layout/header.html @@ -0,0 +1,82 @@ + + + + + 技术派 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/paicoding-ui/src/main/resources/templates/components/layout/navbar.html b/paicoding-ui/src/main/resources/templates/components/layout/navbar.html new file mode 100644 index 000000000..585336f53 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/layout/navbar.html @@ -0,0 +1,885 @@ + + +
                    + + + + + + + + + + +
                    + + + + + + +
                    + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/components/notice/notify-collect-item.html b/paicoding-ui/src/main/resources/templates/components/notice/notify-collect-item.html new file mode 100644 index 000000000..410ed7c7f --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/notice/notify-collect-item.html @@ -0,0 +1,40 @@ + + + +
                    + + + +
                    + +
                    + + 4天前 + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/notice/notify-comment-item.html b/paicoding-ui/src/main/resources/templates/components/notice/notify-comment-item.html new file mode 100644 index 000000000..e7a54dbc3 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/notice/notify-comment-item.html @@ -0,0 +1,45 @@ + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/components/notice/notify-follow-item.html b/paicoding-ui/src/main/resources/templates/components/notice/notify-follow-item.html new file mode 100644 index 000000000..7dee6fc71 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/notice/notify-follow-item.html @@ -0,0 +1,49 @@ + + + +
                    + + + +
                    +
                    +
                    + + 一灰灰 + + 关注了你 +
                    +
                    +
                    + + 4天前 + +
                    +
                    +
                    +
                    + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/notice/notify-praise-item.html b/paicoding-ui/src/main/resources/templates/components/notice/notify-praise-item.html new file mode 100644 index 000000000..e142931cc --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/notice/notify-praise-item.html @@ -0,0 +1,50 @@ + + + +
                    + + + +
                    + +
                    + + 4天前 + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/notice/notify-reply-item.html b/paicoding-ui/src/main/resources/templates/components/notice/notify-reply-item.html new file mode 100644 index 000000000..6de7ef646 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/notice/notify-reply-item.html @@ -0,0 +1,65 @@ + + + +
                    + + + +
                    + + + +
                    + + 4天前 + + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/notice/notify-system-item.html b/paicoding-ui/src/main/resources/templates/components/notice/notify-system-item.html new file mode 100644 index 000000000..c6fc69634 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/notice/notify-system-item.html @@ -0,0 +1,30 @@ + + + +
                    + + + +
                    +
                    +
                    m
                    +
                    +
                    + + 4天前 + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-about.html b/paicoding-ui/src/main/resources/templates/components/side/side-about.html new file mode 100644 index 000000000..493c5e746 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-about.html @@ -0,0 +1,11 @@ + + + +
                    +
                    +
                    关于社区
                    +
                    +
                    + 一个开源社区 +
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-articles.html b/paicoding-ui/src/main/resources/templates/components/side/side-articles.html new file mode 100644 index 000000000..e069af31a --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-articles.html @@ -0,0 +1,27 @@ + + + +
                    + +
                    +
                    +
                    热门推荐
                    +
                    +
                    + + + 01 + +
                    + 推荐文章 +
                    +
                    +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-column.html b/paicoding-ui/src/main/resources/templates/components/side/side-column.html new file mode 100644 index 000000000..c65383edb --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-column.html @@ -0,0 +1,35 @@ + + + +
                    + +
                    +
                    +
                    精选教程
                    +
                    +
                    + +
                    +
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-join.html b/paicoding-ui/src/main/resources/templates/components/side/side-join.html new file mode 100644 index 000000000..3f536abe0 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-join.html @@ -0,0 +1,24 @@ + + + +
                    +
                    +
                    +
                    + + 扫码进群 +
                    +
                    加入”xxx社区“
                    +
                    + +
                    +
                    + + 文本内容 + +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-notice.html b/paicoding-ui/src/main/resources/templates/components/side/side-notice.html new file mode 100644 index 000000000..ade4df7a5 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-notice.html @@ -0,0 +1,68 @@ + + + +
                    + +
                    +
                    +
                    星球推荐
                    +
                    +
                    + +
                    +

                    星球海报

                    +
                    +
                    +
                    +
                    + + 星球名称 +
                    +
                    +
                    +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-pdf.html b/paicoding-ui/src/main/resources/templates/components/side/side-pdf.html new file mode 100644 index 000000000..57bf10c3e --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-pdf.html @@ -0,0 +1,43 @@ + + + +
                    + + +
                    diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-rank.html b/paicoding-ui/src/main/resources/templates/components/side/side-rank.html new file mode 100644 index 000000000..77a416407 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-rank.html @@ -0,0 +1,34 @@ + + + +
                    + +
                    + +
                    + + + 01 + +
                    + 用户 +
                    +
                    + 评分 +
                    +
                    +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/components/side/side-subscribe.html b/paicoding-ui/src/main/resources/templates/components/side/side-subscribe.html new file mode 100644 index 000000000..bafcd211a --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/side/side-subscribe.html @@ -0,0 +1,33 @@ + + + +
                    +
                    +
                    +
                    +
                    订阅
                    +
                    +
                    +
                    +
                    +
                    + +
                    +
                    +
                    10元无门槛代金券
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    + + 订阅图片 + +
                    +
                    +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/components/user/user-card.html b/paicoding-ui/src/main/resources/templates/components/user/user-card.html new file mode 100644 index 000000000..4f421940d --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/components/user/user-card.html @@ -0,0 +1,76 @@ + + + +
                    +
                    +

                    作者介绍

                    +
                    +
                    +
                    + +
                    +

                    + + +

                    +
                    + +
                    + +
                    +
                    +
                    + + + 教程 + +
                    + +
                    +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/error/403.html b/paicoding-ui/src/main/resources/templates/error/403.html new file mode 100644 index 000000000..ac0368bcb --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/error/403.html @@ -0,0 +1,26 @@ + + + + +
                    + 技术派 +
                    + + +
                    + +
                    + +
                    +

                    + 403无权限!!! + 系统异常 +

                    +
                    +
                    +
                    +
                    + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/error/404.html b/paicoding-ui/src/main/resources/templates/error/404.html new file mode 100644 index 000000000..46b9b30fb --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/error/404.html @@ -0,0 +1,20 @@ + + + +
                    + 技术派 +
                    + + +
                    + +
                    + +

                    + 404页面不存在!!! +

                    +
                    +
                    +
                    + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/error/500.html b/paicoding-ui/src/main/resources/templates/error/500.html new file mode 100644 index 000000000..411428fb3 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/error/500.html @@ -0,0 +1,20 @@ + + + + + +
                    + +
                    +

                    + 不好意思,出错啦!!! + 系统异常 +

                    +
                    +
                    +
                    + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/article-category-list/article/list.html b/paicoding-ui/src/main/resources/templates/views/article-category-list/article/list.html new file mode 100644 index 000000000..880dd12b1 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-category-list/article/list.html @@ -0,0 +1,4 @@ + +
                    +
                    正文
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/article-category-list/index.html b/paicoding-ui/src/main/resources/templates/views/article-category-list/index.html new file mode 100644 index 000000000..098204610 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-category-list/index.html @@ -0,0 +1,53 @@ + + +
                    + 技术派 +
                    + + + + +
                    + + +
                    +
                    + +
                    +
                    +
                    文章列表
                    +
                    +
                    +
                    + +
                    +
                    + + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/article-detail/article/list.html b/paicoding-ui/src/main/resources/templates/views/article-detail/article/list.html new file mode 100644 index 000000000..880dd12b1 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-detail/article/list.html @@ -0,0 +1,4 @@ + +
                    +
                    正文
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/article-detail/comment/index.html b/paicoding-ui/src/main/resources/templates/views/article-detail/comment/index.html new file mode 100644 index 000000000..8d7814169 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-detail/comment/index.html @@ -0,0 +1,2 @@ + +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/article-detail/index.html b/paicoding-ui/src/main/resources/templates/views/article-detail/index.html new file mode 100644 index 000000000..499cd97da --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-detail/index.html @@ -0,0 +1,457 @@ + + +
                    + 技术派 +
                    + + + + + + + + + + +
                    + +
                    +
                    +
                    +
                    + +
                    + + +
                    + + +
                    +
                    +
                    + +
                    + +

                    相关推荐

                    +
                    +
                    +
                    +
                    +
                    + +
                    + +
                    + + +
                    +
                    + 侧边通知板块 +
                    +
                    +
                    + +
                    +
                    +

                    目录

                    +
                    +
                    +
                    + + + +
                    +
                    +
                    + + +
                    +
                    + + +
                    + + + + + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/article-detail/side-float-action-bar-md/index.html b/paicoding-ui/src/main/resources/templates/views/article-detail/side-float-action-bar-md/index.html new file mode 100644 index 000000000..5ca9c35f0 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-detail/side-float-action-bar-md/index.html @@ -0,0 +1,120 @@ + + + +
                    +
                    + + + + + + + + + + + + + + + + + + + + + +
                    + + + +
                    + + +
                    + + + +
                    + + +
                    + + + +
                    + + + + +
                    + + + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/article-detail/side-float-action-bar/index.html b/paicoding-ui/src/main/resources/templates/views/article-detail/side-float-action-bar/index.html new file mode 100644 index 000000000..cc3ec83c3 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-detail/side-float-action-bar/index.html @@ -0,0 +1,120 @@ + + + +
                    +
                    + + + + + + + + + + + + + + + + + + + + + +
                    + + + +
                    + + +
                    + + + +
                    + + +
                    + + + +
                    + + + + +
                    + + + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/article-detail/side-recommend-bar/index.html b/paicoding-ui/src/main/resources/templates/views/article-detail/side-recommend-bar/index.html new file mode 100644 index 000000000..6d22984f2 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-detail/side-recommend-bar/index.html @@ -0,0 +1,39 @@ + +
                    +
                    +
                    +
                    + 通知 +
                    +
                    +
                    +
                    +
                    + 推荐 +
                    +
                    +
                    +
                    +
                    + 加入 +
                    +
                    +
                    +
                    + 关于 +
                    +
                    +
                    +
                    + 教程 +
                    +
                    +
                    +
                    + PDF +
                    +
                    +
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/article-edit/index.html b/paicoding-ui/src/main/resources/templates/views/article-edit/index.html new file mode 100644 index 000000000..cdc4aa336 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-edit/index.html @@ -0,0 +1,1193 @@ + + +
                    + 技术派 - 文章发表 +
                    + + + + + + + +
                    +
                    + + +
                    + 保 存 +
                    + +
                    +
                    + +
                    + + + +
                    + + 文件解析中... +
                    + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/article-search-list/article/list.html b/paicoding-ui/src/main/resources/templates/views/article-search-list/article/list.html new file mode 100644 index 000000000..880dd12b1 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-search-list/article/list.html @@ -0,0 +1,4 @@ + +
                    +
                    正文
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/article-search-list/index.html b/paicoding-ui/src/main/resources/templates/views/article-search-list/index.html new file mode 100644 index 000000000..5e94de830 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-search-list/index.html @@ -0,0 +1,52 @@ + + +
                    + 技术派 +
                    + + + +
                    + + +
                    +
                    + +
                    +
                    文章列表
                    +
                    + + +
                    +
                    + + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/article-tag-list/article/list.html b/paicoding-ui/src/main/resources/templates/views/article-tag-list/article/list.html new file mode 100644 index 000000000..880dd12b1 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-tag-list/article/list.html @@ -0,0 +1,4 @@ + +
                    +
                    正文
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/article-tag-list/index.html b/paicoding-ui/src/main/resources/templates/views/article-tag-list/index.html new file mode 100644 index 000000000..59893e664 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/article-tag-list/index.html @@ -0,0 +1,35 @@ + + +
                    + 技术派 +
                    + + + + + +
                    + + +
                    +
                    + +
                    +
                    +
                    文章列表
                    +
                    +
                    +
                    + + +
                    + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/chat-home/index.html b/paicoding-ui/src/main/resources/templates/views/chat-home/index.html new file mode 100644 index 000000000..dd675d1cc --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/chat-home/index.html @@ -0,0 +1,466 @@ + + +
                    + + 派聪明 | 技术派 + +
                    + + + + + + + +
                    +
                    +
                    +
                    + +
                    +
                    +
                    +
                    +
                    +
                    +
                    + 点击登录,体验派聪明AI助手 +
                    +
                    +
                    + 绑定二哥编程星球,提升每天对话次数 + 审核中 + 试用中,添加管理员微信 itwanger 催审核 +
                    + +
                    +
                    +
                    + +
                    + +
                    与派聪明的 0 条对话 + 以天为单位,无限期重置)
                    +
                    + +
                    + +
                    +
                    +
                    +
                    + +
                    + + +
                    +
                    +
                    + +
                    +
                    + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/chat-home/sidebar/index.html b/paicoding-ui/src/main/resources/templates/views/chat-home/sidebar/index.html new file mode 100644 index 000000000..ea6d356ca --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/chat-home/sidebar/index.html @@ -0,0 +1,676 @@ +
                    + +
                    + + + 提示词:对AI进行角色定位,一个简单的示例如:
                    你现在扮演李白,你豪情万丈,狂放不羁;接下来请用李白的口吻和用户对话。
                    +
                    +
                    + + + + + + + + + + + + + +
                    + +
                    + + + +
                    +
                    +
                    聊天历史
                    +
                    +
                    +
                    +
                    + + + + + +
                    + 开启新对话 +
                    + +
                    +
                      + +
                    +
                    + + + + + + +
                    +
                    + + + + + + + + + + + + + +
                    +
                    + + + +
                    + 二哥的编程星球,扫码加入 +
                    + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/column-detail/column-menu/index.html b/paicoding-ui/src/main/resources/templates/views/column-detail/column-menu/index.html new file mode 100644 index 000000000..4d03d8b3c --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/column-detail/column-menu/index.html @@ -0,0 +1,79 @@ +
                    +
                    + + +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/column-detail/index.html b/paicoding-ui/src/main/resources/templates/views/column-detail/index.html new file mode 100644 index 000000000..3c1a79ddb --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/column-detail/index.html @@ -0,0 +1,713 @@ + + +
                    + + 专栏内容详情页 | 技术派 + +
                    + + + + + + + + + + +
                    +
                    + +
                    + +
                    + +
                    + + + + + + + +
                    + + +
                    +
                    + +
                    +
                    + 评论列表 +
                    +
                    +
                    + + +
                    +
                    +
                    +
                    +
                    +

                    目录

                    +
                    +
                    +
                    +
                    + + + +
                    + +
                    +
                    + + + + + + + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/column-home/index.html b/paicoding-ui/src/main/resources/templates/views/column-home/index.html new file mode 100644 index 000000000..9da40a7aa --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/column-home/index.html @@ -0,0 +1,43 @@ + + +
                    + 专栏首页 +
                    + + + + + +
                    + + +
                    +
                    +
                    + +
                    +
                    +
                    专栏列表
                    +
                    + +
                    +
                    + +
                    + +
                    +
                    +
                    +
                    +
                    + +
                    +
                    + + diff --git a/paicoding-ui/src/main/resources/templates/views/column-home/list/index.html b/paicoding-ui/src/main/resources/templates/views/column-home/list/index.html new file mode 100644 index 000000000..7f0307c26 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/column-home/list/index.html @@ -0,0 +1,17 @@ +
                    +
                    + 专栏详情 +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/column-home/sidebar/index.html b/paicoding-ui/src/main/resources/templates/views/column-home/sidebar/index.html new file mode 100644 index 000000000..f6f7c697b --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/column-home/sidebar/index.html @@ -0,0 +1,44 @@ + +
                    +
                    +
                    +
                    + 通知 +
                    +
                    +
                    +
                    +
                    + 推荐 +
                    +
                    +
                    +
                    +
                    + 加入 +
                    +
                    +
                    +
                    + 关于 +
                    +
                    +
                    +
                    + 教程 +
                    +
                    +
                    +
                    + PDF +
                    +
                    +
                    +
                    + 订阅微信公众号 +
                    +
                    +
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/home/article/list.html b/paicoding-ui/src/main/resources/templates/views/home/article/list.html new file mode 100644 index 000000000..715017386 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/home/article/list.html @@ -0,0 +1,4 @@ + +
                    +
                    正文
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/home/index.html b/paicoding-ui/src/main/resources/templates/views/home/index.html new file mode 100644 index 000000000..2d83bac6a --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/home/index.html @@ -0,0 +1,64 @@ + + +
                    + 技术派 +
                    + + + + +
                    + + +
                    + +
                    +
                    精选推荐文章列表
                    + +
                    +
                    +
                    +
                    +
                    文章列表
                    +
                    +
                    + +
                    + +
                    +
                    +
                    +
                    +
                    +
                    + +
                    +
                    + + + diff --git a/paicoding-ui/src/main/resources/templates/views/home/navbar/index.html b/paicoding-ui/src/main/resources/templates/views/home/navbar/index.html new file mode 100644 index 000000000..ad6af51c2 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/home/navbar/index.html @@ -0,0 +1,168 @@ + + +
                    +
                    + + + + + +
                    +
                    + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/home/recommend/index.html b/paicoding-ui/src/main/resources/templates/views/home/recommend/index.html new file mode 100644 index 000000000..50ca53752 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/home/recommend/index.html @@ -0,0 +1,69 @@ + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/home/sidebar/index.html b/paicoding-ui/src/main/resources/templates/views/home/sidebar/index.html new file mode 100644 index 000000000..d0b6f4164 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/home/sidebar/index.html @@ -0,0 +1,44 @@ + +
                    +
                    +
                    +
                    + 通知 +
                    +
                    +
                    +
                    +
                    + 推荐 +
                    +
                    +
                    +
                    +
                    + 加入 +
                    +
                    +
                    +
                    + 关于 +
                    +
                    +
                    +
                    + 教程 +
                    +
                    +
                    +
                    + PDF +
                    +
                    +
                    +
                    + 排行榜 +
                    +
                    +
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/login/code.html b/paicoding-ui/src/main/resources/templates/views/login/code.html new file mode 100644 index 000000000..544ea7ef5 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/login/code.html @@ -0,0 +1,46 @@ + + + + +
                    + 技术派 +
                    + + +
                    + + + +
                    + +
                    + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/login/wx.html b/paicoding-ui/src/main/resources/templates/views/login/wx.html new file mode 100644 index 000000000..0c0bb801d --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/login/wx.html @@ -0,0 +1,69 @@ + + + + +
                    + 技术派 +
                    + + +
                    + + + +
                    + +
                    + + + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/notice/index.html b/paicoding-ui/src/main/resources/templates/views/notice/index.html new file mode 100644 index 000000000..0f2d9d446 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/index.html @@ -0,0 +1,65 @@ + + +
                    + 技术派 +
                    + + + + +
                    + + +
                    + +
                    + +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    + +
                    +
                    + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-collect.html b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-collect.html new file mode 100644 index 000000000..b211346bc --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-collect.html @@ -0,0 +1,5 @@ +
                    +
                    + 消息通知列表 +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-comment.html b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-comment.html new file mode 100644 index 000000000..60fb331c7 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-comment.html @@ -0,0 +1,10 @@ +
                    +
                    + 消息通知列表 +
                    +
                    diff --git a/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-follow.html b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-follow.html new file mode 100644 index 000000000..31b448961 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-follow.html @@ -0,0 +1,5 @@ +
                    +
                    + 消息通知列表 +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-praise.html b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-praise.html new file mode 100644 index 000000000..4313417e2 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-praise.html @@ -0,0 +1,5 @@ +
                    +
                    + 消息通知列表 +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-reply.html b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-reply.html new file mode 100644 index 000000000..d5d78a336 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-reply.html @@ -0,0 +1,5 @@ +
                    +
                    + 消息通知列表 +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-system.html b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-system.html new file mode 100644 index 000000000..e4ed94024 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/notice/tab/notify-system.html @@ -0,0 +1,5 @@ +
                    +
                    + 消息通知列表 +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/rank/index.html b/paicoding-ui/src/main/resources/templates/views/rank/index.html new file mode 100644 index 000000000..d24f3ece0 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/rank/index.html @@ -0,0 +1,78 @@ + + +
                    + + 排行榜 | 技术派 + +
                    + + + + +
                    +
                    +
                    +
                    +
                    +
                    活跃排行榜
                    +
                    + 日榜 + 月榜 +
                    +
                    +
                    + +
                    +
                    + +
                    +
                    +
                    + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/user/achievement/index.html b/paicoding-ui/src/main/resources/templates/views/user/achievement/index.html new file mode 100644 index 000000000..f983c41e8 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/achievement/index.html @@ -0,0 +1,57 @@ + + + +
                    +
                    +
                    标题
                    +
                    +
                    + + 已发布文章 +
                    + +
                    +
                    +
                    + + 文章被点赞 +
                    + +
                    +
                    +
                    + + 文章被阅读 +
                    + +
                    +
                    +
                    + + 文章被收藏 +
                    + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/user/articles/index.html b/paicoding-ui/src/main/resources/templates/views/user/articles/index.html new file mode 100644 index 000000000..6a5671764 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/articles/index.html @@ -0,0 +1,6 @@ + +
                    +
                    + 正文 +
                    +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/user/follows/index.html b/paicoding-ui/src/main/resources/templates/views/user/follows/index.html new file mode 100644 index 000000000..4340a4ad7 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/follows/index.html @@ -0,0 +1,8 @@ + +
                    + +
                    \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/user/history/index.html b/paicoding-ui/src/main/resources/templates/views/user/history/index.html new file mode 100644 index 000000000..4e8a6ce85 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/history/index.html @@ -0,0 +1,18 @@ + + + +
                    +
                    +
                    创造历程
                    +
                    +
                    + 描述 +
                    +
                    +
                    +
                    +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/user/index.html b/paicoding-ui/src/main/resources/templates/views/user/index.html new file mode 100644 index 000000000..e1f76180c --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/index.html @@ -0,0 +1,100 @@ + + +
                    + 技术派 +
                    + + + + + +
                    + + +
                    +
                    +
                    +
                    + +
                    + +
                    + + +
                    + +
                    +
                    文章列表
                    +
                    + + + +
                    +
                    +
                    关注列表
                    +
                    +
                    列表为空~
                    +
                    +
                    +
                    +
                    + +
                    + +
                    +
                    +
                    +
                    +
                    +
                    + + +
                    + +
                    + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/user/info/edit.html b/paicoding-ui/src/main/resources/templates/views/user/info/edit.html new file mode 100644 index 000000000..d65ef310e --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/info/edit.html @@ -0,0 +1,310 @@ + + + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/user/info/index.html b/paicoding-ui/src/main/resources/templates/views/user/info/index.html new file mode 100644 index 000000000..12054cb94 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/info/index.html @@ -0,0 +1,101 @@ + + +
                    +
                    + +
                    +
                    +
                    + 用户名 +
                    +
                    + 星球编号:
                    +
                    + 会员到期时间: +
                    + + 会员可能已过期,点击此处通过知识星球重置 + +
                    +
                    +
                    +
                    + 加入天数 + 1 +
                    +
                    +
                    + 关注数 + 1 +
                    +
                    +
                    + 粉丝数 + 1 +
                    +
                    +
                    + +
                    +
                    +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/user/info/transfer.html b/paicoding-ui/src/main/resources/templates/views/user/info/transfer.html new file mode 100644 index 000000000..d5e6c148e --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/info/transfer.html @@ -0,0 +1,252 @@ + + + \ No newline at end of file diff --git a/paicoding-ui/src/main/resources/templates/views/user/navbar/follow-bar.html b/paicoding-ui/src/main/resources/templates/views/user/navbar/follow-bar.html new file mode 100644 index 000000000..c6ecc328b --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/navbar/follow-bar.html @@ -0,0 +1,15 @@ + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/user/navbar/home-bar.html b/paicoding-ui/src/main/resources/templates/views/user/navbar/home-bar.html new file mode 100644 index 000000000..cf9caf95b --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/navbar/home-bar.html @@ -0,0 +1,15 @@ + + + + diff --git a/paicoding-ui/src/main/resources/templates/views/user/pay-item.html b/paicoding-ui/src/main/resources/templates/views/user/pay-item.html new file mode 100644 index 000000000..c781dc699 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/pay-item.html @@ -0,0 +1,648 @@ + + +
                    + 技术派 +
                    + + + + + +
                    + + +
                    +
                    +
                    + +
                    +
                    +

                    技术派付费产品列表

                    +
                    + +
                    +
                    +

                    5篇付费阅读券

                    +

                    + ¥ + 98.00 +

                    +
                    享 5次付费解锁阅读
                    +
                    +
                    +

                    10篇付费阅读券

                    +

                    + ¥96.00

                    +
                    享 10次付费解锁阅读
                    +
                    +
                    +

                    15篇付费阅读券

                    +

                    + ¥142.00/月 +

                    +
                    享 15次付费解锁阅读
                    +
                    +
                    +

                    专栏解锁券

                    +

                    + ¥ + 126.00 + /月

                    +
                    一篇精选课程、专栏免费看
                    +
                    +
                    +

                    全场任意看

                    +

                    + ¥ + 299.00 +

                    +
                    全场所有内容任意阅读
                    +
                    + +
                    + +
                    +
                    +
                    +
                    +
                    +
                    + +
                    +
                    +
                    打开微信,扫一扫支付
                    +
                    + +
                    +
                    +
                    + +
                    +

                    5篇付费阅读券

                    + 实付: + 96.00元 + 已优惠2.00元! + +
                    +
                    + 购买之后,支持技术派5篇付费阅读文章的解锁 +
                    +
                    + +
                    +
                    + + H5支付 +
                    + +
                    + + native支付 +
                    +
                    +
                    +
                      +
                    • tip: h5支付,请用手机浏览器登录打开本网页,然后点击 h5支付 按钮,会自动唤起微信进行支付
                    • +
                    • tip: native支付,点击按钮之后,左边二维码会刷新,请用手机微信扫一扫实现付款
                    • +
                        +
                    +
                    +
                    +
                    + +
                    +
                    +
                    +
                    + +
                    + + diff --git a/paicoding-ui/src/main/resources/templates/views/user/pay/pay.html b/paicoding-ui/src/main/resources/templates/views/user/pay/pay.html new file mode 100644 index 000000000..1baa49768 --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/pay/pay.html @@ -0,0 +1,269 @@ + + + +
                    +
                    +
                    标题
                    +
                    +
                    + +
                    + qrcode + +
                    + +
                    +
                    +
                    +
                    + + + + + + + +
                    +
                    + + + + + + +
                    +
                    + + + + + + +
                    +
                    +
                    + 点击按钮切换收款二维码 +
                    +
                    +
                    +
                    + + +
                    + diff --git a/paicoding-ui/src/main/resources/templates/views/user/pay/payShow.html b/paicoding-ui/src/main/resources/templates/views/user/pay/payShow.html new file mode 100644 index 000000000..4889543ae --- /dev/null +++ b/paicoding-ui/src/main/resources/templates/views/user/pay/payShow.html @@ -0,0 +1,290 @@ + + + +
                    +
                    +
                    +
                    + +
                    + + +
                    + +

                    +

                    +
                    + +
                    +
                    +
                    + + + + + + + +
                    +
                    + + + + + + +
                    +
                    + + + + + + +
                    +
                    +
                    + 点击按钮切换个人收款二维码 +
                    +
                    + 备注: +
                    +
                    +
                    +
                    + +
                    + diff --git a/forum-web/README.md b/paicoding-web/README.md similarity index 100% rename from forum-web/README.md rename to paicoding-web/README.md diff --git a/paicoding-web/pom.xml b/paicoding-web/pom.xml new file mode 100644 index 000000000..4a203324e --- /dev/null +++ b/paicoding-web/pom.xml @@ -0,0 +1,267 @@ + + + + paicoding-forum + com.github.paicoding.forum + 0.0.1-SNAPSHOT + + 4.0.0 + + paicoding-web + + + 1.35 + 0.2.1 + 2.1-groovy-3.0 + 3.0.23 + 1.0.3 + + + + + com.github.paicoding.forum + paicoding-ui + + + + com.github.paicoding.forum + paicoding-service + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-web + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + io.micrometer + micrometer-registry-prometheus + + + + org.projectlombok + lombok + provided + + + + + com.github.liuyueyi.media + qrcode-plugin + + + + org.liquibase + liquibase-core + + + + + org.springframework.boot + spring-boot-configuration-processor + + + + + commons-io + commons-io + + + + + cn.hutool + hutool-all + + + + + com.itextpdf + itextpdf + + + + + com.esotericsoftware + kryo + + + + + org.apache.commons + commons-collections4 + + + + + org.apache.commons + commons-lang3 + + + + ognl + ognl + + + + + org.springframework.boot + spring-boot-devtools + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + junit + junit + test + + + + + org.spockframework + spock-core + ${spock.version} + test + + + + org.codehaus.groovy + groovy + ${groovy.version} + test + + + + com.sayweee + spock.mockfree + ${spock.mockfree.version} + test + + + + + com.github.usefulness + webp-imageio + ${webp-imageio.version} + test + + + + org.jsoup + jsoup + 1.15.3 + + + + org.openpnp + opencv + + + + com.belerweb + pinyin4j + 2.5.1 + + + + org.apache.commons + commons-exec + 1.3 + + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + test + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + test + + + + + org.springframework.boot + spring-boot-starter-websocket + + + + com.lmax + disruptor + 3.4.2 + + + + org.openjdk.jol + jol-core + 0.9 + + + + org.redisson + redisson + 3.16.4 + + + + com.alibaba.csp + sentinel-core + 1.8.6 + + + + com.alibaba.csp + sentinel-transport-simple-http + 1.8.6 + + + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.1 + + + + compile + compileTests + + + + + + + \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/QuickForumApplication.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/QuickForumApplication.java new file mode 100644 index 000000000..a930b0713 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/QuickForumApplication.java @@ -0,0 +1,102 @@ +package com.github.paicoding.forum.web; + +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.paicoding.forum.core.util.SocketUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.web.config.GlobalViewConfig; +import com.github.paicoding.forum.web.global.ForumExceptionHandler; +import com.github.paicoding.forum.web.hook.interceptor.GlobalViewInterceptor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 入口,直接运行即可 + * + * @author yihui + * @date 2022/7/6 + */ +@Slf4j +@EnableAsync +@EnableScheduling +@EnableCaching +@ServletComponentScan +@SpringBootApplication +public class QuickForumApplication implements WebMvcConfigurer, ApplicationRunner { + @Value("${server.port:8080}") + private Integer webPort; + + @Resource + private GlobalViewInterceptor globalViewInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(globalViewInterceptor).addPathPatterns("/**"); + } + + @Override + public void configureHandlerExceptionResolvers(List resolvers) { + resolvers.add(0, new ForumExceptionHandler()); + } + + /** + * 解决swagger-ui访问 /doc.html 404问题 + * @param registry + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/"); + registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/"); + } + + public static void main(String[] args) { + SpringApplication.run(QuickForumApplication.class, args); + } + + /** + * 兼容本地启动时8080端口被占用的场景; 只有dev启动方式才做这个逻辑 + * + * @return + */ + @Bean + @ConditionalOnExpression(value = "#{'dev'.equals(environment.getProperty('env.name'))}") + public TomcatConnectorCustomizer customServerPortTomcatConnectorCustomizer() { + // 开发环境时,首先判断8080d端口是否可用;若可用则直接使用,否则选择一个可用的端口号启动 + int port = SocketUtil.findAvailableTcpPort(8000, 10000, webPort); + if (port != webPort) { + log.info("默认端口号{}被占用,随机启用新端口号: {}", webPort, port); + webPort = port; + } + return connector -> connector.setPort(port); + } + + @Override + public void run(ApplicationArguments args) { + // 设置类型转换, 主要用于mybatis读取varchar/json类型数据据,并写入到json格式的实体Entity中 + JacksonTypeHandler.setObjectMapper(new ObjectMapper()); + // 应用启动之后执行 + GlobalViewConfig config = SpringUtil.getBean(GlobalViewConfig.class); + if (webPort != null) { + config.setHost("http://127.0.0.1:" + webPort); + } + log.info("启动成功,点击进入首页: {}", config.getHost()); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/package-info.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/package-info.java new file mode 100644 index 000000000..d29330848 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/package-info.java @@ -0,0 +1,7 @@ +/** + * 管理员用户操作路径 + * + * @author yihui + * @date 2022/7/6 + */ +package com.github.paicoding.forum.web.admin; \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/AdminLoginController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/AdminLoginController.java new file mode 100644 index 000000000..f7a37e971 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/AdminLoginController.java @@ -0,0 +1,101 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.service.user.service.AuthorWhiteListService; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.service.user.service.UserService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Optional; + +/** + * 文章后台 + * + * @author YiHui + * @date 2022/12/5 + */ +@RestController +@Api(value = "后台登录登出管理控制器", tags = "后台登录") +@RequestMapping(path = {"/api/admin", "/admin"}) +public class AdminLoginController { + + @Autowired + private UserService userService; + + @Autowired + private LoginService loginOutService; + + @Autowired + private AuthorWhiteListService articleWhiteListService; + + /** + * 后台用户名 & 密码的方式登录 + * + * @param request + * @param response + * @return + */ + @RequestMapping(path = {"login"}) + public ResVo login(HttpServletRequest request, + HttpServletResponse response) { + String username = request.getParameter("username"); + String pwd = request.getParameter("password"); + String session = loginOutService.loginByUserPwd(username, pwd); + if (StringUtils.isNotBlank(session)) { + // cookie中写入用户登录信息 + response.addCookie(SessionUtil.newCookie(LoginService.SESSION_KEY, session)); + return ResVo.ok(userService.queryBasicUserInfo(ReqInfoContext.getReqInfo().getUserId())); + } else { + return ResVo.fail(StatusEnum.LOGIN_FAILED_MIXED, "登录失败,请重试"); + } + } + + /** + * 判断是否有登录 + * + * @return + */ + @RequestMapping(path = "isLogined") + public ResVo isLogined() { + return ResVo.ok(ReqInfoContext.getReqInfo().getUserId() != null); + } + + @ApiOperation("获取当前登录用户信息") + @GetMapping("info") + public ResVo info() { + BaseUserInfoDTO user = ReqInfoContext.getReqInfo().getUser(); + return ResVo.ok(user); + } + + /** + * 登出 + * + * @param response + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping("logout") + public ResVo logOut(HttpServletResponse response) { + Optional.ofNullable(ReqInfoContext.getReqInfo()).ifPresent(s -> loginOutService.logout(s.getSession())); + // 为什么不后端实现重定向? 重定向交给前端执行,避免由于前后端分离,本地开发时端口不一致导致的问题 + // response.sendRedirect("/"); + + // 移除cookie + SessionUtil.delCookies(LoginService.SESSION_KEY); + return ResVo.ok(true); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ArticleSettingRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ArticleSettingRestController.java new file mode 100644 index 000000000..3c7a00779 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ArticleSettingRestController.java @@ -0,0 +1,116 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.OperateArticleEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; +import com.github.paicoding.forum.api.model.vo.article.SearchArticleReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleAdminDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.ArticleSettingService; +import com.github.paicoding.forum.service.article.service.ArticleWriteService; +import com.github.paicoding.forum.web.front.search.vo.SearchArticleVo; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 文章后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.LOGIN) +@Api(value = "文章设置管理控制器", tags = "文章管理") +@RequestMapping(path = {"/api/admin/article/", "/admin/article/"}) +public class ArticleSettingRestController { + + @Autowired + private ArticleSettingService articleSettingService; + + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private ArticleWriteService articleWriteService; + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "save") + public ResVo save(@RequestBody ArticlePostReq req) { + if (NumUtil.nullOrZero(req.getArticleId())) { + // 新增文章 + this.articleWriteService.saveArticle(req, ReqInfoContext.getReqInfo().getUserId()); + } else { + this.articleWriteService.saveArticle(req, null); + } + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "update") + public ResVo update(@RequestBody ArticlePostReq req) { + articleSettingService.updateArticle(req); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "operate") + public ResVo operate(@RequestParam(name = "articleId") Long articleId, @RequestParam(name = "operateType") Integer operateType) { + OperateArticleEnum operate = OperateArticleEnum.fromCode(operateType); + if (operate == OperateArticleEnum.EMPTY) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, operateType + "非法"); + } + articleSettingService.operateArticle(articleId, operate); + return ResVo.ok(); + } + + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "delete") + public ResVo delete(@RequestParam(name = "articleId") Long articleId) { + articleSettingService.deleteArticle(articleId); + return ResVo.ok(); + } + + // 根据文章id获取文章详情 + @ApiOperation("根据文章id获取文章详情") + @GetMapping(path = "detail") + public ResVo detail(@RequestParam(name = "articleId", required = false) Long articleId) { + ArticleDTO articleDTO = new ArticleDTO(); + if (articleId != null) { + // 查询文章详情 + articleDTO = articleReadService.queryDetailArticleInfo(articleId); + } + + return ResVo.ok(articleDTO); + } + + @ApiOperation("获取文章列表") + @PostMapping(path = "list") + public ResVo> list(@RequestBody SearchArticleReq req) { + PageVo articleDTOPageVo = articleSettingService.getArticleList(req); + return ResVo.ok(articleDTOPageVo); + } + + @ApiOperation("文章搜索,按照文章标题关键字") + @GetMapping(path = "query") + public ResVo queryArticleList(@RequestParam(name = "key", required = false) String key) { + List list = articleReadService.querySimpleArticleBySearchKey(key); + SearchArticleVo vo = new SearchArticleVo(); + vo.setKey(key); + vo.setItems(list); + return ResVo.ok(vo); + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ArticleSlugMigrationController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ArticleSlugMigrationController.java new file mode 100644 index 000000000..84ff04e11 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ArticleSlugMigrationController.java @@ -0,0 +1,92 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.service.ArticleSlugMigrationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 文章URL Slug迁移管理接口 + * 仅管理员可访问 + * + * @author Claude + * @date 2025-11-10 + */ +@Slf4j +@RestController +@RequestMapping("/admin/article/slug") +public class ArticleSlugMigrationController { + + @Autowired + private ArticleSlugMigrationService migrationService; + + /** + * 执行全量slug迁移 + * 访问: /admin/article/slug/migrate + * 需要管理员权限 + * + * @return 处理结果 + */ + @Permission(role = UserRole.ADMIN) + @GetMapping("/migrate") + public ResVo migrateAllSlugs() { + try { + int count = migrationService.migrateAllArticleSlugs(); + String message = String.format("迁移完成! 共处理 %d 篇文章", count); + log.info(message); + return ResVo.ok(message); + } catch (Exception e) { + log.error("Slug迁移失败", e); + return ResVo.fail(StatusEnum.UNEXPECT_ERROR, "迁移失败: " + e.getMessage()); + } + } + + /** + * 重新生成指定文章的slug + * 访问: /admin/article/slug/regenerate?articleId=123 + * + * @param articleId 文章ID + * @return 处理结果 + */ + @Permission(role = UserRole.ADMIN) + @GetMapping("/regenerate") + public ResVo regenerateSlug(@RequestParam Long articleId) { + try { + boolean success = migrationService.regenerateSlug(articleId); + if (success) { + return ResVo.ok("Slug重新生成成功"); + } else { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "Slug重新生成失败,文章可能不存在或标题为空"); + } + } catch (Exception e) { + log.error("Slug重新生成失败", e); + return ResVo.fail(StatusEnum.UNEXPECT_ERROR, e.getMessage()); + } + } + + /** + * 查询需要迁移的文章数量 + * 访问: /admin/article/slug/count + * + * @return 数量统计 + */ + @Permission(role = UserRole.ADMIN) + @GetMapping("/count") + public ResVo countNeedMigration() { + try { + long count = migrationService.countArticlesNeedMigration(); + String message = String.format("有 %d 篇文章需要生成slug", count); + return ResVo.ok(message); + } catch (Exception e) { + log.error("统计失败", e); + return ResVo.fail(StatusEnum.UNEXPECT_ERROR, "统计失败: " + e.getMessage()); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/AuthorWhiteListController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/AuthorWhiteListController.java new file mode 100644 index 000000000..94be33a59 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/AuthorWhiteListController.java @@ -0,0 +1,51 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.user.service.AuthorWhiteListService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiImplicitParam; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 作者白名单服务 + * + * @author YiHui + * @date 2023/4/9 + */ +@RestController +@Api(value = "发布文章作者白名单管理控制器", tags = "作者白名单") +@Permission(role = UserRole.ADMIN) +@RequestMapping(path = {"api/admin/author/whitelist"}) +public class AuthorWhiteListController { + @Autowired + private AuthorWhiteListService articleWhiteListService; + + @GetMapping(path = "get") + @ApiOperation(value = "白名单列表", notes = "返回作者白名单列表") + public ResVo> whiteList() { + return ResVo.ok(articleWhiteListService.queryAllArticleWhiteListAuthors()); + } + + @GetMapping(path = "add") + @ApiOperation(value = "添加白名单", notes = "将指定作者加入作者白名单列表") + @ApiImplicitParam(name = "authorId", value = "传入需要添加白名单的作者UserId", required = true, allowEmptyValue = false, example = "1") + public ResVo addAuthor(@RequestParam("authorId") Long authorId) { + articleWhiteListService.addAuthor2ArticleWhitList(authorId); + return ResVo.ok(true); + } + + @GetMapping(path = "remove") + @ApiOperation(value = "删除白名单", notes = "将作者从白名单列表") + @ApiImplicitParam(name = "authorId", value = "传入需要删除白名单的作者UserId", required = true, allowEmptyValue = false, example = "1") + public ResVo rmAuthor(@RequestParam("authorId") Long authorId) { + articleWhiteListService.removeAuthorFromArticleWhiteList(authorId); + return ResVo.ok(true); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/CategorySettingRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/CategorySettingRestController.java new file mode 100644 index 000000000..dc0f1aa12 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/CategorySettingRestController.java @@ -0,0 +1,66 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.CategoryReq; +import com.github.paicoding.forum.api.model.vo.article.SearchCategoryReq; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.service.CategorySettingService; +import io.swagger.annotations.Api; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 分类后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.LOGIN) +@Api(value = "文章类目管理控制器", tags = "类目管理") +@RequestMapping(path = {"api/admin/category/", "admin/category/"}) +public class CategorySettingRestController { + + @Autowired + private CategorySettingService categorySettingService; + + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "save") + public ResVo save(@RequestBody CategoryReq req) { + categorySettingService.saveCategory(req); + return ResVo.ok(); + } + + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "delete") + public ResVo delete(@RequestParam(name = "categoryId") Integer categoryId) { + categorySettingService.deleteCategory(categoryId); + return ResVo.ok(); + } + + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "operate") + public ResVo operate(@RequestParam(name = "categoryId") Integer categoryId, + @RequestParam(name = "pushStatus") Integer pushStatus) { + if (pushStatus != PushStatusEnum.OFFLINE.getCode() && pushStatus!= PushStatusEnum.ONLINE.getCode()) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS); + } + categorySettingService.operateCategory(categoryId, pushStatus); + return ResVo.ok(); + } + + + @PostMapping(path = "list") + public ResVo> list(@RequestBody SearchCategoryReq req) { + PageVo categoryDTOPageVo = categorySettingService.getCategoryList(req); + return ResVo.ok(categoryDTOPageVo); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ColumnSettingRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ColumnSettingRestController.java new file mode 100644 index 000000000..6606d1c0d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ColumnSettingRestController.java @@ -0,0 +1,201 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleGroupReq; +import com.github.paicoding.forum.api.model.vo.article.ColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.ColumnReq; +import com.github.paicoding.forum.api.model.vo.article.MoveColumnArticleOrGroupReq; +import com.github.paicoding.forum.api.model.vo.article.SearchColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.SearchColumnReq; +import com.github.paicoding.forum.api.model.vo.article.SortColumnArticleByIDReq; +import com.github.paicoding.forum.api.model.vo.article.SortColumnArticleReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleGroupDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleColumnDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.ColumnSettingService; +import com.github.paicoding.forum.service.image.service.ImageService; +import com.github.paicoding.forum.web.front.search.vo.SearchColumnVo; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 专栏后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Slf4j +@Permission(role = UserRole.LOGIN) +@Api(value = "专栏及专栏文章管理控制器", tags = "专栏管理") +@RequestMapping(path = {"api/admin/column/", "admin/column/"}) +public class ColumnSettingRestController { + + @Autowired + private ColumnSettingService columnSettingService; + + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private ImageService imageService; + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "saveColumn") + public ResVo saveColumn(@RequestBody ColumnReq req) { + columnSettingService.saveColumn(req); + return ResVo.ok(); + } + + /** + * 维护专栏的分组情况 + * + * @param req + * @return + */ + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "saveColumnGroup") + public ResVo saveColumnArticleGroup(@RequestBody ColumnArticleGroupReq req) { + columnSettingService.saveColumnArticleGroup(req); + return ResVo.ok(true); + } + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "saveColumnArticle") + public ResVo saveColumnArticle(@RequestBody ColumnArticleReq req) { + + // 要求文章必须存在,且已经发布 + ArticleDO articleDO = articleReadService.queryBasicArticle(req.getArticleId()); + if (articleDO == null || articleDO.getStatus() == PushStatusEnum.OFFLINE.getCode()) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "教程对应的文章不存在或未发布!"); + } + + columnSettingService.saveColumnArticle(req); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "deleteColumn") + public ResVo deleteColumn(@RequestParam(name = "columnId") Long columnId) { + columnSettingService.deleteColumn(columnId); + return ResVo.ok(); + } + + /** + * 删除专栏文章分组 + * + * @param groupId 分组id + * @return + */ + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "deleteColumnGroup") + public ResVo deleteColumnGroup(@RequestParam(name = "groupId") Long groupId) { + boolean ans = columnSettingService.deleteColumnGroup(groupId); + return ResVo.ok(ans); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "deleteColumnArticle") + public ResVo deleteColumnArticle(@RequestParam(name = "id") Long id) { + columnSettingService.deleteColumnArticle(id); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "sortColumnArticleApi") + public ResVo sortColumnArticleApi(@RequestBody SortColumnArticleReq req) { + columnSettingService.sortColumnArticleApi(req); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "sortColumnArticleByIDApi") + public ResVo sortColumnArticleByIDApi(@RequestBody SortColumnArticleByIDReq req) { + columnSettingService.sortColumnArticleByIDApi(req); + return ResVo.ok(); + } + + + /** + * 移动专栏中教程或者分组的位置 + * @param req 请求参数 + * @return + */ + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "moveColumnArticleOrGroup") + public ResVo moveColumnArticleOrGroup(@RequestBody MoveColumnArticleOrGroupReq req) { + columnSettingService.moveColumnArticleOrGroup(req); + return ResVo.ok(true); + } + + @ApiOperation("获取教程列表") + @PostMapping(path = "list") + public ResVo> list(@RequestBody SearchColumnReq req) { + PageVo columnDTOPageVo = columnSettingService.getColumnList(req); + return ResVo.ok(columnDTOPageVo); + } + + + @ApiOperation("获取教程分组列表") + @GetMapping(path = "listGroups") + public ResVo> listGroups(@RequestParam("columnId") Long columnId) { + List list = columnSettingService.getColumnGroups(columnId); + return ResVo.ok(list); + } + + /** + * 获取教程配套的文章列表 + *

                    + * 请求参数有教程名、文章名 + * 返回教程配套的文章列表 + * + * @return + */ + @PostMapping(path = "listColumnArticle") + public ResVo> listColumnArticle(@RequestBody SearchColumnArticleReq req) { + PageVo vo = columnSettingService.getColumnArticleList(req); + return ResVo.ok(vo); + } + + + /** + * 教程的文章,根据分组进行汇聚展示 + * + * @param columnId + * @return + */ + @GetMapping(path = "listColumnByGroup") + public ResVo> listColumnArticlesByGroup(@RequestParam("columnId") Long columnId) { + List list = columnSettingService.getColumnGroupAndArticles(columnId); + return ResVo.ok(list); + } + + + @ApiOperation("专栏搜索") + @GetMapping(path = "query") + public ResVo query(@RequestParam(name = "key", required = false) String key) { + List list = columnSettingService.listSimpleColumnBySearchKey(key); + SearchColumnVo vo = new SearchColumnVo(); + vo.setKey(key); + vo.setItems(list); + return ResVo.ok(vo); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ConfigSettingrRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ConfigSettingrRestController.java new file mode 100644 index 000000000..157090c1c --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ConfigSettingrRestController.java @@ -0,0 +1,80 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.banner.ConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.SearchConfigReq; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.config.service.impl.ConfigSettingServiceImpl; +import io.swagger.annotations.Api; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.web.bind.annotation.*; + +/** + * Banner后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.LOGIN) +@Api(value = "后台运营配置管理控制器", tags = "配置管理") +@RequestMapping(path = {"api/admin/config/", "admin/config/"}) +public class ConfigSettingrRestController { + + @Autowired + private ConfigSettingServiceImpl configSettingService; + + @Autowired + private CacheManager caffeineCacheManager; + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "save") + public ResVo save(@RequestBody ConfigReq configReq) { + configSettingService.saveConfig(configReq); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "delete") + public ResVo delete(@RequestParam(name = "configId") Integer configId) { + configSettingService.deleteConfig(configId); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "operate") + public ResVo operate(@RequestParam(name = "configId") Integer configId, + @RequestParam(name = "pushStatus") Integer pushStatus) { + if (pushStatus != PushStatusEnum.OFFLINE.getCode() && pushStatus!= PushStatusEnum.ONLINE.getCode()) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS); + } + configSettingService.operateConfig(configId, pushStatus); + return ResVo.ok(); + } + + /** + * 获取配置列表 + * + * @return + */ + @PostMapping(path = "list") + public ResVo> list(@RequestBody SearchConfigReq req) { + PageVo bannerDTOPageVo = configSettingService.getConfigList(req); + return ResVo.ok(bannerDTOPageVo); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "refresh") + public ResVo refresh() { + caffeineCacheManager.getCacheNames().forEach(cacheName -> { + caffeineCacheManager.getCache(cacheName).clear(); + }); + return ResVo.ok("缓存已刷新"); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/GlobalConfigRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/GlobalConfigRestController.java new file mode 100644 index 000000000..f08c279e8 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/GlobalConfigRestController.java @@ -0,0 +1,50 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.config.GlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.SearchGlobalConfigReq; +import com.github.paicoding.forum.api.model.vo.config.dto.GlobalConfigDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.config.service.GlobalConfigService; +import io.swagger.annotations.Api; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 标签后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.LOGIN) +@Api(value = "全局配置管理控制器", tags = "全局配置") +@RequestMapping(path = {"api/admin/global/config/", "admin/global/config/"}) +public class GlobalConfigRestController { + + @Autowired + private GlobalConfigService globalConfigService; + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "save") + public ResVo save(@RequestBody GlobalConfigReq req) { + globalConfigService.save(req); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "delete") + public ResVo delete(@RequestParam(name = "id") Long id) { + globalConfigService.delete(id); + return ResVo.ok(); + } + + @PostMapping(path = "list") + @Permission(role = UserRole.ADMIN) + public ResVo> list(@RequestBody SearchGlobalConfigReq req) { + PageVo page = globalConfigService.getList(req); + return ResVo.ok(page); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/StatisticsSettingRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/StatisticsSettingRestController.java new file mode 100644 index 000000000..bfc7e0d9b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/StatisticsSettingRestController.java @@ -0,0 +1,67 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsCountDTO; +import com.github.paicoding.forum.api.model.vo.statistics.dto.StatisticsDayDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.statistics.service.StatisticsSettingService; +import io.swagger.annotations.Api; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.List; + +/** + * 数据统计后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.LOGIN) +@Api(value = "全栈统计分析控制器", tags = "统计分析") +@RequestMapping(path = {"api/admin/statistics/", "admin/statistics/"}) +public class StatisticsSettingRestController { + + private static final Logger log = LoggerFactory.getLogger(StatisticsSettingRestController.class); + @Autowired + private StatisticsSettingService statisticsSettingService; + + static final Integer DEFAULT_DAY = 7; + + @GetMapping(path = "queryTotal") + public ResVo queryTotal() { + StatisticsCountDTO statisticsCountDTO = statisticsSettingService.getStatisticsCount(); + return ResVo.ok(statisticsCountDTO); + } + + @ResponseBody + @GetMapping(path = "pvUvDayList") + public ResVo> pvUvDayList(@RequestParam(name = "day", required = false) Integer day) { + day = (day == null || day == 0) ? DEFAULT_DAY : day; + List pvDayList = statisticsSettingService.getPvUvDayList(day); + return ResVo.ok(pvDayList); + } + + @GetMapping("pvUvDayDownload2Excel") + public void pvUvDayDownload2Excel(@RequestParam(name = "day", required = false) Integer day, + HttpServletResponse response) throws IOException { + response.reset(); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String fileName = URLEncoder.encode("技术派", "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + + // 获取数据 + day = (day == null || day == 0) ? DEFAULT_DAY : day; + statisticsSettingService.download2Excel(day, response); + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/TagSettingRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/TagSettingRestController.java new file mode 100644 index 000000000..835258d48 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/TagSettingRestController.java @@ -0,0 +1,62 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.enums.PushStatusEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.SearchTagReq; +import com.github.paicoding.forum.api.model.vo.article.TagReq; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.service.TagSettingService; +import io.swagger.annotations.Api; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 标签后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.LOGIN) +@Api(value = "文章标签管理控制器", tags = "标签管理") +@RequestMapping(path = {"api/admin/tag/", "admin/tag/"}) +public class TagSettingRestController { + + @Autowired + private TagSettingService tagSettingService; + + @Permission(role = UserRole.ADMIN) + @PostMapping(path = "save") + public ResVo save(@RequestBody TagReq req) { + tagSettingService.saveTag(req); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "delete") + public ResVo delete(@RequestParam(name = "tagId") Integer tagId) { + tagSettingService.deleteTag(tagId); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "operate") + public ResVo operate(@RequestParam(name = "tagId") Integer tagId, + @RequestParam(name = "pushStatus") Integer pushStatus) { + if (pushStatus != PushStatusEnum.OFFLINE.getCode() && pushStatus!= PushStatusEnum.ONLINE.getCode()) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS); + } + tagSettingService.operateTag(tagId, pushStatus); + return ResVo.ok(); + } + + @PostMapping(path = "list") + public ResVo> list(@RequestBody SearchTagReq req) { + PageVo tagDTOPageVo = tagSettingService.getTagList(req); + return ResVo.ok(tagDTOPageVo); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/UserSettingRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/UserSettingRestController.java new file mode 100644 index 000000000..a9a7ba5c0 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/UserSettingRestController.java @@ -0,0 +1,53 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.web.front.search.vo.SearchUserVo; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 用户权限管理后台 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Permission(role = UserRole.ADMIN) +@Api(value = "用户管理控制器", tags = "用户管理") +@RequestMapping(path = {"api/admin/user/", "admin/user/"}) +public class UserSettingRestController { + + @Autowired + private UserService userService; + + @ApiOperation("用户搜索") + @GetMapping(path = "query") + public ResVo queryUserList(@RequestParam(name = "key", required = false) String key) { + List list = userService.searchUser(key); + SearchUserVo vo = new SearchUserVo(); + vo.setKey(key); + vo.setItems(list); + return ResVo.ok(vo); + } + + @Permission(role = UserRole.LOGIN) + @ApiOperation("获取当前登录用户信息") + @GetMapping("info") + public ResVo info() { + BaseUserInfoDTO user = ReqInfoContext.getReqInfo().getUser(); + return ResVo.ok(user); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ZsxqWhiteListController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ZsxqWhiteListController.java new file mode 100644 index 000000000..b802fccd4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/admin/rest/ZsxqWhiteListController.java @@ -0,0 +1,70 @@ +package com.github.paicoding.forum.web.admin.rest; + +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.user.SearchZsxqUserReq; +import com.github.paicoding.forum.api.model.vo.user.ZsxqUserBatchOperateReq; +import com.github.paicoding.forum.api.model.vo.user.ZsxqUserPostReq; +import com.github.paicoding.forum.api.model.vo.user.dto.ZsxqUserInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.user.service.ZsxqWhiteListService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 作者白名单服务 + * + * @author YiHui + * @date 2023/4/9 + */ +@RestController +@Api(value = "星球用户白名单管理控制器", tags = "星球白名单") +@Permission(role = UserRole.ADMIN) +@RequestMapping(path = {"api/admin/zsxq/whitelist"}) +public class ZsxqWhiteListController { + @Autowired + private ZsxqWhiteListService zsxqWhiteListService; + + @ApiOperation("获取知识星球白名单用户列表") + @PostMapping(path = "") + public ResVo> list(@RequestBody SearchZsxqUserReq req) { + PageVo articleDTOPageVo = zsxqWhiteListService.getList(req); + return ResVo.ok(articleDTOPageVo); + } + + // 改变用户状态,审核通过 + @ApiOperation("改变用户状态") + @GetMapping(path = "operate") + public ResVo operate(@RequestParam(name = "id") Long id, + @RequestParam(name = "status") Integer status) { + UserAIStatEnum operate = UserAIStatEnum.fromCode(status); + zsxqWhiteListService.operate(id, operate); + return ResVo.ok(); + } + + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "reset") + public ResVo reset(@RequestParam(name = "authorId") Integer authorId) { + zsxqWhiteListService.reset(authorId); + return ResVo.ok(); + } + + // 批量审核通过 + @ApiOperation("批量审核通过") + @PostMapping(path = "batchOperate") + public ResVo batchOperate(@RequestBody ZsxqUserBatchOperateReq req) { + UserAIStatEnum operate = UserAIStatEnum.fromCode(req.getStatus()); + zsxqWhiteListService.batchOperate(req.getIds(), operate); + return ResVo.ok(); + } + + @PostMapping(path = "save") + public ResVo save(@RequestBody ZsxqUserPostReq req) { + zsxqWhiteListService.update(req); + return ResVo.ok(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/DictCommonController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/DictCommonController.java new file mode 100644 index 000000000..8f892dae0 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/DictCommonController.java @@ -0,0 +1,40 @@ +package com.github.paicoding.forum.web.common; + +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.config.service.DictCommonService; +import io.swagger.annotations.Api; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 通用 + * + * @author LouZai + * @date 2022/9/19 + */ +@RestController +@Slf4j +@Permission(role = UserRole.LOGIN) +@Api(value = "通用接口管理控制器", tags = "全局设置") +@RequestMapping(path = {"common/","api/admin/common/", "admin/common/"}) +public class DictCommonController { + + @Autowired + private DictCommonService dictCommonService; + + @ResponseBody + @GetMapping(path = "/dict") + public ResVo> list() { + log.debug("获取字典"); + Map bannerDTOPageVo = dictCommonService.getDict(); + return ResVo.ok(bannerDTOPageVo); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/image/rest/ImageRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/image/rest/ImageRestController.java new file mode 100644 index 000000000..a5fbbdf12 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/image/rest/ImageRestController.java @@ -0,0 +1,91 @@ +package com.github.paicoding.forum.web.common.image.rest; + +import com.github.hui.quick.plugin.qrcode.wrapper.QrCodeDeWrapper; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.image.service.ImageService; +import com.github.paicoding.forum.web.common.image.vo.ImageVo; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletRequest; + +/** + * 图片服务,要求登录之后才允许操作 + * + * @author LouZai + * @date 2022/9/7 + */ +@Permission(role = UserRole.LOGIN) +@RequestMapping(path = {"image/", "admin/image/", "api/admin/image/",}) +@RestController +@Slf4j +public class ImageRestController { + + @Autowired + private ImageService imageService; + + /** + * 图片上传 + * + * @return + */ + + @RequestMapping(path = "upload") + public ResVo upload(HttpServletRequest request) { + ImageVo imageVo = new ImageVo(); + try { + String imagePath = imageService.saveImg(request); + imageVo.setImagePath(imagePath); + } catch (Exception e) { + log.error("save upload file error!", e); + return ResVo.fail(StatusEnum.UPLOAD_PIC_FAILED); + } + return ResVo.ok(imageVo); + } + + /** + * 二维码识别 + * + * @param request + * @return 识别的内容 + */ + @RequestMapping(path = "qrscan") + public ResVo qrscan(HttpServletRequest request) throws Exception { + MultipartFile file = null; + if (request instanceof MultipartHttpServletRequest) { + file = ((MultipartHttpServletRequest) request).getFile("image"); + } + if (file != null) { + return ResVo.ok(QrCodeDeWrapper.decode(ImageIO.read(file.getInputStream()))); + } + return ResVo.ok("nill"); + } + + /** + * 转存图片 + * + * @param imgUrl + * @return + */ + @RequestMapping(path = "save") + public ResVo save(@RequestParam(name = "img", defaultValue = "") String imgUrl) { + ImageVo imageVo = new ImageVo(); + if (StringUtils.isBlank(imgUrl)) { + return ResVo.ok(imageVo); + } + + String url = imageService.saveImg(imgUrl); + imageVo.setImagePath(url); + return ResVo.ok(imageVo); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/image/vo/ImageVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/image/vo/ImageVo.java new file mode 100644 index 000000000..e43477a8e --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/common/image/vo/ImageVo.java @@ -0,0 +1,16 @@ +package com.github.paicoding.forum.web.common.image.vo; + +import lombok.Data; + +/** + * @author LouZai + * @date 2022/9/8 + */ +@Data +public class ImageVo { + + /** + * 图片路径 + */ + private String imagePath; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/component/TemplateEngineHelper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/component/TemplateEngineHelper.java new file mode 100644 index 000000000..c745f2463 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/component/TemplateEngineHelper.java @@ -0,0 +1,56 @@ +package com.github.paicoding.forum.web.component; + +import com.github.paicoding.forum.core.util.MapUtils; +import com.github.paicoding.forum.web.global.GlobalInitService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring5.SpringTemplateEngine; + +/** + * @author YiHui + * @date 2022/9/7 + */ +@Component +public class TemplateEngineHelper { + @Autowired + private SpringTemplateEngine springTemplateEngine; + + @Autowired + private GlobalInitService globalInitService; + + /** + * 模板渲染 + * + * @param template + * @param attrName + * @param attrVal + * @param + * @return + */ + public String render(String template, String attrName, T attrVal) { + Context context = new Context(); + context.setVariable(attrName, attrVal); + context.setVariable("global", globalInitService.globalAttr()); + return springTemplateEngine.process(template, context); + } + + public String render(String template, T attr) { + return render(template, "vo", attr); + } + + /** + * 模板渲染,传参属性放在vo包装类下 + * + * @param template 模板 + * @param second 实际的data属性 + * @param val 传参 + * @param + * @return + */ + public String renderToVo(String template, String second, T val) { + Context context = new Context(); + context.setVariable("vo", MapUtils.create(second, val)); + return springTemplateEngine.process(template, context); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/GlobalViewConfig.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/GlobalViewConfig.java new file mode 100644 index 000000000..32ef4b8b4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/GlobalViewConfig.java @@ -0,0 +1,92 @@ +package com.github.paicoding.forum.web.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * @author yihui + * @date 2022/6/15 + */ +@Data +@ConfigurationProperties(prefix = "view.site") +@Component +public class GlobalViewConfig { + + /** + * true 表示开启了微信支付 + * false 表示未配置微信支付 + */ + private Boolean wxPayEnable; + + private String cdnImgStyle; + + private String websiteRecord; + + private Integer pageSize; + + private String websiteName; + + private String websiteLogoUrl; + + private String websiteFaviconIconUrl; + + private String contactMeWxQrCode; + + private String contactMeStarQrCode; + + /** + * 知识星球的跳转地址 + */ + private String zsxqUrl; + + /** + * 知识星球首页的一个展示图片地址 + */ + private String zsxqImgUrl; + + /** + * 知识星球二维码的地址,派聪明 AI助手用 + */ + private String zsxqPosterUrl; + + private String contactMeTitle; + + /** + * 微信公众号登录url + */ + private String wxLoginUrl; + + private String host; + + /** + * 首次登录的欢迎信息 + */ + private String welcomeInfo; + + /** + * 星球信息 + */ + private String starInfo; + + /** + * oss的地址 + */ + private String oss; + + // 知识星球文章可阅读数 + private String zsxqArticleReadCount; + + // 需要登录文章可阅读数 + private String needLoginArticleReadCount; + + // 需要支付的可阅读数 + private String needPayArticleReadCount; + + public String getOss() { + if (oss == null) { + this.oss = ""; + } + return this.oss; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/PaiWebConfig.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/PaiWebConfig.java new file mode 100644 index 000000000..f9e79035f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/PaiWebConfig.java @@ -0,0 +1,113 @@ +package com.github.paicoding.forum.web.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.paicoding.forum.core.util.JsonUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.spring5.dialect.SpringStandardDialect; +import org.thymeleaf.standard.serializer.IStandardJavaScriptSerializer; + +import javax.annotation.PostConstruct; +import javax.annotation.Resource; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Objects; + +/** + * 注册xml解析器 + * + * @author yihui + * @date 2022/6/20 + */ +@Slf4j +@Configuration +public class PaiWebConfig implements WebMvcConfigurer { + @Resource + private TemplateEngine templateEngine; + + @PostConstruct + private void init() { + log.info("PaiWebConfig init..."); + // 通过templateEngine获取SpringStandardDialect + SpringStandardDialect springStandardDialect = CollectionUtils.findValueOfType(templateEngine.getDialects(), SpringStandardDialect.class); + IStandardJavaScriptSerializer standardJavaScriptSerializer = springStandardDialect.getJavaScriptSerializer(); + // 反射获取 IStandardJavaScriptSerializer + Field delegateField = ReflectionUtils.findField(standardJavaScriptSerializer.getClass(), "delegate"); + if (delegateField == null) { + log.warn("WebConfig init, failed set jackson module, delegateField is null"); + return; + } + ReflectionUtils.makeAccessible(delegateField); + Object delegate = ReflectionUtils.getField(delegateField, standardJavaScriptSerializer); + if (delegate == null) { + log.warn("WebConfig init, failed set jackson module, delegateField is null"); + return; + } + // 如果代理类是JacksonStandardJavaScriptSerializer,则获取mapper,设置model + if (Objects.equals("JacksonStandardJavaScriptSerializer", delegate.getClass().getSimpleName())) { + Field mapperField = ReflectionUtils.findField(delegate.getClass(), "mapper"); + if (mapperField == null) { + log.warn("WebConfig init, failed set jackson module, mapperField is null"); + return; + } + ReflectionUtils.makeAccessible(mapperField); + ObjectMapper objectMapper = (ObjectMapper) ReflectionUtils.getField(mapperField, delegate); + if (objectMapper == null) { + log.warn("WebConfig init, filed set jackson module, mapper is null"); + return; + } + // 设置序列化Module,修改long型序列化为字符串 + objectMapper.registerModule(JsonUtil.bigIntToStrsimpleModule()); + log.info("WebConfig init 设置jackson序列化long为字符串成功!!!"); + } + } + + /** + * 配置序列化方式 + * + * @param converters + */ + @Override + public void configureMessageConverters(List> converters) { + converters.add(new MappingJackson2XmlHttpMessageConverter()); + converters.forEach(s -> { + if (s instanceof MappingJackson2HttpMessageConverter) { + // 长整型序列化返回时,更新为string,避免前端js精度丢失 + // 注意这个仅适用于json数据格式的返回,对于Thymeleaf的模板渲染依然会出现精度问题 + ((MappingJackson2HttpMessageConverter) s).getObjectMapper().registerModule(JsonUtil.bigIntToStrsimpleModule()); + } + }); + } + + /** + * fixme 返回数据类型的配置, 相关知识点可以查看 + * + * @param configurer + */ + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + configurer.favorParameter(true) + .defaultContentType(MediaType.APPLICATION_JSON, MediaType.TEXT_XML, MediaType.APPLICATION_XML, MediaType.TEXT_PLAIN, MediaType.TEXT_EVENT_STREAM, MediaType.APPLICATION_OCTET_STREAM, MediaType.MULTIPART_FORM_DATA, MediaType.MULTIPART_MIXED, MediaType.MULTIPART_RELATED) + // 当下面的配置为false(默认值)时,通过浏览器访问后端接口,会根据acceptHeader协商进行返回,返回结果都是xml格式;与我们日常习惯不太匹配 + // 因此禁用请求头的AcceptHeader,在需要进行xml交互的接口上,手动加上 consumer, produces 属性; 因为本项目中,只有微信的交互是采用的xml进行传参、返回,其他的是通过json进行交互,所以只在微信的 WxRestController 中需要特殊处理;其他的默认即可 + .ignoreAcceptHeader(true) + .parameterName("mediaType") + .mediaType("json", MediaType.APPLICATION_JSON) + .mediaType("xml", MediaType.APPLICATION_XML) + .mediaType("html", MediaType.TEXT_HTML) + .mediaType("text", MediaType.TEXT_PLAIN) + .mediaType("text/event-stream", MediaType.TEXT_EVENT_STREAM) + .mediaType("application/octet-stream", MediaType.APPLICATION_OCTET_STREAM) + .mediaType("multipart/form-data", MediaType.MULTIPART_FORM_DATA) + ; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/init/DbChangeSetLoader.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/init/DbChangeSetLoader.java new file mode 100644 index 000000000..50def4822 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/init/DbChangeSetLoader.java @@ -0,0 +1,80 @@ +package com.github.paicoding.forum.web.config.init; + +import org.springframework.core.io.ClassPathResource; +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; +import org.xml.sax.helpers.DefaultHandler; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author YiHui + * @date 2023/3/2 + */ +public class DbChangeSetLoader { + public static XMLReader getInstance() throws Exception { + // javax.xml.parsers.SAXParserFactory 原生api获取factory + SAXParserFactory factory = SAXParserFactory.newInstance(); + // javax.xml.parsers.SAXParser 原生api获取parse + SAXParser saxParser = factory.newSAXParser(); + // 获取xml + return saxParser.getXMLReader(); + } + + public static List loadDbChangeSetResources(String source) { + try { + XMLReader xmlReader = getInstance(); + ChangeHandler logHandler = new ChangeHandler("include", "file"); + xmlReader.setContentHandler(logHandler); + xmlReader.parse(new ClassPathResource(source.replace("classpath:", "").trim()).getFile().getPath()); + List changeSetFiles = logHandler.getSets(); + + List result = new ArrayList<>(); + ChangeHandler setHandler = new ChangeHandler("sqlFile", "path"); + for (String set : changeSetFiles) { + xmlReader.setContentHandler(setHandler); + // 解析xml + xmlReader.parse(new ClassPathResource(set).getFile().getPath()); + result.addAll(setHandler.getSets().stream().map(ClassPathResource::new).collect(Collectors.toList())); + setHandler.reset(); + } + return result; + } catch (Exception e) { + throw new IllegalStateException("加载初始化脚本异常!"); + } + } + + + public static class ChangeHandler extends DefaultHandler { + private List sets = new ArrayList<>(); + + private final String tag; + private final String attr; + + public ChangeHandler(String tag, String attr) { + this.tag = tag; + this.attr = attr; + } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException { + if (tag.equals(qName)) { + sets.add(attributes.getValue(attr)); + } + } + + public List getSets() { + return sets; + } + + public void reset() { + sets.clear(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/init/ForumDataSourceInitializer.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/init/ForumDataSourceInitializer.java new file mode 100644 index 000000000..ae5e928e7 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/config/init/ForumDataSourceInitializer.java @@ -0,0 +1,139 @@ +package com.github.paicoding.forum.web.config.init; + +import com.github.paicoding.forum.core.util.SpringUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.DataSourceInitializer; +import org.springframework.jdbc.datasource.init.DatabasePopulator; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.util.CollectionUtils; + +import javax.sql.DataSource; +import java.net.URI; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 表初始化,只有首次启动时,才会执行 + * + * @author YiHui + * @date 2022/10/15 + */ +@Slf4j +@Configuration +public class ForumDataSourceInitializer { + @Value("${database.name}") + private String database; + + @Value("${spring.liquibase.enabled:true}") + private Boolean liquibaseEnable; + + @Value("${spring.liquibase.change-log}") + private String liquibaseChangeLog; + + @Bean + public DataSourceInitializer dataSourceInitializer(final DataSource dataSource) { + final DataSourceInitializer initializer = new DataSourceInitializer(); + // 设置数据源 + initializer.setDataSource(dataSource); + boolean enable = needInit(dataSource); + initializer.setEnabled(enable); + initializer.setDatabasePopulator(databasePopulator(enable)); + return initializer; + } + + private DatabasePopulator databasePopulator(boolean initEnable) { + final ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); + // 下面这种是根据sql文件来进行初始化;改成 liquibase 之后不再使用这种方案,由liquibase来统一管理表结构数据变更 + if (initEnable && !liquibaseEnable) { + // fixme: 首次启动时, 对于不支持liquibase的数据库,如mariadb,采用主动初始化 + // fixme 这种方式不支持后续动态的数据表结构更新、数据变更 + populator.addScripts(DbChangeSetLoader.loadDbChangeSetResources(liquibaseChangeLog).toArray(new ClassPathResource[]{})); + populator.setSeparator(";"); + log.info("非Liquibase管理数据库,请手动执行数据库表初始化!"); + } + return populator; + } + + /** + * 检测一下数据库中表是否存在,若存在则不初始化;否则基于 schema-all.sql 进行初始化表 + * + * @param dataSource + * @return true 表示需要初始化; false 表示无需初始化 + */ + private boolean needInit(DataSource dataSource) { + if (autoInitDatabase()) { + return true; + } + // 根据是否存在表来判断是否需要执行sql操作 + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + if (!liquibaseEnable) { + // 非liquibase做数据库版本管理的,根据用户来判断是否有初始化 + List list = jdbcTemplate.queryForList("SELECT table_name FROM information_schema.TABLES where table_name = 'user_info' and table_schema = '" + database + "';"); + return CollectionUtils.isEmpty(list); + } + + // 对于liquibase做数据版本管控的场景,若使用的不是默认的pai_coding,则需要进行修订 + List> record = jdbcTemplate.queryForList("select * from DATABASECHANGELOG where ID='00000000000020' limit 1;"); + if (CollectionUtils.isEmpty(record)) { + // 首次启动,需要初始化库表,直接返回 + return true; + } + + // 非首次启动时,判断记录对应的md5是否准确 + if (Objects.equals(record.get(0).get("MD5SUM"), "8:a1a2d9943b746acf58476ae612c292fc")) { + // 这里主要是为了解决 #71 这个问题 + jdbcTemplate.update("update DATABASECHANGELOG set MD5SUM='8:bb81b67a5219be64eff22e2929fed540' where ID='00000000000020'"); + } + return false; + } + + + /** + * 数据库不存在时,尝试创建数据库 + */ + private boolean autoInitDatabase() { + // 查询失败,可能是数据库不存在,尝试创建数据库之后再次测试 + + // 数据库链接 + URI url = URI.create(SpringUtil.getConfigOrElse("spring.datasource.url", "spring.dynamic.datasource.master.url").substring(5)); + // 用户名 + String uname = SpringUtil.getConfigOrElse("spring.datasource.username", "spring.dynamic.datasource.master.username"); + // 密码 + String pwd = SpringUtil.getConfigOrElse("spring.datasource.password", "spring.dynamic.datasource.master.password"); + // 创建连接 + try (Connection connection = DriverManager.getConnection("jdbc:mysql://" + url.getHost() + ":" + url.getPort() + + "?" + url.getRawQuery(), uname, pwd); + Statement statement = connection.createStatement()) { + // 查询数据库是否存在 + ResultSet set = statement.executeQuery("select schema_name from information_schema.schemata where schema_name = '" + database + "'"); + if (!set.next()) { + // 不存在时,创建数据库 + String createDb = "CREATE DATABASE IF NOT EXISTS " + database; + connection.setAutoCommit(false); + statement.execute(createDb); + connection.commit(); + log.info("创建数据库({})成功", database); + if (set.isClosed()) { + set.close(); + } + return true; + } + set.close(); + log.info("数据库已存在,无需初始化"); + return false; + } catch (SQLException e2) { + throw new RuntimeException(e2); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/error/CustomizeErrorController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/error/CustomizeErrorController.java new file mode 100644 index 000000000..566048752 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/error/CustomizeErrorController.java @@ -0,0 +1,44 @@ +package com.github.paicoding.forum.web.error; + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.http.HttpServletRequest; + +@Controller("/error") +@RequestMapping("${server.error.path:${error.path:/error}}") +public class CustomizeErrorController implements ErrorController { + + @RequestMapping(produces = MediaType.TEXT_HTML_VALUE) + public ModelAndView errorHtml(HttpServletRequest request, Model model) { + HttpStatus status = getStatus(request); + + if (status.is4xxClientError()) { + model.addAttribute("message", "你这个请求错了吧,要不要换个姿势!!!"); + + } + if (status.is5xxServerError()) { + model.addAttribute("message", "服务器冒烟了,要不然你稍后再试试!!!"); + } + + return new ModelAndView("error"); + + } + + private HttpStatus getStatus(HttpServletRequest request) { + Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code"); + if (statusCode == null) { + return HttpStatus.INTERNAL_SERVER_ERROR; + } + try { + return HttpStatus.valueOf(statusCode); + } catch (Exception ex) { + return HttpStatus.INTERNAL_SERVER_ERROR; + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/extra/ArticleReadViewServiceExtend.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/extra/ArticleReadViewServiceExtend.java new file mode 100644 index 000000000..7c5bd8615 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/extra/ArticleReadViewServiceExtend.java @@ -0,0 +1,66 @@ +package com.github.paicoding.forum.web.front.article.extra; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.web.config.GlobalViewConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.function.Supplier; + +/** + * 文章阅读的扩展服务支撑 + * - 用于控制文章阅读模式 + * + * @author YiHui + * @date 2024/10/29 + */ +@Service +public class ArticleReadViewServiceExtend { + @Autowired + private GlobalViewConfig globalViewConfig; + @Autowired + private ArticlePayService articlePayService; + + + public String formatArticleReadType(ArticleDTO article) { + ArticleReadTypeEnum readType = ArticleReadTypeEnum.typeOf(article.getReadType()); + if (readType != null && readType != ArticleReadTypeEnum.NORMAL) { + BaseUserInfoDTO user = ReqInfoContext.getReqInfo().getUser(); + if (readType == ArticleReadTypeEnum.STAR_READ) { + // 星球用户阅读 + return mark(article, () -> user != null && (user.getUserId().equals(article.getAuthor()) + || user.getStarStatus() == UserAIStatEnum.FORMAL), + globalViewConfig::getZsxqArticleReadCount); + } else if (readType == ArticleReadTypeEnum.PAY_READ) { + // 付费阅读 + return mark(article, () -> user != null && (user.getUserId().equals(article.getAuthor()) + || articlePayService.hasPayed(article.getArticleId(), user.getUserId())), + globalViewConfig::getNeedPayArticleReadCount); + } else if (readType == ArticleReadTypeEnum.LOGIN) { + // 登录阅读 + return mark(article, () -> user != null, globalViewConfig::getNeedLoginArticleReadCount); + } + } + + article.setCanRead(true); + return article.getContent(); + } + + private String mark(ArticleDTO article, Supplier condition, Supplier percent) { + if (condition.get()) { + // 可以阅读 + article.setCanRead(true); + return article.getContent(); + } else { + // 不能阅读 + article.setCanRead(false); + return article.getContent() + .substring(0, (int) (article.getContent().length() * Float.parseFloat(percent.get()) / 100)); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleListRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleListRestController.java new file mode 100644 index 000000000..d7d05683f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleListRestController.java @@ -0,0 +1,83 @@ + +package com.github.paicoding.forum.web.front.article.rest; + +import com.github.paicoding.forum.api.model.vo.NextPageHtmlVo; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import com.github.paicoding.forum.web.global.BaseViewController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 文章列表 + * + * @author yihui + */ +@RequestMapping(path = "article/api/list") +@RestController +public class ArticleListRestController extends BaseViewController { + @Autowired + private ArticleReadService articleService; + @Autowired + private TemplateEngineHelper templateEngineHelper; + + /** + * 分类下的文章列表 + * + * @param categoryId 类目id + * @param page 请求页 + * @param size 分页数 + * @return 文章列表 + */ + @GetMapping(path = "data/category/{category}") + public ResVo> categoryDataList(@PathVariable("category") Long categoryId, + @RequestParam(name = "page") Long page, + @RequestParam(name = "size", required = false) Long size) { + PageParam pageParam = buildPageParam(page, size); + PageListVo list = articleService.queryArticlesByCategory(categoryId, pageParam); + return ResVo.ok(list); + } + + + /** + * 分类下的文章列表 + * + * @param categoryId + * @return + */ + @GetMapping(path = "category/{category}") + public ResVo categoryList(@PathVariable("category") Long categoryId, + @RequestParam(name = "page") Long page, + @RequestParam(name = "size", required = false) Long size) { + PageParam pageParam = buildPageParam(page, size); + PageListVo list = articleService.queryArticlesByCategory(categoryId, pageParam); + String html = templateEngineHelper.renderToVo("views/article-category-list/article/list", "articles", list); + return ResVo.ok(new NextPageHtmlVo(html, list.getHasMore())); + } + + /** + * 标签下的文章列表 + * + * @param tagId + * @param page + * @param size + * @return + */ + @GetMapping(path = "tag/{tag}") + public ResVo tagList(@PathVariable("tag") Long tagId, + @RequestParam(name = "page") Long page, + @RequestParam(name = "size", required = false) Long size) { + PageParam pageParam = buildPageParam(page, size); + PageListVo list = articleService.queryArticlesByTag(tagId, pageParam); + String html = templateEngineHelper.renderToVo("views/article-tag-list/article/list", "articles", list); + return ResVo.ok(new NextPageHtmlVo(html, list.getHasMore())); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticlePayRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticlePayRestController.java new file mode 100644 index 000000000..6975cfdaa --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticlePayRestController.java @@ -0,0 +1,94 @@ +package com.github.paicoding.forum.web.front.article.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticlePayInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.service.pay.PayServiceFactory; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.util.function.Function; + +/** + * 返回json格式数据 + * + * @author YiHui + * @date 2022/9/2 + */ +@Slf4j +@RequestMapping(path = "article/api/pay") +@RestController +public class ArticlePayRestController { + @Autowired + private ArticlePayService articlePayService; + + @Autowired + private PayServiceFactory payServiceFactory; + + /** + * 支付解锁文章阅读 + * + * @param articleId + * @return + */ + @Permission(role = UserRole.LOGIN) + @RequestMapping(path = "toPay") + public ResVo toPay( + @RequestParam(value = "articleId") Long articleId, + @RequestParam(value = "notes", required = false) String notes) { + ArticlePayInfoDTO info = articlePayService.toPay(articleId, ReqInfoContext.getReqInfo().getUserId(), notes); + return ResVo.ok(info); + } + + + /** + * 用户自己标记为支付成功;后台将状态设置为支付中 + * + * @param payId + * @param succeed + * @return + */ + @Permission(role = UserRole.LOGIN) + @RequestMapping(path = "paying") + public ResVo payed(@RequestParam(value = "payId") Long payId, @RequestParam("succeed") Boolean succeed, + @RequestParam(value = "notes", required = false) String notes) { + if (BooleanUtils.isTrue(succeed)) { + return ResVo.ok(articlePayService.updatePaying(payId, ReqInfoContext.getReqInfo().getUserId(), notes)); + } + return ResVo.ok(true); + } + + /** + * 支付回调 + *

                    + * 请求参数: verifyCode + payId + succeed + * + * @return + */ + @RequestMapping(path = "callback") + public ResponseEntity> callback(HttpServletRequest request) { + return (ResponseEntity>) payServiceFactory.getPayService(ThirdPayWayEnum.EMAIL) + .payCallback(request, new Function() { + @Override + public Boolean apply(PayCallbackBo transaction) { + log.info("个人收款码支付回调执行业务逻辑 {}", transaction); + return articlePayService.updatePayStatus(transaction.getPayId(), + transaction.getOutTradeNo(), + transaction.getPayStatus(), + transaction.getSuccessTime(), + transaction.getThirdTransactionId()); + } + }); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleRestController.java new file mode 100644 index 000000000..e62ff1c2f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleRestController.java @@ -0,0 +1,242 @@ +package com.github.paicoding.forum.web.front.article.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.OperateTypeEnum; +import com.github.paicoding.forum.api.model.vo.NextPageHtmlVo; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.PageVo; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.ArticlePostReq; +import com.github.paicoding.forum.api.model.vo.article.ContentPostReq; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.mdc.MdcDot; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.MarkdownConverter; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.ArticleRecommendService; +import com.github.paicoding.forum.service.article.service.ArticleWriteService; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.article.service.TagService; +import com.github.paicoding.forum.service.user.service.UserFootService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import com.github.paicoding.forum.web.front.article.vo.ArticleDetailVo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.TimeoutException; + +/** + * 返回json格式数据 + * + * @author YiHui + * @date 2022/9/2 + */ +@Slf4j +@RequestMapping(path = "article/api") +@RestController +public class ArticleRestController { + @Autowired + private ArticleReadService articleReadService; + @Autowired + private UserFootService userFootService; + @Autowired + private CategoryService categoryService; + @Autowired + private TagService tagService; + @Autowired + private ArticleReadService articleService; + @Autowired + private ArticleWriteService articleWriteService; + + @Autowired + private TemplateEngineHelper templateEngineHelper; + + @Autowired + private ArticleRecommendService articleRecommendService; + + @Autowired + private UserService userService; + + /** + * 文章详情页 + * - 参数解析知识点 + * - fixme * [1.Get请求参数解析姿势汇总 | 一灰灰Learning](https://hhui.top/spring-web/01.request/01.190824-springboot%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8Bweb%E7%AF%87%E4%B9%8Bget%E8%AF%B7%E6%B1%82%E5%8F%82%E6%95%B0%E8%A7%A3%E6%9E%90%E5%A7%BF%E5%8A%BF%E6%B1%87%E6%80%BB/) + * + * @param articleId + * @return + */ + @GetMapping("/data/detail/{articleId}") + public ResVo detail(@PathVariable(name = "articleId") Long articleId) throws IOException { + // === Fix: validate articleId to avoid potential NPE === + if (articleId == null || articleId <= 0) { + return ResVo.fail("Invalid articleId: " + articleId); + } + ArticleDetailVo vo = new ArticleDetailVo(); + // 文章相关信息 + ArticleDTO articleDTO = articleService.queryFullArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); + // 返回给前端页面时,转换为html格式 + articleDTO.setContent(MarkdownConverter.markdownToHtml(articleDTO.getContent())); + vo.setArticle(articleDTO); + + // 作者信息 + BaseUserInfoDTO user = userService.queryBasicUserInfo(articleDTO.getAuthor()); + articleDTO.setAuthorName(user.getUserName()); + articleDTO.setAuthorAvatar(user.getPhoto()); + return ResVo.ok(vo); + } + + /** + * 文章的关联推荐 + * + * @param articleId + * @param page + * @param size + * @return + */ + @RequestMapping(path = "recommend") + @MdcDot(bizCode = "#articleId") + public ResVo recommend(@RequestParam(value = "articleId") Long articleId, + @RequestParam(name = "page") Long page, + @RequestParam(name = "size", required = false) Long size) { + size = Optional.ofNullable(size).orElse(PageParam.DEFAULT_PAGE_SIZE); + size = Math.min(size, PageParam.DEFAULT_PAGE_SIZE); + PageListVo articles = articleRecommendService.relatedRecommend(articleId, PageParam.newPageInstance(page, size)); + String html = templateEngineHelper.renderToVo("views/article-detail/article/list", "articles", articles); + return ResVo.ok(new NextPageHtmlVo(html, articles.getHasMore())); + } + + /** + * 查询所有的标签 + * + * @return + */ + @PostMapping(path = "generateSummary") + public ResVo generateSummary(@RequestBody ContentPostReq req) { + return ResVo.ok(articleService.generateSummary(req.getContent())); + } + + /** + * 查询所有的标签 + * + * @return + */ + @GetMapping(path = "tag/list") + public ResVo> queryTags(@RequestParam(name = "key", required = false) String key, + @RequestParam(name = "pageNumber", required = false, defaultValue = "1") Integer pageNumber, + @RequestParam(name = "pageSize", required = false, defaultValue = "10") Integer pageSize) { + PageVo tagDTOPageVo = tagService.queryTags(key, PageParam.newPageInstance(pageNumber, pageSize)); + return ResVo.ok(tagDTOPageVo); + } + + /** + * 获取所有的分类 + * + * @return + */ + @GetMapping(path = "category/list") + public ResVo> getCategoryList(@RequestParam(name = "categoryId", required = false) Long categoryId, + @RequestParam(name = "ignoreNoArticles", required = false) Boolean ignoreNoArticles) { + List list = categoryService.loadAllCategories(); + if (Objects.equals(Boolean.TRUE, ignoreNoArticles)) { + // 查询所有分类的对应的文章数 + Map articleCnt = articleService.queryArticleCountsByCategory(); + // 过滤掉文章数为0的分类 + list.removeIf(c -> articleCnt.getOrDefault(c.getCategoryId(), 0L) <= 0L); + } + list.forEach(c -> c.setSelected(c.getCategoryId().equals(categoryId))); + return ResVo.ok(list); + } + + + /** + * 收藏、点赞等相关操作 + * + * @param articleId + * @param type 取值来自于 OperateTypeEnum#code + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "favor") + @MdcDot(bizCode = "#articleId") + public ResVo favor(@RequestParam(name = "articleId") Long articleId, + @RequestParam(name = "type") Integer type) throws IOException, TimeoutException { + OperateTypeEnum operate = OperateTypeEnum.fromCode(type); + if (operate == OperateTypeEnum.EMPTY) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, type + "非法"); + } + + // 要求文章必须存在 + ArticleDO article = articleReadService.queryBasicArticle(articleId); + if (article == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章不存在!"); + } + + // 更新用户与文章的点赞/收藏状态 + userFootService.favorArticleComment(DocumentTypeEnum.ARTICLE, articleId, article.getUserId(), + ReqInfoContext.getReqInfo().getUserId(), + operate); + return ResVo.ok(true); + } + + + /** + * 发布文章,完成后跳转到详情页 + * - 这里有一个重定向的知识点 + * - fixme 博文:* [5.请求重定向 | 一灰灰Learning](https://hhui.top/spring-web/02.response/05.190929-springboot%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8Bweb%E7%AF%87%E4%B9%8B%E9%87%8D%E5%AE%9A%E5%90%91/) + * + * @return 返回包含articleId和urlSlug的Map,用于前端重定向 + */ + @Permission(role = UserRole.LOGIN) + @PostMapping(path = "post") + @MdcDot(bizCode = "#req.articleId") + public ResVo> post(@RequestBody ArticlePostReq req, HttpServletResponse response) throws IOException { + Long id = articleWriteService.saveArticle(req, ReqInfoContext.getReqInfo().getUserId()); + + // 查询文章信息以获取urlSlug + ArticleDO article = articleReadService.queryBasicArticle(id); + + Map result = new HashMap<>(); + result.put("articleId", id); + result.put("urlSlug", article.getUrlSlug()); + + // 如果使用后端重定向,可以使用下面两种策略 +// return "redirect:/article/detail/" + id; +// response.sendRedirect("/article/detail/" + id); + // 这里采用前端重定向策略,返回articleId和urlSlug + return ResVo.ok(result); + } + + + /** + * 文章删除 + * + * @param articleId + * @return + */ + @Permission(role = UserRole.LOGIN) + @RequestMapping(path = "delete") + @MdcDot(bizCode = "#articleId") + public ResVo delete(@RequestParam(value = "articleId") Long articleId) { + articleWriteService.deleteArticle(articleId, ReqInfoContext.getReqInfo().getUserId()); + return ResVo.ok(true); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ColumnRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ColumnRestController.java new file mode 100644 index 000000000..fb98daf95 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ColumnRestController.java @@ -0,0 +1,63 @@ +package com.github.paicoding.forum.web.front.article.rest; + +import com.github.paicoding.forum.api.model.vo.NextPageHtmlVo; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.service.article.service.ColumnService; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +/** + * @author YiHui + * @date 2022/9/15 + */ +@RestController +@RequestMapping(path = "column/api") +public class ColumnRestController { + @Autowired + private ColumnService columnService; + + @Autowired + private TemplateEngineHelper templateEngineHelper; + + /** + * 翻页的专栏列表 + * + * @param page + * @param size + * @return + */ + @GetMapping(path = "list") + public ResVo list(@RequestParam(name = "page") Long page, + @RequestParam(name = "size", required = false) Long size) { + if (page <= 0) { + page = 1L; + } + size = Optional.ofNullable(size).orElse(PageParam.DEFAULT_PAGE_SIZE); + size = Math.min(size, PageParam.DEFAULT_PAGE_SIZE); + PageListVo list = columnService.listColumn(PageParam.newPageInstance(page, size)); + + String html = templateEngineHelper.renderToVo("biz/column/list", "columns", list); + return ResVo.ok(new NextPageHtmlVo(html, list.getHasMore())); + } + + /** + * 详情页的菜单栏(即专栏的文章列表) + * + * @param columnId + * @return + */ + @GetMapping(path = "menu/{column}") + public ResVo columnMenus(@PathVariable("column") Long columnId) { + List articleList = columnService.queryColumnArticles(columnId); + String html = templateEngineHelper.renderToVo("biz/column/menus", "menu", articleList); + return ResVo.ok(new NextPageHtmlVo(html, false)); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleListViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleListViewController.java new file mode 100644 index 000000000..20037afb7 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleListViewController.java @@ -0,0 +1,69 @@ +package com.github.paicoding.forum.web.front.article.view; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.article.service.TagService; +import com.github.paicoding.forum.web.front.article.vo.ArticleListVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * 文章列表视图 + * + * @author yihui + */ +@RequestMapping(path = "article") +@Controller +public class ArticleListViewController extends BaseViewController { + @Autowired + private ArticleReadService articleService; + @Autowired + private CategoryService categoryService; + @Autowired + private TagService tagService; + + /** + * 查询某个分类下的文章列表 + * + * @param category + * @return + */ + @GetMapping(path = "category/{category}") + public String categoryList(@PathVariable("category") String category, Model model) { + Long categoryId = categoryService.queryCategoryId(category); + PageListVo list = categoryId != null ? articleService.queryArticlesByCategory(categoryId, PageParam.newPageInstance()) : PageListVo.emptyVo(); + ArticleListVo vo = new ArticleListVo(); + vo.setArchives(category); + vo.setArchiveId(categoryId); + vo.setArticles(list); + model.addAttribute("vo", vo); + return "views/article-category-list/index"; + } + + /** + * 查询某个标签下文章列表 + * + * @param tag + * @param model + * @return + */ + @GetMapping(path = "tag/{tag}") + public String tagList(@PathVariable("tag") String tag, Model model) { + Long tagId = tagService.queryTagId(tag); + PageListVo list = tagId != null ? articleService.queryArticlesByTag(tagId, PageParam.newPageInstance()) : PageListVo.emptyVo(); + ArticleListVo vo = new ArticleListVo(); + vo.setArchives(tag); + vo.setArchiveId(tagId); + vo.setArticles(list); + model.addAttribute("vo", vo); + return "views/article-tag-list/index"; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleViewController.java new file mode 100644 index 000000000..153cd5180 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleViewController.java @@ -0,0 +1,255 @@ +package com.github.paicoding.forum.web.front.article.view; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleOtherDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.PayConfirmDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.MarkdownConverter; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.conveter.PayConverter; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.article.service.ColumnService; +import com.github.paicoding.forum.service.article.service.TagService; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.sidebar.service.SidebarService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.web.front.article.extra.ArticleReadViewServiceExtend; +import com.github.paicoding.forum.web.front.article.vo.ArticleDetailVo; +import com.github.paicoding.forum.web.front.article.vo.ArticleEditVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import com.github.paicoding.forum.web.global.SeoInjectService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * 文章 + * todo: 所有的入口都放在一个Controller,会导致功能划分非常混乱 + * : 文章列表 + * : 文章编辑 + * : 文章详情 + * --- + * - 返回视图 view + * - 返回json数据 + * + * @author yihui + */ +@Controller +@RequestMapping(path = "article") +public class ArticleViewController extends BaseViewController { + @Autowired + private ArticleReadService articleService; + + @Autowired + private CategoryService categoryService; + + @Autowired + private TagService tagService; + + @Autowired + private UserService userService; + + @Autowired + private CommentReadService commentService; + + @Autowired + private SidebarService sidebarService; + + @Autowired + private ColumnService columnService; + + @Autowired + private ArticleReadViewServiceExtend articleReadViewServiceExtend; + + @Autowired + private ArticlePayService articlePayService; + + /** + * 文章编辑页 + * + * @param articleId + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "edit") + public String edit(@RequestParam(required = false) Long articleId, Model model) { + ArticleEditVo vo = new ArticleEditVo(); + if (articleId != null) { + ArticleDTO article = articleService.queryDetailArticleInfo(articleId); + vo.setArticle(article); + if (!Objects.equals(article.getAuthor(), ReqInfoContext.getReqInfo().getUserId())) { + // 没有权限 + model.addAttribute("toast", "内容不存在"); + return "redirect:403"; + } + + List categoryList = categoryService.loadAllCategories(); + categoryList.forEach(s -> { + s.setSelected(s.getCategoryId().equals(article.getCategory().getCategoryId())); + }); + vo.setCategories(categoryList); + vo.setTags(article.getTags()); + } else { + List categoryList = categoryService.loadAllCategories(); + vo.setCategories(categoryList); + vo.setTags(Collections.emptyList()); + } + model.addAttribute("vo", vo); + return "views/article-edit/index"; + } + + + /** + * 新的文章详情页URL (SEO优化版本) + * URL格式: /article/detail/{articleId}/{urlSlug} + * 示例: /article/detail/123/spring-boot-tutorial + * + * @param articleId 文章ID + * @param urlSlug URL友好的文章标识 + * @param model 视图模型 + * @param response HTTP响应,用于设置301状态码 + * @return 视图名称或重定向URL + */ + @GetMapping("detail/{articleId}/{urlSlug}") + public String detailWithSlug(@PathVariable(name = "articleId") Long articleId, + @PathVariable(name = "urlSlug") String urlSlug, + Model model, + HttpServletResponse response) throws IOException { + // 针对专栏文章,做一个重定向 + ColumnArticleDO columnArticle = columnService.getColumnArticleRelation(articleId); + if (columnArticle != null) { + return String.format("redirect:/column/%d/%d", columnArticle.getColumnId(), columnArticle.getSection()); + } + + // 获取文章基本信息以验证slug + ArticleDTO articleDTO = articleService.queryFullArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); + + // 检查slug是否正确,如果不正确则301永久重定向到正确的URL + if (StringUtils.isNotBlank(articleDTO.getUrlSlug()) && !articleDTO.getUrlSlug().equals(urlSlug)) { + response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); + return "redirect:/article/detail/" + articleId + "/" + articleDTO.getUrlSlug(); + } + + // 构建详情页视图 + return buildDetailView(articleId, model); + } + + /** + * 旧的文章详情页URL (仅ID版本,兼容性保留) + * URL格式: /article/detail/{articleId} + * 直接显示内容,不重定向(保持向后兼容) + * + * @param articleId 文章ID + * @param model 视图模型 + * @param response HTTP响应 + * @return 视图名称 + */ + @GetMapping("detail/{articleId}") + public String detail(@PathVariable(name = "articleId") Long articleId, + Model model, + HttpServletResponse response) throws IOException { + // 针对专栏文章,做一个重定向 + ColumnArticleDO columnArticle = columnService.getColumnArticleRelation(articleId); + if (columnArticle != null) { + return String.format("redirect:/column/%d/%d", columnArticle.getColumnId(), columnArticle.getSection()); + } + + // 直接显示内容,不重定向(保持向后兼容,避免影响已有的SEO) + return buildDetailView(articleId, model); + } + + /** + * 构建文章详情页视图 + * 提取公共逻辑,避免代码重复 + * + * @param articleId 文章ID + * @param model 视图模型 + * @return 视图名称 + */ + private String buildDetailView(Long articleId, Model model) throws IOException { + ArticleDetailVo vo = new ArticleDetailVo(); + // 文章相关信息 + ArticleDTO articleDTO = articleService.queryFullArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); + // 根据文章类型,来自动处理文章内容 + String content = articleReadViewServiceExtend.formatArticleReadType(articleDTO); + // 返回给前端页面时,转换为html格式 + articleDTO.setContent(MarkdownConverter.markdownToHtml(content)); + vo.setArticle(articleDTO); + + // 评论信息 + List comments = commentService.getArticleComments(articleId, PageParam.newPageInstance(1L, 10L)); + vo.setComments(comments); + + // 热门评论 + TopCommentDTO hotComment = commentService.queryHotComment(articleId); + vo.setHotComment(hotComment); + + // 查询文章的划线评论,用于高亮显示 + List highlightComments = commentService.queryHighlightComments(articleId); + vo.setHighlightComments(highlightComments); + + // 作者信息 + UserStatisticInfoDTO user = userService.queryUserInfoWithStatistic(articleDTO.getAuthor()); + articleDTO.setAuthorName(user.getUserName()); + articleDTO.setAuthorAvatar(user.getPhoto()); + if (articleDTO.getReadType().equals(ArticleReadTypeEnum.PAY_READ.getType())) { + // 付费阅读的文章,构建收款码信息 + user.setPayQrCodes(PayConverter.formatPayCodeInfo(user.getPayCode())); + } + vo.setAuthor(user); + + // 其他信息封装 + ArticleOtherDTO other = new ArticleOtherDTO(); + other.setReadType(articleDTO.getReadType()); + vo.setOther(other); + + // 打赏用户列表 + if (Objects.equals(articleDTO.getReadType(), ArticleReadTypeEnum.PAY_READ.getType())) { + vo.setPayUsers(articlePayService.queryPayUsers(articleId)); + } else { + vo.setPayUsers(Collections.emptyList()); + } + + // 详情页的侧边推荐信息 + List sideBars = sidebarService.queryArticleDetailSidebarList(articleDTO.getAuthor(), articleDTO.getArticleId()); + vo.setSideBarItems(sideBars); + model.addAttribute("vo", vo); + + SpringUtil.getBean(SeoInjectService.class).initColumnSeo(vo); + return "views/article-detail/index"; + } + + + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "payConfirm") + public String payConfirm(@RequestParam("payId") Long payId, Model model) { + PayConfirmDTO confirmDTO = articlePayService.buildPayConfirmInfo(payId, null); + if (!ReqInfoContext.getReqInfo().getUserId().equals(confirmDTO.getReceiveUserId())) { + return "redirect:/error/403"; + } + model.addAttribute("vo", confirmDTO); + return "PayConfirm"; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ColumnViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ColumnViewController.java new file mode 100644 index 000000000..d5f15d01e --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ColumnViewController.java @@ -0,0 +1,232 @@ +package com.github.paicoding.forum.web.front.article.view; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ArticleReadTypeEnum; +import com.github.paicoding.forum.api.model.enums.column.ColumnArticleReadEnum; +import com.github.paicoding.forum.api.model.enums.column.ColumnTypeEnum; +import com.github.paicoding.forum.api.model.enums.user.UserAIStatEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleOtherDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticleFlipDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticlesDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; +import com.github.paicoding.forum.core.util.MarkdownConverter; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.util.StrUtil; +import com.github.paicoding.forum.service.article.repository.entity.ColumnArticleDO; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.ColumnService; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.sidebar.service.SidebarService; +import com.github.paicoding.forum.web.config.GlobalViewConfig; +import com.github.paicoding.forum.web.front.article.vo.ColumnVo; +import com.github.paicoding.forum.web.global.SeoInjectService; +import liquibase.util.StringUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import javax.annotation.Resource; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * 专栏入口 + * + * @author YiHui + * @date 2022/9/15 + */ +@Controller +@RequestMapping(path = "column") +public class ColumnViewController { + @Autowired + private ColumnService columnService; + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private CommentReadService commentReadService; + + @Autowired + private SidebarService sidebarService; + + @Resource + private GlobalViewConfig globalViewConfig; + @Autowired + private ArticlePayService articlePayService; + + /** + * 专栏主页,展示专栏列表 + * + * @param model + * @return + */ + @GetMapping(path = {"list", "/", "", "home"}) + public String list(Model model) { + PageListVo columns = columnService.listColumn(PageParam.newPageInstance()); + List sidebars = sidebarService.queryColumnSidebarList(); + ColumnVo vo = new ColumnVo(); + vo.setColumns(columns); + vo.setSideBarItems(sidebars); + model.addAttribute("vo", vo); + return "views/column-home/index"; + } + + /** + * 专栏详情 + * + * @param columnId + * @return + */ + @GetMapping(path = "{columnId}") + public String column(@PathVariable("columnId") Long columnId, Model model) { + ColumnDTO dto = columnService.queryColumnInfo(columnId); + model.addAttribute("vo", dto); + return "/views/column-index/index"; + } + + + /** + * 专栏的文章阅读界面 + * + * @param columnId 专栏id + * @param section 节数,从1开始 + * @param model + * @return + */ + @GetMapping(path = "{columnId}/{section}") + public String articles(@PathVariable("columnId") Long columnId, @PathVariable("section") Integer section, Model model) { + if (section <= 0) section = 1; + // 查询专栏 + ColumnDTO column = columnService.queryBasicColumnInfo(columnId); + + ColumnArticleDO columnArticle = columnService.queryColumnArticle(columnId, section); + Long articleId = columnArticle.getArticleId(); + // 文章信息 + ArticleDTO articleDTO = articleReadService.queryFullArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); + // 返回html格式的文档内容 + articleDTO.setContent(MarkdownConverter.markdownToHtml(articleDTO.getContent())); + // 评论信息 + List comments = commentReadService.getArticleComments(articleId, PageParam.newPageInstance()); + + // 热门评论 + TopCommentDTO hotComment = commentReadService.queryHotComment(articleId); + + List highlightComment = commentReadService.queryHighlightComments(articleId); + + // 文章列表 + List articles = columnService.queryColumnArticles(columnId); + + ColumnArticlesDTO vo = new ColumnArticlesDTO(); + vo.setArticle(articleDTO); + vo.setComments(comments); + vo.setHotComment(hotComment); + vo.setHighlightComments(highlightComment); + vo.setColumn(columnId); + vo.setSection(section); + vo.setArticleList(articles); + + ArticleOtherDTO other = new ArticleOtherDTO(); + + // 教程类型 + updateReadType(other, column, articleDTO, ColumnArticleReadEnum.valueOf(columnArticle.getReadType())); + + + // 把是文章翻页的参数封装到这里 + // prev 的 href 和 是否显示的 flag + ColumnArticleFlipDTO flip = new ColumnArticleFlipDTO(); + flip.setPrevHref("/column/" + columnId + "/" + (section - 1)); + flip.setPrevShow(section > 1); + // next 的 href 和 是否显示的 flag + flip.setNextHref("/column/" + columnId + "/" + (section + 1)); + flip.setNextShow(section < articles.size()); + other.setFlip(flip); + + // 放入 model 中 + vo.setOther(other); + + // 打赏用户列表 + if (Objects.equals(articleDTO.getReadType(), ArticleReadTypeEnum.PAY_READ.getType())) { + vo.setPayUsers(articlePayService.queryPayUsers(articleId)); + } else { + vo.setPayUsers(Collections.emptyList()); + } + model.addAttribute("vo", vo); + + SpringUtil.getBean(SeoInjectService.class).initColumnSeo(vo, column); + return "views/column-detail/index"; + } + + /** + * 对于要求登录阅读的文章进行进行处理 + * + * @param vo + * @param column + * @param articleDTO + */ + private void updateReadType(ArticleOtherDTO vo, ColumnDTO column, ArticleDTO articleDTO, ColumnArticleReadEnum articleReadEnum) { + Long loginUser = ReqInfoContext.getReqInfo().getUserId(); + if (loginUser != null && loginUser.equals(articleDTO.getAuthor())) { + vo.setReadType(ColumnTypeEnum.FREE.getType()); + return; + } + + if (articleReadEnum == ColumnArticleReadEnum.COLUMN_TYPE) { + // 专栏中的文章,没有特殊指定时,直接沿用专栏的规则 + if (column.getType() == ColumnTypeEnum.TIME_FREE.getType()) { + long now = System.currentTimeMillis(); + if (now > column.getFreeEndTime() || now < column.getFreeStartTime()) { + vo.setReadType(ColumnTypeEnum.LOGIN.getType()); + } else { + vo.setReadType(ColumnTypeEnum.FREE.getType()); + } + } else { + vo.setReadType(column.getType()); + } + } else { + // 直接使用文章特殊设置的规则 + vo.setReadType(articleReadEnum.getRead()); + } + // 如果是星球 or 登录阅读时,不返回全量的文章内容 + articleDTO.setContent(trimContent(vo.getReadType(), articleDTO.getContent())); + // fix 关于 cover 封面,文章详情的前端已经不显示了,这里直接删除 + } + + /** + * 文章内容隐藏 + * + * @param readType + * @param content + * @return + */ + private String trimContent(int readType, String content) { + if (readType == ColumnTypeEnum.STAR_READ.getType()) { + // 判断登录用户是否绑定了星球,如果是,则直接阅读完整的专栏内容 + if (ReqInfoContext.getReqInfo().getUser() != null && ReqInfoContext.getReqInfo().getUser().getStarStatus() == UserAIStatEnum.FORMAL) { + return content; + } + + // 如果没有绑定星球,则返回 10% 的内容 + int count = Integer.parseInt(globalViewConfig.getZsxqArticleReadCount()); + return StrUtil.safeSubstringHtml(content, content.length() * count / 100); + } + + if ((readType == ColumnTypeEnum.LOGIN.getType() && ReqInfoContext.getReqInfo().getUserId() == null)) { + // 如果是登录阅读,但是用户没有登录,则返回 20% 的内容 + int count = Integer.parseInt(globalViewConfig.getNeedLoginArticleReadCount()); + return StrUtil.safeSubstringHtml(content, content.length() * count / 100); + } + + return content; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleDetailVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleDetailVo.java new file mode 100644 index 000000000..6bd09fac4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleDetailVo.java @@ -0,0 +1,61 @@ +package com.github.paicoding.forum.web.front.article.vo; + +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleOtherDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticlePayInfoDTO; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Data +public class ArticleDetailVo { + /** + * 文章信息 + */ + private ArticleDTO article; + + /** + * 评论信息 + */ + private List comments; + + /** + * 热门评论 + */ + private TopCommentDTO hotComment; + + /** + * 划线引用评论 + */ + private List highlightComments; + + /** + * 作者相关信息 + */ + private UserStatisticInfoDTO author; + + + private ArticlePayInfoDTO payInfo; + + // 其他的信息,比如说翻页,比如说阅读类型 + private ArticleOtherDTO other; + + /** + * 侧边栏信息 + */ + private List sideBarItems; + + + /** + * 打赏用户列表 + */ + private List payUsers; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleEditVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleEditVo.java new file mode 100644 index 000000000..07b45f173 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleEditVo.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.web.front.article.vo; + +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Data +public class ArticleEditVo { + + private ArticleDTO article; + + private List categories; + + private List tags; + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleListVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleListVo.java new file mode 100644 index 000000000..621201a38 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ArticleListVo.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.web.front.article.vo; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import lombok.Data; + +/** + * @author YiHui + * @date 2022/10/28 + */ +@Data +public class ArticleListVo { + /** + * 归档类型 + */ + private String archives; + /** + * 归档id + */ + private Long archiveId; + + private PageListVo articles; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ColumnVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ColumnVo.java new file mode 100644 index 000000000..2291c9382 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/vo/ColumnVo.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.web.front.article.vo; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/26 + */ +@Data +public class ColumnVo { + /** + * 专栏列表 + */ + private PageListVo columns; + + /** + * 侧边栏信息 + */ + private List sideBarItems; + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/helper/WsAnswerHelper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/helper/WsAnswerHelper.java new file mode 100644 index 000000000..d7d019dfa --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/helper/WsAnswerHelper.java @@ -0,0 +1,79 @@ +package com.github.paicoding.forum.web.front.chat.helper; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.chat.ChatRecordsVo; +import com.github.paicoding.forum.core.mdc.MdcUtil; +import com.github.paicoding.forum.core.ws.WebSocketResponseUtil; +import com.github.paicoding.forum.service.chatai.ChatFacade; +import com.github.paicoding.forum.service.user.service.LoginService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * @author YiHui + * @date 2023/6/9 + */ +@Slf4j +@Component +public class WsAnswerHelper { + public static final String AI_SOURCE_PARAM = "AI"; + + @Autowired + private ChatFacade chatFacade; + + private void sendMsgToUser(String session, String question) { + ChatRecordsVo res = chatFacade.autoChat(question, vo -> response(session, vo)); + log.info("AI直接返回:{}", res); + } + + public void sendMsgToUser(AISourceEnum ai, String session, String question) { + if (ai == null) { + // 自动选择AI类型 + sendMsgToUser(session, question); + } else { + ChatRecordsVo res = chatFacade.autoChat(ai, question, vo -> response(session, vo)); + log.info("AI直接返回:{}", res); + } + } + + public void sendMsgHistoryToUser(String session, AISourceEnum ai) { + ChatRecordsVo vo = chatFacade.history(ai); + response(session, vo); + } + + /** + * 将返回结果推送给用户 + * + * @param session + * @param response + */ + public void response(String session, ChatRecordsVo response) { + // convertAndSendToUser 方法可以发送信给给指定用户, + // 底层会自动将第二个参数目的地址 /chat/rsp 拼接为 + // /user/username/chat/rsp,其中第二个参数 username 即为这里的第一个参数 session + // username 也是AuthHandshakeHandler中配置的 Principal 用户识别标志 + WebSocketResponseUtil.sendMsgToUser(session, "/chat/rsp", response); + } + + public void execute(Map attributes, Runnable func) { + try { + ReqInfoContext.ReqInfo reqInfo = (ReqInfoContext.ReqInfo) attributes.get(LoginService.SESSION_KEY); + ReqInfoContext.addReqInfo(reqInfo); + String traceId = (String) attributes.get(MdcUtil.TRACE_ID_KEY); + MdcUtil.add(MdcUtil.TRACE_ID_KEY, traceId); + + + // 执行具体的业务逻辑 + func.run(); + + } finally { + ReqInfoContext.clear(); + MdcUtil.clear(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/rest/ChatRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/rest/ChatRestController.java new file mode 100644 index 000000000..94730cc1b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/rest/ChatRestController.java @@ -0,0 +1,137 @@ +package com.github.paicoding.forum.web.front.chat.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.chat.ChatSessionItemVo; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.core.ws.WebSocketResponseUtil; +import com.github.paicoding.forum.service.chatai.service.ChatHistoryService; +import com.github.paicoding.forum.web.front.chat.helper.WsAnswerHelper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * STOMP协议的ChatGpt聊天通讯实现方式 + * + * @author YiHui + * @date 2023/6/5 + */ +@Slf4j +@RestController +public class ChatRestController { + @Autowired + private WsAnswerHelper answerHelper; + @Autowired + private ChatHistoryService chatHistoryService; + + /** + * 接收用户发送的消息 + * + * @param msg + * @param session + * @param attrs + * @return + * @MessageMapping("/chat/{session}")注解的方法将用来接收"/app/chat/xxx路径发送来的消息,
                    如果有 @SendTo,则表示将返回结果,转发到其对应的路径上 (这个sendTo的路径,就是前端订阅的路径) + * @DestinationVariable: 实现路径上的参数解析 + * @Headers 实现请求头格式的参数解析, @Header("headName") 表示获取某个请求头的内容 + */ + @MessageMapping("/chat/{session}") + public void chat(String msg, + @DestinationVariable("session") String session, + @Header("simpSessionAttributes") Map attrs, + SimpMessageHeaderAccessor accessor) { + String aiType = (String) attrs.get(WsAnswerHelper.AI_SOURCE_PARAM); + WebSocketResponseUtil.execute(accessor, () -> { + log.info("{} 用户开始了对话: {} - {}", ReqInfoContext.getReqInfo().getUser(), aiType, msg); + AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); + answerHelper.sendMsgToUser(source, session, msg); + }); + } + + @MessageMapping({"/chat/{session}/{chatId}"}) + public void chat(String msg, + @DestinationVariable("session") String session, + @DestinationVariable("chatId") String chatId, + @Header("simpSessionAttributes") Map attrs, + SimpMessageHeaderAccessor accessor) { + String aiType = (String) attrs.get(WsAnswerHelper.AI_SOURCE_PARAM); + WebSocketResponseUtil.execute(accessor, () -> { + // 设置会话id + ReqInfoContext.getReqInfo().setChatId(chatId); + log.info("{} 用户开始了对话: {} - {}", ReqInfoContext.getReqInfo().getUser(), aiType, msg); + AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); + answerHelper.sendMsgToUser(source, session, msg); + }); + } + + /** + * 查询用户的对话记录 + * + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "/chat/api/listSession") + public ResVo> listChatSessions(String aiType) { + AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); + if (source == null) { + return ResVo.ok(Collections.emptyList()); + } + + return ResVo.ok(chatHistoryService.listChatSessions(source, ReqInfoContext.getReqInfo().getUserId())); + } + + + /** + * 返回用户的历史对话记录 + * + * @param aiType + * @param chatId + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "/chat/api/syncHistory") + public ResVo syncChatSessionHistory(String aiType, String chatId) { + AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); + if (source == null) { + return ResVo.ok(false); + } + + ReqInfoContext.getReqInfo().setChatId(chatId); + SpringUtil.getBean(WsAnswerHelper.class).sendMsgHistoryToUser(ReqInfoContext.getReqInfo().getSession(), source); + return ResVo.ok(true); + } + + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "/chat/api/updateSession") + public ResVo updateChatSession( + @RequestParam String aiType, + @RequestParam String chatId, + @RequestParam(name = "title", required = false) String title, + @RequestParam(name = "deleted", required = false) Boolean deleted) { + AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); + if (source == null) { + return ResVo.ok(false); + } + + if (BooleanUtils.isTrue(deleted)) { + return ResVo.ok(chatHistoryService.removeChatSession(source, chatId, ReqInfoContext.getReqInfo().getUserId())); + } else { + return ResVo.ok(chatHistoryService.updateChatSessionName(source, chatId, title, ReqInfoContext.getReqInfo().getUserId())); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/rest/SimpleChatgptHandler.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/rest/SimpleChatgptHandler.java new file mode 100644 index 000000000..7a31b31eb --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/rest/SimpleChatgptHandler.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.web.front.chat.rest; + +import cn.hutool.core.date.LocalDateTimeUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ChatSocketStateEnum; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 基础的websocket实现通讯的方式 + * + * @author YiHui + * @date 2023/6/5 + */ +@Slf4j +public class SimpleChatgptHandler extends TextWebSocketHandler { + + // 返回 TextMessage + private TextMessage getTextMessage(String msg, Integer type) throws JsonProcessingException { + Map map = new HashMap<>(); + map.put("message", msg); + map.put("type", type.toString()); + map.put("time", LocalDateTimeUtil.formatNormal(LocalDateTime.now())); + + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(map); + + return new TextMessage(json); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + session.sendMessage(getTextMessage("开始你和派聪明的AI之旅吧", ChatSocketStateEnum.Established.getCode())); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + // 延迟 10 秒 + Thread.sleep(1000); + TextMessage msg = getTextMessage(message.getPayload(), ChatSocketStateEnum.Payload.getCode()); + log.info("返回的内容是! {} = {}", ReqInfoContext.getReqInfo().getUserId(), msg); + session.sendMessage(msg); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + session.sendMessage(getTextMessage("下次再撩吧(笑)", ChatSocketStateEnum.Closed.getCode())); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthHandshakeHandler.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthHandshakeHandler.java new file mode 100644 index 000000000..35b8573b6 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthHandshakeHandler.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.web.front.chat.stomp; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.service.user.service.LoginService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +/** + * 握手处理器 + * + * @author YiHui + * @date 2023/6/8 + */ +@Slf4j +public class AuthHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map attributes) { + // case1: 根据cookie来识别用户,即可以实现所有用户连相同的ws地址,然后再 AuthHandshakeChannelInterceptor 中进行destination的转发 + ReqInfoContext.ReqInfo reqInfo = (ReqInfoContext.ReqInfo) attributes.get(LoginService.SESSION_KEY); + if (reqInfo != null) { + return reqInfo; + } + + // case2: 根据路径来区分用户 + // 获取例如 ws://localhost/gpt/id 订阅地址中的最后一个用户 id 参数作为用户的标识, 为实现发送信息给指定用户做准备 + String uri = request.getURI().toString(); + String uid = uri.substring(uri.lastIndexOf("/") + 1); + log.info("{} -> {}", uri, uid); + return () -> uid; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthHandshakeInterceptor.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthHandshakeInterceptor.java new file mode 100644 index 000000000..f8df6d7a0 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthHandshakeInterceptor.java @@ -0,0 +1,67 @@ +package com.github.paicoding.forum.web.front.chat.stomp; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.core.mdc.MdcUtil; +import com.github.paicoding.forum.core.mdc.SelfTraceIdGenerator; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.web.front.chat.helper.WsAnswerHelper; +import com.github.paicoding.forum.web.global.GlobalInitService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; + +import java.util.Map; + +/** + * 握手拦截器, 用于身份验证识别 + * + * @author YiHui + * @date 2023/6/8 + */ +@Slf4j +public class AuthHandshakeInterceptor extends HttpSessionHandshakeInterceptor { + + /** + * 握手前,进行用户身份校验识别 + * + * @param request + * @param response + * @param wsHandler + * @param attributes: 即对应的是Message中的 simpSessionAttributes 请求头 + * @return + * @throws Exception + */ + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { + log.debug("准备开始握手了!"); + String session = SessionUtil.findCookieByName(request, LoginService.SESSION_KEY); + ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo(); + SpringUtil.getBean(GlobalInitService.class).initLoginUser(session, reqInfo); + + if (reqInfo.getUser() == null) { + log.debug("websocket 握手失败,请登录之后再试"); + return false; + } + + // 将用户信息写入到属性中 + attributes.put(MdcUtil.TRACE_ID_KEY, SelfTraceIdGenerator.generate()); + attributes.put(LoginService.SESSION_KEY, reqInfo); + attributes.put(WsAnswerHelper.AI_SOURCE_PARAM, initAiSource(request.getURI().getPath())); + return true; + } + + private String initAiSource(String path) { + int index = path.lastIndexOf("/"); + return path.substring(index + 1); + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) { + log.debug("握手成功了!!!"); + super.afterHandshake(request, response, wsHandler, ex); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthInChannelInterceptor.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthInChannelInterceptor.java new file mode 100644 index 000000000..c84ca8d3d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthInChannelInterceptor.java @@ -0,0 +1,115 @@ +package com.github.paicoding.forum.web.front.chat.stomp; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; + +import java.security.Principal; +import java.util.Objects; + +/** + * 权限拦截器,消息发送前进行拦截 + * + * @author YiHui + * @date 2023/6/8 + */ +@Slf4j +public class AuthInChannelInterceptor implements ChannelInterceptor { + @Override + public Message preSend(Message message, MessageChannel channel) { + final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null) { + return message; + } + + String destination = accessor.getDestination(); + if (StringUtils.isBlank(destination)) { + return message; + } + + + Principal uid = accessor.getUser(); + if (uid == null) { + return message; + } + + // 正常登录的用户,这个uid实际上应该是 ReqInfo 对象 +// log.info("初始化用户标识:{}", uid); + +// 注意:这里注释的这种方案,适用于所有的客户端订阅相同的路径,然后请求头中添加用户身份标识,然后再 AuthHandshakeInterceptor 进行身份识别设置全局属性,AuthHandshakeHandler 这里来决定怎么进行转发 +// if (destination.startsWith("/app")) { +// // 开始进行聊天时,进行身份校验; 路由转发 +// String suffix = destination.substring("/chat".length()); +// String prepareDestination = String.format("%s%s", suffix, uid.getName()); +// accessor.setDestination(prepareDestination); +// } + + return ChannelInterceptor.super.preSend(message, channel); + } + + + @Override + public void postSend(Message message, MessageChannel channel, boolean sent) { +// 基础实现版 +// final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); +// if (StringUtils.equalsIgnoreCase(String.valueOf(message.getHeaders().get("simpMessageType")), "SUBSCRIBE") +// && accessor != null && accessor.getUser() != null) { +// // 订阅成功,返回用户历史聊天记录; 从请求头中,获取具体选择的大数据模型 +// ReqInfoContext.addReqInfo((ReqInfoContext.ReqInfo) accessor.getUser()); +// +// String aiType = (String) ((Map) message.getHeaders().get("simpSessionAttributes")).get(WsAnswerHelper.AI_SOURCE_PARAM); +// AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); +// SpringUtil.getBean(WsAnswerHelper.class).sendMsgHistoryToUser(accessor.getUser().getName(), source); +// ReqInfoContext.clear(); +// return; +// } +// ChannelInterceptor.super.postSend(message, channel, sent); + + + final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + String destination = accessor.getDestination(); + if (accessor.getUser() == null || StringUtils.isBlank(destination) || accessor.getCommand() == null) { + ChannelInterceptor.super.postSend(message, channel, sent); + return; + } + + if (destination.contains("/chat")) { + // 派聪明:长连接入口 + if (Objects.equals(accessor.getCommand(), StompCommand.SUBSCRIBE)) { + // 订阅成功,返回用户历史聊天记录; 从请求头中,获取具体选择的大数据模型 + ReqInfoContext.addReqInfo((ReqInfoContext.ReqInfo) accessor.getUser()); +// 因为派聪明现在支持多轮对话,因此历史消息再用户切换对话之后再进行返回 +// String aiType = (String) (accessor.getSessionAttributes().get(WsAnswerHelper.AI_SOURCE_PARAM)); +// AISourceEnum source = aiType == null ? null : AISourceEnum.valueOf(aiType); +// SpringUtil.getBean(WsAnswerHelper.class).sendMsgHistoryToUser(accessor.getUser().getName(), source); + ReqInfoContext.clear(); + return; + } + } else if (destination.startsWith("/msg") || destination.startsWith("/user/msg")) { + // 建立用户与服务端的消息通知长连接 + SpringUtil.getBean(NotifyService.class).notifyChannelMaintain(accessor); + return; + } + ChannelInterceptor.super.postSend(message, channel, sent); + } + + @Override + public boolean preReceive(MessageChannel channel) { + log.info("preReceive!"); + return ChannelInterceptor.super.preReceive(channel); + } + + @Override + public Message postReceive(Message message, MessageChannel channel) { + log.info("postReceive"); + return ChannelInterceptor.super.postReceive(message, channel); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthOutChannelInterceptor.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthOutChannelInterceptor.java new file mode 100644 index 000000000..a2abbdb33 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/AuthOutChannelInterceptor.java @@ -0,0 +1,49 @@ +package com.github.paicoding.forum.web.front.chat.stomp; + +import com.beust.jcommander.internal.Nullable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.ChannelInterceptor; + +/** + * 权限拦截器,消息广播给用户的场景 + * + * @author YiHui + * @date 2023/6/8 + */ +@Slf4j +public class AuthOutChannelInterceptor implements ChannelInterceptor { + @Override + public boolean preReceive(MessageChannel channel) { + log.debug("Outbound preReceive: channel={}", channel); + return true; + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + log.debug("Outbound preSend: message={}", message); + return message; + } + + @Override + public void postSend(Message message, MessageChannel channel, boolean sent) { + log.debug("Outbound postSend. message={}", message); + } + + @Override + public Message postReceive(Message message, MessageChannel channel) { + log.debug("Outbound postReceive. message={}", message); + return message; + } + + @Override + public void afterSendCompletion(Message message, MessageChannel channel, boolean sent, @Nullable Exception ex) { + log.debug("Outbound afterSendCompletion. message={}", message); + } + + @Override + public void afterReceiveCompletion(@Nullable Message message, MessageChannel channel, @Nullable Exception ex) { + log.debug("Outbound afterReceiveCompletion. message={}", message); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/WsChatConfig.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/WsChatConfig.java new file mode 100644 index 000000000..a11ce9fcf --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/stomp/WsChatConfig.java @@ -0,0 +1,110 @@ +package com.github.paicoding.forum.web.front.chat.stomp; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; + +/** + * v1.1 stomp协议的websocket实现的chatgpt聊天方式 + * + * @author YiHui + * @date 2023/6/5 + */ +@Slf4j +@Configuration +@EnableWebSocketMessageBroker // 开启websocket代理 +public class WsChatConfig implements WebSocketMessageBrokerConfigurer { + /** + * 这里定义的是客户端接收服务端消息的相关信息,如派聪明的回答: WsAnswerHelper#response 就是往 "/chat/rsp" 发送消息 + * 对应的前端订阅的也是 chat/index.html: stompClient.subscribe(`/user/chat/rsp`, xxx) + * + * @param config + */ + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // 开启一个简单的基于内存的消息代理,前缀是/user的将消息会转发给消息代理 broker + // 然后再由消息代理,将消息广播给当前连接的客户端 + // /chat broker用于派聪明聊天; /msg broker用于服务端给用户推送消息 + config.enableSimpleBroker("/chat", "/msg"); + + // 表示配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。 + // 例如,前缀为 /app 的 destination 可以通过@MessageMapping注解的方法处理, + // 而其他 destination (例如 /topic /queue)将被直接交给 broker 处理 + config.setApplicationDestinationPrefixes("/app"); + } + + + /** + * 添加一个服务端点,来接收客户端的连接 + * 即客户端创建ws时,指定的地址, chat/index.html: let socket = new WebSocket(`${protocol}//${host}/gpt/${session}/${aiType}`); + * @param registry + */ + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 注册一个 /gpt/{id} 的 WebSocket endPoint; 其中 {id} 用于让用户连接终端时都可以有自己的路径 + // 作为 Principal 的标识,以便实现向指定用户发送信息 + // sockjs 可以解决浏览器对 WebSocket 的兼容性问题, + registry.addEndpoint("/gpt/{id}/{aiType}", "/notify") + .setHandshakeHandler(new AuthHandshakeHandler()) + .addInterceptors(new AuthHandshakeInterceptor()) + // 注意下面这个,不要使用 setAllowedOrigins("*"),使用之后有啥问题可以实操验证一下🐕 + // setAllowedOrigins接受一个字符串数组作为参数,每个元素代表一个允许访问的客户端地址,内部的值为具体的 "http://localhost:8080" + // setAllowedOriginPatterns接受一个正则表达式数组作为参数,每个元素代表一个允许访问的客户端地址的模式, 内部值可以为正则,如 "*", "http://*:8080" + .setAllowedOriginPatterns("*") + ; + } + + /** + * 配置接收消息的拦截器 + * + * 设置输出消息通道的线程数,默认线程为1,可以自己自定义线程数,最大线程数,线程存活时间 + * + * @param registration + */ + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.taskExecutor() + .corePoolSize(4) + .maxPoolSize(10) + .keepAliveSeconds(60); + registration.interceptors(channelInInterceptor()); + } + + /** + * 配置返回消息的拦截器 + * + * @param registration + */ + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + registration.interceptors(channelOutInterceptor()); + } + + @Bean + public HandshakeHandler handshakeHandler() { + return new AuthHandshakeHandler(); + } + + @Bean + public HttpSessionHandshakeInterceptor handshakeInterceptor() { + return new AuthHandshakeInterceptor(); + } + + @Bean + public ChannelInterceptor channelInInterceptor() { + return new AuthInChannelInterceptor(); + } + + @Bean + public ChannelInterceptor channelOutInterceptor() { + return new AuthOutChannelInterceptor(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/view/ChatViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/view/ChatViewController.java new file mode 100644 index 000000000..57d1001c9 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/view/ChatViewController.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.web.front.chat.view; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping(path = "chat") +public class ChatViewController { + @RequestMapping(path = {"", "/", "home"}) + public String index() { + return "views/chat-home/index"; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/ws/SimpleWsAuthInterceptor.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/ws/SimpleWsAuthInterceptor.java new file mode 100644 index 000000000..27ea9218d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/ws/SimpleWsAuthInterceptor.java @@ -0,0 +1,57 @@ +package com.github.paicoding.forum.web.front.chat.ws; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.core.mdc.MdcUtil; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.web.global.GlobalInitService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; + +import java.util.Map; + +/** + * v1. 简单版本聊天: 长连接的登录校验拦截器 + * + * @author YiHui + * @date 2023/6/6 + */ +@Slf4j +public class SimpleWsAuthInterceptor extends HttpSessionHandshakeInterceptor implements ChannelInterceptor { + + @Override + public boolean preReceive(MessageChannel channel) { + return ChannelInterceptor.super.preReceive(channel); + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { + String session = ((ServletServerHttpRequest) request).getServletRequest().getParameter("session"); + ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo(); + SpringUtil.getBean(GlobalInitService.class).initLoginUser(session, reqInfo); + ReqInfoContext.addReqInfo(reqInfo); + if (reqInfo.getUserId() == null) { + // 未登录,拒绝链接 + log.info("用户未登录,拒绝聊天! "); + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + log.info("{} 开始了聊天!", reqInfo); + MdcUtil.addTraceId(); + return super.beforeHandshake(request, response, wsHandler, attributes); + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) { + ReqInfoContext.clear(); + MdcUtil.clear(); + super.afterHandshake(request, response, wsHandler, ex); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/ws/SimpleWsConfig.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/ws/SimpleWsConfig.java new file mode 100644 index 000000000..8bef20180 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/chat/ws/SimpleWsConfig.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.web.front.chat.ws; + +import com.github.paicoding.forum.web.front.chat.rest.SimpleChatgptHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * v1.0 基础版本的websocket长连接相关配置 + * + * @author YiHui + * @date 2023/6/5 + */ +//@Configuration +//@EnableWebSocket +public class SimpleWsConfig implements WebSocketConfigurer { + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(chatWebSocketHandler(), "/chatgpt") + .setAllowedOrigins("*") + .addInterceptors(new SimpleWsAuthInterceptor()); + } + + @Bean + public WebSocketHandler chatWebSocketHandler() { + return new SimpleChatgptHandler(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/comment/rest/CommentRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/comment/rest/CommentRestController.java new file mode 100644 index 000000000..68c299ab5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/comment/rest/CommentRestController.java @@ -0,0 +1,205 @@ +package com.github.paicoding.forum.web.front.comment.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.DocumentTypeEnum; +import com.github.paicoding.forum.api.model.enums.OperateTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.comment.CommentSaveReq; +import com.github.paicoding.forum.api.model.vo.comment.dto.TopCommentDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.service.article.conveter.ArticleConverter; +import com.github.paicoding.forum.service.article.repository.entity.ArticleDO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.comment.repository.entity.CommentDO; +import com.github.paicoding.forum.service.comment.service.CommentReadService; +import com.github.paicoding.forum.service.comment.service.CommentWriteService; +import com.github.paicoding.forum.service.user.service.UserFootService; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import com.github.paicoding.forum.web.front.article.vo.ArticleDetailVo; +import com.github.paicoding.forum.web.front.comment.vo.HighlightCommentVo; +import org.apache.commons.lang3.StringEscapeUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Optional; + +/** + * 评论 + * + * @author louzai + * @date : 2022/4/22 10:56 + **/ +@RestController +@RequestMapping(path = "comment/api") +public class CommentRestController { + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private CommentReadService commentReadService; + + @Autowired + private CommentWriteService commentWriteService; + + @Autowired + private UserFootService userFootService; + + @Autowired + private TemplateEngineHelper templateEngineHelper; + + /** + * 评论列表页 + * + * @param articleId + * @return + */ + @ResponseBody + @RequestMapping(path = "list") + public ResVo> list(Long articleId, Long pageNum, Long pageSize) { + if (NumUtil.nullOrZero(articleId)) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章id为空"); + } + pageNum = Optional.ofNullable(pageNum).orElse(PageParam.DEFAULT_PAGE_NUM); + pageSize = Optional.ofNullable(pageSize).orElse(PageParam.DEFAULT_PAGE_SIZE); + List result = commentReadService.getArticleComments(articleId, PageParam.newPageInstance(pageNum, pageSize)); + return ResVo.ok(result); + } + + /** + * 保存评论 + * + * @param req + * @return + */ + @Permission(role = UserRole.LOGIN) + @PostMapping(path = "post") + @ResponseBody + public ResVo save(@RequestBody CommentSaveReq req) { + if (req.getArticleId() == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章id为空"); + } + ArticleDO article = articleReadService.queryBasicArticle(req.getArticleId()); + if (article == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章不存在!"); + } + + // 保存评论 + req.setUserId(ReqInfoContext.getReqInfo().getUserId()); + req.setCommentContent(StringEscapeUtils.escapeHtml3(req.getCommentContent())); + commentWriteService.saveComment(req); + + // 返回新的评论信息,用于实时更新详情也的评论列表 + ArticleDetailVo vo = new ArticleDetailVo(); + vo.setArticle(ArticleConverter.toDto(article)); + // 评论信息 + List comments = commentReadService.getArticleComments(req.getArticleId(), PageParam.newPageInstance()); + vo.setComments(comments); + + // 热门评论 + TopCommentDTO hotComment = commentReadService.queryHotComment(req.getArticleId()); + vo.setHotComment(hotComment); + String content = templateEngineHelper.render("views/article-detail/comment/index", vo); + return ResVo.ok(content); + } + + + /** + * 划线评论 + * + * @param req + * @return + */ + @Permission(role = UserRole.LOGIN) + @PostMapping(path = "highlightComment") + @ResponseBody + public ResVo highlightComment(@RequestBody CommentSaveReq req) { + if (req.getArticleId() == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章id为空"); + } + ArticleDO article = articleReadService.queryBasicArticle(req.getArticleId()); + if (article == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "文章不存在!"); + } + + // 保存评论 + req.setUserId(ReqInfoContext.getReqInfo().getUserId()); + req.setCommentContent(StringEscapeUtils.escapeHtml3(req.getCommentContent())); + Long commentId = commentWriteService.saveComment(req); + TopCommentDTO comments = commentReadService.queryTopComments(commentId); + String content = templateEngineHelper.render("components/comment/comment-highlight", comments); + HighlightCommentVo vo = new HighlightCommentVo(); + vo.setCommentId(commentId); + vo.setHtml(content); + return ResVo.ok(vo); + } + + /** + * 获取文章的顶级评论列表 + * + * @param commentId + * @return + */ + @Permission(role = UserRole.ALL) + @GetMapping(path = "listTopComment") + @ResponseBody + public ResVo listTopComment(Long commentId) { + TopCommentDTO comments = commentReadService.queryTopComments(commentId); + String content = templateEngineHelper.render("components/comment/comment-highlight", comments); + return ResVo.ok(content); + } + + /** + * 删除评论 + * + * @param commentId + * @return + */ + @Permission(role = UserRole.LOGIN) + @RequestMapping(path = "delete") + public ResVo delete(Long commentId) { + commentWriteService.deleteComment(commentId, ReqInfoContext.getReqInfo().getUserId()); + return ResVo.ok(true); + } + + /** + * 收藏、点赞等相关操作 + * + * @param commendId + * @param type 取值来自于 OperateTypeEnum#code + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "favor") + public ResVo favor(@RequestParam(name = "commentId") Long commendId, + @RequestParam(name = "type") Integer type) { + OperateTypeEnum operate = OperateTypeEnum.fromCode(type); + if (operate == OperateTypeEnum.EMPTY) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, type + "非法"); + } + + // 要求文章必须存在 + CommentDO comment = commentReadService.queryComment(commendId); + if (comment == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "评论不存在!"); + } + + userFootService.favorArticleComment(DocumentTypeEnum.COMMENT, + commendId, + comment.getUserId(), + ReqInfoContext.getReqInfo().getUserId(), + operate); + return ResVo.ok(true); + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/comment/vo/HighlightCommentVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/comment/vo/HighlightCommentVo.java new file mode 100644 index 000000000..bd0b23cc1 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/comment/vo/HighlightCommentVo.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.web.front.comment.vo; + +import lombok.Data; + +/** + * @author YiHui + * @date 2025/11/4 + */ +@Data +public class HighlightCommentVo { + /** + * 划线评论id + */ + private Long commentId; + + /** + * 划线评论html + */ + private String html; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/IndexController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/IndexController.java new file mode 100644 index 000000000..02d053212 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/IndexController.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.web.front.home; + +import com.github.paicoding.forum.web.front.home.helper.IndexRecommendHelper; +import com.github.paicoding.forum.web.front.home.vo.IndexVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import javax.servlet.http.HttpServletRequest; + +/** + * @author YiHui + * @date 2022/7/6 + */ +@Controller +public class IndexController extends BaseViewController { + @Autowired + private IndexRecommendHelper indexRecommendHelper; + + @GetMapping(path = {"/", "", "/index", "/login"}) + public String index(Model model, HttpServletRequest request) { + String activeTab = request.getParameter("category"); + IndexVo vo = indexRecommendHelper.buildIndexVo(activeTab); + model.addAttribute("vo", vo); + return "views/home/index"; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/SiteMapController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/SiteMapController.java new file mode 100644 index 000000000..b9e286136 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/SiteMapController.java @@ -0,0 +1,51 @@ +package com.github.paicoding.forum.web.front.home; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import com.github.paicoding.forum.service.sitemap.model.SiteMapVo; +import com.github.paicoding.forum.service.sitemap.service.SitemapService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.nio.charset.Charset; + +/** + * 生成 sitemap.xml + * + * @author YiHui + * @date 2023/2/13 + */ +@RestController +public class SiteMapController { + private XmlMapper xmlMapper = new XmlMapper(); + @Resource + private SitemapService sitemapService; + + @RequestMapping(path = "/sitemap", + produces = "application/xml;charset=utf-8") + public SiteMapVo sitemap() { + return sitemapService.getSiteMap(); + } + + @RequestMapping(path = "/sitemap.xml", + produces = "text/xml") + public byte[] sitemapXml() throws JsonProcessingException { + xmlMapper.configure(SerializationFeature.INDENT_OUTPUT, true); + xmlMapper.configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, true); + SiteMapVo vo = sitemapService.getSiteMap(); + String ans = xmlMapper.writeValueAsString(vo); + ans = ans.replaceAll(" xmlns=\"\"", ""); + + return ans.getBytes(Charset.defaultCharset()); + } + + @GetMapping(path = "/sitemap/refresh") + public Boolean refresh() { + sitemapService.refreshSitemap(); + return true; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/helper/IndexRecommendHelper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/helper/IndexRecommendHelper.java new file mode 100644 index 000000000..9cf8ef820 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/helper/IndexRecommendHelper.java @@ -0,0 +1,162 @@ +package com.github.paicoding.forum.web.front.home.helper; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ConfigTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.banner.dto.ConfigDTO; +import com.github.paicoding.forum.api.model.vo.recommend.CarouseDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.common.CommonConstants; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.article.service.CategoryService; +import com.github.paicoding.forum.service.config.service.ConfigService; +import com.github.paicoding.forum.service.sidebar.service.SidebarService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.web.front.home.vo.IndexVo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +/** + * 首页推荐相关 + * + * @author YiHui + * @date 2022/9/6 + */ +@Component +public class IndexRecommendHelper { + @Autowired + private CategoryService categoryService; + + @Autowired + private ArticleReadService articleService; + + @Autowired + private UserService userService; + + @Autowired + private SidebarService sidebarService; + + @Autowired + private ConfigService configService; + + public IndexVo buildIndexVo(String activeTab) { + IndexVo vo = new IndexVo(); + CategoryDTO category = categories(activeTab, vo); + vo.setCategoryId(category.getCategoryId()); + vo.setCurrentCategory(category.getCategory()); + // 并行调度实例,提高响应性能 + AsyncUtil.concurrentExecutor("首页响应") + .async(() -> vo.setArticles(articleList(category.getCategoryId())), "文章列表") + .async(() -> vo.setTopArticles(topArticleList(category)), "置顶文章") + .async(() -> vo.setHomeCarouselList(homeCarouselList()), "轮播图") + .async(() -> vo.setSideBarItems(sidebarService.queryHomeSidebarList()), "侧边栏") + .async(() -> vo.setUser(loginInfo()), "用户信息") + .allExecuted() + .prettyPrint(); + return vo; + } + + public IndexVo buildSearchVo(String key) { + IndexVo vo = new IndexVo(); + vo.setArticles(articleService.queryArticlesBySearchKey(key, PageParam.newPageInstance())); + vo.setSideBarItems(sidebarService.queryHomeSidebarList()); + return vo; + } + + /** + * 轮播图 + * + * @return + */ + private List homeCarouselList() { + List configList = configService.getConfigList(ConfigTypeEnum.HOME_PAGE); + return configList.stream() + .map(configDTO -> new CarouseDTO() + .setName(configDTO.getName()) + .setImgUrl(configDTO.getBannerUrl()) + .setActionUrl(configDTO.getJumpUrl())) + .collect(Collectors.toList()); + } + + /** + * 文章列表 + */ + private PageListVo articleList(Long categoryId) { + return articleService.queryArticlesByCategory(categoryId, PageParam.newPageInstance()); + } + + /** + * 置顶top 文章列表 + */ + private List topArticleList(CategoryDTO category) { + List topArticles = articleService.queryTopArticlesByCategory(category.getCategoryId() == 0 ? null : category.getCategoryId()); + if (topArticles.size() < PageParam.TOP_PAGE_SIZE) { + // 当分类下文章数小于置顶数时,为了避免显示问题,直接不展示 + topArticles.clear(); + return topArticles; + } + + // 查询分类对应的头图列表 + List topPicList = CommonConstants.HOMEPAGE_TOP_PIC_MAP.getOrDefault(category.getCategory(), + CommonConstants.HOMEPAGE_TOP_PIC_MAP.get(CommonConstants.CATEGORY_ALL)); + + // 替换头图,下面做了一个数组越界的保护,避免当topPageSize数量变大,但是默认的cover图没有相应增大导致数组越界异常 + AtomicInteger index = new AtomicInteger(0); + topArticles.forEach(s -> s.setCover(topPicList.get(index.getAndIncrement() % topPicList.size()))); + return topArticles; + } + + /** + * 返回分类列表 + * + * @param active 选中的分类 + * @param vo 返回结果 + * @return 返回选中的分类;当没有匹配时,返回默认的全部分类 + */ + private CategoryDTO categories(String active, IndexVo vo) { + List allList = categoryService.loadAllCategories(); + // 查询所有分类的对应的文章数 + Map articleCnt = articleService.queryArticleCountsByCategory(); + // 过滤掉文章数为0的分类 + allList.removeIf(c -> articleCnt.getOrDefault(c.getCategoryId(), 0L) <= 0L); + + // 刷新选中的分类 + AtomicReference selectedArticle = new AtomicReference<>(); + allList.forEach(category -> { + if (category.getCategory().equalsIgnoreCase(active)) { + category.setSelected(true); + selectedArticle.set(category); + } else { + category.setSelected(false); + } + }); + + // 添加默认的全部分类 + allList.add(0, new CategoryDTO(0L, CategoryDTO.DEFAULT_TOTAL_CATEGORY)); + if (selectedArticle.get() == null) { + selectedArticle.set(allList.get(0)); + allList.get(0).setSelected(true); + } + + vo.setCategories(allList); + return selectedArticle.get(); + } + + + private UserStatisticInfoDTO loginInfo() { + if (ReqInfoContext.getReqInfo() != null && ReqInfoContext.getReqInfo().getUserId() != null) { + return userService.queryUserInfoWithStatistic(ReqInfoContext.getReqInfo().getUserId()); + } + return null; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/vo/IndexVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/vo/IndexVo.java new file mode 100644 index 000000000..e609726dd --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/home/vo/IndexVo.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.web.front.home.vo; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.CategoryDTO; +import com.github.paicoding.forum.api.model.vo.recommend.CarouseDTO; +import com.github.paicoding.forum.api.model.vo.recommend.SideBarDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/6 + */ +@Data +public class IndexVo { + /** + * 分类列表 + */ + private List categories; + + /** + * 当前选中的分类 + */ + private String currentCategory; + + /** + * 当前选中的类目id + */ + private Long categoryId; + + /** + * top 文章列表 + */ + private List topArticles; + + /** + * 文章列表 + */ + private PageListVo articles; + + /** + * 登录用户信息 + */ + private UserStatisticInfoDTO user; + + /** + * 侧边栏信息 + */ + private List sideBarItems; + + /** + * 轮播图 + */ + private List homeCarouselList; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/pwd/LoginRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/pwd/LoginRestController.java new file mode 100644 index 000000000..8a22cf6a5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/pwd/LoginRestController.java @@ -0,0 +1,99 @@ +package com.github.paicoding.forum.web.front.login.pwd; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.UserPwdLoginReq; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.web.front.login.zsxq.helper.ZsxqHelper; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; + +/** + * 用户名 密码方式的登录/登出的入口 + * + * @author YiHui + * @date 2022/8/15 + */ +@RestController +@RequestMapping +public class LoginRestController { + @Autowired + private LoginService loginService; + @Autowired + private ZsxqHelper zsxqHelper; + + /** + * 用户名和密码登录 + * 可以根据星球编号/用户名进行密码匹配 + */ + @PostMapping("/login/username") + public ResVo login(@RequestParam(name = "username") String username, + @RequestParam(name = "password") String password, + HttpServletResponse response) { + String session = loginService.loginByUserPwd(username, password); + if (StringUtils.isNotBlank(session)) { + // cookie中写入用户登录信息,用于身份识别 + response.addCookie(SessionUtil.newCookie(LoginService.SESSION_KEY, session)); + return ResVo.ok(true); + } else { + return ResVo.fail(StatusEnum.LOGIN_FAILED_MIXED, "用户名和密码登录异常,请稍后重试"); + } + } + + /** + * 绑定星球账号 + */ + @PostMapping("/login/register") + public ResVo register(UserPwdLoginReq loginReq, + HttpServletResponse response) { + String session = loginService.registerByUserPwd(loginReq); + if (StringUtils.isNotBlank(session)) { + // cookie中写入用户登录信息,用于身份识别 + response.addCookie(SessionUtil.newCookie(LoginService.SESSION_KEY, session)); + // 获取当前登录用户的ID + Long userId = ReqInfoContext.getReqInfo().getUserId(); + return ResVo.ok(userId); + } else { + return ResVo.fail(StatusEnum.LOGIN_FAILED_MIXED, "用户名和密码登录异常,请稍后重试"); + } + } + + @Permission(role = UserRole.LOGIN) + @RequestMapping("logout") + public ResVo logOut(HttpServletRequest request, HttpServletResponse response) throws IOException { + // 释放会话 + request.getSession().invalidate(); + Optional.ofNullable(ReqInfoContext.getReqInfo()).ifPresent(s -> loginService.logout(s.getSession())); + // 移除cookie + SessionUtil.delCookies(LoginService.SESSION_KEY); + // 重定向到当前页面 + String referer = request.getHeader("Referer"); + if (StringUtils.isBlank(referer)) { + referer = "/"; + } + response.sendRedirect(referer); + return ResVo.ok(true); + } + + /** + * 知识星球登录 + */ + @RequestMapping("login/zsxq") + public void redirectToZsxq(HttpServletResponse response) throws IOException { + String url = zsxqHelper.buildZsxqLoginUrl("login"); + response.sendRedirect(url); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/callback/WxCallbackRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/callback/WxCallbackRestController.java new file mode 100644 index 000000000..dc1207fe5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/callback/WxCallbackRestController.java @@ -0,0 +1,210 @@ +package com.github.paicoding.forum.web.front.login.wx.callback; + +import cn.hutool.core.util.NumberUtil; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.wx.BaseWxMsgResVo; +import com.github.paicoding.forum.api.model.vo.user.wx.WxTxtMsgReqVo; +import com.github.paicoding.forum.api.model.vo.user.wx.WxTxtMsgResVo; +import com.github.paicoding.forum.core.net.HttpRequestHelper; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.service.ArticlePayService; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.service.pay.PayServiceFactory; +import com.github.paicoding.forum.service.pay.model.PayCallbackBo; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.web.front.login.wx.config.WxLoginProperties; +import com.github.paicoding.forum.web.front.login.wx.helper.WxAckHelper; +import com.github.paicoding.forum.web.front.login.wx.helper.WxLoginHelper; +import com.wechat.pay.java.service.refund.model.RefundNotification; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.function.Function; + +/** + * 微信公众号登录相关 + * + * @author YiHui + * @date 2022/9/2 + */ +@Slf4j +@RequestMapping(path = "wx") +@RestController +public class WxCallbackRestController { + @Autowired + private LoginService sessionService; + @Autowired + private WxLoginHelper qrLoginHelper; + @Autowired + private WxAckHelper wxHelper; + @Autowired + private ArticlePayService articlePayService; + @Autowired + private PayServiceFactory payServiceFactory; + + /** + * 微信的公众号接入 token 验证,即返回echostr的参数值 + * + * @param request + * @return + */ + @GetMapping(path = "callback") + public String check(HttpServletRequest request) { + String echoStr = request.getParameter("echostr"); + if (StringUtils.isNoneEmpty(echoStr)) { + return echoStr; + } + return ""; + } + + /** + * fixme: 需要做防刷校验 + * 微信的响应返回 + * 本地测试访问: curl -X POST 'http://localhost:8080/wx/callback' -H 'content-type:application/xml' -d '165570057911111111' -i + * + * @param msg + * @return + */ + @PostMapping(path = "callback", + consumes = {"application/xml", "text/xml"}, + produces = "application/xml;charset=utf-8") + public BaseWxMsgResVo callBack(@RequestBody WxTxtMsgReqVo msg) { + // 对于需要开启安全校验的场景,需要配置 + this.wxCallbackSecurityCheck(); + String code = msg.getContent(); + if ("subscribe".equals(msg.getEvent()) || "scan".equalsIgnoreCase(msg.getEvent())) { + // 对于符号的逻辑,code需要从eventKey中获取 + String key = msg.getEventKey(); + if (StringUtils.isNotBlank(key)) { + // 对于关注事件,key的格式为 qrscene_验证码; 对于扫码事件,key的格式就是 验证码 + if (key.startsWith("qrscene_")) { + code = key.substring(8); + } else { + code = key; + } + } + } + + if (directToLoginOcPai(code)) { + // 命中校招派登录的场景 + return loginOcPai(msg); + } + + // 执行技术派登录、用户响应问答的场景 + BaseWxMsgResVo res = wxHelper.buildResponseBody(msg.getEvent(), code, msg.getFromUserName()); + fillResVo(res, msg); + return res; + } + + + /** + * 对微信的回调进行安全校验 + */ + private void wxCallbackSecurityCheck() { + String securityToken = SpringUtil.getBean(WxLoginProperties.class).getSecurityCheckToken(); + if (StringUtils.isBlank(securityToken)) { + // 没有配置接口签名校验时,直接返回 + return; + } + + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + String sig = request.getParameter("signature"); + String timestamp = request.getParameter("timestamp"); + String nonce = request.getParameter("nonce"); + // 验证签名 + String toSign = timestamp + nonce + securityToken; + if (!DigestUtils.sha1Hex(toSign).equals(sig)) { + log.error("微信回调签名校验失败,请检查接口签名配置"); + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS); + } + } + + + /** + * 当关键词命中下面的规则,表示登录校招派 + * + * @param content + * @return + */ + private boolean directToLoginOcPai(String content) { + return NumberUtil.isNumber(content) && content.length() == 4; + } + + + /** + * oc使用的和技术派是同一个微信公众号进行授权登录,按照验证码的位数进行区分;我们在这里做一个路由转发 + * + * @return + */ + private BaseWxMsgResVo loginOcPai(WxTxtMsgReqVo msg) { + return HttpRequestHelper.postJsonData(SpringUtil.getConfig("paicoding.openapi.oc-login-redirect-url"), msg, WxTxtMsgResVo.class); + } + + private void fillResVo(BaseWxMsgResVo res, WxTxtMsgReqVo msg) { + res.setFromUserName(msg.getToUserName()); + res.setToUserName(msg.getFromUserName()); + res.setCreateTime(System.currentTimeMillis() / 1000); + } + + + /** + * 微信支付回调 + * + * @param request + * @return + * @throws IOException + */ + @PostMapping(path = "payNotify") + public ResponseEntity wxPayCallback(HttpServletRequest request) throws IOException { + return payServiceFactory.getPayService(ThirdPayWayEnum.WX_NATIVE).payCallback(request, new Function() { + @Override + public Boolean apply(PayCallbackBo transaction) { + log.info("微信支付回调执行业务逻辑 {}", transaction); + if (transaction.getOutTradeNo().startsWith("TEST-")) { + // TestController 中关于测试支付的回调逻辑时,我们只通过消息进行通知用户即可 + long payUser = transaction.getPayId(); + SpringUtil.getBean(NotifyService.class).notifyToUser(payUser, "您的一笔微信测试支付状态已更新为:" + transaction.getPayStatus().getMsg()); + return true; + } + + return articlePayService.updatePayStatus(transaction.getPayId(), + transaction.getOutTradeNo(), + transaction.getPayStatus(), + transaction.getSuccessTime(), + transaction.getThirdTransactionId()); + } + }); + } + + + /** + * todo: 退款回调 + * + * @return + */ + @PostMapping(path = "refundNotify") + public ResponseEntity wxRefundCallback(HttpServletRequest request) throws IOException { + return payServiceFactory.getPayService(ThirdPayWayEnum.WX_NATIVE) + .refundCallback(request, new Function() { + @Override + public Boolean apply(RefundNotification refundNotification) { + log.info("微信退款回调执行业务逻辑{}", refundNotification); + return null; + } + }); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/config/WxLoginProperties.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/config/WxLoginProperties.java new file mode 100644 index 000000000..adc98dbbf --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/config/WxLoginProperties.java @@ -0,0 +1,45 @@ +package com.github.paicoding.forum.web.front.login.wx.config; + +import com.github.paicoding.forum.api.model.enums.login.LoginQrTypeEnum; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * @author YiHui + * @date 2025/8/19 + */ +@Component +@Data +@ConfigurationProperties("paicoding.login.wx") +public class WxLoginProperties { + /** + * 登录二维码的类型:微信公众号、服务号 + * + * @see LoginQrTypeEnum#name() + */ + private String loginQrType; + + /** + * 开发者ID(AppID) + */ + private String appId; + /** + * 开发者密码 + */ + private String appSecret; + /** + * 如果是普通公众号登录,这里为公众号的二维码图片地址 + */ + private String qrCodeImg; + + /** + * 二维码的logo,适用于服务号的场景 + */ + private String qrCodeLogo; + + /** + * 微信公众号安全校验token,为后台配置 + */ + private String securityCheckToken; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/controller/WxLoginController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/controller/WxLoginController.java new file mode 100644 index 000000000..9ba12776f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/controller/WxLoginController.java @@ -0,0 +1,104 @@ +package com.github.paicoding.forum.web.front.login.wx.controller; + +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.core.mdc.MdcDot; +import com.github.paicoding.forum.web.front.login.wx.helper.WxLoginHelper; +import com.github.paicoding.forum.web.front.login.wx.vo.WxLoginVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.core.util.NumUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + + +/** + * 公众号登陆的长连接控制器 + * + * @author louzai + * @date : 2022/8/3 10:56 + **/ +@Controller +@Slf4j +public class WxLoginController extends BaseViewController { + @Autowired + private WxLoginHelper qrLoginHelper; + + /** + * 客户端与后端建立扫描二维码的长连接 + * + * @return + */ + @MdcDot + @ResponseBody + @GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE}) + public SseEmitter subscribe(String deviceId) throws IOException { + return qrLoginHelper.subscribe(); + } + + @GetMapping(path = "/login/fetch") + @ResponseBody + public String resendCode(String deviceId) throws IOException { + return qrLoginHelper.resend(); + } + + /** + * 刷新验证码 + * + * @return + * @throws IOException + */ + @MdcDot + @GetMapping(path = "/login/refresh") + @ResponseBody + public ResVo refresh(String deviceId) throws IOException { + WxLoginVo vo = new WxLoginVo(); + String code = qrLoginHelper.refreshCode(); + if (StringUtils.isBlank(code)) { + // 刷新失败,之前的连接已失效,重新建立连接 + vo.setCode(code); + vo.setReconnect(true); + } else { + vo.setCode(code); + vo.setReconnect(false); + } + return ResVo.ok(vo); + } + + /** + * 检查登录状态 + * 用于移动端扫码登录后,页面重新可见时检查是否已登录 + * + * @return 登录状态信息 + */ + @MdcDot + @ResponseBody + @GetMapping(path = "/login/status") + public ResVo> loginStatus() { + Map result = new HashMap<>(); + + // 检查用户是否已登录 + boolean isLogin = ReqInfoContext.getReqInfo() != null + && NumUtil.upZero(ReqInfoContext.getReqInfo().getUserId()); + + result.put("loggedIn", isLogin); + + if (isLogin) { + // 如果已登录,返回用户基本信息 + result.put("userId", ReqInfoContext.getReqInfo().getUserId()); + result.put("username", ReqInfoContext.getReqInfo().getUser().getUserName()); + result.put("photo", ReqInfoContext.getReqInfo().getUser().getPhoto()); + } + + return ResVo.ok(result); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxAckHelper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxAckHelper.java new file mode 100644 index 000000000..b369b927a --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxAckHelper.java @@ -0,0 +1,110 @@ +package com.github.paicoding.forum.web.front.login.wx.helper; + +import com.github.paicoding.forum.api.model.vo.user.wx.BaseWxMsgResVo; +import com.github.paicoding.forum.api.model.vo.user.wx.WxImgTxtItemVo; +import com.github.paicoding.forum.api.model.vo.user.wx.WxImgTxtMsgResVo; +import com.github.paicoding.forum.api.model.vo.user.wx.WxTxtMsgResVo; +import com.github.paicoding.forum.core.util.CodeGenerateUtil; +import com.github.paicoding.forum.service.chatai.service.ChatgptService; +import com.github.paicoding.forum.service.user.service.LoginService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/5 + */ +@Slf4j +@Component +public class WxAckHelper { + @Autowired + private LoginService sessionService; + @Autowired + private WxLoginHelper qrLoginHelper; + + @Autowired + private ChatgptService chatgptService; + + /** + * 返回自动响应的文本 + * + * @return + */ + public BaseWxMsgResVo buildResponseBody(String eventType, String content, String fromUser) { + // 返回的文本消息 + String textRes = null; + // 返回的是图文消息 + List imgTxtList = null; + if (("subscribe".equalsIgnoreCase(eventType) || "scan".equalsIgnoreCase(eventType)) + && !CodeGenerateUtil.isVerifyCode(content)) { + // 单纯的服务号订阅、扫码,而不是登录的场景,返回下面的提示信息 + textRes = "优秀的你一关注,二哥英俊的脸上就泛起了笑容。我这个废柴,既可以把程序人生写得风趣幽默,也可以把技术文章写得通俗易懂。\n" + + "\n" + + "可能是 2023 年最硬核的面试学习资料,内容涵盖 Java、Spring、MySQL、Redis、计算机网络、操作系统、消息队列、分布式等,60 万+字,300 张+手绘图,GitHub 星标 9000+,相信一定能够帮助到你。\n" + + "\n" + + "[勾引]PDF 戳这里获取,手慢无!\n" + + "\n" + + "没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。\n"; + } + // 下面是关键词回复 + else if (chatgptService.inChat(fromUser, content)) { + try { + textRes = chatgptService.chat(fromUser, content); + } catch (Exception e) { + log.error("派聪明 访问异常! content: {}", content, e); + textRes = "派聪明 出了点小状况,请稍后再试!"; + } + } + + // 下面是回复图文消息 + else if ("加群".equalsIgnoreCase(content)) { + WxImgTxtItemVo imgTxt = new WxImgTxtItemVo(); + imgTxt.setTitle("扫码加群"); + imgTxt.setDescription("加入技术派的技术交流群,卷起来!"); + imgTxt.setPicUrl("https://mmbiz.qpic.cn/mmbiz_jpg/sXFqMxQoVLGOyAuBLN76icGMb2LD1a7hBCoialjicOMsicvdsCovZq2ib1utmffHLjVlcyAX2UTmHoslvicK4Mg71Kyw/0?wx_fmt=jpeg"); + imgTxt.setUrl("https://mp.weixin.qq.com/s/aY5lkyKjLHWSUuEf1UT2yQ"); + imgTxtList = Arrays.asList(imgTxt); + } else if ("admin".equalsIgnoreCase(content) || "后台".equals(content) || "002".equals(content)) { + // admin后台登录,返回对应的用户名 + 密码 + textRes = "技术派后台游客登录账号\n-----------\n登录用户名: guest\n登录密码: 123456"; + } else if ("商务合作".equalsIgnoreCase(content)) { + textRes = "商务合作(非诚勿扰):请添加二哥微信 qing_geee 备注\"商务合作\"'"; + } + // 微信公众号登录 + else if (CodeGenerateUtil.isVerifyCode(content)) { + sessionService.autoRegisterWxUserInfo(fromUser); + if (qrLoginHelper.login(content)) { + textRes = "登录成功,开始愉快的玩耍技术派吧!\n\n" + + "🎉 欢迎来到技术派!\n" + + "👉 访问技术派官网 👈\n\n" + + "在这里你可以:\n" + + "• 发布技术文章,分享你的经验\n" + + "• 学习优质内容,提升技术能力\n" + + "• 与技术爱好者交流互动"; + } else { + textRes = "验证码过期了,刷新登录页面重试一下吧"; + } + } else { + textRes = "/:? 还在找其它资料么?\n" + + "\n" + + "[机智] 添加二哥的微信 itwanger 后,微信回复 “110”,即可获得 10 本校招/社招必刷八股文,以及技术派团队的原创手册《高并发手册》、《Spring 源码解析手册》、《设计模式手册》、《JVM 核心手册》、《Java 并发编程手册》、《架构选型手册》,工作面试两不误,工作面试两不误。\n" + + "\n" + + "商务合作/技术交流群:请添加二哥微信 itwanger"; + } + + if (textRes != null) { + WxTxtMsgResVo vo = new WxTxtMsgResVo(); + vo.setContent(textRes); + return vo; + } else { + WxImgTxtMsgResVo vo = new WxImgTxtMsgResVo(); + vo.setArticles(imgTxtList); + vo.setArticleCount(imgTxtList.size()); + return vo; + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxLoginHelper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxLoginHelper.java new file mode 100644 index 000000000..a9372fed1 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxLoginHelper.java @@ -0,0 +1,198 @@ +package com.github.paicoding.forum.web.front.login.wx.helper; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.login.LoginQrTypeEnum; +import com.github.paicoding.forum.api.model.exception.NoVlaInGuavaException; +import com.github.paicoding.forum.core.util.CodeGenerateUtil; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.web.front.login.wx.config.WxLoginProperties; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.servlet.http.Cookie; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * @author YiHui + * @date 2022/9/5 + */ +@Slf4j +@Component +public class WxLoginHelper { + /** + * sse的超时时间,默认15min + */ + private final static Long SSE_EXPIRE_TIME = 15 * 60 * 1000L; + private final LoginService sessionService; + + /** + * key = 验证码, value = 长连接 + */ + private LoadingCache verifyCodeCache; + /** + * key = 设备 value = 验证码 + */ + private LoadingCache deviceCodeCache; + + private final WxLoginQrGenIntegration wxLoginQrGenIntegration; + + public WxLoginHelper(LoginService loginService, WxLoginQrGenIntegration wxLoginQrGenIntegration) { + this.sessionService = loginService; + this.wxLoginQrGenIntegration = wxLoginQrGenIntegration; + verifyCodeCache = CacheBuilder.newBuilder().maximumSize(300).expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader() { + @Override + public SseEmitter load(String s) throws Exception { + throw new NoVlaInGuavaException("no val: " + s); + } + }); + + deviceCodeCache = CacheBuilder.newBuilder().maximumSize(300).expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader() { + @Override + public String load(String s) { + int cnt = 0; + while (true) { + String code; + // 根据登录类型选择不同的验证码生成策略 + if (wxLoginQrGenIntegration.getLoginQrType() == LoginQrTypeEnum.SERVICE_ACCOUNT) { + // 服务号:使用随机验证码,不需要计数器 + code = CodeGenerateUtil.genCode(cnt, LoginQrTypeEnum.SERVICE_ACCOUNT); + } else { + // 订阅号:使用specialCodes,需要计数器 + code = CodeGenerateUtil.genCode(cnt++, LoginQrTypeEnum.SUBSCRIPTION_ACCOUNT); + } + + if (!verifyCodeCache.asMap().containsKey(code)) { + return code; + } + } + } + }); + } + + /** + * 保持与前端的长连接 + *

                    + * 直接根据设备拿之前初始化的验证码,不直接使用传过来的code + * + * @return + */ + public SseEmitter subscribe() throws IOException { + String deviceId = ReqInfoContext.getReqInfo().getDeviceId(); + String realCode = deviceCodeCache.getUnchecked(deviceId); + // fixme 设置15min的超时时间, 超时时间一旦设置不能修改;因此导致刷新验证码并不会增加连接的有效期 + SseEmitter sseEmitter = new SseEmitter(SSE_EXPIRE_TIME); + SseEmitter oldSse = verifyCodeCache.getIfPresent(realCode); + if (oldSse != null) { + oldSse.complete(); + } + verifyCodeCache.put(realCode, sseEmitter); + sseEmitter.onTimeout(() -> { + log.info("sse 超时中断 --> {}", realCode); + verifyCodeCache.invalidate(realCode); + sseEmitter.complete(); + }); + sseEmitter.onError((e) -> { + log.warn("sse error! --> {}", realCode, e); + verifyCodeCache.invalidate(realCode); + sseEmitter.complete(); + }); + // 若实际的验证码与前端显示的不同,则通知前端更新 + sseEmitter.send("initCode!"); + // 发送用于登录的二维码 + sseEmitter.send("qr#" + wxLoginQrGenIntegration.genLoginQrImg(realCode)); + sseEmitter.send("init#" + realCode); + return sseEmitter; + } + + public String resend() throws IOException { + // 获取旧的验证码,注意不使用 getUnchecked, 避免重新生成一个验证码 + String deviceId = ReqInfoContext.getReqInfo().getDeviceId(); + String oldCode = deviceCodeCache.getIfPresent(deviceId); + SseEmitter lastSse = oldCode == null ? null : verifyCodeCache.getIfPresent(oldCode); + if (lastSse != null) { + lastSse.send("resend!"); + lastSse.send("init#" + oldCode); + return oldCode; + } + return "fail"; + } + + /** + * 刷新验证码 + * + * @return + * @throws IOException + */ + public String refreshCode() throws IOException { + String deviceId = ReqInfoContext.getReqInfo().getDeviceId(); + // 获取旧的验证码,注意不使用 getUnchecked, 避免重新生成一个验证码 + String oldCode = deviceCodeCache.getIfPresent(deviceId); + SseEmitter lastSse = oldCode == null ? null : verifyCodeCache.getIfPresent(oldCode); + if (lastSse == null) { + log.info("last deviceId:{}, code:{}, sse closed!", deviceId, oldCode); + deviceCodeCache.invalidate(deviceId); + return null; + } + + // 根据登录类型决定刷新逻辑 + if (wxLoginQrGenIntegration.getLoginQrType() == LoginQrTypeEnum.SERVICE_ACCOUNT) { + // 服务号登录:刷新二维码图片,不更换验证码 + lastSse.send("refreshQr!"); + String newQrImg = wxLoginQrGenIntegration.genLoginQrImg(oldCode); + lastSse.send("qr#" + newQrImg); + log.info("refresh qr image for service account! deviceId:{}, code:{}", deviceId, oldCode); + return oldCode; + } else { + // 普通公众号登录:重新生成验证码 + deviceCodeCache.invalidate(deviceId); + String newCode = deviceCodeCache.getUnchecked(deviceId); + log.info("generate new loginCode! deviceId:{}, oldCode:{}, code:{}", deviceId, oldCode, newCode); + + lastSse.send("updateCode!"); + lastSse.send("refresh#" + newCode); + verifyCodeCache.invalidate(oldCode); + verifyCodeCache.put(newCode, lastSse); + return newCode; + } + } + + /** + * 微信公众号登录 + * + * @param verifyCode 用户输入的登录验证码 + * @return + */ + public boolean login(String verifyCode) { + // 1. 通过验证码找到对应的长连接 + SseEmitter sseEmitter = verifyCodeCache.getIfPresent(verifyCode); + if (sseEmitter == null) { + return false; + } + + // 2. 生成登录凭证 + String session = sessionService.loginByWx(ReqInfoContext.getReqInfo().getUserId()); + try { + // 3. 将登录凭证发送给客户端,用于前端写入Cookie + // 登录成功,写入session + sseEmitter.send(session); + // 设置cookie的路径 + Cookie cookie = SessionUtil.newCookie(LoginService.SESSION_KEY, session); + String setCookieStr = SessionUtil.buildSetCookieString(cookie); + sseEmitter.send("login#" + setCookieStr); + return true; + } catch (Exception e) { + log.error("登录异常: {}", verifyCode, e); + } finally { + // 4. 登录完成,关闭SSE连接;清空验证码与SseEmitter的绑定关系 + sseEmitter.complete(); + verifyCodeCache.invalidate(verifyCode); + } + return false; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxLoginQrGenIntegration.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxLoginQrGenIntegration.java new file mode 100644 index 000000000..b0c60414b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/helper/WxLoginQrGenIntegration.java @@ -0,0 +1,219 @@ +package com.github.paicoding.forum.web.front.login.wx.helper; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.hui.quick.plugin.base.Base64Util; +import com.github.hui.quick.plugin.base.DomUtil; +import com.github.hui.quick.plugin.base.constants.MediaType; +import com.github.hui.quick.plugin.qrcode.wrapper.QrCodeGenV3; +import com.github.paicoding.forum.api.model.enums.login.LoginQrTypeEnum; +import com.github.paicoding.forum.api.model.exception.NoVlaInGuavaException; +import com.github.paicoding.forum.core.net.HttpRequestHelper; +import com.github.paicoding.forum.core.util.MapUtils; +import com.github.paicoding.forum.web.front.login.wx.config.WxLoginProperties; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.Data; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.awt.image.BufferedImage; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * @author YiHui + * @date 2025/9/28 + */ +@Slf4j +@Component +public class WxLoginQrGenIntegration { + + private static final String WX_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}"; + private static final String WX_GEN_QR_URL = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token="; + + private final WxLoginProperties wxLoginProperties; + + // 对于服务号登录的场景 + + private volatile WxAccessToken accessToken; + + + /** + * key = 验证码 value = 根据验证码生成的带参数服务号二维码 + */ + private LoadingCache loginImgCache; + + public WxLoginQrGenIntegration(WxLoginProperties wxLoginProperties) { + this.wxLoginProperties = wxLoginProperties; + + // 缓存五分钟,二维码的有效期为10分钟 + loginImgCache = CacheBuilder.newBuilder().maximumSize(300).expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader() { + @Override + public String load(String s) { + throw new NoVlaInGuavaException("no val: " + s); + } + }); + } + + public LoginQrTypeEnum getLoginQrType() { + return LoginQrTypeEnum.valueOf(wxLoginProperties.getLoginQrType()); + } + + /** + * 生成登录二维码 + * + * @return + */ + public String genLoginQrImg(String code) { + LoginQrTypeEnum type = getLoginQrType(); + if (type == LoginQrTypeEnum.SERVICE_ACCOUNT) { + // 服务号登录,首先获取带链接的二维码信息 + String qrText = genServiceAccountLoginQrCode(code); + // 根据二维码内容生成二维码图片,返回给前端 + String base64Img = genQrImg(qrText); + return DomUtil.toDomSrc(base64Img, MediaType.ImagePng); + } else { + // 普通公众号登录时 + return wxLoginProperties.getQrCodeImg(); + } + } + + private String genQrImg(String qrText) { + try { + BufferedImage img; + if (StringUtils.isBlank(wxLoginProperties.getQrCodeLogo())) { + img = QrCodeGenV3.of(qrText).asImg(); + } else { + img = QrCodeGenV3.of(qrText).setLogo(wxLoginProperties.getQrCodeLogo()).asImg(); + } + return Base64Util.encode(img, "png"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private String getAccessToken() { + if (!checkAccessToken()) { + synchronized (this) { + if (!checkAccessToken()) { + refreshAccessToken(); + } + } + } + + if (accessToken == null || accessToken.accessToken == null) { + log.error("获取微信AccessToken失败,accessToken为null"); + throw new RuntimeException("获取微信AccessToken失败"); + } + return accessToken.accessToken; + } + + /** + * 校验token的有效性 + * + * @return true 有效;false 失效 + */ + private boolean checkAccessToken() { + return accessToken != null && accessToken.expireTimestamp > System.currentTimeMillis() + 60_000L; + } + + private synchronized void refreshAccessToken() { + WxAccessToken token = HttpRequestHelper.get(WX_TOKEN_URL, + MapUtils.create("appid", wxLoginProperties.getAppId(), "secret", wxLoginProperties.getAppSecret()), + WxAccessToken.class); + + if (token == null) { + log.error("刷新微信AccessToken失败,API返回null"); + return; + } + if (StringUtils.isNotBlank(token.getErrCode()) && !"0".equals(token.getErrCode())) { + log.error("刷新微信AccessToken失败,errCode={}, errMsg={}", token.getErrCode(), token.getErrMsg()); + return; + } + + if (token.accessToken == null) { + log.error("刷新微信AccessToken失败,accessToken为null, response={}", token); + return; + } + + accessToken = token; + accessToken.expireTimestamp = System.currentTimeMillis() + token.expiresIn * 1000; + log.info("刷新微信AccessToken成功,过期时间={}", accessToken.expireTimestamp); + } + + /** + * 生成服务号登录的二维码 + * + * @return + * @see + */ + private String genServiceAccountLoginQrCode(String code) { + // 同一个验证码的二维码可以进行缓存,避免重复调用;同时也可以提高接口时效 + String url = loginImgCache.getIfPresent(code); + if (url != null) { + return url; + } else { + String wxApiUrl = WX_GEN_QR_URL + getAccessToken(); + Map params = MapUtils.create("expire_seconds", 600, "action_name", "QR_SCENE"); + params.put("action_info", MapUtils.create("scene", MapUtils.create("scene_id", code, "scene_str", "paiLogin#" + code))); + + WxLoginQrCodeRes res = HttpRequestHelper.postJsonData(wxApiUrl, params, WxLoginQrCodeRes.class); + + // 检查响应是否有效 + if (res == null) { + log.error("微信生成二维码API返回null,code={}", code); + throw new RuntimeException("微信生成二维码失败:API返回null"); + } + + if (StringUtils.isNotBlank(res.getErrCode()) && !"0".equals(res.getErrCode())) { + log.error("微信生成二维码API返回错误,code={}, errCode={}, errMsg={}", code, res.getErrCode(), res.getErrMsg()); + throw new RuntimeException("微信生成二维码失败:" + res.getErrMsg()); + } + + if (StringUtils.isBlank(res.url)) { + log.error("微信生成二维码API返回的url为空,code={}, response={}", code, res); + throw new RuntimeException("微信生成二维码失败:返回的url为空"); + } + + // 只有在url不为空时才放入缓存 + loginImgCache.put(code, res.url); + return res.url; + } + } + + @Data + private static class BaseWxRes { + @JsonProperty("errcode") + private String errCode; + @JsonProperty("errmsg") + private String errMsg; + } + + @Data + @ToString(callSuper = true) + private static class WxAccessToken extends BaseWxRes { + // 访问令牌 + @JsonProperty("access_token") + private String accessToken; + // 失效时间 + @JsonProperty("expires_in") + private Integer expiresIn; + // 令牌失效时间戳 + private Long expireTimestamp = 0L; + } + + @Data + @ToString(callSuper = true) + private static class WxLoginQrCodeRes extends BaseWxRes { + @JsonProperty("ticket") + private String ticket; + @JsonProperty("expire_seconds") + private String expireSeconds; + // 二维码内容,需要自己生成对应的二维码图片 + @JsonProperty("url") + private String url; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/vo/WxLoginVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/vo/WxLoginVo.java new file mode 100644 index 000000000..a2af0d76f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/wx/vo/WxLoginVo.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.web.front.login.wx.vo; + +import lombok.Data; + +/** + * @author YiHui + * @date 2022/9/5 + */ +@Data +public class WxLoginVo { + /** + * 验证码 + */ + private String code; + + /** + * 二维码 + */ + private String qr; + + /** + * true 表示需要重新建立连接 + */ + private boolean reconnect; + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/config/ZsxqProperties.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/config/ZsxqProperties.java new file mode 100644 index 000000000..855c7d863 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/config/ZsxqProperties.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.web.front.login.zsxq.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * @author YiHui + * @date 2025/8/19 + */ +@Component +@Data +@ConfigurationProperties("paicoding.login.zsxq") +public class ZsxqProperties { + + /** + * 请求地址 + */ + private String api; + + /** + * 应用id + */ + private String appId; + /** + * 星球号 + */ + private String groupNumber; + /** + * 密钥 + */ + private String secret; + /** + * 回调地址 + */ + private String redirectUrl; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/controller/ZsxqLoginController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/controller/ZsxqLoginController.java new file mode 100644 index 000000000..0d14a9b59 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/controller/ZsxqLoginController.java @@ -0,0 +1,113 @@ +package com.github.paicoding.forum.web.front.login.zsxq.controller; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.UserZsxqLoginReq; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.core.util.StarNumberUtil; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.service.user.service.UserTransferService; +import com.github.paicoding.forum.web.front.login.zsxq.helper.ZsxqHelper; +import com.github.paicoding.forum.web.front.login.zsxq.vo.ZsxqLoginVo; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; + +/** + * 知识星球登录 + * + * @author YiHui + * @date 2025/8/19 + */ +@RestController +@Slf4j +public class ZsxqLoginController { + @Autowired + private ZsxqHelper zsxqHelper; + + @Autowired + private LoginService loginService; + @Autowired + private UserService userService; + @Autowired + private UserTransferService userTransferService; + + /** + * 用户信息绑定 + * + * @param useXqName true 表示使用星球的昵称/头像来更新技术派的用户信息 + * @return + */ + @RequestMapping("zsxq/bind") + public void autoBindZsxqUser(@RequestParam(name = "useXqName", required = false, defaultValue = "true") Boolean useXqName, HttpServletResponse response) throws IOException { + String url = zsxqHelper.buildZsxqLoginUrl("" + useXqName); + response.sendRedirect(url); + } + + /** + * 知识星球的回调 + * + * @param login + * @param response + * @throws IOException + */ + @RequestMapping("login/zsxq/callback") + public void callbackZsxq(ZsxqLoginVo login, HttpServletResponse response) throws IOException { + // 1. 首先进行签名校验 + if (!zsxqHelper.verifySignature(login)) { + log.info("登录失败:{}", login); + throw ExceptionUtil.of(StatusEnum.FORBID_ERROR_MIXED, "请确认知识星球正常完成了系统授权登录哦~"); + } + + String starNumber = StarNumberUtil.formatStarNumber(login.getUser_number()); + // 2. 对于未登录的场景,执行星球登录 + if (ReqInfoContext.getReqInfo().getUser() == null) { + String session = loginService.loginByZsxq(new UserZsxqLoginReq() + .setStarUserId(login.getUser_id()) + .setUsername("zsxq_" + starNumber) + .setDisplayName(login.getUser_name()) + .setStarNumber(starNumber) + .setAvatar(login.getUser_icon()) + .setExpireTime(login.getExpire_time() * 1000L) + ); + + if (StringUtils.isNotBlank(session)) { + // cookie中写入用户登录信息,用于身份识别 + response.addCookie(SessionUtil.newCookie(LoginService.SESSION_KEY, session)); + response.sendRedirect("/"); + } else { + response.sendError(403, "登录失败,请重试再试"); + } + return; + } + + // 3. 如果是通过知识星球进行账号迁移 + if (Objects.equals(login.getExtra(), ZsxqHelper.EXTRA_TAG_USER_TRANSFER)) { + // 迁移完成之后,跳转到新的用户主页 + userTransferService.transferUser(starNumber); + } + + // 4. 对于已登录场景,执行星球信息绑定 + userService.bindUserInfo(new UserZsxqLoginReq() + .setUpdateUserInfo(BooleanUtils.toBoolean(login.getExtra())) + .setUsername("zsxq_" + starNumber) + .setDisplayName(login.getUser_name()) + .setStarNumber(starNumber) + .setAvatar(login.getUser_icon()) + .setExpireTime(login.getExpire_time() * 1000L) + .setStarUserId(login.getUser_id()) + ); + // 绑定成功 + response.sendRedirect("/user/home?userId=" + ReqInfoContext.getReqInfo().getUserId()); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/helper/ZsxqHelper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/helper/ZsxqHelper.java new file mode 100644 index 000000000..b81a12ee1 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/helper/ZsxqHelper.java @@ -0,0 +1,206 @@ +package com.github.paicoding.forum.web.front.login.zsxq.helper; + +import cn.hutool.core.net.URLEncodeUtil; +import com.github.paicoding.forum.web.front.login.zsxq.config.ZsxqProperties; +import com.github.paicoding.forum.web.front.login.zsxq.vo.ZsxqLoginVo; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.jcajce.provider.digest.SHA1; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.TreeMap; + +/** + * 知识星球登录相关类 + * + * @author YiHui + * @date 2025/8/19 + */ +@Slf4j +@Component +public class ZsxqHelper { + public static final String EXTRA_TAG_USER_TRANSFER = "zsxqUserTransfer"; + + @Autowired + private ZsxqProperties zsxqProperties; + + public String buildZsxqLoginUrl(String type) { + StringBuilder builder = new StringBuilder(); + builder.append("app_id=").append(zsxqProperties.getAppId()); + builder.append("&extra=").append(type); + builder.append("&group_number=").append(zsxqProperties.getGroupNumber()); + builder.append("&redirect_url=").append(URLEncodeUtil.encodeAll(zsxqProperties.getRedirectUrl())); + builder.append("×tamp=").append(System.currentTimeMillis() / 1000L); + + String toSignParam = builder + "&secret=" + zsxqProperties.getSecret(); + // 请求参数签名 + String sign = sha1(toSignParam); + builder.append("&signature=").append(sign); + return zsxqProperties.getApi() + "?" + builder; + } + + /** + * 使用SHA1算法对输入字符串进行摘要计算 + * + * @param input 需要进行摘要计算的字符串 + * @return SHA1摘要结果(十六进制字符串) + */ + private String sha1(String input) { + SHA1.Digest digest = new SHA1.Digest(); + byte[] result = digest.digest(input.getBytes()); + StringBuilder sb = new StringBuilder(); + for (byte b : result) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + public boolean verifySignature(ZsxqLoginVo vo) { + // 校验签名,首先使用Map来承接请求参数,key为参数名称(驼峰转下划线),value为参数值 + // 根据Map的key 按照 ascii 排序,然后拼接成字符串,最后进行sha1加密,与signature进行对比 + + log.debug("开始验证知识星球签名,用户名: [{}], 头像: [{}]", vo.getUser_name(), vo.getUser_icon()); + + // 方案1: 不进行额外编码 + boolean verified = verifySignatureWithoutEncoding(vo); + log.debug("方案1验证结果(不编码): {}", verified); + + // 方案2: 使用UTF-8编码 + if (!verified) { + verified = verifySignatureWithUTF8Encoding(vo); + log.debug("方案2验证结果(UTF-8编码): {}", verified); + } + + // 方案3: 使用空格转+号的方式编码(知识星球可能使用这种方式) + if (!verified) { + verified = verifySignatureWithSpacePlusEncoding(vo); + log.debug("方案3验证结果(空格转+号编码): {}", verified); + } + + if (!verified) { + log.warn("知识星球签名验证失败,用户名: [{}], 预期签名: [{}]", vo.getUser_name(), vo.getSignature()); + } else { + log.info("知识星球签名验证成功,用户名: [{}]", vo.getUser_name()); + } + + return verified; + } + + /** + * 方案1: 不对user_name和user_icon进行额外编码 + */ + private boolean verifySignatureWithoutEncoding(ZsxqLoginVo vo) { + Map params = buildSignatureParams(vo, false); + return computeAndVerifySignature(params, vo.getSignature()); + } + + /** + * 方案2: 使用UTF-8编码(备用方案) + */ + private boolean verifySignatureWithUTF8Encoding(ZsxqLoginVo vo) { + Map params = buildSignatureParams(vo, true); + return computeAndVerifySignature(params, vo.getSignature()); + } + + /** + * 方案3: 使用空格转+号的方式编码(模拟知识星球的编码方式) + */ + private boolean verifySignatureWithSpacePlusEncoding(ZsxqLoginVo vo) { + Map params = buildSignatureParamsWithSpacePlus(vo); + return computeAndVerifySignature(params, vo.getSignature()); + } + + /** + * 构建签名参数(空格转+号编码版本) + */ + private Map buildSignatureParamsWithSpacePlus(ZsxqLoginVo vo) { + Map params = new TreeMap<>(); + params.put("app_id", vo.getApp_id()); + params.put("group_number", vo.getGroup_number()); + params.put("user_id", vo.getUser_id()); + + // 使用特殊的编码方式:空格转+号,其他字符进行UTF-8编码 + params.put("user_name", vo.getUser_name() != null ? encodeWithSpacePlus(vo.getUser_name()) : null); + params.put("user_icon", vo.getUser_icon() != null ? encodeWithSpacePlus(vo.getUser_icon()) : null); + + params.put("user_number", vo.getUser_number()); + params.put("user_role", vo.getUser_role()); + params.put("extra", vo.getExtra()); + params.put("join_time", vo.getJoin_time()); + params.put("expire_time", vo.getExpire_time()); + params.put("timestamp", vo.getTimestamp()); + + return params; + } + + /** + * 特殊编码:先进行UTF-8编码,然后将%20替换为+ + */ + private String encodeWithSpacePlus(String input) { + if (input == null) { + return null; + } + // 先进行标准URL编码 + String encoded = URLEncodeUtil.encodeAll(input); + // 将%20(空格的URL编码)替换为+号 + return encoded.replace("%20", "+"); + } + + /** + * 构建签名参数 + */ + private Map buildSignatureParams(ZsxqLoginVo vo, boolean needEncoding) { + Map params = new TreeMap<>(); + params.put("app_id", vo.getApp_id()); + params.put("group_number", vo.getGroup_number()); + params.put("user_id", vo.getUser_id()); + + // 根据needEncoding参数决定是否编码 + if (needEncoding) { + params.put("user_name", vo.getUser_name() != null ? URLEncodeUtil.encodeAll(vo.getUser_name()) : null); + params.put("user_icon", vo.getUser_icon() != null ? URLEncodeUtil.encodeAll(vo.getUser_icon()) : null); + } else { + params.put("user_name", vo.getUser_name()); + params.put("user_icon", vo.getUser_icon()); + } + + params.put("user_number", vo.getUser_number()); + params.put("user_role", vo.getUser_role()); + params.put("extra", vo.getExtra()); + params.put("join_time", vo.getJoin_time()); + params.put("expire_time", vo.getExpire_time()); + params.put("timestamp", vo.getTimestamp()); + + return params; + } + + /** + * 计算并验证签名 + */ + private boolean computeAndVerifySignature(Map params, String expectedSignature) { + // 拼接参数字符串 + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() != null && !entry.getValue().toString().isEmpty()) { + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(entry.getKey()).append("=").append(entry.getValue()); + } + } + + // 添加secret + String toSignParam = sb + "&secret=" + zsxqProperties.getSecret(); + + // 计算签名 + String sign = sha1(toSignParam); + + // 调试日志 + log.debug("签名参数字符串: [{}]", toSignParam.replaceAll("&secret=.*", "&secret=***")); + log.debug("计算得到的签名: [{}], 预期签名: [{}]", sign, expectedSignature); + + // 比较签名 + return sign.equals(expectedSignature); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/vo/ZsxqLoginVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/vo/ZsxqLoginVo.java new file mode 100644 index 000000000..e8ebe39f0 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/login/zsxq/vo/ZsxqLoginVo.java @@ -0,0 +1,72 @@ +package com.github.paicoding.forum.web.front.login.zsxq.vo; + +import lombok.Data; + +/** + * 知识星球的登录方式 + * + * @author YiHui + * @date 2025/8/19 + */ +@Data +public class ZsxqLoginVo { + /** + * 应用id + */ + private String app_id; + + /** + * 过期时间(s) + */ + private Long expire_time; + + /** + * 扩展字段 + */ + private String extra; + + /** + * 星球号 + */ + private String group_number; + + /** + * 加入时间(s) + */ + private Long join_time; + + /** + * 签名 + */ + private String signature; + + /** + * 时间戳(s) + */ + private Long timestamp; + + /** + * 用户头像 + */ + private String user_icon; + + /** + * 用户id + */ + private Long user_id; + + /** + * 用户名 + */ + private String user_name; + + /** + * 用户编号 + */ + private String user_number; + + /** + * 用户角色 + */ + private String user_role; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/rest/NoticeRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/rest/NoticeRestController.java new file mode 100644 index 000000000..62dc15d7e --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/rest/NoticeRestController.java @@ -0,0 +1,134 @@ +package com.github.paicoding.forum.web.front.notice.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.exception.ExceptionUtil; +import com.github.paicoding.forum.api.model.vo.NextPageHtmlVo; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.notify.dto.NotifyMsgDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.ws.WebSocketResponseUtil; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import com.github.paicoding.forum.web.front.notice.vo.NoticeResVo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +/** + * 消息通知 + * + * @author louzai + * @date : 2022/9/4 10:56 + **/ +@Permission(role = UserRole.LOGIN) +@RestController +@RequestMapping(path = "notice/api") +public class NoticeRestController { + @Autowired + private TemplateEngineHelper templateEngineHelper; + + private NotifyService notifyService; + + public NoticeRestController(NotifyService notifyService) { + this.notifyService = notifyService; + } + + private PageListVo listItems(String type, Long page, Long pageSize) { + NotifyTypeEnum typeEnum = NotifyTypeEnum.typeOf(type); + if (typeEnum == null) { + throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "type" + type + "非法"); + } + if (pageSize == null) { + pageSize = PageParam.DEFAULT_PAGE_SIZE; + } + return notifyService.queryUserNotices(ReqInfoContext.getReqInfo().getUserId(), + typeEnum, PageParam.newPageInstance(page, pageSize)); + + } + + /** + * 消息通知列表,用于前后端分离的场景 + * + * @param type @link NotifyTypeEnum + * @param page + * @param pageSize + * @return + */ + @RequestMapping(path = "list") + public ResVo> list(@RequestParam(name = "type") String type, + @RequestParam("page") Long page, + @RequestParam(name = "pageSize", required = false) Long pageSize) { + return ResVo.ok(listItems(type, page, pageSize)); + } + + /** + * 返回渲染好的分页信息 + * + * @param type + * @param page + * @param pageSize + * @return + */ + @RequestMapping(path = "items") + public ResVo listForView(@RequestParam(name = "type") String type, + @RequestParam("page") Long page, + @RequestParam(name = "pageSize", required = false) Long pageSize) { + type = type.toLowerCase().trim(); + PageListVo list = listItems(type, page, pageSize); + NoticeResVo vo = new NoticeResVo(); + vo.setList(list); + vo.setSelectType(type); + String html = templateEngineHelper.render("views/notice/tab/notify-" + type, vo); + return ResVo.ok(new NextPageHtmlVo(html, list.getHasMore())); + } + + + /** + * 消息通知的检测 + * + * @param content 发送的内容 + */ + @MessageMapping("/msg/health") + public void health(String content, SimpMessageHeaderAccessor headerAccessor) { + ReqInfoContext.ReqInfo user = (ReqInfoContext.ReqInfo) headerAccessor.getUser(); + if (user != null) { + String response = "ping".equalsIgnoreCase(content) ? "pong" : content; + notifyService.notifyToUser(user.getUserId(), response); + } + } + + /** + * 给自己发送通知消息 -- 用于测试消息通知 + * + * @param content + * @return + */ + @RequestMapping(path = "notifyToSelf") + public ResVo notifyToSelf(String content) { + notifyService.notifyToUser(ReqInfoContext.getReqInfo().getUserId(), content); + return ResVo.ok(true); + } + + /** + * 发送广播消息 + * + * @param content + * @return + */ + @RequestMapping(path = "notifyToAll") + public ResVo notifyToAll(String content) { + BaseUserInfoDTO user = ReqInfoContext.getReqInfo().getUser(); + WebSocketResponseUtil.broadcastMsg(NotifyService.NOTIFY_TOPIC, String.format("【%s】发送了一条广播消息: %s", user.getUserName(), content)); + return ResVo.ok(true); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/view/NoticeViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/view/NoticeViewController.java new file mode 100644 index 000000000..330d0b1ee --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/view/NoticeViewController.java @@ -0,0 +1,54 @@ +package com.github.paicoding.forum.web.front.notice.view; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.NotifyTypeEnum; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.web.front.notice.vo.NoticeResVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; + +/** + * 消息通知 + * + * @author louzai + * @date : 2022/9/4 10:56 + **/ +@Controller +@Permission(role = UserRole.LOGIN) +@RequestMapping(path = "notice") +public class NoticeViewController extends BaseViewController { + @Autowired + private NotifyService notifyService; + + @RequestMapping({"/{type}", "/"}) + public String list(@PathVariable(name = "type", required = false) String type, Model model) { + Long loginUserId = ReqInfoContext.getReqInfo().getUserId(); + Map map = notifyService.queryUnreadCounts(loginUserId); + + NotifyTypeEnum typeEnum = type == null ? null : NotifyTypeEnum.typeOf(type); + if (typeEnum == null) { + // 若没有指定查询的消息类别,则找一个存在消息未读数的进行展示 + typeEnum = map.entrySet().stream().filter(s -> s.getValue() > 0) + .map(s -> NotifyTypeEnum.typeOf(s.getKey())) + .findAny() + .orElse(NotifyTypeEnum.COMMENT); + } + + NoticeResVo vo = new NoticeResVo(); + vo.setList(notifyService.queryUserNotices(loginUserId, typeEnum, PageParam.newPageInstance())); + + vo.setSelectType(typeEnum.name().toLowerCase()); + vo.setUnreadCountMap(notifyService.queryUnreadCounts(loginUserId)); + model.addAttribute("vo", vo); + return "views/notice/index"; + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/vo/NoticeResVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/vo/NoticeResVo.java new file mode 100644 index 000000000..6dbbfa7e4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/notice/vo/NoticeResVo.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.web.front.notice.vo; + +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.notify.dto.NotifyMsgDTO; +import lombok.Data; + +import java.util.Map; + +/** + * @author YiHui + * @date 2022/9/4 + */ +@Data +public class NoticeResVo { + /** + * 消息通知列表 + */ + private PageListVo list; + + /** + * 每个分类的未读数量 + */ + private Map unreadCountMap; + + /** + * 当前选中的消息类型 + */ + private String selectType; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/package-info.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/package-info.java new file mode 100644 index 000000000..34560c5a4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/package-info.java @@ -0,0 +1,14 @@ +/** + * 前台页用户包路径 + * + * 入口层,不做复杂的业务逻辑,主要干的事情 + * 1. 参数解析 + * 2. 视图数据封装,就是往Model中写数据 + * 3. 重定向控制 + * 4. todo: 权限判断(个人页,需要登录...) + * + * + * @author yihui + * @date 2022/7/6 + */ +package com.github.paicoding.forum.web.front; \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/rank/RankController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/rank/RankController.java new file mode 100644 index 000000000..78f559287 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/rank/RankController.java @@ -0,0 +1,48 @@ +package com.github.paicoding.forum.web.front.rank; + +import com.github.paicoding.forum.api.model.enums.rank.ActivityRankTimeEnum; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.rank.dto.RankInfoDTO; +import com.github.paicoding.forum.api.model.vo.rank.dto.RankItemDTO; +import com.github.paicoding.forum.service.rank.service.UserActivityRankService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; + +/** + * 排行榜 + * + * @author YiHui + * @date 2023/8/20 + */ +@Controller +public class RankController { + @Autowired + private UserActivityRankService userActivityRankService; + + /** + * 活跃用户排行榜 + * + * @param time + * @param model + * @return + */ + @RequestMapping(path = "/rank/{time}") + public String rank(@PathVariable(value = "time") String time, Model model) { + ActivityRankTimeEnum rankTime = ActivityRankTimeEnum.nameOf(time); + if (rankTime == null) { + rankTime = ActivityRankTimeEnum.MONTH; + } + List list = userActivityRankService.queryRankList(rankTime, 30); + RankInfoDTO info = new RankInfoDTO(); + info.setItems(list); + info.setTime(rankTime); + ResVo vo = ResVo.ok(info); + model.addAttribute("vo", vo); + return "views/rank/index"; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/rest/SearchRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/rest/SearchRestController.java new file mode 100644 index 000000000..7afcdea53 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/rest/SearchRestController.java @@ -0,0 +1,66 @@ +package com.github.paicoding.forum.web.front.search.rest; + +import com.github.paicoding.forum.api.model.vo.NextPageHtmlVo; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import com.github.paicoding.forum.web.front.search.vo.SearchArticleVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 推荐服务接口 + * + * @author YiHui + * @date 2022/10/28 + */ +@RequestMapping(path = "search/api") +@RestController +public class SearchRestController extends BaseViewController { + + @Autowired + private ArticleReadService articleReadService; + + @Autowired + private TemplateEngineHelper templateEngineHelper; + + /** + * 根据关键词给出搜索下拉框 + * + * @param key + */ + @GetMapping(path = "hint") + public ResVo recommend(@RequestParam(name = "key", required = false) String key) {List list = articleReadService.querySimpleArticleBySearchKey(key); + SearchArticleVo vo = new SearchArticleVo(); + vo.setKey(key); + vo.setItems(list); + return ResVo.ok(vo); + } + + + /** + * 分类下的文章列表 + * + * @param key + * @return + */ + @GetMapping(path = "list") + public ResVo searchList(@RequestParam(name = "key", required = false) String key, + @RequestParam(name = "page") Long page, + @RequestParam(name = "size", required = false) Long size) { + PageParam pageParam = buildPageParam(page, size); + PageListVo list = articleReadService.queryArticlesBySearchKey(key, pageParam); + String html = templateEngineHelper.renderToVo("views/article-search-list/article/list", "articles", list); + return ResVo.ok(new NextPageHtmlVo(html, list.getHasMore())); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/view/SearchViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/view/SearchViewController.java new file mode 100644 index 000000000..c0cc2f348 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/view/SearchViewController.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.web.front.search.view; + +import com.github.paicoding.forum.web.front.home.helper.IndexRecommendHelper; +import com.github.paicoding.forum.web.front.home.vo.IndexVo; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 推荐服务接口 + * + * @author YiHui + * @date 2022/10/28 + */ +@Controller +public class SearchViewController { + @Autowired + private IndexRecommendHelper indexRecommendHelper; + + + /** + * 查询文章列表 + * + * @param model + */ + @GetMapping(path = "search") + public String searchArticleList(@RequestParam(name = "key") String key, Model model) { + if (!StringUtils.isBlank(key)) { + IndexVo vo = indexRecommendHelper.buildSearchVo(key); + model.addAttribute("vo", vo); + } + return "views/article-search-list/index"; + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchArticleVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchArticleVo.java new file mode 100644 index 000000000..61cb7673d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchArticleVo.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.web.front.search.vo; + +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleArticleDTO; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * @author YiHui + * @date 2022/10/28 + */ +@Data +@ApiModel(value="文章信息") +public class SearchArticleVo implements Serializable { + private static final long serialVersionUID = -2989169905031769195L; + + @ApiModelProperty("搜索的关键词") + private String key; + + @ApiModelProperty("文章列表") + private List items; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchColumnVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchColumnVo.java new file mode 100644 index 000000000..0001e4320 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchColumnVo.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.web.front.search.vo; + +import com.github.paicoding.forum.api.model.vo.article.dto.SimpleColumnDTO; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * @author YiHui + * @date 2022/10/28 + */ +@Data +@ApiModel(value="专栏信息") +public class SearchColumnVo implements Serializable { + private static final long serialVersionUID = -2989169905031769195L; + + @ApiModelProperty("搜索的关键词") + private String key; + + @ApiModelProperty("专栏列表") + private List items; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchUserVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchUserVo.java new file mode 100644 index 000000000..d8ae1c2bc --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/search/vo/SearchUserVo.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.web.front.search.vo; + +import com.github.paicoding.forum.api.model.vo.user.dto.SimpleUserInfoDTO; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * @author YiHui + * @date 2022/10/28 + */ +@Data +@ApiModel(value="用户信息") +public class SearchUserVo implements Serializable { + private static final long serialVersionUID = -2989169905031769195L; + + @ApiModelProperty("搜索的关键词") + private String key; + + @ApiModelProperty("用户列表") + private List items; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/shortlink/package-info.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/shortlink/package-info.java new file mode 100644 index 000000000..c390d1582 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/shortlink/package-info.java @@ -0,0 +1,8 @@ +/** + * 短链服务 + * + * @author YiHui + * @date 2025/2/18 + */ +package com.github.paicoding.forum.web.front.shortlink; + diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/shortlink/rest/ShortLinkController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/shortlink/rest/ShortLinkController.java new file mode 100644 index 000000000..e3b5dbbfd --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/shortlink/rest/ShortLinkController.java @@ -0,0 +1,78 @@ +package com.github.paicoding.forum.web.front.shortlink.rest; + +import com.github.hui.quick.plugin.base.awt.ImageLoadUtil; +import com.github.hui.quick.plugin.qrcode.v3.entity.QrResource; +import com.github.hui.quick.plugin.qrcode.wrapper.QrCodeGenV3; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.shortlink.ShortLinkReq; +import com.github.paicoding.forum.api.model.vo.shortlink.ShortLinkVO; +import com.github.paicoding.forum.api.model.vo.shortlink.dto.ShortLinkDTO; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.shortlink.service.ShortLinkService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletResponse; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; + +@RestController +@RequestMapping("/sol") +public class ShortLinkController { + + @Resource + private ShortLinkService shortLinkService; + + /** + * 创建短链接 + * + * @param shortLinkReq 包含原始长链接 + * @return 创建的短链接信息 + */ + @PostMapping("/url") + public ResVo createShortLink(@RequestBody ShortLinkReq shortLinkReq) throws NoSuchAlgorithmException { + String userId = (null == ReqInfoContext.getReqInfo().getUser()) ? "" : ReqInfoContext.getReqInfo().getUser().getUserId().toString(); + ShortLinkDTO shortLinkDTO = new ShortLinkDTO(shortLinkReq.getOriginalUrl(), userId, ""); + return ResVo.ok(shortLinkService.createShortLink(shortLinkDTO)); + } + + /** + * 根据短链接获取原始长链接 + * + * @param shortCode 短链接 + */ + @GetMapping("/{shortCode}") + public void getOriginalLink(@PathVariable String shortCode, HttpServletResponse response) throws IOException { + ShortLinkVO shortLinkVO = shortLinkService.getOriginalLink(shortCode); + response.sendRedirect(shortLinkVO.getOriginalUrl()); + } + + @GetMapping("/gen") + public void generateQrCode(@RequestParam String content, @RequestParam(required = false) Integer size, HttpServletResponse response) throws Exception { + BufferedImage img = QrCodeGenV3.of(content) + .setSize(size == null || size < 200 ? 200 : Math.min(size, 500)) + .setLogo(getDefaultLogo()) + .asImg(); + response.setContentType("image/png"); + ImageIO.write(img, "png", response.getOutputStream()); + } + + private QrResource getDefaultLogo() { + BufferedImage img; + try { + img = ImageLoadUtil.getImageByPath(SpringUtil.getConfigOrElse("view.site.websiteFaviconIconUrl", "https://paicoding.com/img/icon.png")); + } catch (Exception e) { + return null; + } + return new QrResource().setImg(img); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/rest/HealthServlet.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/rest/HealthServlet.java new file mode 100644 index 000000000..02a2d5973 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/rest/HealthServlet.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.web.front.test.rest; + +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * @author YiHui + * @date 2023/3/25 + */ +@WebServlet(urlPatterns = "/check") +public class HealthServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + PrintWriter writer = resp.getWriter(); + writer.write("ok"); + writer.flush(); + writer.close(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/rest/TestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/rest/TestController.java new file mode 100644 index 000000000..43d88ca5f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/rest/TestController.java @@ -0,0 +1,509 @@ +package com.github.paicoding.forum.web.front.test.rest; + +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.FastExcel; +import cn.idev.excel.write.metadata.WriteSheet; +import com.alibaba.fastjson.JSONObject; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.ai.AISourceEnum; +import com.github.paicoding.forum.api.model.enums.pay.ThirdPayWayEnum; +import com.github.paicoding.forum.api.model.exception.ForumAdviceException; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.Status; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.autoconf.DynamicConfigContainer; +import com.github.paicoding.forum.core.dal.DsAno; +import com.github.paicoding.forum.core.dal.DsSelectExecutor; +import com.github.paicoding.forum.core.dal.MasterSlaveDsEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.senstive.SensitiveService; +import com.github.paicoding.forum.core.util.EmailUtil; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.conveter.PayConverter; +import com.github.paicoding.forum.service.chatai.ChatFacade; +import com.github.paicoding.forum.service.config.service.GlobalConfigService; +import com.github.paicoding.forum.service.pay.model.PrePayInfoResBo; +import com.github.paicoding.forum.service.pay.model.ThirdPayOrderReqBo; +import com.github.paicoding.forum.service.pay.service.ThirdPayHandler; +import com.github.paicoding.forum.service.statistics.converter.StatisticsConverter; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountDO; +import com.github.paicoding.forum.service.statistics.repository.entity.RequestCountExcelDO; +import com.github.paicoding.forum.service.statistics.service.RequestCountService; +import com.github.paicoding.forum.service.statistics.service.StatisticsSettingService; +import com.github.paicoding.forum.service.statistics.service.impl.CountServiceImpl; +import com.github.paicoding.forum.web.front.test.vo.EmailReqVo; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringEscapeUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.ProxyUtils; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.URLEncoder; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 用于一些功能测试的入口,默认都使用从库,不支持修改数据 + * + * @author YiHui + * @date 2023/3/19 + */ +@Slf4j +@DsAno(MasterSlaveDsEnum.SLAVE) +@RestController +@RequestMapping(path = "test") +public class TestController { + private AtomicInteger cnt = new AtomicInteger(1); + + /** + * 测试邮件发送 + * + * @param req + * @return + */ + @Permission(role = UserRole.ADMIN) + @RequestMapping(path = "email") + public ResVo email(EmailReqVo req) { + if (StringUtils.isBlank(req.getTo()) || req.getTo().indexOf("@") <= 0) { + return ResVo.fail(Status.newStatus(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "非法的邮箱接收人")); + } + if (StringUtils.isBlank(req.getTitle())) { + req.setTitle("技术派的测试邮件发送"); + } + if (StringUtils.isBlank(req.getContent())) { + req.setContent("技术派的测试发送内容"); + } else { + // 测试邮件内容,不支持发送邮件正文,避免出现垃圾情况 + req.setContent(StringEscapeUtils.escapeHtml4(req.getContent())); + } + + boolean ans = EmailUtil.sendMail(req.getTitle(), req.getTo(), req.getContent()); + log.info("测试邮件发送,计数:{},发送内容:{}", cnt.addAndGet(1), req); + return ResVo.ok(String.valueOf(ans)); + } + + @RequestMapping(path = "alarm") + public ResVo alarm(String content) { + content = StringEscapeUtils.escapeHtml4(content); + log.error("测试异常报警: {}", content); + return ResVo.ok("移除日志接收完成!"); + } + + @RequestMapping(path = "testControllerAdvice") + @ResponseBody + public String testControllerAdvice() { + throw new ForumAdviceException(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "测试ControllerAdvice异常"); + } + + @RequestMapping(path = "exception") + @ResponseBody + public String unexpect() { + throw new RuntimeException("非预期异常"); + } + + /** + * 测试 Knife4j + * + * @return + */ + @RequestMapping(value = "/testKnife4j", method = RequestMethod.POST) + public String testKnife4j() { + return "沉默王二又帅又丑"; + } + + // POST 请求,使用 HttpServletRequest 获取请求参数 + @PostMapping(path = "testPost") + public String testPost(HttpServletRequest request) { + String name = request.getParameter("name"); + String age = request.getParameter("age"); + return "name=" + name + ", age=" + age; + } + + // POST 请求,使用 HttpServletRequest 获取请求参数,使用 JSON 把参数转为字符串 + @PostMapping(path = "testPostJson") + public String testPostJson(HttpServletRequest request) { + return JsonUtil.toStr(request.getParameterMap()); + } + + // POST 请求,使用 HttpServletRequest 获取 JSON 请求参数 + @PostMapping(path = "testPostJson2") + public String testPostJson2(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } catch (Exception e) { + e.printStackTrace(); + } + + return sb.toString(); // body中即是JSON格式的请求参数 + } + + @PostMapping(path = "testPostJson3") + public String testPostJson3(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } catch (Exception e) { + e.printStackTrace(); + } + + log.info("testPostJson3 第一次: {}", sb); + + StringBuilder sb1 = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb1.append(line); + } + } catch (Exception e) { + e.printStackTrace(); + } + + log.info("testPostJson3 第二次: {}", sb1); + + return sb1.toString(); // body中即是JSON格式的请求参数 + } + + @Autowired + private StatisticsSettingService statisticsSettingService; + + /** + * 只读测试,如果有更新就会报错 + * + * @return + */ + @GetMapping(path = "ds/read") + public String readOnly() { + // 保存请求计数 + statisticsSettingService.saveRequestCount(ReqInfoContext.getReqInfo().getClientIp()); + return "使用从库:更新成功!"; + } + + /** + * 只读测试,如果有更新就会报错 + * + * @return + */ + @GetMapping(path = "ds/write2") + public String write2() { + log.info("------------------- 业务逻辑进入 ----------------------------"); + long old = statisticsSettingService.getStatisticsCount().getPvCount(); + DsSelectExecutor.execute(MasterSlaveDsEnum.MASTER, () -> statisticsSettingService.saveRequestCount(ReqInfoContext.getReqInfo() + .getClientIp())); + // 保存请求计数 + long n = statisticsSettingService.getStatisticsCount().getPvCount(); + log.info("------------------- 业务逻辑结束 ----------------------------"); + return "编程式切换主库:更新成功! old=" + old + " new=" + n; + } + + + @DsAno(MasterSlaveDsEnum.MASTER) + @GetMapping(path = "ds/write") + public String write() { + // 保存请求计数 + long old = statisticsSettingService.getStatisticsCount().getPvCount(); + statisticsSettingService.saveRequestCount(ReqInfoContext.getReqInfo().getClientIp()); + long n = statisticsSettingService.getStatisticsCount().getPvCount(); + return "使用主库:更新成功! old=" + old + " new=" + n; + } + + + /** + * 打印配置信息 + * + * @param beanName + * @return + */ + @Permission(role = UserRole.ADMIN) + @GetMapping("print") + public String printInfo(String beanName) throws Exception { + Object bean = SpringUtil.getBeanOrNull(beanName); + if (bean == null) { + try { + Class clz = ClassUtils.forName(beanName, this.getClass().getClassLoader()); + bean = SpringUtil.getBeanOrNull(clz); + } catch (ClassNotFoundException e) { + } + } + + if (bean != null && ClassUtils.isCglibProxy(bean)) { + return printProxyFields(bean); + } + + return JsonUtil.toStr(bean); + } + + private String printProxyFields(Object proxy) { + Class clz = ProxyUtils.getUserClass(proxy); + Field[] fields = clz.getDeclaredFields(); + JSONObject obj = new JSONObject(); + for (Field f : fields) { + f.setAccessible(true); + obj.put(f.getName(), ReflectionUtils.getField(f, proxy)); + } + return obj.toString(); + } + + + /** + * 刷新global_config动态配置 + * + * @return + */ + @Permission(role = UserRole.ADMIN) + @GetMapping("refresh/config") + public String refreshConfig() { + DynamicConfigContainer configContainer = SpringUtil.getBean(DynamicConfigContainer.class); + configContainer.forceRefresh(); + return JsonUtil.toStr(configContainer.getCache()); + } + + /** + * 更新启用的AI模型 + * + * @param ai + * @return + */ + @Permission(role = UserRole.ADMIN) + @GetMapping("ai/update") + public AISourceEnum updateAi(String ai) { + ChatFacade chatFacade = SpringUtil.getBean(ChatFacade.class); + chatFacade.refreshAiSourceCache(AISourceEnum.valueOf(ai)); + return chatFacade.getRecommendAiSource(); + } + + @Autowired + private SensitiveService sensitiveService; + + /** + * 敏感词校验 + * + * @param txt + * @return + */ + @GetMapping(path = "sensitive/check") + public List sensitiveWords(String txt) { + return sensitiveService.findAll(txt); + } + + + /** + * 返回所有命中的敏感词 + * + * @return + */ + @GetMapping(path = "sensitive/all") + public Map showAllHitSensitiveWords() { + return sensitiveService.getHitSensitiveWords(); + } + + + /** + * 将敏感词添加到白名单内 + * + * @param word + * @return + */ + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "sensitive/addAllowWord") + public String addSensitiveAllowWord(String word) { + SpringUtil.getBean(GlobalConfigService.class).addSensitiveWhiteWord(word); + return "ok"; + } + + + @Autowired + private CountServiceImpl countServiceImpl; + + @GetMapping(path = "autoRefreshUserInfo") + public String autoRefreshUserInfo() { + countServiceImpl.autoRefreshAllUserStatisticInfo(); + return "ok"; + } + + // 前端把一些数据发送到这里并打印出来 + @PostMapping(path = "loadmore") + public void testLoadMore(@RequestBody String loadmore) { + log.info("loadmore: {}", loadmore); + } + + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "h5pay") + public ResVo testH5Pay(String outTradeNo, int amount) throws Exception { + ThirdPayOrderReqBo req = new ThirdPayOrderReqBo(); + req.setOutTradeNo("TEST-H5-" + outTradeNo + "-" + ReqInfoContext.getReqInfo().getUserId()); + req.setDescription(ReqInfoContext.getReqInfo().getUser().getUserName() + "-测试h5支付"); + req.setTotal(amount <= 0 ? 1 : amount); + req.setPayWay(ThirdPayWayEnum.WX_H5); + ThirdPayHandler payFacade = SpringUtil.getBeanOrNull(ThirdPayHandler.class); + if (payFacade != null) { + PrePayInfoResBo res = payFacade.createPayOrder(req); + log.info("返回结果: {}", res); + return ResVo.ok(res); + } else { + return ResVo.ok(null); + } + } + + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "nativePay") + public ResVo testNativePay(String outTradeNo, int amount) throws Exception { + ThirdPayOrderReqBo req = new ThirdPayOrderReqBo(); + req.setOutTradeNo("TEST-N-" + outTradeNo + "-" + ReqInfoContext.getReqInfo().getUserId()); + req.setDescription(ReqInfoContext.getReqInfo().getUser().getUserName() + "-测试native支付"); + req.setTotal(amount <= 0 ? 1 : amount); + req.setPayWay(ThirdPayWayEnum.WX_NATIVE); + ThirdPayHandler thirdPayService = SpringUtil.getBeanOrNull(ThirdPayHandler.class); + if (thirdPayService != null) { + PrePayInfoResBo res = thirdPayService.createPayOrder(req); + log.info("返回结果: {}", res); + res.setPrePayId(PayConverter.genQrCode(res.getPrePayId())); + return ResVo.ok(res); + } else { + return ResVo.ok(null); + } + } + + @Autowired + private RequestCountService requestCountService; + + // 准备使用 FastExcel 批量导出 500万条数据 + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "exportBatch") + public void exportBatch(HttpServletResponse response) throws IOException { + OutputStream outputStream = response.getOutputStream(); + + // 查出总数量 + long total = requestCountService.count(); + + // 每页大小 + int pageSize = 100000; // 每页 1 万条数据 + + // 每个 Sheet 容纳数据条数 + int sheetSize = 1000000; // 每个 Sheet 100 万条数据 + int sheetCount = (int) (total / sheetSize + (total % sheetSize == 0 ? 0 : 1)); + + // 文件名 + String fileName = URLEncoder.encode("批量导出测试.xlsx", "UTF-8").replaceAll("\\+", "%20"); + + // 设置响应头 + response.reset(); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName); + + // 开始导出 + try (ExcelWriter excelWriter = FastExcel.write(outputStream, RequestCountExcelDO.class).build()) { + for (int sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { + // 命名 Sheet + WriteSheet sheet = FastExcel.writerSheet(sheetIndex, "sheet" + (sheetIndex + 1)).build(); + + // 查询数据 + for (int pageIndex = 0; pageIndex < sheetSize / pageSize; pageIndex++) { + // 第一页是从 0-9999,第二页是从 10000-19999 + int offset = sheetIndex * sheetSize + pageIndex * pageSize + 1; + // TODO 自定义线程池+ CountDownLatch 进行优化 + List data = requestCountService.listRequestCount(PageParam.newPageInstance(offset, pageSize)); + List list = StatisticsConverter.convertToRequestCountExcelDOList(data); + excelWriter.write(list, sheet); + log.info("导出第 {} 页数据,目前是第{} 条数据", pageIndex, offset); + } + + } + } + } + + // 准备使用 FastExcel 批量导出 500万条数据 + // 自定义线程池,以及 CountDownLatch 进行优化 + @Permission(role = UserRole.ADMIN) + @GetMapping(path = "exportBatchPoolCountDownLatch") + public void exportBatchPoolCountDownLatch(HttpServletResponse response) throws IOException { + OutputStream outputStream = response.getOutputStream(); + + // 查出总数量 + long total = requestCountService.count(); + + // 每页大小 + int pageSize = 100000; // 每页 1 万条数据 + + // 每个 Sheet 容纳数据条数 + int sheetSize = 1000000; // 每个 Sheet 100 万条数据 + int sheetCount = (int) (total / sheetSize + (total % sheetSize == 0 ? 0 : 1)); + + // 文件名 + String fileName = URLEncoder.encode("批量导出测试.xlsx", "UTF-8").replaceAll("\\+", "%20"); + + // 设置响应头 + response.reset(); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName); + + // 开始导出 + int threadPoolSize = Runtime.getRuntime().availableProcessors(); // 根据 CPU 核心数动态分配线程 + ExecutorService threadPool = Executors.newFixedThreadPool(threadPoolSize); + CountDownLatch latch = new CountDownLatch(sheetCount * (sheetSize / pageSize)); + + try (ExcelWriter excelWriter = FastExcel.write(outputStream, RequestCountExcelDO.class).build()) { + for (int sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { + WriteSheet sheet = FastExcel.writerSheet(sheetIndex, "sheet" + (sheetIndex + 1)).build(); + for (int pageIndex = 0; pageIndex < sheetSize / pageSize; pageIndex++) { + int finalSheetIndex = sheetIndex; + int finalPageIndex = pageIndex; + threadPool.submit(() -> { + try { + // 计算分页偏移量 + int offset = finalSheetIndex * sheetSize + finalPageIndex * pageSize + 1; + List data = requestCountService.listRequestCount(PageParam.newPageInstance(offset, pageSize)); + List list = StatisticsConverter.convertToRequestCountExcelDOList(data); + + synchronized (excelWriter) { + excelWriter.write(list, sheet); // 写入操作需要同步,避免线程冲突 + } + log.info("导出第 {} 页数据,目前是第 {} 条数据", finalPageIndex, offset); + } catch (Exception e) { + log.error("导出第 {} 页数据时出错", finalPageIndex, e); + } finally { + latch.countDown(); // 减少计数器 + } + }); + } + } + latch.await(); // 等待所有线程完成 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("线程中断: ", e); + } finally { + threadPool.shutdown(); // 关闭线程池 + } + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/vo/EmailReqVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/vo/EmailReqVo.java new file mode 100644 index 000000000..67655db3f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/test/vo/EmailReqVo.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.web.front.test.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 邮件发送验证 + * + * @author YiHui + * @date 2023/3/19 + */ +@Data +public class EmailReqVo implements Serializable { + private static final long serialVersionUID = -8560585303684975482L; + + private String to; + + private String title; + + private String content; + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/rest/UserRestController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/rest/UserRestController.java new file mode 100644 index 000000000..06dbc649f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/rest/UserRestController.java @@ -0,0 +1,130 @@ +package com.github.paicoding.forum.web.front.user.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.FollowTypeEnum; +import com.github.paicoding.forum.api.model.enums.HomeSelectEnum; +import com.github.paicoding.forum.api.model.vo.NextPageHtmlVo; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.api.model.vo.user.UserInfoSaveReq; +import com.github.paicoding.forum.api.model.vo.user.UserRelationReq; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.user.service.relation.UserRelationServiceImpl; +import com.github.paicoding.forum.service.user.service.user.UserServiceImpl; +import com.github.paicoding.forum.web.component.TemplateEngineHelper; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.Objects; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@RestController +@RequestMapping(path = "user/api") +public class UserRestController { + + @Resource + private UserServiceImpl userService; + + @Resource + private UserRelationServiceImpl userRelationService; + + @Resource + private TemplateEngineHelper templateEngineHelper; + + + @Resource + private ArticleReadService articleReadService; + + + /** + * 保存用户关系 + * + * @param req + * @return + * @throws Exception + */ + @Permission(role = UserRole.LOGIN) + @PostMapping(path = "saveUserRelation") + public ResVo saveUserRelation(@RequestBody UserRelationReq req) { + userRelationService.saveUserRelation(req); + return ResVo.ok(true); + } + + /** + * 保存用户详情 + * + * @param req + * @return + * @throws Exception + */ + @Permission(role = UserRole.LOGIN) + @PostMapping(path = "saveUserInfo") + @Transactional(rollbackFor = Exception.class) + public ResVo saveUserInfo(@RequestBody UserInfoSaveReq req) { + if (req.getUserId() == null || !Objects.equals(req.getUserId(), ReqInfoContext.getReqInfo().getUserId())) { + // 不能修改其他用户的信息 + return ResVo.fail(StatusEnum.FORBID_ERROR_MIXED, "无权修改"); + } + userService.saveUserInfo(req); + return ResVo.ok(true); + } + + /** + * 用户的文章列表翻页 + * + * @param userId + * @param homeSelectType + * @return + */ + @GetMapping(path = "articleList") + public ResVo articleList(@RequestParam(name = "userId") Long userId, + @RequestParam(name = "homeSelectType") String homeSelectType, + @RequestParam("page") Long page, + @RequestParam(name = "pageSize", required = false) Long pageSize) { + HomeSelectEnum select = HomeSelectEnum.fromCode(homeSelectType); + if (select == null) { + return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS); + } + + if (pageSize == null) pageSize = PageParam.DEFAULT_PAGE_SIZE; + PageParam pageParam = PageParam.newPageInstance(page, pageSize); + PageListVo dto = articleReadService.queryArticlesByUserAndType(userId, pageParam, select); + String html = templateEngineHelper.renderToVo("views/user/articles/index", "homeSelectList", dto); + return ResVo.ok(new NextPageHtmlVo(html, dto.getHasMore())); + } + + @GetMapping(path = "followList") + public ResVo followList(@RequestParam(name = "userId") Long userId, + @RequestParam(name = "followSelectType") String followSelectType, + @RequestParam("page") Long page, + @RequestParam(name = "pageSize", required = false) Long pageSize) { + if (pageSize == null) pageSize = PageParam.DEFAULT_PAGE_SIZE; + PageParam pageParam = PageParam.newPageInstance(page, pageSize); + PageListVo followList; + boolean needUpdateRelation = false; + if (followSelectType.equals(FollowTypeEnum.FOLLOW.getCode())) { + followList = userRelationService.getUserFollowList(userId, pageParam); + } else { + // 查询粉丝列表时,只能确定粉丝关注了userId,但是不能反向判断,因此需要再更新下映射关系,判断userId是否有关注这个用户 + followList = userRelationService.getUserFansList(userId, pageParam); + needUpdateRelation = true; + } + + Long loginUserId = ReqInfoContext.getReqInfo().getUserId(); + if (!Objects.equals(loginUserId, userId) || needUpdateRelation) { + userRelationService.updateUserFollowRelationId(followList, userId); + } + String html = templateEngineHelper.renderToVo("views/user/follows/index", "followList", followList); + return ResVo.ok(new NextPageHtmlVo(html, followList.getHasMore())); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/rest/UserTransferController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/rest/UserTransferController.java new file mode 100644 index 000000000..f66040bf5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/rest/UserTransferController.java @@ -0,0 +1,55 @@ +package com.github.paicoding.forum.web.front.user.rest; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.service.user.service.UserTransferService; +import com.github.paicoding.forum.web.front.login.zsxq.helper.ZsxqHelper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 用户账号迁移 + * + * @author YiHui + * @date 2025/9/29 + */ +@Permission(role = UserRole.LOGIN) +@RestController +@RequestMapping("/user/api/transfer") +public class UserTransferController { + + @Autowired + private UserTransferService userTransferService; + + @Autowired + private ZsxqHelper zsxqHelper; + + /** + * 用户名密码方式账号迁移 + * + * @param username 用户名 + * @param password 密码 + * @return + */ + @PostMapping("/userPwd") + public ResVo transferByUserPwd(@RequestParam(name = "username") String username, + @RequestParam(name = "password") String password, + HttpServletResponse response) throws IOException { + boolean ans = userTransferService.transferUser(username, password); + return ans ? ResVo.ok(ReqInfoContext.getReqInfo().getUserId()) : ResVo.ok(0L); + } + + @RequestMapping("/zsxq") + public void transferByZsxq(HttpServletResponse response) throws IOException { + String url = zsxqHelper.buildZsxqLoginUrl(ZsxqHelper.EXTRA_TAG_USER_TRANSFER); + response.sendRedirect(url); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/view/UserViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/view/UserViewController.java new file mode 100644 index 000000000..29f5d7dd4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/view/UserViewController.java @@ -0,0 +1,216 @@ +package com.github.paicoding.forum.web.front.user.view; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.enums.FollowSelectEnum; +import com.github.paicoding.forum.api.model.enums.FollowTypeEnum; +import com.github.paicoding.forum.api.model.enums.HomeSelectEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.PageParam; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagSelectDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.article.conveter.PayConverter; +import com.github.paicoding.forum.service.article.service.ArticleReadService; +import com.github.paicoding.forum.service.user.service.UserRelationService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.web.front.user.vo.UserHomeVo; +import com.github.paicoding.forum.web.global.BaseViewController; +import com.github.paicoding.forum.web.global.SeoInjectService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + + +/** + * 用户注册、取消,登录、登出 + * + * @author louzai + * @date : 2022/8/3 10:56 + **/ +@Controller +@RequestMapping(path = "user") +@Slf4j +public class UserViewController extends BaseViewController { + + @Resource + private UserService userService; + + @Resource + private UserRelationService userRelationService; + + @Resource + private ArticleReadService articleReadService; + + private static final List homeSelectTags = Arrays.asList("article", "read", "follow", "collection"); + private static final List followSelectTags = Arrays.asList("follow", "fans"); + + /** + * 获取用户主页信息,通常只有作者本人才能进入这个页面 + * + * @return + */ + @Permission(role = UserRole.LOGIN) + @GetMapping(path = "home") + public String getUserHome(@RequestParam(name = "userId") Long userId, + @RequestParam(name = "homeSelectType", required = false) String homeSelectType, + @RequestParam(name = "followSelectType", required = false) String followSelectType, + Model model) { + UserHomeVo vo = new UserHomeVo(); + vo.setHomeSelectType(StringUtils.isBlank(homeSelectType) ? HomeSelectEnum.ARTICLE.getCode() : homeSelectType); + vo.setFollowSelectType(StringUtils.isBlank(followSelectType) ? FollowTypeEnum.FOLLOW.getCode() : followSelectType); + + UserStatisticInfoDTO userInfo = userService.queryUserInfoWithStatistic(userId); + vo.setUserHome(userInfo); + + List homeSelectTags = homeSelectTags(vo.getHomeSelectType(), Objects.equals(userId, ReqInfoContext.getReqInfo().getUserId())); + vo.setHomeSelectTags(homeSelectTags); + + vo.setPayQrCodes(PayConverter.formatPayCodeInfo(userInfo.getPayCode())); + + userHomeSelectList(vo, userId); + model.addAttribute("vo", vo); + SpringUtil.getBean(SeoInjectService.class).initUserSeo(vo); + return "views/user/index"; + } + + /** + * 访问其他用户的主页 + * + * @param userId + * @param homeSelectType + * @param followSelectType + * @param model + * @return + */ + @GetMapping(path = "/{userId}") + public String detail(@PathVariable(name = "userId") Long userId, @RequestParam(name = "homeSelectType", required = false) String homeSelectType, + @RequestParam(name = "followSelectType", required = false) String followSelectType, + Model model) { + UserHomeVo vo = new UserHomeVo(); + vo.setHomeSelectType(StringUtils.isBlank(homeSelectType) ? HomeSelectEnum.ARTICLE.getCode() : homeSelectType); + vo.setFollowSelectType(StringUtils.isBlank(followSelectType) ? FollowTypeEnum.FOLLOW.getCode() : followSelectType); + + UserStatisticInfoDTO userInfo = userService.queryUserInfoWithStatistic(userId); + vo.setUserHome(userInfo); + + List homeSelectTags = homeSelectTags(vo.getHomeSelectType(), Objects.equals(userId, ReqInfoContext.getReqInfo().getUserId())); + vo.setHomeSelectTags(homeSelectTags); + + userHomeSelectList(vo, userId); + model.addAttribute("vo", vo); + SpringUtil.getBean(SeoInjectService.class).initUserSeo(vo); + return "views/user/index"; + } + + /** + * 返回Home页选择列表标签 + * + * @param selectType + * @param isAuthor true 表示当前为查看自己的个人主页 + * @return + */ + private List homeSelectTags(String selectType, boolean isAuthor) { + List tags = new ArrayList<>(); + homeSelectTags.forEach(tag -> { + if (!isAuthor && "read".equals(tag)) { + // 只有本人才能看自己的阅读历史 + return; + } + TagSelectDTO tagSelectDTO = new TagSelectDTO(); + tagSelectDTO.setSelectType(tag); + tagSelectDTO.setSelectDesc(HomeSelectEnum.fromCode(tag).getDesc()); + tagSelectDTO.setSelected(selectType.equals(tag)); + tags.add(tagSelectDTO); + }); + return tags; + } + + /** + * 返回关注用户选择列表标签 + * + * @param selectType + * @return + */ + private List followSelectTags(String selectType) { + List tags = new ArrayList<>(); + followSelectTags.forEach(tag -> { + TagSelectDTO tagSelectDTO = new TagSelectDTO(); + tagSelectDTO.setSelectType(tag); + tagSelectDTO.setSelectDesc(FollowSelectEnum.fromCode(tag).getDesc()); + tagSelectDTO.setSelected(selectType.equals(tag)); + tags.add(tagSelectDTO); + }); + return tags; + } + + /** + * 返回选择列表 + * + * @param vo + * @param userId + */ + private void userHomeSelectList(UserHomeVo vo, Long userId) { + PageParam pageParam = PageParam.newPageInstance(); + HomeSelectEnum select = HomeSelectEnum.fromCode(vo.getHomeSelectType()); + if (select == null) { + return; + } + + switch (select) { + case ARTICLE: + case READ: + case COLLECTION: + PageListVo dto = articleReadService.queryArticlesByUserAndType(userId, pageParam, select); + vo.setHomeSelectList(dto); + return; + case FOLLOW: + // 关注用户与被关注用户 + // 获取选择标签 + List followSelectTags = followSelectTags(vo.getFollowSelectType()); + vo.setFollowSelectTags(followSelectTags); + initFollowFansList(vo, userId, pageParam); + return; + default: + } + } + + private void initFollowFansList(UserHomeVo vo, long userId, PageParam pageParam) { + PageListVo followList; + boolean needUpdateRelation = false; + if (vo.getFollowSelectType().equals(FollowTypeEnum.FOLLOW.getCode())) { + followList = userRelationService.getUserFollowList(userId, pageParam); + } else { + // 查询粉丝列表时,只能确定粉丝关注了userId,但是不能反向判断,因此需要再更新下映射关系,判断userId是否有关注这个用户 + followList = userRelationService.getUserFansList(userId, pageParam); + needUpdateRelation = true; + } + + Long loginUserId = ReqInfoContext.getReqInfo().getUserId(); + if (!Objects.equals(loginUserId, userId) || needUpdateRelation) { + userRelationService.updateUserFollowRelationId(followList, loginUserId); + } + vo.setFollowList(followList); + } + + + @GetMapping("pay") + @Permission(role = UserRole.LOGIN) + public String pay() { + return "views/user/pay-item"; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/vo/UserHomeVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/vo/UserHomeVo.java new file mode 100644 index 000000000..ab6d1796f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/front/user/vo/UserHomeVo.java @@ -0,0 +1,43 @@ +package com.github.paicoding.forum.web.front.user.vo; + +import com.github.paicoding.forum.api.model.enums.FollowSelectEnum; +import com.github.paicoding.forum.api.model.vo.PageListVo; +import com.github.paicoding.forum.api.model.vo.article.dto.ArticleDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagSelectDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.FollowUserInfoDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserPayCodeDTO; +import com.github.paicoding.forum.api.model.vo.user.dto.UserStatisticInfoDTO; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author YiHui + * @date 2022/9/2 + */ +@Data +public class UserHomeVo { + private String homeSelectType; + private List homeSelectTags; + /** + * 关注列表/粉丝列表 + */ + private PageListVo followList; + /** + * @see FollowSelectEnum#getCode() + */ + private String followSelectType; + private List followSelectTags; + private UserStatisticInfoDTO userHome; + + /** + * 文章列表 + */ + private PageListVo homeSelectList; + + /** + * 收款二维码 + */ + private Map payQrCodes; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/BaseViewController.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/BaseViewController.java new file mode 100644 index 000000000..78747a2f2 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/BaseViewController.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.web.global; + +import com.github.paicoding.forum.api.model.vo.PageParam; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * 全局属性配置 + * + * @author YiHui + * @date 2022/9/3 + */ +public class BaseViewController { + @Autowired + protected GlobalInitService globalInitService; + + public PageParam buildPageParam(Long page, Long size) { + if (page <= 0) { + page = PageParam.DEFAULT_PAGE_NUM; + } + if (size == null || size > PageParam.DEFAULT_PAGE_SIZE) { + size = PageParam.DEFAULT_PAGE_SIZE; + } + return PageParam.newPageInstance(page, size); + } + +// +// 推荐使用它替代 GlobalViewInterceptor 中的全局属性设置 +// /** +// * 全局属性配置 +// * +// * @param model +// */ +// @ModelAttribute +// public void globalAttr(Model model) { +// model.addAttribute("global", globalInitService.globalAttr()); +// } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/ForumExceptionHandler.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/ForumExceptionHandler.java new file mode 100644 index 000000000..4bc1d7439 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/ForumExceptionHandler.java @@ -0,0 +1,143 @@ +package com.github.paicoding.forum.web.global; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.exception.ForumException; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.Status; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.core.NestedRuntimeException; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.messaging.handler.annotation.support.MethodArgumentTypeMismatchException; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 全局异常处理 + * fixme: 除了这种姿势之外,还可以使用 ControllerAdvice 注解方式 + * + * @author YiHui + * @date 2022/9/3 + */ +@Slf4j +@Order(-100) +public class ForumExceptionHandler implements HandlerExceptionResolver { + + @Override + public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + Status errStatus = buildToastMsg(ex); + + if (restResponse(request, response)) { + // 表示返回json数据格式的异常提示信息 + if (response.isCommitted()) { + // 如果返回已经提交过,直接退出即可 + return new ModelAndView(); + } + + try { + response.reset(); + // 若是rest接口请求异常时,返回json格式的异常数据;而不是专门的500页面 + response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); + response.setHeader("Cache-Control", "no-cache, must-revalidate"); + response.getWriter().println(JsonUtil.toStr(ResVo.fail(errStatus))); + response.getWriter().flush(); + response.getWriter().close(); + return new ModelAndView(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + String view = getErrorPage(errStatus, response); + ModelAndView mv = new ModelAndView(view); + response.setContentType(MediaType.TEXT_HTML_VALUE); + mv.getModel().put("global", SpringUtil.getBean(GlobalInitService.class).globalAttr()); + mv.getModel().put("res", ResVo.fail(errStatus)); + mv.getModel().put("toast", JsonUtil.toStr(ResVo.fail(errStatus))); + return mv; + } + + private Status buildToastMsg(Exception ex) { + if (ex instanceof ForumException) { + return ((ForumException) ex).getStatus(); + } else if (ex instanceof AsyncRequestTimeoutException) { + return Status.newStatus(StatusEnum.UNEXPECT_ERROR, "超时未登录"); + } else if (ex instanceof HttpMediaTypeNotAcceptableException) { + return Status.newStatus(StatusEnum.RECORDS_NOT_EXISTS, ExceptionUtils.getStackTrace(ex)); + } else if (ex instanceof HttpRequestMethodNotSupportedException || ex instanceof MethodArgumentTypeMismatchException || ex instanceof IOException) { + // 请求方法不匹配 + return Status.newStatus(StatusEnum.ILLEGAL_ARGUMENTS, ExceptionUtils.getStackTrace(ex)); + } else if (ex instanceof NestedRuntimeException) { + log.error("unexpect NestedRuntimeException error! {}", ReqInfoContext.getReqInfo(), ex); + return Status.newStatus(StatusEnum.UNEXPECT_ERROR, ex.getMessage()); + } else { + log.error("unexpect error! {}", ReqInfoContext.getReqInfo(), ex); + return Status.newStatus(StatusEnum.UNEXPECT_ERROR, ExceptionUtils.getStackTrace(ex)); + } + } + + private String getErrorPage(Status status, HttpServletResponse response) { + // 根据异常码解析需要返回的错误页面 + if (StatusEnum.is5xx(status.getCode())) { + response.setStatus(500); + return "error/500"; + } else if (StatusEnum.is403(status.getCode())) { + response.setStatus(403); + return "error/403"; + } else { + response.setStatus(404); + return "error/404"; + } + } + + /** + * 后台请求、api数据请求、上传图片等接口,返回json格式的异常提示信息 + * 其他异常,返回500的页面 + * + * @param request + * @param response + * @return + */ + private boolean restResponse(HttpServletRequest request, HttpServletResponse response) { + if (request.getRequestURI().startsWith("/api/admin/") || request.getRequestURI().startsWith("/admin/")) { + return true; + } + + if (request.getRequestURI().startsWith("/image/upload")) { + return true; + } + + if (response.getContentType() != null && response.getContentType().contains(MediaType.APPLICATION_JSON_VALUE)) { + return true; + } + + if (isAjaxRequest(request)) { + return true; + } + + // 数据接口请求 + AntPathMatcher pathMatcher = new AntPathMatcher(); + if (pathMatcher.match("/**/api/**", request.getRequestURI())) { + return true; + } + return false; + } + + private boolean isAjaxRequest(HttpServletRequest request) { + String requestedWith = request.getHeader("X-Requested-With"); + return "XMLHttpRequest".equals(requestedWith); + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalExceptionHandler.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalExceptionHandler.java new file mode 100644 index 000000000..05af4e1c9 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalExceptionHandler.java @@ -0,0 +1,21 @@ +package com.github.paicoding.forum.web.global; + +import com.github.paicoding.forum.api.model.exception.ForumAdviceException; +import com.github.paicoding.forum.api.model.vo.ResVo; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 4/17/23 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(value = ForumAdviceException.class) + public ResVo handleForumAdviceException(ForumAdviceException e) { + return ResVo.fail(e.getStatus()); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalInitService.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalInitService.java new file mode 100644 index 000000000..e40e4b4e7 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalInitService.java @@ -0,0 +1,145 @@ +package com.github.paicoding.forum.web.global; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.seo.Seo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.core.util.NumUtil; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.service.notify.service.NotifyService; +import com.github.paicoding.forum.service.sitemap.service.SitemapService; +import com.github.paicoding.forum.service.statistics.service.UserStatisticService; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.service.user.service.UserService; +import com.github.paicoding.forum.web.config.GlobalViewConfig; +import com.github.paicoding.forum.web.front.login.wx.config.WxLoginProperties; +import com.github.paicoding.forum.web.global.vo.GlobalVo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.annotation.Resource; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import java.time.LocalDate; +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Slf4j +@Service +public class GlobalInitService { + @Value("${env.name}") + private String env; + @Autowired + private UserService userService; + + @Resource + private GlobalViewConfig globalViewConfig; + + @Resource + private NotifyService notifyService; + + @Resource + private SeoInjectService seoInjectService; + + @Resource + private UserStatisticService userStatisticService; + + @Resource + private SitemapService sitemapService; + + @Resource + private WxLoginProperties wxLoginProperties; + + /** + * 全局属性配置 + */ + public GlobalVo globalAttr() { + GlobalVo vo = new GlobalVo(); + vo.setEnv(env); + vo.setSiteInfo(globalViewConfig); + vo.setOnlineCnt(userStatisticService.getOnlineUserCnt()); + vo.setSiteStatisticInfo(sitemapService.querySiteVisitInfo(null, null)); + vo.setTodaySiteStatisticInfo(sitemapService.querySiteVisitInfo(LocalDate.now(), null)); + vo.setLoginQrType(wxLoginProperties.getLoginQrType()); + + if (ReqInfoContext.getReqInfo() == null || ReqInfoContext.getReqInfo().getSeo() == null || CollectionUtils.isEmpty(ReqInfoContext.getReqInfo().getSeo().getOgp())) { + Seo seo = seoInjectService.defaultSeo(); + vo.setOgp(seo.getOgp()); + vo.setJsonLd(JSONUtil.toJsonStr(seo.getJsonLd())); + } else { + Seo seo = ReqInfoContext.getReqInfo().getSeo(); + vo.setOgp(seo.getOgp()); + vo.setJsonLd(JSONUtil.toJsonStr(seo.getJsonLd())); + } + + try { + if (ReqInfoContext.getReqInfo() != null && NumUtil.upZero(ReqInfoContext.getReqInfo().getUserId())) { + vo.setIsLogin(true); + vo.setUser(ReqInfoContext.getReqInfo().getUser()); + vo.setMsgNum(ReqInfoContext.getReqInfo().getMsgNum()); + } else { + vo.setIsLogin(false); + } + + HttpServletRequest request = + ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + if (request.getRequestURI().startsWith("/column")) { + vo.setCurrentDomain("column"); + } else if (request.getRequestURI().startsWith("/chat")) { + vo.setCurrentDomain("chat"); + } else { + vo.setCurrentDomain("article"); + } + } catch (Exception e) { + log.error("loginCheckError:", e); + } + return vo; + } + + /** + * 初始化用户信息 + * + * @param reqInfo + */ + public void initLoginUser(ReqInfoContext.ReqInfo reqInfo) { + HttpServletRequest request = + ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + if (request.getCookies() == null) { + return; + } + + List list = SessionUtil.findCookiesByName(request, LoginService.SESSION_KEY); + if (CollectionUtils.isEmpty(list)) { + return; + } + for (Cookie ck : list) { + if (initLoginUser(ck.getValue(), reqInfo)) { + // 成功登录 + return; + } else { + // 未登录,直接删除 + SessionUtil.delCookie(ck); + } + } + } + + public boolean initLoginUser(String session, ReqInfoContext.ReqInfo reqInfo) { + BaseUserInfoDTO user = userService.getAndUpdateUserIpInfoBySessionId(session, null); + if (user != null) { + reqInfo.setSession(session); + reqInfo.setUserId(user.getUserId()); + reqInfo.setUser(user); + reqInfo.setMsgNum(notifyService.queryUserNotifyMsgCount(user.getUserId())); + return true; + } + return false; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/SeoInjectService.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/SeoInjectService.java new file mode 100644 index 000000000..803075d06 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/SeoInjectService.java @@ -0,0 +1,227 @@ +package com.github.paicoding.forum.web.global; + +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnArticlesDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.ColumnDTO; +import com.github.paicoding.forum.api.model.vo.article.dto.TagDTO; +import com.github.paicoding.forum.api.model.vo.seo.Seo; +import com.github.paicoding.forum.api.model.vo.seo.SeoTagVo; +import com.github.paicoding.forum.core.util.DateUtil; +import com.github.paicoding.forum.web.config.GlobalViewConfig; +import com.github.paicoding.forum.web.front.article.vo.ArticleDetailVo; +import com.github.paicoding.forum.web.front.user.vo.UserHomeVo; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * seo注入服务,下面加个页面使用 + * - 首页 + * - 文章详情页 + * - 用户主页 + * - 专栏内容详情页 + *

                    + * ogp seo标签: 开放内容协议 OGP + * + * @author YiHui + * @date 2023/2/13 + */ +@Service +public class SeoInjectService { + private static final String KEYWORDS = "技术派,开源社区,java,springboot,IT,程序员,开发者,mysql,redis,Java基础,多线程,JVM,虚拟机,数据库,MySQL,Spring,Redis,MyBatis,系统设计,分布式,RPC,高可用,高并发,沉默王二"; + private static final String DES = "技术派,一个基于 Spring Boot、MyBatis-Plus、MySQL、Redis、ElasticSearch、MongoDB、Docker、RabbitMQ 等技术栈实现的社区系统,采用主流的互联网技术架构、全新的UI设计、支持一键源码部署,拥有完整的文章&教程发布/搜索/评论/统计流程等,代码完全开源,没有任何二次封装,是一个非常适合二次开发/实战的现代化社区项目。学编程,就上技术派"; + + @Resource + private GlobalViewConfig globalViewConfig; + + /** + * 文章详情页的seo标签 + * + * @param detail + */ + public void initColumnSeo(ArticleDetailVo detail) { + Seo seo = initBasicSeoTag(); + List list = seo.getOgp(); + Map jsonLd = seo.getJsonLd(); + + String title = detail.getArticle().getTitle(); + String description = detail.getArticle().getSummary(); + String authorName = detail.getAuthor().getUserName(); + String updateTime = DateUtil.time2LocalTime(detail.getArticle().getLastUpdateTime()).toString(); + String publishedTime = DateUtil.time2LocalTime(detail.getArticle().getCreateTime()).toString(); + String image = detail.getArticle().getCover(); + + list.add(new SeoTagVo("og:title", title)); + list.add(new SeoTagVo("og:description", detail.getArticle().getSummary())); + list.add(new SeoTagVo("og:type", "article")); + list.add(new SeoTagVo("og:locale", "zh-CN")); + list.add(new SeoTagVo("og:updated_time", updateTime)); + + list.add(new SeoTagVo("article:modified_time", updateTime)); + list.add(new SeoTagVo("article:published_time", publishedTime)); + list.add(new SeoTagVo("article:tag", detail.getArticle().getTags().stream().map(TagDTO::getTag).collect(Collectors.joining(",")))); + list.add(new SeoTagVo("article:section", detail.getArticle().getCategory().getCategory())); + list.add(new SeoTagVo("article:author", authorName)); + + list.add(new SeoTagVo("author", authorName)); + list.add(new SeoTagVo("title", title)); + list.add(new SeoTagVo("description", description)); + list.add(new SeoTagVo("keywords", detail.getArticle().getCategory().getCategory() + "," + detail.getArticle().getTags().stream().map(TagDTO::getTag).collect(Collectors.joining(",")))); + + if (StringUtils.isNotBlank(image)) { + list.add(new SeoTagVo("og:image", image)); + jsonLd.put("image", image); + } + + jsonLd.put("headline", title); + jsonLd.put("description", description); + Map author = new HashMap<>(); + author.put("@type", "Person"); + author.put("name", authorName); + jsonLd.put("author", author); + jsonLd.put("dateModified", updateTime); + jsonLd.put("datePublished", publishedTime); + + ReqInfoContext.getReqInfo().setSeo(seo); + } + + /** + * 教程详情seo标签 + * + * @param detail + */ + public void initColumnSeo(ColumnArticlesDTO detail, ColumnDTO column) { + Seo seo = initBasicSeoTag(); + List list = seo.getOgp(); + Map jsonLd = seo.getJsonLd(); + + String title = detail.getArticle().getTitle(); + String description = detail.getArticle().getSummary(); + String authorName = column.getAuthorName(); + String updateTime = DateUtil.time2LocalTime(detail.getArticle().getLastUpdateTime()).toString(); + String publishedTime = DateUtil.time2LocalTime(detail.getArticle().getCreateTime()).toString(); + String image = column.getCover(); + + list.add(new SeoTagVo("og:title", title)); + list.add(new SeoTagVo("og:description", description)); + list.add(new SeoTagVo("og:type", "article")); + list.add(new SeoTagVo("og:locale", "zh-CN")); + + list.add(new SeoTagVo("og:updated_time", updateTime)); + list.add(new SeoTagVo("og:image", image)); + + list.add(new SeoTagVo("article:modified_time", updateTime)); + list.add(new SeoTagVo("article:published_time", publishedTime)); + list.add(new SeoTagVo("article:tag", detail.getArticle().getTags().stream().map(TagDTO::getTag).collect(Collectors.joining(",")))); + list.add(new SeoTagVo("article:section", column.getColumn())); + list.add(new SeoTagVo("article:author", authorName)); + + list.add(new SeoTagVo("author", authorName)); + list.add(new SeoTagVo("title", title)); + list.add(new SeoTagVo("description", detail.getArticle().getSummary())); + list.add(new SeoTagVo("keywords", detail.getArticle().getCategory().getCategory() + "," + detail.getArticle().getTags().stream().map(TagDTO::getTag).collect(Collectors.joining(",")))); + + + jsonLd.put("headline", title); + jsonLd.put("description", description); + Map author = new HashMap<>(); + author.put("@type", "Person"); + author.put("name", authorName); + jsonLd.put("author", author); + jsonLd.put("dateModified", updateTime); + jsonLd.put("datePublished", publishedTime); + jsonLd.put("image", image); + + if (ReqInfoContext.getReqInfo() != null) ReqInfoContext.getReqInfo().setSeo(seo); + } + + /** + * 用户主页的seo标签 + * + * @param user + */ + public void initUserSeo(UserHomeVo user) { + Seo seo = initBasicSeoTag(); + List list = seo.getOgp(); + Map jsonLd = seo.getJsonLd(); + + String title = "技术派 | " + user.getUserHome().getUserName() + " 的主页"; + list.add(new SeoTagVo("og:title", title)); + list.add(new SeoTagVo("og:description", user.getUserHome().getProfile())); + list.add(new SeoTagVo("og:type", "article")); + list.add(new SeoTagVo("og:locale", "zh-CN")); + + list.add(new SeoTagVo("article:tag", "后端,前端,Java,Spring,计算机")); + list.add(new SeoTagVo("article:section", "主页")); + list.add(new SeoTagVo("article:author", user.getUserHome().getUserName())); + + list.add(new SeoTagVo("author", user.getUserHome().getUserName())); + list.add(new SeoTagVo("title", title)); + list.add(new SeoTagVo("description", user.getUserHome().getProfile())); + list.add(new SeoTagVo("keywords", KEYWORDS)); + + jsonLd.put("headline", title); + jsonLd.put("description", user.getUserHome().getProfile()); + Map author = new HashMap<>(); + author.put("@type", "Person"); + author.put("name", user.getUserHome().getUserName()); + jsonLd.put("author", author); + + if (ReqInfoContext.getReqInfo() != null) ReqInfoContext.getReqInfo().setSeo(seo); + } + + + public Seo defaultSeo() { + Seo seo = initBasicSeoTag(); + List list = seo.getOgp(); + list.add(new SeoTagVo("og:title", "技术派")); + list.add(new SeoTagVo("og:description", DES)); + list.add(new SeoTagVo("og:type", "article")); + list.add(new SeoTagVo("og:locale", "zh-CN")); + + list.add(new SeoTagVo("article:tag", "后端,前端,Java,Spring,计算机")); + list.add(new SeoTagVo("article:section", "开源社区")); + list.add(new SeoTagVo("article:author", "技术派")); + + list.add(new SeoTagVo("author", "技术派")); + list.add(new SeoTagVo("title", "技术派")); + list.add(new SeoTagVo("description", DES)); + list.add(new SeoTagVo("keywords", KEYWORDS)); + + Map jsonLd = seo.getJsonLd(); + jsonLd.put("@context", "https://schema.org"); + jsonLd.put("@type", "Article"); + jsonLd.put("headline", "技术派"); + jsonLd.put("description", DES); + + if (ReqInfoContext.getReqInfo() != null) { + ReqInfoContext.getReqInfo().setSeo(seo); + } + return seo; + } + + private Seo initBasicSeoTag() { + + List list = new ArrayList<>(); + Map map = new HashMap<>(); + + HttpServletRequest request = + ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + String url = globalViewConfig.getHost() + request.getRequestURI(); + + list.add(new SeoTagVo("og:url", url)); + map.put("url", url); + + return Seo.builder().jsonLd(map).ogp(list).build(); + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/vo/GlobalVo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/vo/GlobalVo.java new file mode 100644 index 000000000..97dc75527 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/global/vo/GlobalVo.java @@ -0,0 +1,66 @@ +package com.github.paicoding.forum.web.global.vo; + +import com.github.paicoding.forum.api.model.vo.seo.SeoTagVo; +import com.github.paicoding.forum.api.model.vo.user.dto.BaseUserInfoDTO; +import com.github.paicoding.forum.service.sitemap.model.SiteCntVo; +import com.github.paicoding.forum.web.config.GlobalViewConfig; +import lombok.Data; + +import java.util.List; + +/** + * @author YiHui + * @date 2022/9/3 + */ +@Data +public class GlobalVo { + /** + * 网站相关配置 + */ + private GlobalViewConfig siteInfo; + /** + * 站点统计信息 + */ + private SiteCntVo siteStatisticInfo; + + /** + * 今日的站点统计想你洗 + */ + private SiteCntVo todaySiteStatisticInfo; + + /** + * 环境 + */ + private String env; + + /** + * 是否已登录 + */ + private Boolean isLogin; + + /** + * 登录用户信息 + */ + private BaseUserInfoDTO user; + + /** + * 消息通知数量 + */ + private Integer msgNum; + + /** + * 在线用户人数 + */ + private Integer onlineCnt; + + private String currentDomain; + + private List ogp; + private String jsonLd; + + /** + * 微信登录二维码类型 + */ + private String loginQrType; + +} diff --git a/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/filter/BodyReaderHttpServletRequestWrapper.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/BodyReaderHttpServletRequestWrapper.java similarity index 91% rename from forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/filter/BodyReaderHttpServletRequestWrapper.java rename to paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/BodyReaderHttpServletRequestWrapper.java index fa313f959..24e9485ba 100644 --- a/forum-web/src/main/java/com/github/liuyueyi/forum/web/hook/filter/BodyReaderHttpServletRequestWrapper.java +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/BodyReaderHttpServletRequestWrapper.java @@ -1,4 +1,4 @@ -package com.github.liuyueyi.forum.web.hook.filter; +package com.github.paicoding.forum.web.hook.filter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,7 +33,7 @@ public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapp public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) { super(request); - if (POST_METHOD.contains(request.getMethod()) && !isMultipart(request) && !isBinaryContent(request)) { + if (POST_METHOD.contains(request.getMethod()) && !isMultipart(request) && !isBinaryContent(request) && !isFormPost(request)) { bodyString = getBodyString(request); body = bodyString.getBytes(StandardCharsets.UTF_8); } else { @@ -67,7 +67,7 @@ public boolean isFinished() { @Override public boolean isReady() { - return false; + return true; } @Override @@ -133,4 +133,8 @@ private boolean isBinaryContent(final HttpServletRequest request) { private boolean isMultipart(final HttpServletRequest request) { return request.getContentType() != null && request.getContentType().startsWith("multipart/form-data"); } + + private boolean isFormPost(final HttpServletRequest request) { + return request.getContentType() != null && request.getContentType().startsWith("application/x-www-form-urlencoded"); + } } diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/OpenApiFilter.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/OpenApiFilter.java new file mode 100644 index 000000000..3521268e5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/OpenApiFilter.java @@ -0,0 +1,97 @@ +package com.github.paicoding.forum.web.hook.filter; + +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.util.IpUtil; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.web.openapi.config.OpenApiProperties; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 1. 请求参数日志输出过滤器 + * 2. 判断用户是否登录 + * + * @author YiHui + * @date 2022/7/6 + */ +@Slf4j +@Order(Integer.MIN_VALUE) +@WebFilter(urlPatterns = "/openapi/*", filterName = "openApiFilter", asyncSupported = true) +public class OpenApiFilter implements Filter { + /** + * 用于身份校验的方式 + */ + private static final String OPEN_API_APP_ID = "pai-open-appid"; + + @Autowired + private OpenApiProperties openApiProperties; + + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + + if (checkOpenApiAuth(request)) { + filterChain.doFilter(request, servletResponse); + } else { + // 没有权限访问 + log.info("No Permission to Access OpenApi Endpoint!"); + HttpServletResponse response = (HttpServletResponse) servletResponse; + response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); + response.getWriter().println(JsonUtil.toStr(ResVo.fail(StatusEnum.FORBID_ERROR_MIXED, "Can't Access OpenApi Endpoint!"))); + response.getWriter().flush(); + } + } + + private boolean checkOpenApiAuth(HttpServletRequest request) { + String appId = request.getHeader(OPEN_API_APP_ID); + if (StringUtils.isBlank(appId) || !openApiProperties.appIdList().contains(appId)) { + log.info("request appId: {} not in {}", appId, openApiProperties.appIdList()); + return false; + } + + if (StringUtils.isBlank(openApiProperties.getIpWhiteList())) { + return true; + } + + // ip白名单设置 + String ip = IpUtil.getClientIp(request); + for (String whiteIp : openApiProperties.ipWhiteList()) { + if (whiteIp.contains("/")) { + // CIDR范围匹配 + if (IpUtil.isIpInRange(ip, whiteIp)) { + return true; + } + } else if (whiteIp.equals(ip)) { + // 精确IP匹配 + return true; + } + } + + log.info("request ip {} not in {}", ip, openApiProperties.ipWhiteList()); + return false; + } + + @Override + public void destroy() { + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/ReqRecordFilter.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/ReqRecordFilter.java new file mode 100644 index 000000000..1c93fbefb --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/filter/ReqRecordFilter.java @@ -0,0 +1,250 @@ +package com.github.paicoding.forum.web.hook.filter; + +import cn.hutool.core.date.StopWatch; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.core.async.AsyncUtil; +import com.github.paicoding.forum.core.mdc.MdcUtil; +import com.github.paicoding.forum.core.util.CrossUtil; +import com.github.paicoding.forum.core.util.EnvUtil; +import com.github.paicoding.forum.core.util.IpUtil; +import com.github.paicoding.forum.core.util.SessionUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.sitemap.service.impl.SitemapServiceImpl; +import com.github.paicoding.forum.service.statistics.service.StatisticsSettingService; +import com.github.paicoding.forum.service.user.service.LoginService; +import com.github.paicoding.forum.web.global.GlobalInitService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URL; +import java.net.URLDecoder; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 1. 请求参数日志输出过滤器 + * 2. 判断用户是否登录 + * + * @author YiHui + * @date 2022/7/6 + */ +@Slf4j +@WebFilter(urlPatterns = "/*", filterName = "reqRecordFilter", asyncSupported = true) +public class ReqRecordFilter implements Filter { + private static Logger REQ_LOG = LoggerFactory.getLogger("req"); + /** + * 返回给前端的traceId,用于日志追踪 + */ + private static final String GLOBAL_TRACE_ID_HEADER = "g-trace-id"; + + @Autowired + private GlobalInitService globalInitService; + + @Autowired + private StatisticsSettingService statisticsSettingService; + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + long start = System.currentTimeMillis(); + HttpServletRequest request = null; + StopWatch stopWatch = new StopWatch("请求耗时"); + try { + stopWatch.start("请求参数构建"); + request = this.initReqInfo((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse); + stopWatch.stop(); + stopWatch.start("cors"); + CrossUtil.buildCors(request, (HttpServletResponse) servletResponse); + stopWatch.stop(); + stopWatch.start("业务执行"); + filterChain.doFilter(request, servletResponse); + } finally { + if (stopWatch.isRunning()) { + // 避免doFitler执行异常,导致上面的 stopWatch无法结束,这里先首当结束一下上次的计数 + stopWatch.stop(); + } + stopWatch.start("输出请求日志"); + buildRequestLog(ReqInfoContext.getReqInfo(), request, System.currentTimeMillis() - start); + // 一个链路请求完毕,清空MDC相关的变量(如GlobalTraceId,用户信息) + MdcUtil.clear(); + ReqInfoContext.clear(); + stopWatch.stop(); + + if (!isStaticURI(request) && !EnvUtil.isPro()) { + log.info("{} - cost:\n{}", request.getRequestURI(), stopWatch.prettyPrint(TimeUnit.MILLISECONDS)); + } + } + } + + @Override + public void destroy() { + } + + private HttpServletRequest initReqInfo(HttpServletRequest request, HttpServletResponse response) { + if (isStaticURI(request)) { + // 静态资源直接放行 + return request; + } + + StopWatch stopWatch = new StopWatch("请求参数构建"); + try { + stopWatch.start("traceId"); + // 添加全链路的traceId + MdcUtil.addTraceId(); + stopWatch.stop(); + + stopWatch.start("请求基本信息"); + // 手动写入一个session,借助 OnlineUserCountListener 实现在线人数实时统计 + request.getSession().setAttribute("latestVisit", System.currentTimeMillis()); + + ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo(); + String forwardedHost = request.getHeader("X-Forwarded-Host"); + String hostHeader = request.getHeader("host"); + if (StringUtils.isNotBlank(forwardedHost)) { + // 需要配合修改nginx的转发,添加 proxy_set_header X-Forwarded-Host $host; + reqInfo.setHost(forwardedHost); + } else if (StringUtils.isNotBlank(hostHeader)) { + reqInfo.setHost(hostHeader); + } else { + URL reqUrl = new URL(request.getRequestURL().toString()); + reqInfo.setHost(reqUrl.getHost()); + } + reqInfo.setPath(request.getPathInfo()); + if (reqInfo.getPath() == null) { + String url = request.getRequestURI(); + int index = url.indexOf("?"); + if (index > 0) { + url = url.substring(0, index); + } + reqInfo.setPath(url); + } + reqInfo.setReferer(request.getHeader("referer")); + reqInfo.setClientIp(IpUtil.getClientIp(request)); + reqInfo.setUserAgent(request.getHeader("User-Agent")); + reqInfo.setDeviceId(getOrInitDeviceId(request, response)); + + request = this.wrapperRequest(request, reqInfo); + stopWatch.stop(); + + stopWatch.start("登录用户信息"); + // 初始化登录信息 + globalInitService.initLoginUser(reqInfo); + stopWatch.stop(); + + ReqInfoContext.addReqInfo(reqInfo); + stopWatch.start("pv/uv站点统计"); + // 更新uv/pv计数 + AsyncUtil.execute(() -> SpringUtil.getBean(SitemapServiceImpl.class).saveVisitInfo(reqInfo.getClientIp(), reqInfo.getPath())); + stopWatch.stop(); + + stopWatch.start("回写traceId"); + // 返回头中记录traceId + response.setHeader(GLOBAL_TRACE_ID_HEADER, Optional.ofNullable(MdcUtil.getTraceId()).orElse("")); + stopWatch.stop(); + } catch (Exception e) { + log.error("init reqInfo error!", e); + } finally { + if (!EnvUtil.isPro()) { + log.info("{} -> 请求构建耗时: \n{}", request.getRequestURI(), stopWatch.prettyPrint(TimeUnit.MILLISECONDS)); + } + } + + return request; + } + + private void buildRequestLog(ReqInfoContext.ReqInfo req, HttpServletRequest request, long costTime) { + if (req == null || isStaticURI(request)) { + return; + } + + StringBuilder msg = new StringBuilder(); + msg.append("method=").append(request.getMethod()).append("; "); + if (StringUtils.isNotBlank(req.getReferer())) { + msg.append("referer=").append(URLDecoder.decode(req.getReferer())).append("; "); + } + msg.append("remoteIp=").append(req.getClientIp()); + msg.append("; agent=").append(req.getUserAgent()); + + if (req.getUserId() != null) { + // 打印用户信息 + msg.append("; user=").append(req.getUserId()); + } + + msg.append("; uri=").append(request.getRequestURI()); + if (StringUtils.isNotBlank(request.getQueryString())) { + msg.append('?').append(URLDecoder.decode(request.getQueryString())); + } + + msg.append("; payload=").append(req.getPayload()); + msg.append("; cost=").append(costTime); + REQ_LOG.info("{}", msg); + + // 保存请求计数 + statisticsSettingService.saveRequestCount(req.getClientIp()); + } + + + private HttpServletRequest wrapperRequest(HttpServletRequest request, ReqInfoContext.ReqInfo reqInfo) { + if (!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())) { + return request; + } + + BodyReaderHttpServletRequestWrapper requestWrapper = new BodyReaderHttpServletRequestWrapper(request); + reqInfo.setPayload(requestWrapper.getBodyString()); + return requestWrapper; + } + + private boolean isStaticURI(HttpServletRequest request) { + return request == null + || request.getRequestURI().endsWith("css") + || request.getRequestURI().endsWith("js") + || request.getRequestURI().endsWith("png") + || request.getRequestURI().endsWith("ico") + || request.getRequestURI().endsWith("gif") + || request.getRequestURI().endsWith("svg") + || request.getRequestURI().endsWith("min.js.map") + || request.getRequestURI().endsWith("min.css.map"); + } + + + /** + * 初始化设备id + * + * @return + */ + private String getOrInitDeviceId(HttpServletRequest request, HttpServletResponse response) { + String deviceId = request.getParameter("deviceId"); + if (StringUtils.isNotBlank(deviceId) && !"null".equalsIgnoreCase(deviceId)) { + return deviceId; + } + + Cookie device = SessionUtil.findCookieByName(request, LoginService.USER_DEVICE_KEY); + if (device == null) { + deviceId = UUID.randomUUID().toString(); + if (response != null) { + response.addCookie(SessionUtil.newCookie(LoginService.USER_DEVICE_KEY, deviceId)); + } + return deviceId; + } + return device.getValue(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/interceptor/GlobalViewInterceptor.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/interceptor/GlobalViewInterceptor.java new file mode 100644 index 000000000..085cff15d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/interceptor/GlobalViewInterceptor.java @@ -0,0 +1,102 @@ +package com.github.paicoding.forum.web.hook.interceptor; + +import com.baomidou.mybatisplus.core.toolkit.ObjectUtils; +import com.github.paicoding.forum.api.model.context.ReqInfoContext; +import com.github.paicoding.forum.api.model.vo.ResVo; +import com.github.paicoding.forum.api.model.vo.constants.StatusEnum; +import com.github.paicoding.forum.core.permission.Permission; +import com.github.paicoding.forum.core.permission.UserRole; +import com.github.paicoding.forum.core.util.JsonUtil; +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.rank.service.UserActivityRankService; +import com.github.paicoding.forum.service.rank.service.model.ActivityScoreBo; +import com.github.paicoding.forum.web.global.GlobalInitService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.AsyncHandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 注入全局的配置信息: + * - thymleaf 站点信息,基本信息,在这里注入 + * + * @author yihui + * @date 2022/6/15 + */ +@Slf4j +@Component +public class GlobalViewInterceptor implements AsyncHandlerInterceptor { + @Autowired + private GlobalInitService globalInitService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (handler instanceof HandlerMethod) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + Permission permission = handlerMethod.getMethod().getAnnotation(Permission.class); + if (permission == null) { + permission = handlerMethod.getBeanType().getAnnotation(Permission.class); + } + + if (permission == null || permission.role() == UserRole.ALL) { + if (ReqInfoContext.getReqInfo() != null) { + // 用户活跃度更新 + SpringUtil.getBean(UserActivityRankService.class).addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPath(ReqInfoContext.getReqInfo().getPath())); + } + return true; + } + + if (ReqInfoContext.getReqInfo() == null || ReqInfoContext.getReqInfo().getUserId() == null) { + if (handlerMethod.getMethod().getAnnotation(ResponseBody.class) != null + || handlerMethod.getMethod().getDeclaringClass().getAnnotation(RestController.class) != null) { + // 访问需要登录的rest接口 + response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); + response.getWriter().println(JsonUtil.toStr(ResVo.fail(StatusEnum.FORBID_NOTLOGIN))); + response.getWriter().flush(); + return false; + } else if (request.getRequestURI().startsWith("/api/admin/") || request.getRequestURI().startsWith("/admin/")) { + response.sendRedirect("/admin"); + } else { + // 访问需要登录的页面时,直接跳转到登录界面 + response.sendRedirect("/"); + } + return false; + } + if (permission.role() == UserRole.ADMIN && !UserRole.ADMIN.name().equalsIgnoreCase(ReqInfoContext.getReqInfo().getUser().getRole())) { + // 设置为无权限 + response.setStatus(HttpStatus.FORBIDDEN.value()); + return false; + } + } + return true; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { + if (!ObjectUtils.isEmpty(modelAndView)) { + if (response.getStatus() != HttpStatus.OK.value()) { + try { + ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo(); + // fixme 对于异常重定向到 /error 时,会导致登录信息丢失,待解决 + globalInitService.initLoginUser(reqInfo); + ReqInfoContext.addReqInfo(reqInfo); + modelAndView.getModel().put("global", globalInitService.globalAttr()); + } finally { + ReqInfoContext.clear(); + } + } else { + modelAndView.getModel().put("global", globalInitService.globalAttr()); + } + } + + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/listener/OnlineUserCountListener.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/listener/OnlineUserCountListener.java new file mode 100644 index 000000000..ecef1b473 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/hook/listener/OnlineUserCountListener.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.web.hook.listener; + +import com.github.paicoding.forum.core.util.SpringUtil; +import com.github.paicoding.forum.service.statistics.service.UserStatisticService; + +import javax.servlet.annotation.WebListener; +import javax.servlet.http.HttpSessionEvent; +import javax.servlet.http.HttpSessionListener; + +/** + * 通过监听session来实现实时人数统计 + * + * @author YiHui + * @date 2023/3/26 + */ +@WebListener +public class OnlineUserCountListener implements HttpSessionListener { + + + /** + * 新增session,在线人数统计数+1 + * + * @param se + */ + public void sessionCreated(HttpSessionEvent se) { + HttpSessionListener.super.sessionCreated(se); + SpringUtil.getBean(UserStatisticService.class).incrOnlineUserCnt(1); + } + + /** + * session失效,在线人数统计数-1 + * + * @param se + */ + public void sessionDestroyed(HttpSessionEvent se) { + HttpSessionListener.super.sessionDestroyed(se); + SpringUtil.getBean(UserStatisticService.class).incrOnlineUserCnt(-1); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/array1/BCryptExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/array1/BCryptExample.java new file mode 100644 index 000000000..9787f5a4f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/array1/BCryptExample.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.web.javabetter.array1; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +public class BCryptExample { + + public static void main(String[] args) { + // 创建一个 BCryptPasswordEncoder 实例 + BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + // 注册时加密密码 + String rawPassword = "沉默王二是条狗"; + String encodedPassword = passwordEncoder.encode(rawPassword); + + // 打印加密后的密码(每次加密结果都不同) + System.out.println("加密后的密码: " + encodedPassword); + + System.out.println(args); + + // 登录时验证密码 + boolean isPasswordMatch = passwordEncoder.matches(rawPassword, encodedPassword); + System.out.println("密码验证结果: " + isPasswordMatch); // 输出为 true + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/baidu/ExtractUrls.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/baidu/ExtractUrls.java new file mode 100644 index 000000000..77604ccc3 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/baidu/ExtractUrls.java @@ -0,0 +1,60 @@ +package com.github.paicoding.forum.web.javabetter.baidu; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +public class ExtractUrls { + public static void main(String[] args) { + String sitemapUrl = "https://javabetter.cn/sitemap.xml"; + String outputFilePath = "urls.txt"; + + try { + // 发送 HTTP 请求获取 sitemap + URL url = new URL(sitemapUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + // 解析 XML + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(conn.getInputStream()); + + // 提取所有 元素的内容 + NodeList locNodes = doc.getElementsByTagName("loc"); + BufferedWriter writer = new BufferedWriter(new FileWriter(outputFilePath)); + for (int i = 0; i < locNodes.getLength(); i++) { + String loc = locNodes.item(i).getTextContent(); + writer.write(loc); + writer.newLine(); + } + writer.close(); + + System.out.println("提取了 " + locNodes.getLength() + " 个 URL 并写入 urls.txt 文件"); + + // 使用 curl 提交 URL + ProcessBuilder pb = new ProcessBuilder( + "curl", "-H", "Content-Type:text/plain", "--data-binary", "@" + outputFilePath, + "http://data.zz.baidu.com/urls?site=https://javabetter.cn&token=dmbQj2wFYFLPNz7I" + ); + pb.redirectErrorStream(true); + Process process = pb.start(); + BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = in.readLine()) != null) { + System.out.println(line); + } + in.close(); + + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/baidu/Test.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/baidu/Test.java new file mode 100644 index 000000000..54aa1ea7d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/baidu/Test.java @@ -0,0 +1,7 @@ +package com.github.paicoding.forum.web.javabetter.baidu; + +public class Test { + public static void main(String[] args) { + System.out.println(9.9 > 9.11); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/collection1/ArrayListSerializationDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/collection1/ArrayListSerializationDemo.java new file mode 100644 index 000000000..8194df17e --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/collection1/ArrayListSerializationDemo.java @@ -0,0 +1,29 @@ +package com.github.paicoding.forum.web.javabetter.collection1; + +import java.io.*; +import java.util.ArrayList; + +public class ArrayListSerializationDemo { + public static void main(String[] args) { + ArrayList list = new ArrayList<>(); + list.add("Java"); + list.add("Python"); + list.add("C++"); + + // 序列化 + try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("list.ser"))) { + oos.writeObject(list); + System.out.println("ArrayList 已序列化"); + } catch (IOException e) { + e.printStackTrace(); + } + + // 反序列化 + try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("list.ser"))) { + ArrayList deserializedList = (ArrayList) ois.readObject(); + System.out.println("反序列化后的 ArrayList:" + deserializedList); + } catch (IOException | ClassNotFoundException e) { + e.printStackTrace(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/exception1/TryFinally.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/exception1/TryFinally.java new file mode 100644 index 000000000..f4a47b01e --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/exception1/TryFinally.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.web.javabetter.exception1; + +public class TryFinally { + public static void main(String[] args) { + try { + throw new Exception("Exception in try"); + } catch (Exception e) { + throw new RuntimeException("Exception in catch"); + } finally { + throw new IllegalArgumentException("Exception in finally"); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/exception1/TryFinallyAll.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/exception1/TryFinallyAll.java new file mode 100644 index 000000000..382941429 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/exception1/TryFinallyAll.java @@ -0,0 +1,22 @@ +package com.github.paicoding.forum.web.javabetter.exception1; + +public class TryFinallyAll { + public static void main(String[] args) { + Exception catchException = null; + try { + throw new Exception("Exception in try"); + } catch (Exception e) { + catchException = e; + throw new RuntimeException("Exception in catch"); + } finally { + try { + throw new IllegalArgumentException("Exception in finally"); + } catch (IllegalArgumentException e) { + if (catchException != null) { + System.out.println("Catch exception: " + catchException.getMessage()); + } + System.out.println("Finally exception: " + e.getMessage()); + } + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/integer1/IntegerMaxDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/integer1/IntegerMaxDemo.java new file mode 100644 index 000000000..5790e282f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/integer1/IntegerMaxDemo.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.web.javabetter.integer1; + +public class IntegerMaxDemo { + public static void main(String[] args) { + int maxValue = Integer.MAX_VALUE; + System.out.println("Integer.MAX_VALUE = " + maxValue); + System.out.println("Integer.MAX_VALUE + 1 = " + (maxValue + 1)); + + // 用二进制来表示最大值和最小值 + System.out.println("Integer.MAX_VALUE in binary: " + Integer.toBinaryString(maxValue)); + System.out.println("Integer.MIN_VALUE in binary: " + Integer.toBinaryString(Integer.MIN_VALUE)); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/PathsDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/PathsDemo.java new file mode 100644 index 000000000..ea17c38c3 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/PathsDemo.java @@ -0,0 +1,12 @@ +package com.github.paicoding.forum.web.javabetter.io1; + +import com.github.paicoding.forum.web.javabetter.top.copydown.Constants; + +import java.nio.file.Paths; + +public class PathsDemo { + public static void main(String[] args) { + System.out.println(Paths.get(Constants.DESTINATION, + "images","nice-article").toString()); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/TcpClient.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/TcpClient.java new file mode 100644 index 000000000..f75f5b4ac --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/TcpClient.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.web.javabetter.io1; + +import java.io.*; +import java.net.*; + +public class TcpClient { + public static void main(String[] args) throws IOException { + Socket socket = new Socket("127.0.0.1", 8080); // 连接服务器 + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + out.println("Hello, Server!"); // 发送消息 + System.out.println("Server response: " + in.readLine()); // 接收服务器响应 + + socket.close(); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/TcpServer.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/TcpServer.java new file mode 100644 index 000000000..ffce2f172 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/io1/TcpServer.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.web.javabetter.io1; + +import java.io.*; +import java.net.*; + +public class TcpServer { + public static void main(String[] args) throws IOException { + ServerSocket serverSocket = new ServerSocket(8080); // 创建服务器端Socket + System.out.println("Server started, waiting for connection..."); + Socket socket = serverSocket.accept(); // 等待客户端连接 + System.out.println("Client connected: " + socket.getInetAddress()); + + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + String message; + while ((message = in.readLine()) != null) { + System.out.println("Received: " + message); + out.println("Echo: " + message); // 回送消息 + } + + socket.close(); + serverSocket.close(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/CloseTLABDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/CloseTLABDemo.java new file mode 100644 index 000000000..ccefc4b7f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/CloseTLABDemo.java @@ -0,0 +1,15 @@ +package com.github.paicoding.forum.web.javabetter.jvm; + +class CloseTLABDemo { + public static void main(String[] args) { + for (int i = 0; i < 10_000_000; i++) { + allocate(); // 创建大量对象 + } + System.gc(); // 强制触发垃圾回收 + } + + private static void allocate() { + // 小对象分配,通常会使用 TLAB + byte[] bytes = new byte[64]; + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/FileWatcher.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/FileWatcher.java new file mode 100644 index 000000000..5e229f031 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/FileWatcher.java @@ -0,0 +1,75 @@ +package com.github.paicoding.forum.web.javabetter.jvm; + +import java.io.IOException; +import java.nio.file.*; + +import static java.nio.file.StandardWatchEventKinds.*; + +class FileWatcher { + + public static void watchDirectoryPath(Path path) { + // 检查路径是否是有效目录 + if (!isDirectory(path)) { + System.err.println("Provided path is not a directory: " + path); + return; + } + + System.out.println("Starting to watch path: " + path); + + // 获取文件系统的 WatchService + try (WatchService watchService = path.getFileSystem().newWatchService()) { + // 注册目录监听服务,监听创建、修改和删除事件 + path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); + + while (true) { + WatchKey key; + try { + // 阻塞直到有事件发生 + key = watchService.take(); + } catch (InterruptedException e) { + System.out.println("WatchService interrupted, stopping directory watch."); + Thread.currentThread().interrupt(); + break; + } + + // 处理事件 + for (WatchEvent event : key.pollEvents()) { + processEvent(event); + } + + // 重置 key,如果失败则退出 + if (!key.reset()) { + System.out.println("WatchKey no longer valid. Exiting watch loop."); + break; + } + } + } catch (IOException e) { + System.err.println("An error occurred while setting up the WatchService: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static boolean isDirectory(Path path) { + return Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS); + } + + private static void processEvent(WatchEvent event) { + WatchEvent.Kind kind = event.kind(); + + // 处理事件类型 + if (kind == OVERFLOW) { + System.out.println("Event overflow occurred. Some events might have been lost."); + return; + } + + @SuppressWarnings("unchecked") + Path fileName = ((WatchEvent) event).context(); + System.out.println("Event: " + kind.name() + ", File affected: " + fileName); + } + + public static void main(String[] args) { + // 设置监控路径为当前目录 + Path pathToWatch = Paths.get("."); + watchDirectoryPath(pathToWatch); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/HotSwapClassLoader.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/HotSwapClassLoader.java new file mode 100644 index 000000000..f99ed6b38 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/HotSwapClassLoader.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.web.javabetter.jvm; + +class HotSwapClassLoader extends ClassLoader { + public HotSwapClassLoader() { + super(ClassLoader.getSystemClassLoader()); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + // 加载指定路径下的类文件字节码 + byte[] classBytes = loadClassData(name); + if (classBytes == null) { + throw new ClassNotFoundException(name); + } + // 调用defineClass将字节码转换为Class对象 + return defineClass(name, classBytes, 0, classBytes.length); + } + + private byte[] loadClassData(String name) { + // 实现从文件系统或其他来源加载类文件的字节码 + // ... + return null; + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/TLABDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/TLABDemo.java new file mode 100644 index 000000000..b6908d4a2 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/jvm/TLABDemo.java @@ -0,0 +1,19 @@ +package com.github.paicoding.forum.web.javabetter.jvm; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +class TLABDemo { + public static void main(String[] args) { + for (int i = 0; i < 10_000_000; i++) { + allocate(); // 创建大量对象 + } + System.gc(); // 强制触发垃圾回收 + } + + private static void allocate() { + // 小对象分配,通常会使用 TLAB + byte[] bytes = new byte[64]; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/bytedaceai.svg b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/bytedaceai.svg new file mode 100644 index 000000000..bb7d5ae78 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/bytedaceai.svg @@ -0,0 +1,59 @@ + + + + + 字节跳动 AI 产品分类图谱 + + + 模型层(Flow 团队) + - 豆包大模型(语言) + + + 应用层 + + + Agent定制 + - 扣子(国内,基于豆包) + + + 聊天 + - 豆包(国内,基于豆包) + + + 社交 + - 猫箱(国内) + - 星绘(国内) + + + 图像/视频 + - 即梦 Dreamina(剪映) + + + 办公 + - 小悟空(国内,基于云雀) + + + 教育 + - 河马爱学(国内) + + + 内容创作 + - 即创(国内) + + + 音乐 + - 海绵乐队(国内) + + + 教育(其它) + - 识典古籍(国内) + + + 代码生成 + - CodeGen + diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/danzhudashi-v3new.html b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/danzhudashi-v3new.html new file mode 100644 index 000000000..14f358eef --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/danzhudashi-v3new.html @@ -0,0 +1,1189 @@ + + + + + 像素弹球大师 - 红白机风格 + + + +

                    像素弹球大师

                    + +
                    + + + +
                    AI 模式: 预测中...
                    + +
                    +
                    像素弹球大师
                    +
                    + 观看AI演示或按空格键开始游戏
                    + 方向键控制挡板,空格键发球 +
                    + + +
                    + + + + +
                    + +
                    +
                    + + 0 +
                    +
                    + + 0 +
                    +
                    + + 1 +
                    +
                    + + 3 +
                    +
                    + +
                    + + + +
                    + + + + \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/test.html b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/test.html new file mode 100644 index 000000000..0068180c3 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/test.html @@ -0,0 +1,193 @@ + + + + + + 像素弹球大师 + + + + +
                    + Score: 0 + Lives: 3 + Level: 1 + +
                    + + + \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/test1.html b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/test1.html new file mode 100644 index 000000000..b3e16a198 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/test1.html @@ -0,0 +1,125 @@ + + + + + + DeepSeek V3 发布 + + + +
                    +

                    DeepSeek V3 发布

                    +

                    探索未来,智领无限可能

                    +
                    +
                    +

                    全新升级,超越期待

                    +

                    DeepSeek V3 带来了更强大的功能、更智能的体验和更高效的解决方案,助力您在全球竞争中脱颖而出。

                    +
                    +
                    +

                    智能优化

                    +

                    基于最新AI技术,提供更精准的数据分析和决策支持。

                    +
                    +
                    +

                    高效性能

                    +

                    优化算法与架构,实现更快的处理速度和更高的稳定性。

                    +
                    +
                    +

                    安全可靠

                    +

                    多重加密与安全机制,保障您的数据隐私与系统安全。

                    +
                    +
                    +
                    +

                    立即体验 DeepSeek V3

                    + 开始使用 +
                    +
                    +
                    +

                    © 2023 DeepSeek. All rights reserved.

                    +
                    + + \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/v3new.html b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/v3new.html new file mode 100644 index 000000000..bf76463e3 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mp/v3new.html @@ -0,0 +1,480 @@ + + + + + + DeepSeek V3 - 新一代AI大模型发布 + + + + + + +
                    +
                    +
                    + +

                    DeepSeek V3 震撼发布

                    +

                    新一代AI大语言模型,128K上下文窗口,更智能、更高效、更强大

                    + +
                    +
                    +
                    + +
                    +
                    +
                    +

                    突破性创新

                    +

                    DeepSeek V3 带来了前所未有的AI体验,重新定义智能交互

                    +
                    + +
                    +
                    +
                    🚀
                    +

                    超长上下文

                    +

                    支持128K tokens超长上下文理解,处理复杂文档、长对话游刃有余,保持超强一致性。

                    +
                    + +
                    +
                    🧠
                    +

                    多模态能力

                    +

                    全新升级的多模态理解能力,可处理文本、图像、表格等多种格式输入,提供更丰富的交互体验。

                    +
                    + +
                    +
                    +

                    极速响应

                    +

                    优化后的推理引擎,响应速度提升40%,让交互更加流畅自然,大幅提升工作效率。

                    +
                    + +
                    +
                    🔒
                    +

                    企业级安全

                    +

                    端到端加密处理,数据隐私保护机制全面升级,满足金融、医疗等敏感行业需求。

                    +
                    + +
                    +
                    🌍
                    +

                    多语言支持

                    +

                    精通中英等数十种语言,跨文化交流无障碍,全球业务拓展的得力助手。

                    +
                    + +
                    +
                    🛠️
                    +

                    API集成

                    +

                    全新开发者工具链,简单几行代码即可集成到现有系统,快速实现AI能力升级。

                    +
                    +
                    +
                    +
                    + +
                    +
                    +
                    +
                    +

                    性能全面领先

                    +

                    在多项基准测试中,DeepSeek V3展现出卓越的性能表现,推理速度、准确率和稳定性均大幅提升。

                    +

                    相比上一代产品,V3在复杂任务处理能力上提升了65%,同时保持了业界领先的性价比。

                    + 查看详细数据 +
                    +
                    +
                    V2
                    +
                    V3
                    +
                    竞品A
                    +
                    竞品B
                    +
                    V2
                    +
                    V3
                    +
                    竞品A
                    +
                    竞品B
                    +
                    +
                    +
                    +
                    + +
                    +
                    +

                    开启AI新纪元

                    +

                    DeepSeek V3现已全面开放,立即注册体验下一代AI的强大能力,或联系我们获取企业定制方案。

                    + +
                    +
                    + + + + + + \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mysql1/Insert2TestExcel.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mysql1/Insert2TestExcel.java new file mode 100644 index 000000000..532edb522 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/mysql1/Insert2TestExcel.java @@ -0,0 +1,83 @@ +package com.github.paicoding.forum.web.javabetter.mysql1; + +import java.sql.*; + +import java.sql.*; +import java.time.LocalDate; + +public class Insert2TestExcel { + public static void main(String[] args) { + // 连接 MySQL 数据库 + Connection conn = null; + PreparedStatement pstmt = null; + Statement stmt = null; + try { + conn = DriverManager.getConnection( + "jdbc:mysql://localhost:3306/pai_coding?useSSL=false&rewriteBatchedStatements=true", + "root", + "" + ); + + stmt = conn.createStatement(); +// // 创建表 test_excel,表的字段有 id,day,pu 和 pv +// stmt.executeUpdate("CREATE TABLE IF NOT EXISTS test_excel (" + +// "id INT PRIMARY KEY AUTO_INCREMENT, " + +// "day DATE, " + +// "pu INT, " + +// "pv INT)" +// ); + + // 批量插入数据 + conn.setAutoCommit(false); // 关闭自动提交 + + String insertSQL = "INSERT INTO request_count (host, cnt, date) VALUES (?, ?, ?)"; + pstmt = conn.prepareStatement(insertSQL); + + int batchSize = 5000; // 批量大小 + LocalDate baseDate = Date.valueOf("2020-01-01").toLocalDate(); + for (int i = 0; i < 5000000; i++) { + pstmt.setString(1, "127.0.0." + (i % 255)); + pstmt.setInt(2, 100 + i % 10000); + + pstmt.setDate(3, Date.valueOf(baseDate.plusDays(i % 31))); + pstmt.addBatch(); + + if (i % batchSize == 0) { + pstmt.executeBatch(); + } + } + pstmt.executeBatch(); + conn.commit(); // 手动提交 + + // 查询数据 +// ResultSet rs = stmt.executeQuery("SELECT id, day, pu, pv FROM test_excel LIMIT 10"); +// while (rs.next()) { +// System.out.printf("ID: %d, Day: %s, PU: %d, PV: %d%n", +// rs.getInt("id"), rs.getString("day"), rs.getInt("pu"), rs.getInt("pv")); +// } + + // 查多少行 + ResultSet rs2 = stmt.executeQuery("SELECT COUNT(*) AS total FROM request_count"); + if (rs2.next()) { + System.out.println("Total rows: " + rs2.getInt("total")); + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + // 释放资源 + try { + if (pstmt != null) { + pstmt.close(); + } + if (stmt != null) { + stmt.close(); + } + if (conn != null) { + conn.close(); + } + } catch (SQLException e) { + e.printStackTrace(); + } + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/network/DownloadFileThread.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/network/DownloadFileThread.java new file mode 100644 index 000000000..7f608a752 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/network/DownloadFileThread.java @@ -0,0 +1,58 @@ +package com.github.paicoding.forum.web.javabetter.network; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class DownloadFileThread { + public static void main(String[] args) throws IOException { + URL url = new URL("https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-mianznxjsjwllsewswztwxxssc-fee87ab7-0475-429b-aba6-7a8df6841572.jpg"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + int fileSize = connection.getContentLength(); // 获取文件大小 + connection.disconnect(); + + System.out.println("文件大小:" + fileSize); + + int numThreads = 4; + int chunkSize = fileSize / numThreads; + String outputPath = "/Users/itwanger/Documents/file.png·"; + + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + for (int i = 0; i < numThreads; i++) { + int start = i * chunkSize; + int end = (i == numThreads - 1) ? fileSize - 1 : (start + chunkSize - 1); + executor.execute(() -> downloadChunk(String.valueOf(url), start, end, outputPath)); + } + executor.shutdown(); + } + + public static void downloadChunk(String url, int start, int end, String outputPath) { + try { + URL fileUrl = new URL(url); + HttpURLConnection connection = (HttpURLConnection) fileUrl.openConnection(); + connection.setRequestProperty("Range", "bytes=" + start + "-" + end); + + InputStream inputStream = connection.getInputStream(); + RandomAccessFile file = new RandomAccessFile(outputPath, "rw"); + file.seek(start); // 定位到文件的相应位置 + + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + file.write(buffer, 0, bytesRead); + } + + file.close(); + inputStream.close(); + connection.disconnect(); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/oo/demo.puml b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/oo/demo.puml new file mode 100644 index 000000000..ac2c2d391 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/oo/demo.puml @@ -0,0 +1,11 @@ +@startuml +'https://plantuml.com/sequence-diagram + +autonumber + +Alice -> Bob: Authentication Request +Bob --> Alice: Authentication Response + +Alice -> Bob: Another authentication Request +Alice <-- Bob: another authentication Response +@enduml \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/redis1/RedissonWatchdogExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/redis1/RedissonWatchdogExample.java new file mode 100644 index 000000000..305e529f1 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/redis1/RedissonWatchdogExample.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.web.javabetter.redis1; + +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; + +import java.util.concurrent.TimeUnit; + +public class RedissonWatchdogExample { + public static void main(String[] args) { + // 配置 Redisson 客户端 + Config config = new Config(); + config.useSingleServer().setAddress("redis://127.0.0.1:6379"); + RedissonClient redisson = Redisson.create(config); + + // 获取锁对象 + RLock lock = redisson.getLock("myLock"); + + try { + // 获取锁,默认看门狗机制会启动 + lock.lock(); + + // 模拟任务执行 + System.out.println("Task is running..."); + Thread.sleep(40000); // 模拟长时间任务(40秒) + + System.out.println("Task completed."); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + // 释放锁 + lock.unlock(); + } + + // 关闭 Redisson 客户端 + redisson.shutdown(); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/redis1/SetnxDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/redis1/SetnxDemo.java new file mode 100644 index 000000000..3a6f34fb1 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/redis1/SetnxDemo.java @@ -0,0 +1,5 @@ +package com.github.paicoding.forum.web.javabetter.redis1; + +public class SetnxDemo { + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/sentinel/Demo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/sentinel/Demo.java new file mode 100644 index 000000000..78be41d94 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/sentinel/Demo.java @@ -0,0 +1,42 @@ +package com.github.paicoding.forum.web.javabetter.sentinel; + +import com.alibaba.csp.sentinel.Entry; +import com.alibaba.csp.sentinel.SphU; +import com.alibaba.csp.sentinel.slots.block.BlockException; +import com.alibaba.csp.sentinel.slots.block.RuleConstant; +import com.alibaba.csp.sentinel.slots.block.flow.FlowRule; +import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager; + +import java.util.ArrayList; +import java.util.List; + +class Demo { + public static void main(String[] args) { + // 配置规则. + initFlowRules(); + + while (true) { + // 1.5.0 版本开始可以直接利用 try-with-resources 特性 + try (Entry entry = SphU.entry("HelloWorld")) { + // 被保护的逻辑 + System.out.println("hello world"); + } catch (BlockException ex) { + System.out.println(ex.getMessage()); + // 处理被流控的逻辑 + System.out.println("blocked!"); + } + } + } + + private static void initFlowRules(){ + List rules = new ArrayList<>(); + FlowRule rule = new FlowRule(); + rule.setResource("HelloWorld"); + rule.setGrade(RuleConstant.FLOW_GRADE_QPS); + // Set limit QPS to 20. + rule.setCount(20); + rules.add(rule); + FlowRuleManager.loadRules(rules); + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shejimoshi/ThreadFactoryDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shejimoshi/ThreadFactoryDemo.java new file mode 100644 index 000000000..5a74475d9 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shejimoshi/ThreadFactoryDemo.java @@ -0,0 +1,34 @@ +package com.github.paicoding.forum.web.javabetter.shejimoshi; + +import java.util.concurrent.ThreadFactory; + +class NamedThreadFactory implements ThreadFactory { + private final String prefix; + private int count = 0; + + public NamedThreadFactory(String prefix) { + this.prefix = prefix; + } + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r); + thread.setName(prefix + "-" + count++); + return thread; + } +} + +public class ThreadFactoryDemo { + public static void main(String[] args) { + ThreadFactory factory = new NamedThreadFactory("MyThread"); + + Runnable task = () -> { + System.out.println("线程名称: " + Thread.currentThread().getName()); + }; + + for (int i = 0; i < 5; i++) { + Thread thread = factory.newThread(task); + thread.start(); + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shousi/ArrayIntersection.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shousi/ArrayIntersection.java new file mode 100644 index 000000000..cbd163fae --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shousi/ArrayIntersection.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.web.javabetter.shousi; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class ArrayIntersection { + public static void main(String[] args) { + // 示例数组 + String[] a = {"沉默王二", "沉默王三", "沉默王四", "沉默王五"}; + String[] b = {"沉默王三", "沉默王六", "沉默王二", "沉默王八"}; + + // 使用 Stream API 获取两个数组的交集 + Set commonElements = findCommonElements(a, b); + + // 输出结果 + System.out.println("相同的元素: " + commonElements); + } + + public static Set findCommonElements(String[] a, String[] b) { + // 将数组 a 转换为 List,然后使用 filter 来过滤出数组 b 中也存在的元素 + return Arrays.stream(a) + .filter(element -> Arrays.asList(b).contains(element)) // 过滤条件 + .collect(Collectors.toSet()); // 收集结果为 Set 集合,去除重复元素 + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shousi/MaxRotatedString.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shousi/MaxRotatedString.java new file mode 100644 index 000000000..7aab0bd0b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/shousi/MaxRotatedString.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.web.javabetter.shousi; + +public class MaxRotatedString { + public static void main(String[] args) { + String numStr = "23132"; // 例子中的字符串 + String maxStr = findMaxRotation(numStr); + System.out.println("最大的轮询结果: " + maxStr); + } + + public static String findMaxRotation(String numStr) { + String maxStr = numStr; // 初始化为原始字符串 + int length = numStr.length(); + + // 轮询整个字符串,比较每次轮询结果 + for (int i = 1; i < length; i++) { + // 生成轮询字符串 + String rotatedStr = numStr.substring(i) + numStr.substring(0, i); + + // 更新最大值 + if (rotatedStr.compareTo(maxStr) > 0) { + maxStr = rotatedStr; + } + } + return maxStr; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/LifecycleDemoBeanDemo.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/LifecycleDemoBeanDemo.java new file mode 100644 index 000000000..b66de0478 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/LifecycleDemoBeanDemo.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.web.javabetter.spring1; + +import org.springframework.context.annotation.Bean; + +public class LifecycleDemoBeanDemo { + @Bean(initMethod = "customInit", destroyMethod = "customDestroy") + public LifecycleDemoBean lifecycleDemoBean() { + return new LifecycleDemoBean(); + } + + private class LifecycleDemoBean { + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository2.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository2.java new file mode 100644 index 000000000..bcdc7920a --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository2.java @@ -0,0 +1,4 @@ +package com.github.paicoding.forum.web.javabetter.spring1; + +public interface UserRepository2 { +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository21.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository21.java new file mode 100644 index 000000000..dc9264bbb --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository21.java @@ -0,0 +1,7 @@ +package com.github.paicoding.forum.web.javabetter.spring1; + +import org.springframework.stereotype.Component; + +@Component("userRepository21") +public class UserRepository21 implements UserRepository2 { +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository22.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository22.java new file mode 100644 index 000000000..b9fef2073 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserRepository22.java @@ -0,0 +1,7 @@ +package com.github.paicoding.forum.web.javabetter.spring1; + +import org.springframework.stereotype.Component; + +@Component("userRepository22") +public class UserRepository22 implements UserRepository2 { +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserServiceV2.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserServiceV2.java new file mode 100644 index 000000000..78928254e --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/spring1/UserServiceV2.java @@ -0,0 +1,45 @@ +package com.github.paicoding.forum.web.javabetter.spring1; + +import com.github.paicoding.forum.service.user.repository.dao.UserDao; +import com.github.paicoding.forum.service.user.repository.entity.UserInfoDO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; + +@Service +public class UserServiceV2 { + + @Resource + private UserDao userDao; + + @Autowired + @Qualifier("userRepository21") + private UserRepository2 userRepository21; + + @Resource(name = "userRepository22") + private UserRepository2 userRepository22; + + // 查询用户信息并缓存结果 + @Cacheable(value = "userCache", key = "#userId") + public UserInfoDO getUserById(Long userId) { + // 模拟数据库访问 + return userDao.getByUserId(userId); + } + + // 更新用户信息并更新缓存 + @CachePut(value = "userCache", key = "#user.id") + public boolean updateUser(UserInfoDO user) { + return userDao.updateById(user); + } + + // 删除缓存 + @CacheEvict(value = "userCache", key = "#userId") + public void deleteUser(Long userId) { + userDao.removeById(userId); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ABAFix.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ABAFix.java new file mode 100644 index 000000000..63c097640 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ABAFix.java @@ -0,0 +1,21 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.atomic.AtomicStampedReference; + +class ABAFix { + private static AtomicStampedReference ref = new AtomicStampedReference<>("100", 1); + + public static void main(String[] args) { + new Thread(() -> { + int stamp = ref.getStamp(); + ref.compareAndSet("100", "200", stamp, stamp + 1); + ref.compareAndSet("200", "100", ref.getStamp(), ref.getStamp() + 1); + }).start(); + + new Thread(() -> { + try { Thread.sleep(100); } catch (InterruptedException e) {} + int stamp = ref.getStamp(); + System.out.println("CAS 结果:" + ref.compareAndSet("100", "300", stamp, stamp + 1)); + }).start(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Account.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Account.java new file mode 100644 index 000000000..a56c04fa9 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Account.java @@ -0,0 +1,25 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.atomic.AtomicReference; + +class Account { + static class Balance { + final int money; + final int points; + + Balance(int money, int points) { + this.money = money; + this.points = points; + } + } + + private AtomicReference balance = new AtomicReference<>(new Balance(100, 10)); + + public void update(int newMoney, int newPoints) { + Balance oldBalance, newBalance; + do { + oldBalance = balance.get(); + newBalance = new Balance(newMoney, newPoints); + } while (!balance.compareAndSet(oldBalance, newBalance)); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CasRetryExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CasRetryExample.java new file mode 100644 index 000000000..189a5b60f --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CasRetryExample.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.atomic.AtomicInteger; + +class CasRetryExample { + private static AtomicInteger counter = new AtomicInteger(0); + private static final int MAX_RETRIES = 5; + + public static void main(String[] args) { + boolean success = false; + int retries = 0; + + while (retries < MAX_RETRIES) { + int currentValue = counter.get(); + boolean updated = counter.compareAndSet(currentValue, currentValue + 1); + + if (updated) { + System.out.println("更新成功,当前值: " + counter.get()); + success = true; + break; + } else { + retries++; + System.out.println("更新失败,进行第 " + retries + " 次重试"); + } + } + + if (!success) { + System.out.println("达到最大重试次数,操作失败"); + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CountDownLatchExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CountDownLatchExample.java new file mode 100644 index 000000000..350c96d65 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CountDownLatchExample.java @@ -0,0 +1,26 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.CountDownLatch; + +class CountDownLatchExample { + public static void main(String[] args) throws InterruptedException { + int threadCount = 3; + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + Thread.sleep((long) (Math.random() * 1000)); // 模拟任务执行 + System.out.println(Thread.currentThread().getName() + " 执行完毕"); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + latch.countDown(); // 线程完成后,计数器 -1 + } + }).start(); + } + + latch.await(); // 主线程等待 + System.out.println("所有子线程执行完毕,主线程继续执行"); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CountDownLatchQueryExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CountDownLatchQueryExample.java new file mode 100644 index 000000000..23d82c392 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CountDownLatchQueryExample.java @@ -0,0 +1,46 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +public class CountDownLatchQueryExample { + + private static final int THREAD_COUNT = 20; // 线程数 + private static final int TOTAL_RECORDS = 100000; // 数据总量 + private static final int BATCH_SIZE = TOTAL_RECORDS / THREAD_COUNT; // 每个线程处理的数量 + + public static void main(String[] args) throws InterruptedException { + ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT); + Semaphore semaphore = new Semaphore(THREAD_COUNT); // 信号量,初始值 20 + List results = new CopyOnWriteArrayList<>(); // 线程安全列表,存放查询结果 + + for (int i = 0; i < THREAD_COUNT; i++) { + final int start = i * BATCH_SIZE; + final int end = (i == THREAD_COUNT - 1) ? TOTAL_RECORDS : start + BATCH_SIZE; + + threadPool.execute(() -> { + try { + List partialResults = queryData(start, end); // 模拟查询数据 + results.addAll(partialResults); // 存入结果集 + } finally { + semaphore.release(); // 任务完成,释放信号量 + } + }); + } + + semaphore.acquire(THREAD_COUNT); // 主线程阻塞,直到所有信号量被释放 + System.out.println("所有查询任务已完成,最终结果:" + results.size()); + + threadPool.shutdown(); // 关闭线程池 + } + + // 模拟查询数据 + private static List queryData(int start, int end) { + List data = new ArrayList<>(); + for (int i = start; i < end; i++) { + data.add("记录-" + i); + } + return data; + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomRejectedExecutionHandler.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomRejectedExecutionHandler.java new file mode 100644 index 000000000..a5f8267d5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomRejectedExecutionHandler.java @@ -0,0 +1,39 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * CustomRejectedExecutionHandler contains several common rejection policies. + */ +public class CustomRejectedExecutionHandler { + + /** + * AbortPolicy throws a RuntimeException when the task is rejected. + */ + public static class AbortPolicy implements RejectedExecutionHandler { + public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { + throw new RuntimeException("Task " + r.toString() + " rejected from " + e.toString()); + } + } + + /** + * DiscardPolicy silently discards the rejected task. + */ + public static class DiscardPolicy implements RejectedExecutionHandler { + public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { + // Do nothing + } + } + + /** + * CallerRunsPolicy runs the rejected task in the caller's thread. + */ + public static class CallerRunsPolicy implements RejectedExecutionHandler { + public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { + if (!e.isShutdown()) { + r.run(); + } + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomRejectedHandler1.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomRejectedHandler1.java new file mode 100644 index 000000000..4057d8950 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomRejectedHandler1.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.*; + +class CustomRejectedHandler1 { + public static void main(String[] args) { + // 自定义拒绝策略 + RejectedExecutionHandler rejectedHandler = (r, executor) -> { + System.out.println("Task " + r.toString() + " rejected. Queue size: " + + executor.getQueue().size()); + }; + + // 自定义线程池 + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 2, // 核心线程数 + 4, // 最大线程数 + 10, // 空闲线程存活时间 + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(2), // 阻塞队列容量 + Executors.defaultThreadFactory(), + rejectedHandler // 自定义拒绝策略 + ); + + for (int i = 0; i < 10; i++) { + final int taskNumber = i; + executor.execute(() -> { + System.out.println("Executing task " + taskNumber); + try { + Thread.sleep(1000); // 模拟任务耗时 + } catch (InterruptedException e) { + e.printStackTrace(); + } + }); + } + + executor.shutdown(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomThreadPoolExecutor.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomThreadPoolExecutor.java new file mode 100644 index 000000000..108db0e21 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CustomThreadPoolExecutor.java @@ -0,0 +1,112 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.*; + +/** + * CustomThreadPoolExecutor is a simple implementation of a thread pool. + */ +public class CustomThreadPoolExecutor { + + private final int corePoolSize; + private final int maximumPoolSize; + private final long keepAliveTime; + private final TimeUnit unit; + private final BlockingQueue workQueue; + private final RejectedExecutionHandler handler; + + private volatile boolean isShutdown = false; + private int currentPoolSize = 0; + + /** + * Constructs a CustomThreadPoolExecutor. + * + * @param corePoolSize the number of core threads. + * @param maximumPoolSize the maximum number of threads. + * @param keepAliveTime the time to keep extra threads alive. + * @param unit the time unit for keepAliveTime. + * @param workQueue the queue to hold runnable tasks. + * @param handler the handler to use when execution is blocked. + */ + public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, + BlockingQueue workQueue, RejectedExecutionHandler handler) { + this.corePoolSize = corePoolSize; + this.maximumPoolSize = maximumPoolSize; + this.keepAliveTime = keepAliveTime; + this.unit = unit; + this.workQueue = workQueue; + this.handler = handler; + } + + /** + * Executes a given task using the thread pool. + * + * @param task the task to execute. + */ + public void execute(Runnable task) { + if (isShutdown) { + throw new IllegalStateException("ThreadPool is shutdown"); + } + + synchronized (this) { + // If current pool size is less than core pool size, create a new worker thread + if (currentPoolSize < corePoolSize) { + new Worker(task).start(); + currentPoolSize++; + return; + } + + // Try to add task to the queue, if full create a new worker thread if possible + if (!workQueue.offer(task)) { + if (currentPoolSize < maximumPoolSize) { + new Worker(task).start(); + currentPoolSize++; + } else { + // If maximum pool size reached, apply the rejection handler + handler.rejectedExecution(task, null); + } + } + } + } + + /** + * Shuts down the thread pool. + */ + public void shutdown() { + isShutdown = true; + } + + /** + * Worker is an internal class that represents a worker thread in the pool. + */ + private class Worker extends Thread { + private Runnable task; + + Worker(Runnable task) { + this.task = task; + } + + @Override + public void run() { + while (task != null || (task = getTask()) != null) { + try { + task.run(); + } finally { + task = null; + } + } + } + + /** + * Gets a task from the work queue, waiting up to keepAliveTime if necessary. + * + * @return a task to run, or null if the keepAliveTime expires. + */ + private Runnable getTask() { + try { + return workQueue.poll(keepAliveTime, unit); + } catch (InterruptedException e) { + return null; + } + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CyclicBarrierExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CyclicBarrierExample.java new file mode 100644 index 000000000..c979daaed --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/CyclicBarrierExample.java @@ -0,0 +1,23 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; + +public class CyclicBarrierExample { + private static final int THREAD_COUNT = 3; + private static final CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT); + + public static void main(String[] args) { + for (int i = 0; i < THREAD_COUNT; i++) { + new Thread(() -> { + try { + System.out.println(Thread.currentThread().getName() + " 到达屏障"); + barrier.await(); // 线程阻塞,直到所有线程都到达 + System.out.println(Thread.currentThread().getName() + " 继续执行"); + } catch (InterruptedException | BrokenBarrierException e) { + e.printStackTrace(); + } + }).start(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/DataQueryExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/DataQueryExample.java new file mode 100644 index 000000000..b748ec054 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/DataQueryExample.java @@ -0,0 +1,47 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +class DataQueryExample { + + public static void main(String[] args) throws InterruptedException { + // 模拟10万条数据 + int totalRecords = 100000; + int threadCount = 20; + int batchSize = totalRecords / threadCount; // 每个线程处理的数据量 + + // 创建线程池 + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + // 模拟查询结果 + ConcurrentLinkedQueue results = new ConcurrentLinkedQueue<>(); + + for (int i = 0; i < threadCount; i++) { + int start = i * batchSize; + int end = (i == threadCount - 1) ? totalRecords : (start + batchSize); + + executor.execute(() -> { + try { + // 模拟查询操作 + for (int j = start; j < end; j++) { + results.add("Data-" + j); + } + System.out.println(Thread.currentThread().getName() + " 处理数据 " + start + " - " + end); + } finally { + latch.countDown(); // 线程任务完成,计数器减1 + } + }); + } + + // 等待所有线程完成 + latch.await(); + executor.shutdown(); + + // 输出结果 + System.out.println("所有线程执行完毕,查询结果总数:" + results.size()); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ExchangerExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ExchangerExample.java new file mode 100644 index 000000000..b3ee54952 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ExchangerExample.java @@ -0,0 +1,31 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.Exchanger; + +public class ExchangerExample { + private static final Exchanger exchanger = new Exchanger<>(); + + public static void main(String[] args) { + new Thread(() -> { + try { + String threadAData = "数据 A"; + System.out.println("线程 A 交换前的数据:" + threadAData); + String received = exchanger.exchange(threadAData); + System.out.println("线程 A 收到的数据:" + received); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }).start(); + + new Thread(() -> { + try { + String threadBData = "数据 B"; + System.out.println("线程 B 交换前的数据:" + threadBData); + String received = exchanger.exchange(threadBData); + System.out.println("线程 B 收到的数据:" + received); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }).start(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ForkJoinExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ForkJoinExample.java new file mode 100644 index 000000000..8d4375527 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ForkJoinExample.java @@ -0,0 +1,63 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.RecursiveTask; +import java.util.concurrent.ForkJoinPool; + +public class ForkJoinExample { + public static void main(String[] args) { + int[] arr = new int[100]; + for (int i = 0; i < 100; i++) { + arr[i] = i + 1; // 填充数据 1 到 100 + } + + // 创建 ForkJoinPool,默认使用可用的处理器核心数 + ForkJoinPool pool = new ForkJoinPool(); + + // 创建 ForkJoin 任务 + SumTask task = new SumTask(arr, 0, arr.length); + + // 执行任务 + Integer result = pool.invoke(task); + + System.out.println("数组的和是: " + result); + } + + // 自定义任务,继承 RecursiveTask + static class SumTask extends RecursiveTask { + private int[] arr; + private int start; + private int end; + + public SumTask(int[] arr, int start, int end) { + this.arr = arr; + this.start = start; + this.end = end; + } + + @Override + protected Integer compute() { + if (end - start <= 10) { // 如果任务足够小,就直接计算 + int sum = 0; + for (int i = start; i < end; i++) { + sum += arr[i]; + } + return sum; + } else { + // 否则拆分任务 + int mid = (start + end) / 2; + SumTask left = new SumTask(arr, start, mid); + SumTask right = new SumTask(arr, mid, end); + + // 分别执行子任务 + left.fork(); + right.fork(); + + // 合并结果 + int leftResult = left.join(); + int rightResult = right.join(); + + return leftResult + rightResult; // 汇总结果 + } + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/InheritableThreadLocalExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/InheritableThreadLocalExample.java new file mode 100644 index 000000000..f7b4196e4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/InheritableThreadLocalExample.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +class InheritableThreadLocalExample { + private static final InheritableThreadLocal inheritableThreadLocal = new InheritableThreadLocal<>(); + + public static void main(String[] args) { + inheritableThreadLocal.set("父线程的值"); + + new Thread(() -> { + System.out.println("子线程获取的值:" + inheritableThreadLocal.get()); // 继承了父线程的值 + }).start(); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/MyTaskMain.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/MyTaskMain.java new file mode 100644 index 000000000..b21505179 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/MyTaskMain.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +class MyTask implements Runnable { + @Override + public void run() { + while (!Thread.currentThread().isInterrupted()) { + try { + System.out.println("Running..."); + Thread.sleep(1000); // 模拟工作 + } catch (InterruptedException e) { + // 捕获中断异常后,重置中断状态 + Thread.currentThread().interrupt(); + System.out.println("Thread interrupted, exiting..."); + break; + } + } + } +} + +public class MyTaskMain { + public static void main(String[] args) throws InterruptedException { + Thread thread = new Thread(new MyTask()); + thread.start(); + Thread.sleep(3000); // 主线程等待3秒 + thread.interrupt(); // 请求终止线程 + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/MyThreadPool.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/MyThreadPool.java new file mode 100644 index 000000000..c8ed0a480 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/MyThreadPool.java @@ -0,0 +1,103 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.*; +import java.util.*; + +// 一个简单的线程池实现 +class MyThreadPool { + private int coreSize; // 核心线程数 + private int maxSize; // 最大线程数 + private BlockingQueue taskQueue; // 任务队列 + private Set workers = new HashSet<>(); // 存放工作线程 + private volatile boolean isShutdown = false; // 线程池是否关闭 + + // 构造方法,初始化核心线程数、最大线程数和队列容量 + public MyThreadPool(int coreSize, int maxSize, int queueCapacity) { + this.coreSize = coreSize; + this.maxSize = maxSize; + this.taskQueue = new LinkedBlockingQueue<>(queueCapacity); + + // 预先启动核心线程 + for (int i = 0; i < coreSize; i++) { + Worker worker = new Worker(); + workers.add(worker); + new Thread(worker).start(); + } + } + + // 提交任务的方法 + public void submit(Runnable task) throws InterruptedException { + if (isShutdown) { + throw new IllegalStateException("线程池已经关闭"); + } + // 如果队列有空间,直接加入任务队列 + if (taskQueue.offer(task)) { + return; + } + // 如果队列满了,但线程数还没到最大,就创建新的线程 + synchronized (this) { + if (workers.size() < maxSize) { + Worker worker = new Worker(); + workers.add(worker); + new Thread(worker).start(); + } + } + // 将任务加入队列,注意这里用 put 会阻塞直到有空间 + taskQueue.put(task); + } + + // 关闭线程池的方法 + public void shutdown() { + isShutdown = true; + // 通知所有工作线程停止 + for (Worker worker : workers) { + worker.stop(); + } + } + + // 内部类,工作线程 + class Worker implements Runnable { + private volatile boolean running = true; + + @Override + public void run() { + // 只要线程池没有关闭或者任务队列不为空,就一直尝试获取任务 + while (running || !taskQueue.isEmpty()) { + try { + // 这里设置了超时获取任务,防止无限阻塞 + Runnable task = taskQueue.poll(500, TimeUnit.MILLISECONDS); + if (task != null) { + task.run(); + } + } catch (InterruptedException e) { + // 捕获中断异常,继续下一次循环 + } + } + } + + // 停止该工作线程 + public void stop() { + running = false; + } + } + + // 测试代码 + public static void main(String[] args) throws InterruptedException { + MyThreadPool pool = new MyThreadPool(2, 4, 10); + // 模拟提交 15 个任务 + for (int i = 0; i < 15; i++) { + int index = i; + pool.submit(() -> { + System.out.println("任务 " + index + " 正在被 " + Thread.currentThread().getName() + " 执行"); + try { + Thread.sleep(1000); // 模拟任务执行时间 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + // 等待一段时间后关闭线程池 + Thread.sleep(5000); + pool.shutdown(); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/PrintTask.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/PrintTask.java new file mode 100644 index 000000000..25e230044 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/PrintTask.java @@ -0,0 +1,52 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +class PrintTask implements Runnable { + private static int number = 1; // 共享变量 + private int threadId; // 当前线程的编号 + private static final Object lock = new Object(); + + public PrintTask(int threadId) { + this.threadId = threadId; + } + + @Override + public void run() { + while (number <= 100) { + synchronized (lock) { + if (number % 3 == threadId) { // 判断是否当前线程的打印轮次 + System.out.println("Thread-" + threadId + " prints: " + number++); + lock.notifyAll(); // 唤醒其他等待的线程 + } else { + try { + lock.wait(); // 不是当前线程轮次,进入等待 + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + } + } + + // 主线程 + public static void main(String[] args) { + Thread t1 = new Thread(new PrintTask(0)); // Thread-0 + Thread t2 = new Thread(new PrintTask(1)); // Thread-1 + Thread t3 = new Thread(new PrintTask(2)); // Thread-2 + + t1.start(); + t2.start(); + t3.start(); + + try { + t1.join(); // main 线程等待 t1 完成 + t2.join(); // main 线程等待 t2 完成 + t3.join(); // main 线程等待 t3 完成 + } catch (InterruptedException e) { + e.printStackTrace(); + } + + System.out.println("所有线程执行完毕,Main 线程开始执行"); + } +} + + diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ReentrantExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ReentrantExample.java new file mode 100644 index 000000000..257b4bb88 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ReentrantExample.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +class ReentrantExample { + public synchronized void method1() { + System.out.println("Method1 acquired lock"); + method2(); // 线程已经持有锁,能继续调用 method2 + } + + public synchronized void method2() { + System.out.println("Method2 acquired lock"); + } + + public static void main(String[] args) { + ReentrantExample example = new ReentrantExample(); + example.method1(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SemaphoreExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SemaphoreExample.java new file mode 100644 index 000000000..5749692a4 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SemaphoreExample.java @@ -0,0 +1,24 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.Semaphore; + +public class SemaphoreExample { + private static final int THREAD_COUNT = 5; + private static final Semaphore semaphore = new Semaphore(2); // 最多允许 2 个线程访问 + + public static void main(String[] args) { + for (int i = 0; i < THREAD_COUNT; i++) { + new Thread(() -> { + try { + semaphore.acquire(); // 获取许可(如果没有可用许可,则阻塞) + System.out.println(Thread.currentThread().getName() + " 访问资源..."); + Thread.sleep(2000); // 模拟任务执行 + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + semaphore.release(); // 释放许可 + } + }).start(); + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SemaphoreTest.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SemaphoreTest.java new file mode 100644 index 000000000..99aa665c5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SemaphoreTest.java @@ -0,0 +1,28 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; + +class SemaphoreTest { + private static final int THREAD_COUNT = 30; + private static ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT); + private static Semaphore s = new Semaphore(10); + + public static void main(String[] args) { + for (int i = 0; i < THREAD_COUNT; i++) { + threadPool.execute(new Runnable() { + @Override + public void run() { + try { + s.acquire(); + System.out.println("save data"); + s.release(); + } catch (InterruptedException e) { + } + } + }); + } + threadPool.shutdown(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SimpleConnectionPool.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SimpleConnectionPool.java new file mode 100644 index 000000000..243d901e0 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SimpleConnectionPool.java @@ -0,0 +1,92 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class SimpleConnectionPool { + // 配置 + private String jdbcUrl; + private String username; + private String password; + private int maxConnections; + private BlockingQueue connectionPool; + + // 构造函数 + public SimpleConnectionPool(String jdbcUrl, String username, String password, int maxConnections) throws SQLException { + this.jdbcUrl = jdbcUrl; + this.username = username; + this.password = password; + this.maxConnections = maxConnections; + this.connectionPool = new LinkedBlockingQueue<>(maxConnections); + + // 初始化连接池 + for (int i = 0; i < maxConnections; i++) { + connectionPool.add(createNewConnection()); + } + } + + // 创建新连接 + private Connection createNewConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl, username, password); + } + + // 获取连接 + public Connection getConnection(long timeout, TimeUnit unit) throws InterruptedException, SQLException { + Connection connection = connectionPool.poll(timeout, unit); // 等待指定时间获取连接 + if (connection == null) { + throw new SQLException("Timeout: Unable to acquire a connection."); + } + return connection; + } + + // 归还连接 + public void releaseConnection(Connection connection) throws SQLException { + if (connection != null) { + if (connection.isClosed()) { + // 如果连接已关闭,创建一个新连接补充到池中 + connectionPool.add(createNewConnection()); + } else { + // 将连接归还到池中 + connectionPool.offer(connection); + } + } + } + + // 关闭所有连接 + public void closeAllConnections() throws SQLException { + for (Connection connection : connectionPool) { + if (!connection.isClosed()) { + connection.close(); + } + } + } + + // 测试用例 + public static void main(String[] args) { + try { + SimpleConnectionPool pool = new SimpleConnectionPool( + "jdbc:mysql://localhost:3306/pai_coding", "root", "", 5 + ); + + // 获取连接 + Connection conn = pool.getConnection(5, TimeUnit.SECONDS); + + // 使用连接(示例查询) + System.out.println("Connection acquired: " + conn); + Thread.sleep(2000); // 模拟查询 + + // 归还连接 + pool.releaseConnection(conn); + System.out.println("Connection returned."); + + // 关闭所有连接 + pool.closeAllConnections(); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SpinLock.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SpinLock.java new file mode 100644 index 000000000..06248b245 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SpinLock.java @@ -0,0 +1,36 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class SpinLock { + private AtomicBoolean lock = new AtomicBoolean(false); + + public void lock() { + while (!lock.compareAndSet(false, true)) { + // 自旋等待,不断尝试获取锁 + } + } + + public void unlock() { + lock.set(false); + } + + public static void main(String[] args) { + SpinLock spinLock = new SpinLock(); + + Runnable task = () -> { + spinLock.lock(); + try { + System.out.println(Thread.currentThread().getName() + " 获取到锁"); + } finally { + spinLock.unlock(); + } + }; + + Thread t1 = new Thread(task); + Thread t2 = new Thread(task); + + t1.start(); + t2.start(); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/StackOverflowErrorTest1.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/StackOverflowErrorTest1.java new file mode 100644 index 000000000..25d3b1bbb --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/StackOverflowErrorTest1.java @@ -0,0 +1,17 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.atomic.AtomicInteger; + +class StackOverflowErrorTest1 { + private static AtomicInteger count = new AtomicInteger(0); + public static void main(String[] args) { + while (true) { + testStackOverflowError(); + } + } + + public static void testStackOverflowError() { + System.out.println(count.incrementAndGet()); + testStackOverflowError(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SyncExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SyncExample.java new file mode 100644 index 000000000..24d058934 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SyncExample.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.CountDownLatch; + +public class SyncExample { + public static void main(String[] args) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(3); + + // 创建3个子线程 + for (int i = 0; i < 3; i++) { + new Thread(() -> { + try { + Thread.sleep(1000); // 模拟任务 + System.out.println("打完王者了."); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + latch.countDown(); // 每个线程任务完成后计数器减1 + } + }).start(); + } + + System.out.println("等打完三把王者就去睡觉..."); + latch.await(); // 主线程等待子线程完成 + System.out.println("好,王者玩完了,可以睡了"); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Synchronized1.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Synchronized1.java new file mode 100644 index 000000000..4fb348477 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Synchronized1.java @@ -0,0 +1,16 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +class Synchronized1 { + public static void main(String[] args) { + // 假设我们有一个共享资源 x 和 flag + int x; + boolean flag = false; + Object lock = new Object(); // 用于同步的锁对象 + + synchronized(lock) { + x = 1; + flag = true; + } + + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SynchronizedVisibility.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SynchronizedVisibility.java new file mode 100644 index 000000000..ec72382d5 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/SynchronizedVisibility.java @@ -0,0 +1,20 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +class SynchronizedVisibility { + private static boolean flag = true; + + public static void main(String[] args) { + new Thread(() -> { + synchronized (SynchronizedVisibility.class) { + while (flag) {} // 线程 A 现在一定能看到 flag=false + System.out.println("线程 A 退出"); + } + }).start(); + + try { Thread.sleep(1000); } catch (InterruptedException e) {} + + synchronized (SynchronizedVisibility.class) { + flag = false; // 线程 B 修改 flag + } + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/T1.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/T1.java new file mode 100644 index 000000000..0242dfa5b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/T1.java @@ -0,0 +1,27 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.*; + +public class T1 { + public static void main(String[] args) { + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 2, // 核心线程数 + 4, // 最大线程数 + 10, // 空闲线程存活时间 + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(2), + new ThreadPoolExecutor.AbortPolicy() + ); + Future future = executor.submit(() -> { + System.out.println("任务开始"); + int result = 1 / 0; // 除零异常 + return result; + }); + + try { + future.get(); + } catch (InterruptedException | ExecutionException e) { + System.err.println("捕获异常:" + e.getMessage()); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ThreadLocalExample.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ThreadLocalExample.java new file mode 100644 index 000000000..18f1a34ff --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ThreadLocalExample.java @@ -0,0 +1,13 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +public class ThreadLocalExample { + private static final ThreadLocal threadLocal = new ThreadLocal<>(); + + public static void main(String[] args) { + threadLocal.set("父线程的值"); + + new Thread(() -> { + System.out.println("子线程获取的值:" + threadLocal.get()); // null + }).start(); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ThreadPoolTest.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ThreadPoolTest.java new file mode 100644 index 000000000..dd04e0d30 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/ThreadPoolTest.java @@ -0,0 +1,30 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class ThreadPoolTest { + public static void main(String[] args) { + // Create a thread pool with core size 2, max size 4, and a queue capacity of 2 + CustomThreadPoolExecutor executor = new CustomThreadPoolExecutor( + 2, 4, 10, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(2), + new CustomRejectedExecutionHandler.AbortPolicy()); + + // Submit 10 tasks to the pool + for (int i = 0; i < 10; i++) { + final int index = i; + executor.execute(() -> { + System.out.println("Task " + index + " is running"); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }); + } + + // Shutdown the thread pool + executor.shutdown(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/VolatilePerformanceTest.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/VolatilePerformanceTest.java new file mode 100644 index 000000000..dd803f155 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/VolatilePerformanceTest.java @@ -0,0 +1,139 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +/** + * volatile关键字单线程性能测试 + * 测试volatile和非volatile变量在单线程环境下的性能差异 + * + * @author 沉默王二 + */ +public class VolatilePerformanceTest { + + // 普通变量 + private static int normalVar = 0; + + // volatile变量 + private static volatile int volatileVar = 0; + + // 测试次数 + private static final int TEST_COUNT = 100_000_000; + + public static void main(String[] args) { + System.out.println("开始单线程volatile性能测试..."); + System.out.println("测试次数: " + TEST_COUNT); + System.out.println(); + + // 测试普通变量读写性能 + testNormalVariable(); + + // 测试volatile变量读写性能 + testVolatileVariable(); + + // 对比测试 + comparePerformance(); + } + + /** + * 测试普通变量的读写性能 + */ + private static void testNormalVariable() { + System.out.println("=== 测试普通变量性能 ==="); + + // 测试写入性能 + long startTime = System.nanoTime(); + for (int i = 0; i < TEST_COUNT; i++) { + normalVar = i; + } + long writeTime = System.nanoTime() - startTime; + + // 测试读取性能 + startTime = System.nanoTime(); + int temp; + for (int i = 0; i < TEST_COUNT; i++) { + temp = normalVar; + } + long readTime = System.nanoTime() - startTime; + + System.out.println("普通变量写入耗时: " + writeTime / 1_000_000 + " ms"); + System.out.println("普通变量读取耗时: " + readTime / 1_000_000 + " ms"); + System.out.println("普通变量总耗时: " + (writeTime + readTime) / 1_000_000 + " ms"); + System.out.println(); + } + + /** + * 测试volatile变量的读写性能 + */ + private static void testVolatileVariable() { + System.out.println("=== 测试volatile变量性能 ==="); + + // 测试写入性能 + long startTime = System.nanoTime(); + for (int i = 0; i < TEST_COUNT; i++) { + volatileVar = i; + } + long writeTime = System.nanoTime() - startTime; + + // 测试读取性能 + startTime = System.nanoTime(); + int temp; + for (int i = 0; i < TEST_COUNT; i++) { + temp = volatileVar; + } + long readTime = System.nanoTime() - startTime; + + System.out.println("volatile变量写入耗时: " + writeTime / 1_000_000 + " ms"); + System.out.println("volatile变量读取耗时: " + readTime / 1_000_000 + " ms"); + System.out.println("volatile变量总耗时: " + (writeTime + readTime) / 1_000_000 + " ms"); + System.out.println(); + } + + /** + * 对比测试两种变量的性能差异 + */ + private static void comparePerformance() { + System.out.println("=== 性能对比测试 ==="); + + // 多次测试取平均值 + final int testRounds = 10; + long normalTotalTime = 0; + long volatileTotalTime = 0; + + for (int round = 0; round < testRounds; round++) { + // 测试普通变量 + long startTime = System.nanoTime(); + for (int i = 0; i < TEST_COUNT; i++) { + normalVar = i; + int temp = normalVar; + } + normalTotalTime += (System.nanoTime() - startTime); + + // 测试volatile变量 + startTime = System.nanoTime(); + for (int i = 0; i < TEST_COUNT; i++) { + volatileVar = i; + int temp = volatileVar; + } + volatileTotalTime += (System.nanoTime() - startTime); + } + + long normalAvg = normalTotalTime / testRounds / 1_000_000; + long volatileAvg = volatileTotalTime / testRounds / 1_000_000; + + System.out.println("经过 " + testRounds + " 轮测试的平均结果:"); + System.out.println("普通变量平均耗时: " + normalAvg + " ms"); + System.out.println("volatile变量平均耗时: " + volatileAvg + " ms"); + + if (volatileAvg > normalAvg) { + double overhead = ((double) (volatileAvg - normalAvg) / normalAvg) * 100; + System.out.println("volatile性能开销: " + String.format("%.2f", overhead) + "%"); + } else { + System.out.println("在此测试中,volatile变量表现更好或相当"); + } + + System.out.println(); + System.out.println("=== 结论 ==="); + System.out.println("1. volatile主要影响编译器优化和内存可见性"); + System.out.println("2. 单线程下性能差异通常很小(几个百分点)"); + System.out.println("3. volatile的主要作用是保证多线程间的可见性"); + System.out.println("4. 不应该仅为性能考虑而避免使用volatile"); + } +} \ No newline at end of file diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Volatiletest.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Volatiletest.java new file mode 100644 index 000000000..b7838b80d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/thread1/Volatiletest.java @@ -0,0 +1,5 @@ +package com.github.paicoding.forum.web.javabetter.thread1; + +public class Volatiletest { + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Constants.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Constants.java new file mode 100644 index 000000000..5f83f4719 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Constants.java @@ -0,0 +1,37 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import java.nio.file.Paths; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/28/22 + */ +public class Constants { + // 文章目录 + // 图片目录 + public static final String DESTINATION = Paths.get(System.getProperty("user.home"), + "Documents", "GitHub", "toBeBetterJavaer").toString(); + // 默认作者名 + public static final String DEFAULT_AUTHOR = "佚名"; + // 填写Bucket名称,例如examplebucket。 + public static final String bucketName = "itwanger-oss"; + // OSS 的前缀文件夹 + public static final String ossFolder = "tobebetterjavaer/images/"; + + // 不需要转链的路径 + public final static String ossOrCdnUrls [] = { + "http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/", + "https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/", + "https://itwanger-oss.oss-cn-beijing.aliyuncs.com/tobebetterjavaer/images/" }; + + // 匹配图片的 markdown 语法 + // ![](hhhx.png) + // ![xx](hhhx.png?ax) + public static final String mdImgPattern = "\\!\\[(.*)\\]\\((.*)\\)"; + + // 图片后缀 + public static final String[] imgExtension = {".jpg", ".jpeg", ".png", ".gif"}; + public static final String fileSeparator = System.getProperty("file.separator"); +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Convert2OssFromHtml.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Convert2OssFromHtml.java new file mode 100644 index 000000000..50e6735c1 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Convert2OssFromHtml.java @@ -0,0 +1,56 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import cn.hutool.core.io.file.FileReader; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutionException; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/31/22 + */ +@Slf4j +public class Convert2OssFromHtml { + public static void main(String[] args) throws IOException, ExecutionException, InterruptedException { + // 如果是爬虫 + // 原创 + // 原创需要指定路径 + ImgOption imgOption = ImgOption.builder() + .imgDownloadDestPrefix(Constants.DESTINATION + "images" ) + .imgOssFolder(Constants.ossFolder) + .imgCdnPrefix(Constants.ossOrCdnUrls[0]) + .imgNamePrefix("other-conggslxmysqldswmysqljslt") + .build(); + + + // 对整个文档里面的图片链接转链 + // 下载到本地,上传到 OSS,替换链接 + // 正则表达式,找到对应的图片 + String [] imgNamePrefixs = imgOption.getImgNamePrefix().split("-"); + String mdPath = Constants.DESTINATION + "docs" + Constants.fileSeparator + + imgOption.getImgCategory() + Constants.fileSeparator + + imgNamePrefixs[0] + Constants.fileSeparator + + imgNamePrefixs[1] + ".md"; + + File md = new File(mdPath); + FileReader fileReader = FileReader.create(md, StandardCharsets.UTF_8); + // 读取全部内容 + String content; + try { + content = OSSUtil.upload(fileReader.readString(), imgOption); + } catch (Exception e) { + log.error("上传文件到OSS失败", e); + throw new RuntimeException("上传文件到OSS失败", e); + } + + FileWriter writer = new FileWriter(md); + writer.write(content); + writer.flush(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/GetWeixinFengmiantu.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/GetWeixinFengmiantu.java new file mode 100644 index 000000000..16ae98afd --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/GetWeixinFengmiantu.java @@ -0,0 +1,56 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.http.HttpUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.DataNode; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class GetWeixinFengmiantu { + public static final String fileSeparator = System.getProperty("file.separator"); + public static final String destination = System.getProperty("user.home") + +fileSeparator+"Documents" +fileSeparator+ + "weixin" +fileSeparator; + public static final String url = "https://mp.weixin.qq.com/s/9f706T20JfNII78YK5n4pA"; + public static final String imageKey = "msg_cdn_url"; + public static void main(String[] args) { + Document doc = null; + try { + doc = Jsoup.connect(url).get(); + } catch (IOException e) { + log.error("jsoup error{}", e); + } + + for (Element scripts : doc.getElementsByTag("script")) { + for (DataNode dataNode : scripts.dataNodes()) { + // find data which contains + if (dataNode.getWholeData().contains(imageKey)) { + log.info("contains"); + + // 封面图 + Pattern pattern = Pattern.compile("var\\s+" + imageKey + "\\s+=\\s+\"(.*)\";"); + Matcher matcher = pattern.matcher(dataNode.getWholeData()); + if (matcher.find()) { + String msg_cdn_url = matcher.group(1); + log.info("find msg_cdn_url success {}", msg_cdn_url); + + if (StringUtils.isNotBlank(msg_cdn_url)) { + long size = HttpUtil.downloadFile(msg_cdn_url, + FileUtil.file(destination + DateUtil.now() + ".jpg")); + log.info("cover image size{}", size); + } + } + } + } + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Html2md.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Html2md.java new file mode 100644 index 000000000..ec891c334 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/Html2md.java @@ -0,0 +1,78 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import com.github.paicoding.forum.web.javabetter.top.copydown.strategy.*; +import com.github.paicoding.forum.web.javabetter.top.furstenheim.*; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; + +@Slf4j +public class Html2md { + public static void main(String[] args) throws IOException { + String url = "https://mp.weixin.qq.com/s/ipQLDlCc6a2BeYeEkwOwMg"; + + // itwanger/Documents/GitHub/toBeBetterJavaer/docs/nice-article/ + HtmlSourceOption option = HtmlSourceOption.builder() + .keywordsKey("meta[name='keywords']") + .titleKey("head title") + .descriptionKey("meta[name='description']") + .url(url) + .build(); + +// // 首先登录 +// Connection.Response loginResponse = Jsoup.connect("https://blog.csdn.net/login") +// .data("username", "www.qing_gee@163.com", "password", "") // 你的登录表单参数 +// .method(Connection.Method.POST) +// .execute(); + + Document document = Jsoup.connect(option.getUrl()) +// .cookies(loginResponse.cookies()) +// .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537") + .get(); + // 生成 markdown + OptionsBuilder optionsBuilder = OptionsBuilder.anOptions(); + // 设置 markdown 选项 + Options options = optionsBuilder + .withEmDelimiter("*") + .withBr("\r") + .withCodeBlockStyle(CodeBlockStyle.FENCED) + .withHeadingStyle(HeadingStyle.ATX) + .build(); + + // 创建转换器 + CopyDown copyDown = new CopyDown(options); + + List strategies = Arrays.asList( + new JuejinUrlHandlerStrategy(copyDown, document), + new WeixinUrlHandlerStrategy(copyDown, document), + new ZhihuUrlHandlerStrategy(copyDown, document), + new DefaultUrlHandlerStrategy(copyDown, document) + // 其他策略... + ); + + for (UrlHandlerStrategy strategy : strategies) { + if (strategy.match(url)) { + strategy.handleOptions(option); + + HtmlSourceResult result = strategy.convertToMD(option); + result.setFileDir(Paths.get(Constants.DESTINATION, + "docs", + "nice-article").toString()); + result.setImgDest(Paths.get(Constants.DESTINATION, + "images","nice-article").toString()); + // 转载链接 + result.setSourceLink(option.getUrl()); + // 转一下 + result.setHtmlSourceType(option.getHtmlSourceType()); + + strategy.md2file(result); + break; + } + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceOption.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceOption.java new file mode 100644 index 000000000..5ee7b9dec --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceOption.java @@ -0,0 +1,36 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import lombok.Builder; +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/27/22 + */ +@Data +@Builder +public class HtmlSourceOption { + // 地址 + private String url; + // 内容选择器 + private String contentSelector; + // 封面图 key + private String coverImageKey; + // 标题 key + private String titleKey; + // 作者名 + private String authorKey; + // 昵称 + private String nicknameKey; + // 类型 + private HtmlSourceType htmlSourceType; + // keywords + private String keywordsKey; + // description + private String descriptionKey; + // cookie + private String cookie; + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceResult.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceResult.java new file mode 100644 index 000000000..5f3c35e47 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceResult.java @@ -0,0 +1,35 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import lombok.Builder; +import lombok.Data; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/27/22 + */ +@Data +@Builder +public class HtmlSourceResult { + + // 封面图路径 + private String cover; + // 标题 + private String title = "unknown"; + // 作者名 + private String author; + // 原文链接 + private String sourceLink; + // MD 内容 + private String markdown; + // keywords + private String keywords; + // description + private String description; + private HtmlSourceType htmlSourceType; + // 文件目录 + private String fileDir; + // 图片目录 + private String imgDest; +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceType.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceType.java new file mode 100644 index 000000000..df663d4ae --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/HtmlSourceType.java @@ -0,0 +1,38 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/28/22 + */ +public enum HtmlSourceType { + WEIXIN("weixin", "微信公众号"), + JUEJIN("juejin", "掘金社区"), + BOKEYUAN("cnblog", "博客园"), + CSDN("csdn", "CSDN"), + ZHIHU("zhihu", "知乎"), + ITMIND("itmind", "小白学堂"), + segmentfault("segmentfault", "思否"), + newcoder("newcoder", "牛客"), + github("github", "Github"), + leetcode("leetcode", "LeetCode"), + OTHER("other", "其他网站"); + + private String name; + private String category; + + public String getName() { + return name; + } + + public String getCategory() { + return category; + } + + HtmlSourceType(String name, String category) { + this.name = name; + this.category = category; + } + +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/ImageUtil.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/ImageUtil.java new file mode 100644 index 000000000..f4e7311fe --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/ImageUtil.java @@ -0,0 +1,18 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/31/22 + */ +public class ImageUtil { + public static String getImgExt(String url) { + for (String extItem : Constants.imgExtension) { + if (url.indexOf(extItem) != -1) { + return extItem; + } + } + return Constants.imgExtension[0]; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/ImgOption.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/ImgOption.java new file mode 100644 index 000000000..22fa2ad0b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/ImgOption.java @@ -0,0 +1,80 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import lombok.Builder; +import lombok.Data; +import lombok.experimental.Tolerate; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/31/22 + */ +@Data +@Builder +public class ImgOption { + // imgName one-01 + // imgNameWithExt one-01.jpg + // imgDest itwanger/Documents/GitHub/toBeBetterJavaer/images/nice-article/ + // objectName nice-article/one-01.jpg + // ossFolder tobebetterjavaer/images/ + + // 图片的源链接 https://img2018.cnblogs.com/blog/31085/201906/31085-20190607182832666-1215142380.png + private String imgOriginUrl; + // 图片名前缀 文件名 + private String imgNamePrefix; + // 图片名 one-01 + private String imgName; + // 图片后缀 .jpg + private String imgExt; + // 图片的分类 nice-article/ + private String imgCategory; + // 图片保存到本地的路径前缀 itwanger/Documents/GitHub/toBeBetterJavaer/images/ + private String imgDownloadDestPrefix; + + // 图片在 OSS 中的目录 tobebetterjavaer/images/ + private String imgOssFolder; + // 图片的描述 ![xxxx]() + private String imgDescription; + // 是否需要上传到 OSS + private boolean needUploadOss; + // CDN pre + private String imgCdnPrefix; + + @Tolerate + public ImgOption() {} + + /** + * @return itwanger/Documents/GitHub/toBeBetterJavaer/images/nice-article/one-01.jpg + */ + public String getImgDownloadDestComplete() { + return this.getImgDownloadDestPrefix() + this.getImgCategory() + Constants.fileSeparator + this.getImgName() + this.getImgExt(); + } + + /** + * @return itwanger/Documents/GitHub/toBeBetterJavaer/images/nice-article/ + */ + public String getImgDownloadCategoryComplete() { + return this.getImgDownloadDestPrefix() + this.getImgCategory(); + } + + public void setImgOriginUrl(String imgOriginUrl) { + this.imgOriginUrl = imgOriginUrl; + + // 是否需要上传到 OSS + this.setNeedUploadOss(OSSUtil.needUploadOss(this.getImgOriginUrl())); + + // 后缀 + this.setImgExt(ImageUtil.getImgExt(this.getImgOriginUrl())); + } + + // 图片在 OSS 中的 name nice-article/one-01.jpg + public String getImgOssObjectName() { + return this.getImgOssFolder() + this.getImgCategory() + "/"+ this.getImgName() + this.getImgExt(); + } + + // 图片的 CDN 链接 http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/one-01.png + public String getImgCndUrl() { + return this.getImgCdnPrefix() + this.getImgCategory() +"/" + this.getImgName() + this.getImgExt(); + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/OSSUtil.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/OSSUtil.java new file mode 100644 index 000000000..1e18601cd --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/OSSUtil.java @@ -0,0 +1,93 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown; + +import cn.hutool.core.lang.UUID; +import cn.hutool.http.HttpUtil; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.github.paicoding.forum.core.config.ImageProperties; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.File; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 微信搜索「沉默王二」,回复 Java + * + * @author 沉默王二 + * @date 5/31/22 + */ +@Slf4j +public class OSSUtil { + @Autowired + @Setter + @Getter + private static ImageProperties properties; + + public static boolean needUploadOss(String imageUrl) { + boolean flag = true; + for (String url : Constants.ossOrCdnUrls) { + if (imageUrl.indexOf(url) != -1) { + flag = false; + break; + } + } + return flag; + } + + public static String upload(String md, ImgOption imgOption) { + Pattern p = Pattern.compile(Constants.mdImgPattern, Pattern.CASE_INSENSITIVE); + Matcher m = p.matcher(md); + + + + OSS ossClient = new OSSClientBuilder().build(properties.getOss().getEndpoint(), properties.getOss().getAk(), properties.getOss().getSk()); + + while (m.find()) { + // 图片描述 + imgOption.setImgDescription(m.group(1)); + // 图片路径 + imgOption.setImgOriginUrl(m.group(2)); + // 图片名 + imgOption.setImgName(imgOption.getImgNamePrefix() + "-" + UUID.fastUUID()); + log.info("图片参数{}", imgOption); + + // 需要处理的图片链接 + // 设置路径的时候设置过了 + if (imgOption.isNeedUploadOss()) { + // imgName one-01 + // imgNameWithExt one-01.jpg + // 先下载到本地 + // imgDest itwanger/Documents/GitHub/toBeBetterJavaer/images/nice-article/one-01.jpg + + String temp = imgOption.getImgOriginUrl(); + if (imgOption.getImgNamePrefix().indexOf(HtmlSourceType.segmentfault.getName()) != -1 + && imgOption.getImgOriginUrl().indexOf(HtmlSourceType.segmentfault.getName()) == -1) { + temp = "https://segmentfault.com" + imgOption.getImgOriginUrl(); + log.info("思否{}", temp); + } + + if (imgOption.getImgNamePrefix().indexOf(HtmlSourceType.github.getName()) != -1 + && imgOption.getImgOriginUrl().indexOf(HtmlSourceType.github.getName()) == -1) { + temp = "https://github.com" + imgOption.getImgOriginUrl(); + log.info("GitHub{}", temp); + } + + // 替换链接 + md = md.replace(imgOption.getImgOriginUrl(), imgOption.getImgCndUrl()); + + File downloadImage = HttpUtil.downloadFileFromUrl(temp, imgOption.getImgDownloadDestComplete()); + + // objectName nice-article/one-01.jpg + // 目录+分类 + // ossFolder tobebetterjavaer/images/ + ossClient.putObject(Constants.bucketName, imgOption.getImgOssObjectName(), downloadImage); + + } + } + return md; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/Coverter.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/Coverter.java new file mode 100644 index 000000000..627970e4d --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/Coverter.java @@ -0,0 +1,16 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown.strategy; + +import com.github.paicoding.forum.web.javabetter.top.furstenheim.CopyDown; +import lombok.Data; +import org.jsoup.nodes.Document; + +@Data +public class Coverter { + private CopyDown copyDown; + private Document document; + + public Coverter(CopyDown copyDown, Document document) { + this.copyDown = copyDown; + this.document = document; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/DefaultUrlHandlerStrategy.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/DefaultUrlHandlerStrategy.java new file mode 100644 index 000000000..f5bd31e54 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/DefaultUrlHandlerStrategy.java @@ -0,0 +1,78 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown.strategy; + +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceOption; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceResult; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceType; +import com.github.paicoding.forum.web.javabetter.top.furstenheim.CopyDown; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.nodes.Document; +import org.jsoup.select.Elements; + +public class DefaultUrlHandlerStrategy extends Coverter implements UrlHandlerStrategy{ + public DefaultUrlHandlerStrategy(CopyDown copyDown, Document document) { + super(copyDown, document); + } + + @Override + public boolean match(String url) { + return true; + } + + @Override + public void handleOptions(HtmlSourceOption option) { + // 其他定制 + option.setHtmlSourceType(HtmlSourceType.OTHER); + // 字符串,用, 隔开 + String selector = StringUtils.joinWith(",", + ".blogpost-body", // 博客园 + ".article_content", // CSDN + ".article-wrapper", // 开发者社区阿里云 + "article.article-content", // 小白学堂,麦客搜 + ".article-content, .content-main", // 思否 infoq + ".post-topic-des, .post-content-box, .nc-post-content", // 牛客 + "article.markdown-body", // GitHub + ".e13l6k8o9", // LeetCode + // .writing-content 墨滴 + "writing-content", + // div.byte-viewer-container 开发者客栈 + "div.byte-viewer-container", + // div#x-content 廖雪峰 + "div#x-content", + // div.cloud-blog-detail-content-wrap 华为云 + "div.cloud-blog-detail-content-wrap", + // div#arc-body C 语言中文网 + "div#arc-body", + "div.Mid2L_con", // 游民星空 + "article"); + option.setContentSelector(selector); + } + + @Override + public HtmlSourceResult convertToMD(HtmlSourceOption option) { + Document doc = getDocument(); + + HtmlSourceResult result = HtmlSourceResult.builder().build(); + // 标题 + Elements title = doc.select(option.getTitleKey()); + result.setTitle(title.text()); + + // keywords + if (StringUtils.isNotBlank(option.getKeywordsKey())) { + Elements keywords = doc.select(option.getKeywordsKey()); + result.setKeywords(keywords.attr("content")); + } + + if (StringUtils.isNotBlank(option.getDescriptionKey())) { + // description + Elements description = doc.select(option.getDescriptionKey()); + result.setDescription(description.attr("content")); + } + + // 获取文章内容 + Elements content = doc.select(option.getContentSelector()); + String input = content.html(); + result.setMarkdown(getCopyDown().convert(input)); + + return result; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/JuejinUrlHandlerStrategy.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/JuejinUrlHandlerStrategy.java new file mode 100644 index 000000000..897e8f01b --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/JuejinUrlHandlerStrategy.java @@ -0,0 +1,118 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown.strategy; + +import cn.hutool.core.text.StrBuilder; +import cn.hutool.core.text.StrSplitter; +import cn.hutool.core.text.UnicodeUtil; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceOption; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceResult; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceType; +import com.github.paicoding.forum.web.javabetter.top.furstenheim.CopyDown; +import org.jsoup.nodes.DataNode; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class JuejinUrlHandlerStrategy extends Coverter implements UrlHandlerStrategy{ + + public JuejinUrlHandlerStrategy(CopyDown copyDown, Document document) { + super(copyDown, document); + } + + @Override + public boolean match(String url) { + return url.contains("juejin.cn"); + } + + @Override + public void handleOptions(HtmlSourceOption option) { + // 掘金的链接 + option.setHtmlSourceType(HtmlSourceType.JUEJIN); + option.setContentSelector("mark_content"); + option.setTitleKey("meta[itemprop='headline']"); + option.setAuthorKey("div[itemprop='author'] meta[itemprop='name']"); + } + + @Override + public HtmlSourceResult convertToMD(HtmlSourceOption option) { + HtmlSourceResult result = findJuejin(option); + List splits = StrSplitter.split(result.getMarkdown(), "\\n", 0, false, false); + + StrBuilder builder = StrBuilder.create(); + for (String str : splits) { + builder.append(str); + builder.append("\n"); + } + + String markdown = UnicodeUtil.toString(builder.toString()); + result.setMarkdown(markdown); + return result; + } + + /** + * 查找掘金文章标题、作者、封面图 + * + * @param option + * @return + */ + private HtmlSourceResult findJuejin(HtmlSourceOption option) { + HtmlSourceResult result = HtmlSourceResult.builder().build(); + Document doc = getDocument(); + // 标题 + Elements title = doc.select(option.getTitleKey()); + result.setTitle(title.attr("content")); + + // 作者名 + Elements authorName = doc.select(option.getAuthorKey()); + result.setAuthor(authorName.attr("content")); + + // keywords + Elements keywords = doc.select(option.getKeywordsKey()); + result.setKeywords(keywords.attr("content")); + + // description + Elements description = doc.select(option.getDescriptionKey()); + result.setDescription(description.attr("content")); + + // 转载链接 + result.setSourceLink(option.getUrl()); + + // 如果包含 .markdown-body 就直接从这里获取 + Elements markdownBody = doc.select(".markdown-body"); + if (markdownBody.size() > 0) { + String input = markdownBody.html(); + result.setMarkdown(getCopyDown().convert(input)); + return result; + } + + // 文章内容 + // 掘金的不是以 HTML 格式显示的,所以需要额外的处理 + // mark_content:" + // ,is_english:d,is_original:g,user_index:13.31714372615673,original_type:d,original_author:e,content:e,ctime:"1650429118",mtime:"1650858329",rtime:"1650435284",draft_id:"7088517368665604127",view_count:36440,collect_count:346,digg_count:340,comment_count:239,hot_index:2401,is_hot:d,rank_index:.3438144,status:g,verify_status:g,audit_status:k,mark_content:"---\ntheme: awesome-green\n---\n + // ",display_count:d} + // div.global-component-box + // audit_status:i,mark_content:"---\ntheme: devui-blue\n---\n\n\n# 自我介绍\n\n首页和大家介绍一下我,我叫阿杆(笔名及游戏名🤣),19级本科在读,双非院校,专业是数字媒体技术,但我主修软件工程,学习方向是后端开发,主要语👨‍💻。\n",display_count:b,is_markdown:g + Pattern mdPattern = Pattern.compile(option.getContentSelector()+":\"(.*)\",display_count"); + for (Element scripts : doc.getElementsByTag("script")) { + for (DataNode dataNode : scripts.dataNodes()) { + String wholeData = dataNode.getWholeData(); + log.info("juejin dataNode:{}", wholeData); + if (wholeData.contains(option.getContentSelector())) { + log.info("juejin contains"); + // 内容 + Matcher matcher = mdPattern.matcher(wholeData); + if (matcher.find()) { + String md = matcher.group(1); + log.info("find md text success{}", md); + result.setMarkdown(md); + return result; + } + } + } + } + return result; + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/UrlHandlerStrategy.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/UrlHandlerStrategy.java new file mode 100644 index 000000000..9d0e2c690 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/UrlHandlerStrategy.java @@ -0,0 +1,109 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown.strategy; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.file.FileWriter; +import cn.hutool.core.text.StrBuilder; +import cn.hutool.http.HttpUtil; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceOption; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceResult; +import com.github.paicoding.forum.web.javabetter.top.furstenheim.Pinyin4jUtil; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Paths; + +public interface UrlHandlerStrategy { + // 构造日志对象 + Logger log = LoggerFactory.getLogger(UrlHandlerStrategy.class); + + boolean match(String url); + void handleOptions(HtmlSourceOption option); + + HtmlSourceResult convertToMD(HtmlSourceOption option); + + default void md2file(HtmlSourceResult result) { + // 将标题转换为拼音 + String filename = Pinyin4jUtil.getFirstSpellPinYin(result.getTitle(), true); + log.info("filename{}", filename); + + StrBuilder builder = StrBuilder.create(); + builder.append("---\n"); + // 标题写入到文件中 + builder.append("title: ").append(result.getTitle()).append("\n"); + builder.append("shortTitle: ").append(result.getTitle()).append("\n"); + + boolean hasMeta = false; + if (StringUtils.isNotBlank(result.getDescription())) { + builder.append("description: ").append(result.getDescription()).append("\n"); + } + if(StringUtils.isNotBlank(result.getKeywords())) { + hasMeta = true; + builder.append("tag:" + "\n"); + builder.append(" - 优质文章" + "\n"); + } + + if(StringUtils.isNotBlank(result.getAuthor())) { + builder.append("author: ").append(result.getAuthor()).append("\n"); + } + + builder.append("category:\n"); + builder.append(" - ").append(result.getHtmlSourceType().getCategory()).append("\n"); + + if (hasMeta) { + builder.append("head:\n"); + } + + if (StringUtils.isNotBlank(result.getKeywords())) { + builder.append(" - - meta\n"); + builder.append(" - name: keywords\n"); + builder.append(" content: ").append(result.getKeywords()).append("\n"); + } + + builder.append("---\n\n"); + builder.append(result.getMarkdown()); + + if (StringUtils.isNotBlank(result.getSourceLink())) { + builder.append("\n\n>参考链接:[").append(result.getSourceLink()).append("](").append(result.getSourceLink()).append(")"); + builder.append(",整理:沉默王二\n"); + } + + // 准备写入到 MD 文档 + // category + String category = result.getHtmlSourceType().getName(); + log.info("category{}", category); + // 文件路径带文件名 + String mdPath = Paths.get(result.getFileDir(), category, filename + ".md").toString(); + + FileWriter writer = new FileWriter(mdPath); + writer.write(builder.toString()); + log.info("all done, category+filename: {}-{}", category, filename); + + // 调用默认文本编辑器打开文件 + try { + String pathToSublime = "/Applications/Sublime Text.app/Contents/MacOS/sublime_text"; + String[] command = {pathToSublime, mdPath}; + Runtime.getRuntime().exec(command); + +// CommandLine cmdLine = new CommandLine(pathToSublime); +// cmdLine.addArgument(mdPath); // false 表示不需要引号包围 +// +// DefaultExecutor executor = new DefaultExecutor(); +// executor.setExitValue(1); +// ExecuteWatchdog watchdog = new ExecuteWatchdog(60000); +// executor.setWatchdog(watchdog); +// executor.execute(cmdLine); + } catch (IOException e) { + log.error("open file error", e); + } + + // 下载封面图 + if (StringUtils.isNotBlank(result.getCover())) { + String coverPath = Paths.get(result.getImgDest(), category, filename + ".jpg").toString(); + long size = HttpUtil.downloadFile(result.getCover(), + FileUtil.file(coverPath)); + log.info("cover image size{}", size); + } + } +} diff --git a/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/WeixinUrlHandlerStrategy.java b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/WeixinUrlHandlerStrategy.java new file mode 100644 index 000000000..839ddaba0 --- /dev/null +++ b/paicoding-web/src/main/java/com/github/paicoding/forum/web/javabetter/top/copydown/strategy/WeixinUrlHandlerStrategy.java @@ -0,0 +1,128 @@ +package com.github.paicoding.forum.web.javabetter.top.copydown.strategy; + +import com.github.paicoding.forum.web.javabetter.top.copydown.Constants; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceOption; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceResult; +import com.github.paicoding.forum.web.javabetter.top.copydown.HtmlSourceType; +import com.github.paicoding.forum.web.javabetter.top.furstenheim.CopyDown; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.nodes.DataNode; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class WeixinUrlHandlerStrategy extends Coverter implements UrlHandlerStrategy{ + + public WeixinUrlHandlerStrategy(CopyDown copyDown, Document document) { + super(copyDown, document); + } + + @Override + public boolean match(String url) { + return url.contains("mp.weixin.qq.com"); + } + + @Override + public void handleOptions(HtmlSourceOption option) { + // 微信的链接 + option.setHtmlSourceType(HtmlSourceType.WEIXIN); + option.setContentSelector("div.rich_media_content"); + option.setCoverImageKey("msg_cdn_url"); + option.setTitleKey("msg_title"); + option.setNicknameKey("nickname"); + option.setAuthorKey("author"); + option.setKeywordsKey(""); + } + + @Override + public HtmlSourceResult convertToMD(HtmlSourceOption option) { + Document doc = getDocument(); + // 先找作者名,如果找到,不用找订阅号名了 + HtmlSourceResult result = findWeixinImgAndTitleAndNickname(option); + + String author = findWeixinAuthor(option.getAuthorKey()); + assert result != null; + result.setAuthor(author); + + Elements description = doc.select(option.getDescriptionKey()); + result.setDescription(description.attr("content")); + + // 获取文章内容 + Elements content = doc.select(option.getContentSelector()); + String input = content.html(); + result.setMarkdown(getCopyDown().convert(input)); + + return result; + } + + /** + * 查找微信文档作者 + * + * @param authorKey + * @return + */ + private String findWeixinAuthor(String authorKey) { + for (Element metaTag : getDocument().getElementsByTag("meta")) { + String content = metaTag.attr("content"); + String name = metaTag.attr("name"); + if (authorKey.equals(name)) { + return content; + } + } + + return Constants.DEFAULT_AUTHOR; + } + + /** + * 查找微信网页的封面图、标题、订阅号名 + * + * @param option + * @return + */ + private HtmlSourceResult findWeixinImgAndTitleAndNickname(HtmlSourceOption option) { + // get