feat: optimize source project pages and SEO

This commit is contained in:
王鹏
2026-08-25 11:08:59 +08:00
parent e528aa4876
commit 695fac5e3a
42 changed files with 5038 additions and 1025 deletions

View File

@@ -114,6 +114,10 @@ swagger:
# 请求前缀
pathMapping: /dev-api
# Search engine canonical URL configuration
seo:
site-url: https://www.yidaima.cn
# 防止XSS攻击
xss:
# 过滤开关

View File

@@ -14,6 +14,7 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.SysPermissionService;
import org.springframework.security.access.prepost.PreAuthorize;
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.PutMapping;
@@ -66,7 +67,7 @@ public class CmsBlogController extends BaseController
startPage();
//状态为发布
cmsBlog.setStatus("1");
List<CmsBlog> list = cmsBlogService.selectCmsBlogList(cmsBlog);
List<CmsBlog> list = cmsBlogService.selectCmsBlogCardList(cmsBlog);
return getDataTable(list);
}
@@ -74,7 +75,7 @@ public class CmsBlogController extends BaseController
* 首页获取文章详细信息
*/
@GetMapping(value = { "/cms/detail/", "/cms/detail/{id}" })
public AjaxResult getInfoDetail(@PathVariable(value = "id", required = false) Long id)
public ResponseEntity<AjaxResult> getInfoDetail(@PathVariable(value = "id", required = false) Long id)
{
AjaxResult ajax = AjaxResult.success();
CmsType cmsType = new CmsType();
@@ -83,9 +84,14 @@ public class CmsBlogController extends BaseController
ajax.put("tags", cmsTagService.selectCmsTagList(cmsTag));
if (StringUtils.isNotNull(id))
{
ajax.put(AjaxResult.DATA_TAG, cmsBlogService.selectCmsBlogById(id));
CmsBlog blog = cmsBlogService.selectPublishedCmsBlogById(id);
if (blog == null)
{
return ResponseEntity.notFound().build();
}
return ajax;
ajax.put(AjaxResult.DATA_TAG, blog);
}
return ResponseEntity.ok(ajax);
}
/**
@@ -127,14 +133,18 @@ public class CmsBlogController extends BaseController
* 首页增加阅读量
*/
@GetMapping("/cms/addBlogViews/{id}")
public AjaxResult addBlogViews(@PathVariable(value = "id", required = false) Long id)
public ResponseEntity<AjaxResult> addBlogViews(@PathVariable(value = "id", required = false) Long id)
{
CmsBlog cmsBlog = cmsBlogService.selectCmsBlogById(id);
CmsBlog cmsBlog = cmsBlogService.selectPublishedCmsBlogById(id);
if (cmsBlog == null)
{
return ResponseEntity.notFound().build();
}
Long views = cmsBlog.getViews();
views+=Long.parseLong("1");
views = views == null ? 1L : views + 1L;
cmsBlog.setViews(views);
cmsBlogService.updateCmsBlog(cmsBlog);
return AjaxResult.success(id);
return ResponseEntity.ok(AjaxResult.success(id));
}
/**

View File

@@ -8,6 +8,7 @@ package com.ruoyi.cms.blog.domain;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.cms.tag.domain.CmsTag;
/**
* @Author: WangNing
@@ -18,6 +19,7 @@ public class CmsBlogTag {
private Long tagId;
private Long blogId;
private Long[] tagIds;
private CmsTag tag;
public Long getTagId() {
return tagId;
@@ -43,6 +45,14 @@ public class CmsBlogTag {
this.tagIds = tagIds;
}
public CmsTag getTag() {
return tag;
}
public void setTag(CmsTag tag) {
this.tag = tag;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)

View File

@@ -8,6 +8,7 @@ package com.ruoyi.cms.blog.domain;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.cms.type.domain.CmsType;
/**
* @Author: WangNing
@@ -18,6 +19,7 @@ public class CmsBlogType {
private Long typeId;
private Long blogId;
private Long[] typeIds;
private CmsType type;
public Long getTypeId() {
return typeId;
@@ -43,6 +45,14 @@ public class CmsBlogType {
this.typeIds = typeIds;
}
public CmsType getType() {
return type;
}
public void setType(CmsType type) {
this.type = type;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)

View File

@@ -20,6 +20,11 @@ public interface CmsBlogMapper
*/
public CmsBlog selectCmsBlogById(Long id);
/**
* Query a public detail page. Drafts must behave like missing pages.
*/
public CmsBlog selectPublishedCmsBlogById(Long id);
/**
* 查询文章管理列表
*
@@ -28,6 +33,16 @@ public interface CmsBlogMapper
*/
public List<CmsBlog> selectCmsBlogList(CmsBlog cmsBlog);
/**
* 查询首页卡片所需的轻量字段
*/
public List<CmsBlog> selectCmsBlogCardList(CmsBlog cmsBlog);
/**
* 批量读取缺少封面的文章正文,用于提取首图
*/
public List<CmsBlog> selectCmsBlogContentByIds(List<Long> ids);
public List<CmsBlog> selectCmsBlogListBetweenCreateTime(@Param("title") String title,@Param("type") String type,@Param("top") String top,@Param("status") String status,@Param("createTimeBegin") String createTimeBegin,@Param("createTimeEnd") String createTimeEnd,@Param("createBy") String createBy);
/**

View File

@@ -27,6 +27,10 @@ public interface CmsBlogTagMapper {
* 查询文件列表
*/
public List<CmsBlogTag> selectBlogTagList(Long blogId);
/**
* 批量查询博客及其标签,避免列表页逐条查询
*/
public List<CmsBlogTag> selectBlogTagsByBlogIds(List<Long> blogIds);
/**
* 通过tagId删除blog文件关联
*/

View File

@@ -27,6 +27,10 @@ public interface CmsBlogTypeMapper {
* 查询博客列表
*/
public List<CmsBlogType> selectBlogTypeList(Long blogId);
/**
* 批量查询博客及其分类,避免列表页逐条查询
*/
public List<CmsBlogType> selectBlogTypesByBlogIds(List<Long> blogIds);
/**
* 通过typeId删除blog文件关联
*/

View File

@@ -19,6 +19,14 @@ public interface ICmsBlogService
*/
public CmsBlog selectCmsBlogById(Long id);
/**
* Query an indexable public detail page.
*
* @param id content primary key
* @return published content, or null when it must not be indexed
*/
public CmsBlog selectPublishedCmsBlogById(Long id);
/**
* 查询文章管理列表
*
@@ -27,6 +35,11 @@ public interface ICmsBlogService
*/
public List<CmsBlog> selectCmsBlogList(CmsBlog cmsBlog);
/**
* 查询首页项目卡片列表
*/
public List<CmsBlog> selectCmsBlogCardList(CmsBlog cmsBlog);
/**
* 查询推荐文章列表
*/

View File

@@ -1,7 +1,9 @@
package com.ruoyi.cms.blog.service.impl;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -54,6 +56,10 @@ public class CmsBlogServiceImpl implements ICmsBlogService
public CmsBlog selectCmsBlogById(Long id)
{
CmsBlog blog = cmsBlogMapper.selectCmsBlogById(id);
if (blog == null)
{
return null;
}
//查询标签列表
List<CmsBlogTag> blogTagList = cmsBlogTagMapper.selectBlogTagList(id);
Long[] tagIds = new Long[blogTagList.size()];
@@ -83,6 +89,13 @@ public class CmsBlogServiceImpl implements ICmsBlogService
return blog;
}
@Override
public CmsBlog selectPublishedCmsBlogById(Long id)
{
CmsBlog blog = selectCmsBlogById(id);
return blog != null && "1".equals(blog.getStatus()) ? blog : null;
}
/**
* 查询文章管理列表
*
@@ -103,18 +116,19 @@ public class CmsBlogServiceImpl implements ICmsBlogService
return blogList;
}
@Override
public List<CmsBlog> selectCmsBlogCardList(CmsBlog cmsBlog)
{
return prepareBlogCards(cmsBlogMapper.selectCmsBlogCardList(cmsBlog));
}
/**
* 查询推荐文章列表
*/
@Override
public List<CmsBlog> selectCmsBlogListRecommend(CmsBlog cmsBlog) {
List<CmsBlog> cmsBlogList = cmsBlogMapper.selectCmsBlogListRecommend(cmsBlog);
// 为每个博客设置首页图片(如果为空,则从content中提取)
for (CmsBlog blog : cmsBlogList) {
setDefaultBlogPicFromContent(blog);
}
List<CmsBlog> blogList = BlogListAddTypeAndTag(cmsBlogList);
return blogList;
return prepareBlogCards(cmsBlogList);
}
/**
@@ -123,12 +137,7 @@ public class CmsBlogServiceImpl implements ICmsBlogService
@Override
public List<CmsBlog> selectCmsBlogListByTypeId(Long id) {
List<CmsBlog> cmsBlogList = cmsBlogMapper.selectCmsBlogListByTypeId(id);
// 为每个博客设置首页图片(如果为空,则从content中提取)
for (CmsBlog blog : cmsBlogList) {
setDefaultBlogPicFromContent(blog);
}
List<CmsBlog> blogList = BlogListAddTypeAndTag(cmsBlogList);
return blogList;
return prepareBlogCards(cmsBlogList);
}
/**
@@ -137,12 +146,7 @@ public class CmsBlogServiceImpl implements ICmsBlogService
@Override
public List<CmsBlog> selectCmsBlogListByTagId(Long id) {
List<CmsBlog> cmsBlogList = cmsBlogMapper.selectCmsBlogListByTagId(id);
// 为每个博客设置首页图片(如果为空,则从content中提取)
for (CmsBlog blog : cmsBlogList) {
setDefaultBlogPicFromContent(blog);
}
List<CmsBlog> blogList = BlogListAddTypeAndTag(cmsBlogList);
return blogList;
return prepareBlogCards(cmsBlogList);
}
/**
@@ -307,30 +311,71 @@ public class CmsBlogServiceImpl implements ICmsBlogService
return null;
}
private List<CmsBlog> BlogListAddTypeAndTag(List<CmsBlog> cmsBlogList){
if (cmsBlogList==null||cmsBlogList.size()<0){
/**
* 列表不返回正文;仅对没有独立封面的项目批量读取正文并提取首图。
*/
private List<CmsBlog> prepareBlogCards(List<CmsBlog> cmsBlogList) {
if (cmsBlogList == null || cmsBlogList.isEmpty()) {
return cmsBlogList;
}
Map<Long, CmsBlog> missingCoverBlogs = new HashMap<>();
for (CmsBlog blog : cmsBlogList) {
Long blogId = blog.getId();
//查询标签列表
List<CmsBlogTag> blogTagList = cmsBlogTagMapper.selectBlogTagList(blogId);
List<CmsTag> cmsTagList = new ArrayList<>();
blogTagList.forEach((CmsBlogTag cmsBlogTag)->{
Long tagId = cmsBlogTag.getTagId();
CmsTag cmsTag = cmsTagMapper.selectCmsTagByTagId(tagId);
cmsTagList.add(cmsTag);
});
blog.setTags(cmsTagList);
//查询分类列表
List<CmsBlogType> blogTypeList = cmsBlogTypeMapper.selectBlogTypeList(blogId);
List<CmsType> cmsTypeList = new ArrayList<>();
blogTypeList.forEach((CmsBlogType cmsBlogType)->{
Long typeId = cmsBlogType.getTypeId();
CmsType cmsType = cmsTypeMapper.selectCmsTypeByTypeId(typeId);
cmsTypeList.add(cmsType);
});
blog.setTypes(cmsTypeList);
boolean hasCover = (blog.getBlogPic() != null && !blog.getBlogPic().trim().isEmpty())
|| (blog.getBlogPicLink() != null && !blog.getBlogPicLink().trim().isEmpty());
if (!hasCover) {
missingCoverBlogs.put(blog.getId(), blog);
}
}
if (!missingCoverBlogs.isEmpty()) {
List<Long> ids = new ArrayList<>(missingCoverBlogs.keySet());
for (CmsBlog contentBlog : cmsBlogMapper.selectCmsBlogContentByIds(ids)) {
CmsBlog targetBlog = missingCoverBlogs.get(contentBlog.getId());
if (targetBlog == null || contentBlog.getContent() == null) {
continue;
}
String firstImageUrl = extractFirstImageFromContent(contentBlog.getContent());
if (firstImageUrl != null && !firstImageUrl.trim().isEmpty()) {
targetBlog.setBlogPicType("0");
targetBlog.setBlogPicLink(firstImageUrl);
}
}
}
return BlogListAddTypeAndTag(cmsBlogList);
}
private List<CmsBlog> BlogListAddTypeAndTag(List<CmsBlog> cmsBlogList){
if (cmsBlogList == null || cmsBlogList.isEmpty()) {
return cmsBlogList;
}
List<Long> blogIds = new ArrayList<>();
Map<Long, List<CmsTag>> tagsByBlogId = new HashMap<>();
Map<Long, List<CmsType>> typesByBlogId = new HashMap<>();
for (CmsBlog blog : cmsBlogList) {
blogIds.add(blog.getId());
tagsByBlogId.put(blog.getId(), new ArrayList<>());
typesByBlogId.put(blog.getId(), new ArrayList<>());
}
for (CmsBlogTag relation : cmsBlogTagMapper.selectBlogTagsByBlogIds(blogIds)) {
List<CmsTag> tags = tagsByBlogId.get(relation.getBlogId());
if (tags != null && relation.getTag() != null) {
tags.add(relation.getTag());
}
}
for (CmsBlogType relation : cmsBlogTypeMapper.selectBlogTypesByBlogIds(blogIds)) {
List<CmsType> types = typesByBlogId.get(relation.getBlogId());
if (types != null && relation.getType() != null) {
types.add(relation.getType());
}
}
for (CmsBlog blog : cmsBlogList) {
blog.setTags(tagsByBlogId.get(blog.getId()));
blog.setTypes(typesByBlogId.get(blog.getId()));
}
return cmsBlogList;
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.cms.sitemap.controller;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import com.ruoyi.cms.sitemap.service.SitemapService;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Public XML sitemap endpoint.
*/
@RestController
public class SitemapController
{
private static final MediaType APPLICATION_XML_UTF8 =
new MediaType("application", "xml", StandardCharsets.UTF_8);
private final SitemapService sitemapService;
public SitemapController(SitemapService sitemapService)
{
this.sitemapService = sitemapService;
}
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> sitemap()
{
return ResponseEntity.ok()
.contentType(APPLICATION_XML_UTF8)
.cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic())
.body(sitemapService.buildSitemap());
}
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.cms.sitemap.domain;
import java.util.Date;
/**
* A canonical, indexable URL source used to build the public sitemap.
*/
public class SitemapUrl
{
private Long id;
private String pageType;
private Date lastModified;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public String getPageType()
{
return pageType;
}
public void setPageType(String pageType)
{
this.pageType = pageType;
}
public Date getLastModified()
{
return lastModified;
}
public void setLastModified(Date lastModified)
{
this.lastModified = lastModified;
}
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.cms.sitemap.mapper;
import java.util.List;
import com.ruoyi.cms.sitemap.domain.SitemapUrl;
/**
* Queries only URLs that have a real, published public page.
*/
public interface SitemapMapper
{
public List<SitemapUrl> selectIndexableCategories();
public List<SitemapUrl> selectIndexableContent();
}

View File

@@ -0,0 +1,128 @@
package com.ruoyi.cms.sitemap.service;
import java.io.StringWriter;
import java.net.URI;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import com.ruoyi.cms.sitemap.domain.SitemapUrl;
import com.ruoyi.cms.sitemap.mapper.SitemapMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* Builds the UTF-8 sitemap from canonical URLs backed by published data.
*/
@Service
public class SitemapService
{
private static final String SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9";
private final SitemapMapper sitemapMapper;
private final String siteUrl;
public SitemapService(SitemapMapper sitemapMapper,
@Value("${seo.site-url:https://www.yidaima.cn}") String siteUrl)
{
this.sitemapMapper = sitemapMapper;
this.siteUrl = normalizeSiteUrl(siteUrl);
}
public String buildSitemap()
{
Map<String, SitemapUrl> canonicalUrls = new LinkedHashMap<>();
addUrls(canonicalUrls, sitemapMapper.selectIndexableCategories());
addUrls(canonicalUrls, sitemapMapper.selectIndexableContent());
try
{
StringWriter output = new StringWriter();
XMLStreamWriter xml = XMLOutputFactory.newFactory().createXMLStreamWriter(output);
xml.writeStartDocument("UTF-8", "1.0");
xml.writeStartElement("urlset");
xml.writeDefaultNamespace(SITEMAP_NAMESPACE);
for (Map.Entry<String, SitemapUrl> entry : canonicalUrls.entrySet())
{
xml.writeStartElement("url");
writeElement(xml, "loc", siteUrl + entry.getKey());
if (entry.getValue().getLastModified() != null)
{
writeElement(xml, "lastmod",
DateTimeFormatter.ISO_INSTANT.format(entry.getValue().getLastModified().toInstant()));
}
xml.writeEndElement();
}
xml.writeEndElement();
xml.writeEndDocument();
xml.close();
return output.toString();
}
catch (XMLStreamException exception)
{
throw new IllegalStateException("Unable to generate sitemap XML", exception);
}
}
private void addUrls(Map<String, SitemapUrl> canonicalUrls, List<SitemapUrl> urls)
{
if (urls == null)
{
return;
}
for (SitemapUrl url : urls)
{
if (url == null || url.getId() == null || !isSupportedPageType(url.getPageType()))
{
continue;
}
canonicalUrls.putIfAbsent("/" + url.getPageType() + "/" + url.getId(), url);
}
}
private boolean isSupportedPageType(String pageType)
{
return "category".equals(pageType) || "source".equals(pageType) || "article".equals(pageType);
}
private void writeElement(XMLStreamWriter xml, String name, String value) throws XMLStreamException
{
xml.writeStartElement(name);
xml.writeCharacters(value);
xml.writeEndElement();
}
private String normalizeSiteUrl(String configuredSiteUrl)
{
String value = configuredSiteUrl == null ? "" : configuredSiteUrl.trim();
while (value.endsWith("/"))
{
value = value.substring(0, value.length() - 1);
}
URI uri;
try
{
uri = URI.create(value);
}
catch (IllegalArgumentException exception)
{
throw new IllegalStateException("seo.site-url must be an absolute HTTP(S) URL", exception);
}
if (uri.getHost() == null
|| (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme()))
|| uri.getQuery() != null || uri.getFragment() != null)
{
throw new IllegalStateException("seo.site-url must be an absolute HTTP(S) URL without query or fragment");
}
return value;
}
}

View File

@@ -47,12 +47,7 @@ public class CmsTagServiceImpl implements ICmsTagService
@Override
public List<CmsTag> selectCmsTagList(CmsTag cmsTag)
{
List<CmsTag> cmsTagList = cmsTagMapper.selectCmsTagList(cmsTag);
for (CmsTag tag : cmsTagList) {
int blogNum = cmsBlogTagMapper.countBlogByTagId(tag.getTagId());
tag.setBlogNum(blogNum);
}
return cmsTagList;
return cmsTagMapper.selectCmsTagList(cmsTag);
}
/**

View File

@@ -51,12 +51,7 @@ public class CmsTypeServiceImpl implements ICmsTypeService
@Override
public List<CmsType> selectCmsTypeList(CmsType cmsType)
{
List<CmsType> cmsTypeList = cmsTypeMapper.selectCmsTypeList(cmsType);
for (CmsType type : cmsTypeList) {
int blogNum = cmsBlogTypeMapper.countBlogByTypeId(type.getTypeId());
type.setBlogNum(blogNum);
}
return cmsTypeList;
return cmsTypeMapper.selectCmsTypeList(cmsType);
}
/**

View File

@@ -29,6 +29,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select id, create_by, create_time, update_by, update_time, title, type, content, content_type, content_markdown, Top, views, status, blog_pic_type, blog_pic, blog_pic_link, blog_desc, blog_files from cms_blog
</sql>
<sql id="selectCmsBlogCardVo">
select id, create_by, create_time, update_time, title, type, Top, views, status,
blog_pic_type, blog_pic, blog_pic_link, blog_desc
from cms_blog
</sql>
<select id="selectCmsBlogList" parameterType="CmsBlog" resultMap="CmsBlogResult">
<include refid="selectCmsBlogVo"/>
<where>
@@ -43,24 +49,54 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
ORDER BY Top DESC, create_time DESC
</select>
<select id="selectCmsBlogCardList" parameterType="CmsBlog" resultMap="CmsBlogResult">
<include refid="selectCmsBlogCardVo"/>
<where>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="type != null and type != ''"> and type = #{type}</if>
<if test="top != null and top != ''"> and Top = #{top}</if>
<if test="views != null "> and views = #{views}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
<if test="createBy != null and createBy != ''"> and create_by = #{createBy}</if>
</where>
ORDER BY Top DESC, create_time DESC
</select>
<select id="selectCmsBlogContentByIds" resultMap="CmsBlogResult">
select id, content
from cms_blog
where id in
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<select id="selectCmsBlogById" parameterType="Long" resultMap="CmsBlogResult">
<include refid="selectCmsBlogVo"/>
where id = #{id}
</select>
<select id="selectCmsBlogListByTypeId" parameterType="Long" resultMap="CmsBlogResult">
<select id="selectPublishedCmsBlogById" parameterType="Long" resultMap="CmsBlogResult">
<include refid="selectCmsBlogVo"/>
where id = #{id} and status = '1'
</select>
<select id="selectCmsBlogListByTypeId" parameterType="Long" resultMap="CmsBlogResult">
<include refid="selectCmsBlogCardVo"/>
<where>
<if test="id != null and id != ''"> and id in (SELECT blog_id FROM cms_blog_type WHERE type_id = #{id})</if>
and status = '1'
</where>
ORDER BY Top DESC, create_time DESC
</select>
<select id="selectCmsBlogListByTagId" parameterType="Long" resultMap="CmsBlogResult">
<include refid="selectCmsBlogVo"/>
<include refid="selectCmsBlogCardVo"/>
<where>
<if test="id != null and id != ''"> and id in (SELECT blog_id FROM cms_blog_tag WHERE tag_id = #{id})</if>
and status = '1'
</where>
ORDER BY Top DESC, create_time DESC
</select>
<select id="selectCmsBlogListRecommend" parameterType="CmsBlog" resultMap="CmsBlogResult">
<include refid="selectCmsBlogVo"/>
<include refid="selectCmsBlogCardVo"/>
<where>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="type != null and type != ''"> and type = #{type}</if>

View File

@@ -5,6 +5,14 @@
<result property="tagId" column="tag_id" />
<result property="blogId" column="blog_id" />
</resultMap>
<resultMap type="cmsBlogTag" id="blogTagWithTagResult">
<result property="tagId" column="tag_id" />
<result property="blogId" column="blog_id" />
<association property="tag" javaType="com.ruoyi.cms.tag.domain.CmsTag">
<id property="tagId" column="tag_id" />
<result property="tagName" column="tag_name" />
</association>
</resultMap>
<insert id="batchBlogTag">
insert into cms_blog_tag(tag_id, blog_id) values
@@ -30,6 +38,15 @@
<select id="selectBlogTagList" parameterType="Long" resultMap="blogTagResult">
select tag_id, blog_id from cms_blog_tag where blog_id = #{blogId}
</select>
<select id="selectBlogTagsByBlogIds" resultMap="blogTagWithTagResult">
select bt.blog_id, t.tag_id, t.tag_name
from cms_blog_tag bt
inner join cms_tag t on t.tag_id = bt.tag_id
where bt.blog_id in
<foreach collection="list" item="blogId" open="(" separator="," close=")">
#{blogId}
</foreach>
</select>
<select id="countBlogByTagId" parameterType="Long" resultType="java.lang.Integer">
select count(*) from cms_blog_tag where tag_id=#{tagId}
</select>

View File

@@ -5,6 +5,14 @@
<result property="typeId" column="type_id" />
<result property="blogId" column="blog_id" />
</resultMap>
<resultMap type="CmsBlogType" id="blogTypeWithTypeResult">
<result property="typeId" column="type_id" />
<result property="blogId" column="blog_id" />
<association property="type" javaType="com.ruoyi.cms.type.domain.CmsType">
<id property="typeId" column="type_id" />
<result property="typeName" column="type_name" />
</association>
</resultMap>
<insert id="batchBlogType">
insert into cms_blog_type(type_id, blog_id) values
@@ -30,6 +38,15 @@
<select id="selectBlogTypeList" parameterType="Long" resultMap="blogTypeResult">
select type_id, blog_id from cms_blog_type where blog_id = #{blogId}
</select>
<select id="selectBlogTypesByBlogIds" resultMap="blogTypeWithTypeResult">
select bt.blog_id, t.type_id, t.type_name
from cms_blog_type bt
inner join cms_type t on t.type_id = bt.type_id
where bt.blog_id in
<foreach collection="list" item="blogId" open="(" separator="," close=")">
#{blogId}
</foreach>
</select>
<select id="countBlogByTypeId" parameterType="Long" resultType="java.lang.Integer">
select count(*) from cms_blog_type where type_id=#{typeId}
</select>

View File

@@ -11,6 +11,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="tagName" column="tag_name" />
<result property="blogNum" column="blog_num" />
</resultMap>
<sql id="selectCmsTagVo">
@@ -18,12 +19,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</sql>
<select id="selectCmsTagList" parameterType="CmsTag" resultMap="CmsTagResult">
<include refid="selectCmsTagVo"/>
select t.tag_id, t.create_by, t.create_time, t.update_by, t.update_time, t.tag_name,
count(bt.blog_id) as blog_num
from cms_tag t
left join cms_blog_tag bt on bt.tag_id = t.tag_id
<where>
<if test="tagName != null and tagName != ''"> and tag_name like concat('%', #{tagName}, '%')</if>
<if test="createBy != null and createBy != ''"> and create_by = #{createBy}</if>
<if test="tagName != null and tagName != ''"> and t.tag_name like concat('%', #{tagName}, '%')</if>
<if test="createBy != null and createBy != ''"> and t.create_by = #{createBy}</if>
</where>
ORDER BY create_time DESC
group by t.tag_id, t.create_by, t.create_time, t.update_by, t.update_time, t.tag_name
ORDER BY t.create_time DESC
</select>
<select id="selectCmsTagByTagId" parameterType="Long" resultMap="CmsTagResult">

View File

@@ -14,6 +14,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="typePicType" column="type_pic_type" />
<result property="typePic" column="type_pic" />
<result property="typePicLink" column="type_pic_link" />
<result property="blogNum" column="blog_num" />
</resultMap>
<sql id="selectCmsTypeVo">
@@ -21,13 +22,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</sql>
<select id="selectCmsTypeList" parameterType="CmsType" resultMap="CmsTypeResult">
<include refid="selectCmsTypeVo"/>
select t.type_id, t.create_by, t.create_time, t.update_by, t.update_time, t.type_name,
t.type_pic_type, t.type_pic, t.type_pic_link, count(bt.blog_id) as blog_num
from cms_type t
left join cms_blog_type bt on bt.type_id = t.type_id
<where>
<if test="typeName != null and typeName != ''"> and type_name like concat('%', #{typeName}, '%')</if>
<if test="typePic != null and typePic != ''"> and type_pic = #{typePic}</if>
<if test="createBy != null and createBy != ''"> and create_by = #{createBy}</if>
<if test="typeName != null and typeName != ''"> and t.type_name like concat('%', #{typeName}, '%')</if>
<if test="typePic != null and typePic != ''"> and t.type_pic = #{typePic}</if>
<if test="createBy != null and createBy != ''"> and t.create_by = #{createBy}</if>
</where>
ORDER BY create_time DESC
group by t.type_id, t.create_by, t.create_time, t.update_by, t.update_time, t.type_name,
t.type_pic_type, t.type_pic, t.type_pic_link
ORDER BY t.create_time DESC
</select>
<select id="selectCmsTypeByTypeId" parameterType="Long" resultMap="CmsTypeResult">

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.cms.sitemap.mapper.SitemapMapper">
<resultMap type="com.ruoyi.cms.sitemap.domain.SitemapUrl" id="SitemapUrlResult">
<result property="id" column="id" />
<result property="pageType" column="page_type" />
<result property="lastModified" column="last_modified" />
</resultMap>
<!-- Empty categories do not have an indexable category page. -->
<select id="selectIndexableCategories" resultMap="SitemapUrlResult">
SELECT
t.type_id AS id,
'category' AS page_type,
CASE
WHEN COALESCE(t.update_time, t.create_time) > MAX(COALESCE(b.update_time, b.create_time))
THEN COALESCE(t.update_time, t.create_time)
ELSE MAX(COALESCE(b.update_time, b.create_time))
END AS last_modified
FROM cms_type t
INNER JOIN cms_blog_type bt ON bt.type_id = t.type_id
INNER JOIN cms_blog b ON b.id = bt.blog_id AND b.status = '1'
GROUP BY t.type_id, t.update_time, t.create_time
ORDER BY t.type_id
</select>
<!-- Published content has exactly one article or source canonical URL. -->
<select id="selectIndexableContent" resultMap="SitemapUrlResult">
SELECT
b.id,
CASE
WHEN b.blog_files IS NOT NULL
AND LOWER(REPLACE(TRIM(b.blog_files), ' ', '')) NOT IN ('', '[]', 'null')
THEN 'source'
ELSE 'article'
END AS page_type,
COALESCE(b.update_time, b.create_time) AS last_modified
FROM cms_blog b
WHERE b.status = '1'
ORDER BY b.id
</select>
</mapper>

View File

@@ -97,6 +97,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
// 过滤请求
.authorizeRequests()
.antMatchers("/profile/**").permitAll()
// Public sitemap must be crawlable without a token.
.antMatchers(HttpMethod.GET, "/sitemap.xml").permitAll()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").anonymous()
// 首页获取文章信息 允许匿名访问

View File

@@ -6,3 +6,6 @@ ENV = 'production'
# 若依管理系统/生产环境
VUE_APP_BASE_API = '/prod-api'
# Canonical host used by public pages and the XML sitemap
VUE_APP_SITE_URL = 'https://www.yidaima.cn'

View File

@@ -6,7 +6,8 @@
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= webpackConfig.name %></title>
<title>南音源码库 - Java、Python、前端项目源码与开发教程</title>
<meta name="description" content="南音源码库提供 Java、Python、Vue 等项目源码、开发教程、运行说明和技术文档,帮助开发者学习与快速搭建项目。">
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<style>
html,

View File

@@ -1,2 +1,9 @@
User-agent: *
Disallow: /
Allow: /
Disallow: /login
Disallow: /cmsLogin
Disallow: /cmsRegister
Disallow: /admin/
Sitemap: https://www.yidaima.cn/sitemap.xml

View File

@@ -8,11 +8,20 @@
export default {
name: 'App',
metaInfo() {
const meta = []
if (this.$route.meta.public !== true) {
meta.push({
vmid: 'robots',
name: 'robots',
content: 'noindex,nofollow'
})
}
return {
title: this.$store.state.settings.dynamicTitle && this.$store.state.settings.title,
titleTemplate: title => {
return title ? `${title} - ${process.env.VUE_APP_TITLE}` : process.env.VUE_APP_TITLE
}
},
meta
}
}
}

View File

@@ -1,5 +1,5 @@
import request from '@/utils/request'
import { praseStrEmpty } from "@/utils/ruoyi";
import { praseStrEmpty } from '@/utils/ruoyi'
// 首页查询文章列表
export function cmsListBlog(query) {
@@ -27,25 +27,27 @@ export function getBlogDetail(id) {
}
// 首页按分类查询文章列表
export function cmsListByTypeId(id) {
export function cmsListByTypeId(id, query) {
return request({
url: '/cms/blog/cms/cmsListByType/' + id,
headers: {
isToken: false
},
method: 'get',
params: query,
timeout: 40000
})
}
// 首页按标签查询文章列表
export function cmsListByTagId(id) {
export function cmsListByTagId(id, query) {
return request({
url: '/cms/blog/cms/cmsListByTag/' + id,
headers: {
isToken: false
},
method: 'get',
params: query,
timeout: 40000
})
}

View File

@@ -38,7 +38,7 @@ router.beforeEach((to, from, next) => {
}
} else {
// 没有token
if (whiteList.indexOf(to.path) !== -1) {
if (whiteList.indexOf(to.path) !== -1 || to.meta.public === true) {
// 在免登录白名单,直接进入
next()
} else {

View File

@@ -36,12 +36,82 @@ export const constantRoutes = [{
component: (resolve) => require(['@/views/redirect'], resolve)
}]
},
{
path: '/',
component: (resolve) => require(['@/views/cms/index'], resolve),
hidden: true,
children: [
{
path: '',
component: (resolve) => require(['@/views/cms/components/cmsIndex'], resolve),
name: 'home',
meta: {
title: '首页',
public: true
}
}
]
},
{
path: '/cms',
component: (resolve) => require(['@/views/cms/index'], resolve),
hidden: true,
redirect: '/cms/main/cmsIndex',
redirect: '/',
children: [
{
path: '/projects',
component: (resolve) => require(['@/views/cms/components/cmsIndex'], resolve),
name: 'projects',
meta: {
title: '源码项目',
public: true
}
},
{
path: '/categories',
component: (resolve) => require(['@/views/cms/components/cmsCategories'], resolve),
name: 'categories',
meta: {
title: '项目分类',
public: true
}
},
{
path: '/category/:typeId(\\d+)',
component: (resolve) => require(['@/views/cms/components/cmsIndex'], resolve),
name: 'category',
meta: {
title: '分类',
public: true
}
},
{
path: '/tag/:tagId(\\d+)',
component: (resolve) => require(['@/views/cms/components/cmsIndex'], resolve),
name: 'tag',
meta: {
title: '标签',
public: true
}
},
{
path: '/article/:id(\\d+)',
component: (resolve) => require(['@/views/cms/components/cmsBlog'], resolve),
name: 'article',
meta: {
title: '源码项目详情',
public: true
}
},
{
path: '/source/:id(\\d+)',
component: (resolve) => require(['@/views/cms/components/cmsBlog'], resolve),
name: 'source',
meta: {
title: '源码项目详情',
public: true
}
},
{
path: 'main',
component: (resolve) => require(['@/views/cms/main'], resolve),
@@ -49,11 +119,7 @@ export const constantRoutes = [{
children: [
{
path: 'cmsIndex',
component: (resolve) => require(['@/views/cms/components/cmsIndex'], resolve),
name: 'cmsIndex',
meta: {
title: '首页',
}
redirect: '/'
},
{
path: 'essay',
@@ -77,6 +143,7 @@ export const constantRoutes = [{
name: 'blog',
meta: {
title: '博客详情',
public: true
}
}
]
@@ -125,11 +192,11 @@ export const constantRoutes = [{
hidden: true
},
{
path: '',
path: '/dashboard',
component: Layout,
redirect: '/cms',
redirect: '/index',
children: [{
path: 'index',
path: '/index',
component: (resolve) => require(['@/views/index_v2'], resolve),
name: 'Index',
meta: {

128
ruoyi-ui/src/utils/seo.js Normal file
View File

@@ -0,0 +1,128 @@
const configuredSiteUrl = process.env.VUE_APP_SITE_URL
const siteName = '南音源码库'
export function siteUrl() {
const fallback = typeof window !== 'undefined' ? window.location.origin : ''
return (configuredSiteUrl || fallback).replace(/\/+$/, '')
}
export function isSourceContent(blog) {
if (!blog || blog.blogFiles == null) {
return false
}
if (Array.isArray(blog.blogFiles)) {
return blog.blogFiles.length > 0
}
if (typeof blog.blogFiles !== 'string') {
return true
}
const value = blog.blogFiles.trim()
if (!value || value === '[]' || value.toLowerCase() === 'null') {
return false
}
try {
const files = JSON.parse(value)
return Array.isArray(files) ? files.length > 0 : files != null
} catch (error) {
return true
}
}
export function contentPath(blog) {
if (!blog || blog.id == null) {
return '/'
}
return `/source/${blog.id}`
}
export function canonicalUrl(path) {
return `${siteUrl()}${path}`
}
export function absoluteUrl(value) {
if (!value) {
return ''
}
if (/^https?:\/\//i.test(value)) {
return value
}
const path = value.startsWith('/') ? value : `/${value}`
return canonicalUrl(path)
}
export function blogImageUrl(blog) {
if (!blog) {
return ''
}
if (blog.blogPicType === '0' && blog.blogPicLink) {
return absoluteUrl(blog.blogPicLink)
}
if (!blog.blogPic) {
return ''
}
if (/^https?:\/\//i.test(blog.blogPic)) {
return blog.blogPic
}
const apiBase = process.env.VUE_APP_BASE_API || ''
const imagePath = blog.blogPic.startsWith(apiBase)
? blog.blogPic
: `${apiBase}${blog.blogPic.startsWith('/') ? '' : '/'}${blog.blogPic}`
return absoluteUrl(imagePath)
}
export function plainText(value) {
if (!value) {
return ''
}
return String(value)
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\s+/g, ' ')
.trim()
}
export function seoDescription(blog, maxLength = 160) {
const source = blog && (
blog.blogDesc ||
blog.contentMarkdown ||
blog.content
)
const text = plainText(source)
if (text.length <= maxLength) {
return text
}
return `${text.slice(0, maxLength - 1).trim()}…`
}
export function isoDate(value) {
if (!value) {
return undefined
}
const normalized = String(value).replace(' ', 'T')
const parsed = new Date(normalized)
return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString()
}
export function siteIdentity() {
return {
name: siteName,
url: canonicalUrl('/')
}
}
export function jsonLdScript(vmid, json) {
return {
vmid,
type: 'application/ld+json',
json
}
}

View File

@@ -1,18 +1,70 @@
<template>
<div class="footer">
<span style="color: #FFFFFF;line-height:40px;font-size: 12px;">Copyright © 2024-2025 www.yidaima.cn 版权所有</span>
<span style="color: #FFFFFF;line-height:40px;font-size: 12px;">工信部备案:蒙ICP备17001571号</span>
<footer class="site-footer">
<div class="footer-wrap">
<div class="footer-brand">
<span>&lt;/&gt;</span>
<div>
<strong>南音源码库</strong>
<small>可运行的项目源码与配套资料</small>
</div>
</div>
<div class="footer-meta">
<span>Copyright © 2024-2026 yidaima.cn</span>
<span>蒙ICP备17001571号</span>
</div>
</div>
</footer>
</template>
<script>
export default {
name: 'cmsFooter',
name: 'cmsFooter'
}
</script>
<style scoped>
.footer {
text-align: center;
.site-footer {
position: relative;
z-index: 1;
color: rgba(255, 255, 255, .62);
background: #132238;
}
.footer-wrap {
width: min(1180px, calc(100% - 40px));
min-height: 76px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
}
.footer-brand > span {
color: #ef6a3a;
font: 700 12px/1 Consolas, monospace;
}
.footer-brand div,
.footer-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.footer-brand strong { color: #fff; font-size: 13px; }
.footer-brand small,
.footer-meta { font-size: 9px; }
.footer-meta { align-items: flex-end; }
@media (max-width: 620px) {
.footer-wrap { padding: 18px 0; align-items: flex-start; flex-direction: column; }
.footer-meta { align-items: flex-start; }
}
</style>

View File

@@ -1,438 +1,433 @@
<template>
<el-header :style="'margin-bottom:'+ headerBottom +'px'">
<h2 class="logo" @click="goToHome"><svg-icon icon-class="guide" /> 南音源码库</h2>
<div class="bg-purple-light">
<el-menu :default-active="activeIndex" router class="el-menu-demo" mode="horizontal" style="border: none;"
background-color="rgba(0,0,0,0)" text-color="#fff" active-text-color="#ffd04b">
<el-menu-item index="/cms/main/cmsIndex"><i class="el-icon-s-home" style="color: rgba(255, 255, 255);"></i>首页
</el-menu-item>
<el-menu-item :index="item.path" v-for="item in menulist" :key="item.id">
<!-- 图标-->
<i :class="item.icon" style="color: rgba(255, 255, 255);"></i>
<!-- 文本-->
{{item.authName}}
</el-menu-item>
</el-menu>
</div>
<div class="bg-purple-light el-menu-hidden" v-if="menuHiddenVisiable">
<el-menu :default-active="activeIndex" router background-color="rgba(84,92,100,0.5)" text-color="#fff"
active-text-color="#ffd04b">
<el-menu-item index="/cms/main/cmsIndex" @click="menuAway"><i class="el-icon-s-home"
style="color: rgba(255, 255, 255);"></i>首页</el-menu-item>
<el-menu-item :index="item.path" v-for="item in menulist" :key="item.id" @click="menuAway">
<!-- 图标-->
<i :class="item.icon" style="color: rgba(255, 255, 255);"></i>
<!-- 文本-->
{{item.authName}}
</el-menu-item>
</el-menu>
</div>
<header class="site-header">
<div class="nav-wrap">
<router-link to="/" class="brand" aria-label="南音源码库首页">
<span class="brand-mark"><span>&lt;/&gt;</span></span>
<span class="brand-copy">
<strong>南音源码库</strong>
<small>CODE · NOTES · IDEAS</small>
</span>
</router-link>
<div class="menu-expend" @click="menuExpend">
<i class="el-icon-menu" style="color: rgba(255, 255, 255);"></i>
</div>
<nav class="desktop-nav" aria-label="主导航">
<router-link exact to="/">首页</router-link>
<router-link v-for="item in menulist" :key="item.id" :to="item.path">
{{ item.authName }}
</router-link>
</nav>
<div v-if="searchInput" class="search_input">
<el-input @focus="checkInput" @blur="notSearching()" class="search" placeholder="搜索博客"
prefix-icon="el-icon-search" v-model="queryInfo.query" size="mini">
</el-input>
<ul v-if="searching">
<li class="animate__animated animate__fadeInDown search-blog" v-for="blog in searchList" :key="blog.id"
@click="getBlogInfo(blog.id)">
<a><span v-html="blog.title"></span></a>
<div class="nav-actions">
<div class="search-box">
<i class="el-icon-search"></i>
<input
v-model.trim="queryInfo.query"
type="search"
placeholder="搜索名称、编号或技术栈"
aria-label="搜索源码项目"
@focus="checkInput"
@blur="notSearching"
>
<ul v-if="searching" class="search-results">
<li v-for="blog in searchList" :key="blog.id">
<router-link :to="contentPath(blog)">{{ blog.title }}</router-link>
</li>
</ul>
</div>
<div v-if="islogin" class="bg-purple">
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
<div class="avatar-wrapper">
<el-avatar class="user-avatar" :src="avatar" @error="errorHandler">
<i class="el-icon-s-custom" />
<el-dropdown v-if="islogin" trigger="click" class="account-menu">
<button type="button" class="account-button">
<el-avatar :size="30" :src="avatar" @error="errorHandler">
<i class="el-icon-user-solid"></i>
</el-avatar>
<p class="avatar-Name">{{name}}</p>
</div>
<span>{{ name || '账户' }}</span>
</button>
<el-dropdown-menu slot="dropdown">
<router-link target="_blank" to="/index">
<el-dropdown-item>管理博客</el-dropdown-item>
</router-link>
<el-dropdown-item divided @click.native="logout">
<span>退出登录</span>
</el-dropdown-item>
<el-dropdown-item divided @click.native="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<div v-else class="bg-purple">
<div class="avatar-wrapper">
<!-- <el-avatar class="avatar" src="avatar" @error="errorHandler">
<i class="el-icon-s-custom" @click="tologin"/>
</el-avatar> -->
<p class="avatar-Name" @click="tologin">登录 | 注册</p>
<button v-else type="button" class="login-button" @click="tologin">登录</button>
<button
type="button"
class="mobile-toggle"
:aria-expanded="menuHiddenVisiable ? 'true' : 'false'"
aria-label="展开导航"
@click="menuExpend"
>
<i :class="menuHiddenVisiable ? 'el-icon-close' : 'el-icon-menu'"></i>
</button>
</div>
</div>
</el-header>
<transition name="menu-slide">
<nav v-if="menuHiddenVisiable" class="mobile-nav" aria-label="移动端导航">
<router-link exact to="/" @click.native="menuAway">首页</router-link>
<router-link v-for="item in menulist" :key="item.id" :to="item.path" @click.native="menuAway">
{{ item.authName }}
</router-link>
</nav>
</transition>
</header>
</template>
<script>
import {
mapGetters
} from 'vuex'
import {
getToken
} from '@/utils/auth'
import 'element-ui/lib/theme-chalk/display.css'
import {
cmsListBlog,
} from "@/api/cms/blog";
import { mapGetters } from 'vuex'
import { getToken } from '@/utils/auth'
import { cmsListBlog } from '@/api/cms/blog'
import { contentPath } from '@/utils/seo'
export default {
name: 'cmsNavBar',
data() {
return {
activeIndex: 'this.$router.path',
islogin: false,
searchInput: true,
menuHiddenVisiable: false,
headerBottom: 0,
timer: null,
queryInfo: {
query: '',
timer: null
query: ''
},
searchList: [],
searching: false,
menulist: [{
id: 1,
authName: '随笔',
path: '/cms/main/essay',
icon: 'el-icon-edit',
},
{
id: 2,
authName: '留言',
path: '/cms/main/message',
icon: 'el-icon-chat-dot-round',
},
{
id: 3,
authName: '文档',
path: '/cms/doucument',
icon: 'el-icon-document',
},
menulist: [
{ id: 1, authName: '源码项目', path: '/projects' },
{ id: 3, authName: '运行教程', path: '/cms/doucument' },
{ id: 4, authName: '开发笔记', path: '/cms/main/essay' }
],
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
pageSize: 6,
title: null,
type: 1,
content: null,
top: null,
views: null,
status: null,
},
};
status: null
}
}
},
computed: {
...mapGetters([
'avatar',
'name'
]),
...mapGetters(['avatar', 'name'])
},
watch: {
'queryInfo.query': {
handler(value) {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
this.searchBlog()
}, 300)
}
'queryInfo.query'(value) {
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => this.searchBlog(value), 300)
}
},
created() {
this.login();
// this.ResponsiveLayout();
this.islogin = Boolean(getToken())
},
beforeDestroy() {
if (this.timer) clearTimeout(this.timer)
},
methods: {
menulistAdd() {
//push()方法一般是添加到数组的最后的位置;unshift()方法是往最前面的位置添加。
// this.menulist.push({id:"",authName:""})
this.menulist.unshift({
id: "",
authName: ""
})
},
//响应式布局
ResponsiveLayout() {
//浏览器窗口的内部高度
var w = window.innerWidth ||
document.body.clientWidth;
//浏览器窗口的内部宽度
var h = window.innerHeight ||
document.body.clientHeight;
console.log(w, h);
},
// 展开菜单栏
contentPath,
menuExpend() {
this.menuHiddenVisiable = !this.menuHiddenVisiable
if (this.menuHiddenVisiable === true) {
this.headerBottom = (this.menulist.length + 1) * 56
} else {
this.headerBottom = 0
}
},
//收起菜单
menuAway() {
this.menuHiddenVisiable = false;
this.headerBottom = 0;
this.menuHiddenVisiable = false
},
notSearching() {
setTimeout(() => {
this.searching = false
}, 100)
}, 150)
},
checkInput() {
this.searching = this.queryInfo.query !== '';
this.searching = Boolean(this.queryInfo.query && this.searchList.length)
},
tologin() {
this.$router.push({
path: "/cmsLogin"
});
},
login() {
let token = getToken();
if (token == null || token == '') {
this.islogin = false;
} else {
this.islogin = true;
}
this.$router.push('/cmsLogin')
},
errorHandler() {
return true
},
async searchBlog() {
if (this.queryInfo.query === '') {
async searchBlog(value) {
if (!value) {
this.searchList = []
this.searching = false
return
}
this.queryParams.title = this.queryInfo.query;
cmsListBlog(this.queryParams).then(response => {
let lsitSize = response.rows.length;
if(lsitSize>0){
for(let i = 0;i<lsitSize;i++){
let redTitle = this.brightenKeyword(response.rows[i].title,this.queryInfo.query);
response.rows[i].title = redTitle;
}
};
this.searchList = response.rows;
if (this.searchList.length !== 0) {
this.searching = true
}
});
},
//搜索关建字标红
brightenKeyword(val, keyword) {
const Reg = new RegExp(keyword, 'i');
let res = '';
if (val) {
res = val.replace(Reg, `<span style="color: red;">${keyword}</span>`);
return res;
this.queryParams.title = value
try {
const response = await cmsListBlog(this.queryParams)
this.searchList = response.rows || []
this.searching = this.searchList.length > 0
} catch (error) {
this.searchList = []
this.searching = false
}
},
// 跳转到博客详情页
getBlogInfo(blogId) {
let routeUrl = this.$router.resolve({
path: '/cms/main/blog',
query: {
id: blogId
}
});
window.open(routeUrl.href, '_blank');
},
async logout() {
logout() {
this.$confirm('确定注销并退出系统登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {
location.href = '/cms/main/cmsIndex';
location.href = '/'
})
}).catch(() => {});
},
goToHome() {
this.$router.push({
path: '/cms/main/cmsIndex'
});
}).catch(() => {})
}
}
}
</script>
<style scoped>
.el-header {
display: flex;
justify-content: space-between;
background-color: rgba(0, 0, 0, 0.2);
align-items: center;
transition: .2s;
}
.el-header:hover {
opacity: 1 !important;
}
.el-menu {
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0) !important;
}
.el-menu /deep/ .el-menu-item {
background-color: rgba(0, 0, 0, 0) !important;
}
.el-menu /deep/ .el-menu-item i {
color: rgba(255, 255, 255);
}
.el-menu /deep/ .el-menu-item:hover i {
color: white;
}
.el-menu /deep/ .el-menu-item:hover {
background-color: rgba(0, 0, 0, 0.5) !important;
}
.search_input {
.site-header {
height: 76px;
position: relative;
box-sizing: border-box;
z-index: 20;
color: #fff;
background: rgba(19, 34, 56, .97);
border-bottom: 1px solid rgba(255, 255, 255, .08);
box-shadow: 0 12px 32px rgba(19, 34, 56, .12);
}
.nav-wrap {
width: min(1180px, calc(100% - 40px));
height: 100%;
margin: 0 auto;
display: flex;
align-items: center;
gap: 40px;
}
.search_input ul {
position: absolute;
top: 30px;
width: 100%;
border: 1px solid #e5e9ef;
margin-top: 1px;
background: #fff;
z-index: 10000;
border-radius: 2px;
box-shadow: 0 2px 4px rgba(58, 118, 142, 0.16);
padding: 10px 0;
.brand {
display: inline-flex;
align-items: center;
gap: 11px;
flex: 0 0 auto;
color: #fff;
text-decoration: none;
}
.brand-mark {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 12px;
color: #fff;
background: #ef6a3a;
box-shadow: 0 8px 20px rgba(239, 106, 58, .28);
font: 700 13px/1 Consolas, monospace;
transform: rotate(-3deg);
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 3px;
}
.brand-copy strong {
font-size: 18px;
letter-spacing: .04em;
}
.brand-copy small {
color: rgba(255, 255, 255, .5);
font-size: 8px;
letter-spacing: .18em;
}
.desktop-nav {
height: 100%;
display: flex;
align-items: center;
gap: 30px;
}
.desktop-nav a {
height: 100%;
position: relative;
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, .7);
font-size: 14px;
box-sizing: border-box;
text-decoration: none;
transition: color .2s ease;
}
.search_input ul li {
padding: 0 16px;
height: 32px;
line-height: 32px;
cursor: pointer;
word-wrap: break-word;
word-break: break-all;
display: block;
color: #222;
position: relative;
/*transition: .2 ease;*/
}
.search_input ul li:hover {
background-color: #e8f3ff;
}
.search-blog {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
padding-left: 20px;
padding-right: 20px;
}
.bg-purple-light {
float: right;
}
.bg-purple {
float: right;
}
.user-avatar {
float: left;
cursor: pointer;
border: dashed rgba($color: #ffff7f, $alpha: 0.5);
}
.avatar-container {
margin-right: 30px;
}
.avatar-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
}
.avatar-Name {
margin-left: 10px;
cursor: pointer;
float: right;
font-size: 16px;
color: #ffffff;
}
.logo {
float: left;
color: #019cfd;
font-weight: bold;
}
.logo:hover {
cursor: pointer;
}
.el-menu-hidden {
/*display: none;*/
.desktop-nav a::after {
content: '';
position: absolute;
top: 60px;
right: 50%;
bottom: 0;
left: 50%;
height: 3px;
background: #ef6a3a;
transition: right .2s ease, left .2s ease;
}
.desktop-nav a:hover,
.desktop-nav a.router-link-active {
color: #fff;
}
.desktop-nav a:hover::after,
.desktop-nav a.router-link-active::after {
right: 0;
left: 0;
border-top: 1px solid #ccc;
border-right: none;
}
.nav-actions {
min-width: 0;
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.search-box {
width: 224px;
height: 38px;
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, .13);
border-radius: 11px;
background: rgba(255, 255, 255, .07);
}
.search-box i {
color: rgba(255, 255, 255, .5);
}
.search-box input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
color: #fff;
background: transparent;
font-size: 13px;
}
.menu-expend {
display: none !important;
.search-box input::placeholder {
color: rgba(255, 255, 255, .42);
}
/* 窗口可视区域小于1000隐藏搜索框 */
@media screen and (max-width: 1000px) {
.search_input {
display: none;
}
.search-results {
width: 320px;
position: absolute;
top: 48px;
right: 0;
margin: 0;
padding: 8px;
list-style: none;
border: 1px solid #e6e1d8;
border-radius: 14px;
background: #fffdf8;
box-shadow: 0 20px 50px rgba(19, 34, 56, .18);
}
@media screen and (max-width: 768px) {
.el-menu /deep/ .el-menu-item {
background-color: rgba(0, 0, 0, 0.3) !important;
.search-results a {
display: block;
padding: 11px 12px;
overflow: hidden;
color: #26364c;
border-radius: 9px;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
}
.el-menu-demo {
display: none;
.search-results a:hover {
color: #d8562d;
background: #fff0e8;
}
.menu-expend {
display: block !important;
float: right;
}
.menu-expend:hover {
color: #ffd04b;
.account-button,
.login-button,
.mobile-toggle {
border: 0;
color: #fff;
background: transparent;
cursor: pointer;
}
.account-button {
display: flex;
align-items: center;
gap: 8px;
}
.login-button {
height: 38px;
padding: 0 18px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 10px;
font-weight: 600;
}
.login-button:hover {
border-color: #ef6a3a;
background: #ef6a3a;
}
.mobile-toggle {
width: 38px;
height: 38px;
display: none;
border-radius: 10px;
background: rgba(255, 255, 255, .08);
font-size: 18px;
}
.mobile-nav {
position: absolute;
top: 76px;
right: 0;
left: 0;
display: none;
padding: 12px 20px 18px;
background: #132238;
box-shadow: 0 18px 30px rgba(19, 34, 56, .18);
}
.mobile-nav a {
display: block;
padding: 13px 12px;
color: rgba(255, 255, 255, .78);
border-bottom: 1px solid rgba(255, 255, 255, .08);
text-decoration: none;
}
.menu-slide-enter-active,
.menu-slide-leave-active {
transition: opacity .2s ease, transform .2s ease;
}
.menu-slide-enter,
.menu-slide-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (max-width: 1020px) {
.nav-wrap { gap: 24px; }
.desktop-nav { gap: 20px; }
.search-box { width: 190px; }
}
@media (max-width: 820px) {
.site-header { height: 68px; }
.nav-wrap { width: min(100% - 28px, 1180px); }
.brand-mark { width: 36px; height: 36px; }
.brand-copy small { display: none; }
.desktop-nav,
.search-box,
.account-menu span,
.login-button { display: none; }
.mobile-toggle,
.mobile-nav { display: block; }
.mobile-nav { top: 68px; }
}
</style>

View File

@@ -141,7 +141,7 @@ export default {
Cookies.remove('rememberMe');
}
this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({ path: "/cms/main/cmsIndex" });
this.$router.push({ path: "/" });
}).catch(() => {
this.loading = false;
if (this.captchaOnOff) {

View File

@@ -365,9 +365,8 @@ export default {
// 跳转到评论页
getBlogInfo(mes) {
let routeUrl = this.$router.resolve({
path: '/cms/main/blog',
path: `/article/${mes.blogId}`,
query: {
id: mes.blogId,
commentId: mes.id
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
<template>
<div class="categories-page">
<section class="categories-hero">
<div>
<span class="eyebrow"><i></i> PROJECT DIRECTORY</span>
<h1>按方向发现源码项目</h1>
<p>从项目场景或技术栈出发,快速缩小范围。每个分类都可以继续查看完整项目、功能截图和运行说明。</p>
</div>
<div class="directory-stats">
<div><strong>{{ fullTypeList.length }}</strong><span>项目类型</span></div>
<div><strong>{{ fullTagList.length }}</strong><span>技术标签</span></div>
<router-link to="/projects">浏览全部项目 <i class="el-icon-right"></i></router-link>
</div>
</section>
<main v-loading="loading" class="directory-content">
<section class="type-section">
<header class="section-heading">
<div>
<span>01 · PROJECT TYPES</span>
<h2>项目类型</h2>
</div>
<p>按照应用形态和开发方向选择</p>
</header>
<div class="type-grid">
<router-link
v-for="(type, index) in fullTypeList"
:key="type.typeId"
:to="`/category/${type.typeId}`"
class="type-card"
>
<div class="type-card-top">
<span class="type-index">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="type-icon"><i :class="typeIcon(type.typeName)"></i></span>
</div>
<h3>{{ type.typeName }}</h3>
<p>{{ typeDescription(type.typeName) }}</p>
<div class="type-card-bottom">
<span><strong>{{ type.blogNum || 0 }}</strong> 个项目</span>
<b>查看分类 <i class="el-icon-right"></i></b>
</div>
</router-link>
</div>
</section>
<section class="stack-section">
<header class="section-heading light-heading">
<div>
<span>02 · TECH STACKS</span>
<h2>按技术栈筛选</h2>
</div>
<p>直接定位熟悉或准备学习的开发技术</p>
</header>
<div class="stack-cloud">
<router-link v-for="tag in fullTagList" :key="tag.tagId" :to="`/tag/${tag.tagId}`">
<span>#</span>
<strong>{{ tag.tagName }}</strong>
<small>{{ tag.blogNum || 0 }} PROJECTS</small>
</router-link>
</div>
</section>
<section class="directory-guide">
<div>
<span class="guide-mark">&lt;/&gt;</span>
<div><strong>还不确定选哪个方向?</strong><p>进入完整项目库,通过关键词、编号和技术标签组合筛选。</p></div>
</div>
<router-link to="/projects">进入源码项目库 <i class="el-icon-right"></i></router-link>
</section>
</main>
<el-backtop :bottom="32" :right="28">
<div class="back-top"><i class="el-icon-top"></i></div>
</el-backtop>
</div>
</template>
<script>
import { getBlogDetail } from '@/api/cms/blog'
import { canonicalUrl } from '@/utils/seo'
export default {
name: 'cmsCategories',
data() {
return {
loading: true,
fullTypeList: [],
fullTagList: []
}
},
metaInfo() {
const title = '项目分类与技术栈 - 南音源码库'
const description = '按项目类型与技术栈浏览 Java、Spring Boot、Vue、微信小程序等完整源码项目。'
return {
title,
titleTemplate: null,
meta: [
{ vmid: 'description', name: 'description', content: description },
{ vmid: 'robots', name: 'robots', content: 'index,follow' },
{ vmid: 'og:title', property: 'og:title', content: title },
{ vmid: 'og:description', property: 'og:description', content: description }
],
link: [{ vmid: 'canonical', rel: 'canonical', href: canonicalUrl('/categories') }]
}
},
created() {
this.loadDirectory()
},
methods: {
async loadDirectory() {
this.loading = true
try {
const response = await getBlogDetail()
this.fullTypeList = response.types || []
this.fullTagList = response.tags || []
} finally {
this.loading = false
}
},
typeIcon(name) {
const value = String(name || '').toLowerCase()
if (value.includes('小程序')) return 'el-icon-mobile-phone'
if (value.includes('spring')) return 'el-icon-cpu'
if (value.includes('java')) return 'el-icon-monitor'
return 'el-icon-folder-opened'
},
typeDescription(name) {
const value = String(name || '').toLowerCase()
if (value.includes('小程序')) return '适合移动服务、校园工具、商城与预约类应用。'
if (value.includes('spring')) return '主流 Java 企业级项目,前后端分离,便于二次开发。'
if (value.includes('ssm')) return '经典 Java Web 技术组合,适合课程设计和基础学习。'
if (value.includes('java')) return '包含管理系统、信息平台与业务应用等常见选题。'
return '查看该方向下的完整项目源码、截图与运行说明。'
}
}
}
</script>
<style scoped>
.categories-page {
width: 100%;
padding-bottom: 72px;
color: #132238;
}
.categories-hero {
width: min(1180px, calc(100% - 40px));
margin: 0 auto;
padding: 68px 0 54px;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, .8fr);
align-items: end;
gap: 80px;
}
.eyebrow {
display: inline-flex;
align-items: center;
color: #cf562f;
font: 700 11px/1.4 Consolas, Monaco, monospace;
letter-spacing: .18em;
}
.eyebrow i {
width: 34px;
height: 2px;
margin-right: 12px;
background: #ef6a3a;
}
.categories-hero h1 {
margin: 22px 0 16px;
font-size: clamp(40px, 5vw, 62px);
line-height: 1.12;
letter-spacing: -.05em;
}
.categories-hero p {
max-width: 660px;
margin: 0;
color: #667286;
font-size: 16px;
line-height: 1.9;
}
.directory-stats {
padding: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
border: 1px solid #ded8ce;
border-radius: 18px;
background: rgba(255, 253, 248, .9);
box-shadow: 0 18px 42px rgba(19, 34, 56, .08);
}
.directory-stats div { display: flex; flex-direction: column; gap: 5px; }
.directory-stats strong { font: 700 30px/1 Consolas, monospace; }
.directory-stats span { color: #8b929d; font-size: 11px; }
.directory-stats a {
grid-column: 1 / -1;
padding-top: 15px;
border-top: 1px solid #e4ded4;
color: #d85831;
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.directory-content { width: min(1180px, calc(100% - 40px)); margin: 0 auto; }
.type-section { padding: 42px 0 64px; border-top: 1px solid #ddd8cf; }
.section-heading {
margin-bottom: 25px;
display: flex;
align-items: end;
justify-content: space-between;
}
.section-heading span { color: #df6238; font: 700 10px/1 Consolas, monospace; letter-spacing: .16em; }
.section-heading h2 { margin: 10px 0 0; font-size: 30px; letter-spacing: -.03em; }
.section-heading > p { color: #9298a2; font-size: 12px; }
.type-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.type-card {
min-height: 240px;
padding: 26px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #ddd8cf;
border-radius: 18px;
color: #132238;
background: rgba(255, 253, 248, .92);
box-shadow: 0 10px 30px rgba(19, 34, 56, .04);
text-decoration: none;
transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease;
}
.type-card:hover { transform: translateY(-4px); border-color: #ef9b7d; box-shadow: 0 18px 38px rgba(19, 34, 56, .09); }
.type-card-top { display: flex; align-items: center; justify-content: space-between; }
.type-index { color: #dc633b; font: 700 11px/1 Consolas, monospace; letter-spacing: .12em; }
.type-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
color: #28727a;
background: #e8f2f0;
font-size: 18px;
}
.type-card h3 { margin: 24px 0 9px; font-size: 23px; }
.type-card > p { margin: 0; color: #7b8492; font-size: 13px; line-height: 1.75; }
.type-card-bottom {
margin-top: auto;
padding-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid #ece6dc;
}
.type-card-bottom span { color: #9198a3; font-size: 11px; }
.type-card-bottom span strong { color: #172941; font: 700 16px/1 Consolas, monospace; }
.type-card-bottom b { color: #d85831; font-size: 11px; }
.stack-section {
padding: 42px;
border-radius: 22px;
color: #fff;
background: #172941;
box-shadow: 0 24px 50px rgba(19, 34, 56, .16);
}
.light-heading h2 { color: #fff; }
.light-heading > p { color: rgba(255, 255, 255, .42); }
.stack-cloud { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 11px; }
.stack-cloud a {
min-height: 92px;
padding: 16px;
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, .09);
border-radius: 12px;
color: rgba(255, 255, 255, .84);
background: rgba(255, 255, 255, .035);
text-decoration: none;
transition: border-color .2s ease, background .2s ease;
}
.stack-cloud a:hover { border-color: rgba(239, 106, 58, .7); background: rgba(239, 106, 58, .11); }
.stack-cloud a > span { color: #ef825d; font: 700 11px/1 Consolas, monospace; }
.stack-cloud strong { margin: 11px 0 8px; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.stack-cloud small { margin-top: auto; color: rgba(255, 255, 255, .3); font: 700 8px/1 Consolas, monospace; letter-spacing: .08em; }
.directory-guide {
margin-top: 30px;
padding: 27px 30px;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid #ddd8cf;
border-radius: 18px;
background: rgba(255, 253, 248, .88);
}
.directory-guide > div { display: flex; align-items: center; gap: 18px; }
.guide-mark { color: #ef6a3a; font: 700 22px/1 Consolas, monospace; }
.directory-guide strong { font-size: 15px; }
.directory-guide p { margin: 5px 0 0; color: #8b929d; font-size: 11px; }
.directory-guide > a { padding: 13px 18px; border-radius: 10px; color: #fff; background: #ef6a3a; font-size: 11px; font-weight: 700; text-decoration: none; }
.back-top { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 12px; color: #fff; background: #172941; }
@media (max-width: 900px) {
.categories-hero { grid-template-columns: 1fr; gap: 30px; }
.directory-stats { max-width: 420px; }
.stack-cloud { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.categories-page { padding-bottom: 48px; }
.categories-hero, .directory-content { width: min(100% - 28px, 1180px); }
.categories-hero { padding: 44px 0 36px; }
.categories-hero h1 { font-size: 38px; }
.type-grid { grid-template-columns: 1fr; }
.section-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.section-heading > p { margin: 0; }
.stack-section { padding: 28px 20px; }
.stack-cloud { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.directory-guide { align-items: flex-start; flex-direction: column; gap: 20px; }
}
</style>

View File

@@ -1,39 +1,41 @@
<template>
<el-container>
<div class="document-page">
<iframe
class="document-frame"
src="/docs/index.html"
id="qt"
title="南音源码库项目文档"
scrolling="auto"
frameborder="0"
style="position:absolute;top:64px;left: 0px;right:0px;bottom:100px;opacity:1;"
></iframe>
</el-container>
</div>
</template>
<script>
export default {
name: 'CmsDocument',
data() {
return {}
},
mounted() {
/**
* iframe-宽高自适应显示
*/
function changeqtIframe() {
const qt = document.getElementById('qt')
const deviceWidth = document.body.clientWidth
const deviceHeight = document.body.clientHeight
qt.style.width = Number(deviceWidth) + 'px' // 数字是页面布局宽度差值
qt.style.height = Number(deviceHeight) + 'px' // 数字是页面布局高度差
}
changeqtIframe()
window.onresize = function() {
changeqtIframe()
}
}
name: 'CmsDocument'
}
</script>
<style scoped rel="stylesheet/scss" lang="scss">
<style scoped>
.document-page {
width: 100%;
height: calc(100vh - 76px);
min-height: 620px;
line-height: 0;
background: #fff;
}
.document-frame {
width: 100%;
height: 100%;
display: block;
border: 0;
}
@media (max-width: 820px) {
.document-page {
height: calc(100vh - 68px);
min-height: 520px;
}
}
</style>

View File

@@ -1,56 +1,84 @@
<template>
<el-row :gutter="20">
<el-col :sm="3" class="hidden-xs-only" style="opacity:0;">左侧占位</el-col>
<el-col :xs="24" :sm="18">
<el-container>
<el-timeline class="animate__animated animate__fadeInLeft">
<el-timeline-item :color="color" v-for="essay in essayList" :key="essay.id" :timestamp="essay.createTime"
placement="top">
<el-card
style="letter-spacing: 1px;border: 1px solid rgba(255,190,23,0.9); background-color: rgba(255,255,255,0.9);box-shadow: 0 0 30px -10px ">
<h2 v-if="essay.title" style="color: rgba(255,190,23,0.9);">{{essay.title}}</h2>
<div v-if="essay.contentType ==='1'" class="typo ql-editor" v-html="essay.content"></div>
<div v-else v-html="essay.content"></div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-container>
</el-col>
<el-col :sm="3" class="hidden-xs-only" style="opacity:0;">右侧占位</el-col>
<!-- 设置底部距离的 -->
<el-backtop :bottom="60">
<div
style="{
height: 50px;
width: 50px;
background-color: rgba(240,239,241,1);
box-shadow: 0 0 6px rgba(0,0,0, .12);
text-align: center;
line-height: 40px;
border-radius:2px;
color: #1989fa;
}"
>
<svg-icon icon-class="top" />
<div class="notes-page">
<section class="notes-hero" aria-labelledby="notes-title">
<div>
<p class="eyebrow"><span /> DEV NOTES</p>
<h1 id="notes-title">开发笔记</h1>
<p class="hero-copy">记录项目部署、问题排查与开发过程中的实用经验。</p>
</div>
<div class="hero-total" aria-label="笔记数量">
<strong>{{ total }}</strong>
<span>篇记录</span>
</div>
</section>
<main class="notes-content">
<div v-if="loading" class="notes-state" aria-live="polite">
<i class="el-icon-loading" />
<span>正在加载开发笔记...</span>
</div>
<div v-else-if="!essayList.length" class="notes-state notes-empty">
<span class="empty-icon"><i class="el-icon-edit-outline" /></span>
<h2>还没有开发笔记</h2>
<p>新的记录会在这里按时间依次展示。</p>
</div>
<ol v-else class="notes-timeline" aria-label="开发笔记时间线">
<li v-for="(essay, index) in essayList" :key="essay.id" class="timeline-item">
<div class="time-rail" :aria-label="essay.createTime || '未标记时间'">
<span class="time-dot" />
<time v-if="essay.createTime" :datetime="essay.createTime">
<strong>{{ formatDate(essay.createTime) }}</strong>
<span>{{ formatTime(essay.createTime) }}</span>
</time>
<span v-else class="undated">未标记日期</span>
</div>
<article class="note-card" :class="{ 'without-header': !essay.title }">
<header v-if="essay.title" class="note-header">
<span class="note-number">NOTE {{ formatIndex(index) }}</span>
<h2>{{ essay.title }}</h2>
</header>
<div
class="note-body"
:class="{ 'ql-editor': essay.contentType === '1' }"
v-html="essay.content"
/>
</article>
</li>
</ol>
<el-pagination
v-if="total > queryParams.pageSize"
class="notes-pagination"
background
layout="prev, pager, next"
:current-page.sync="queryParams.pageNum"
:page-size="queryParams.pageSize"
:total="total"
@current-change="handlePageChange"
/>
</main>
<el-backtop :bottom="32" :right="32" class="notes-backtop">
<i class="el-icon-top" />
</el-backtop>
</el-row>
</div>
</template>
<script>
// import 'cherry-markdown/dist/cherry-markdown.min.css'
//使用prism.js代码高亮
import '@/views/cms/plugins/prism.js'
import '@/views/cms/plugins/prism.css'
import {
cmsEssayList,
} from "@/api/cms/blog";
import { cmsEssayList } from '@/api/cms/blog'
export default {
name: 'essay',
name: 'CmsEssay',
data() {
return {
essayList: [],
// 查询参数
loading: true,
total: 0,
queryParams: {
pageNum: 1,
pageSize: 10,
@@ -59,9 +87,8 @@ import '@/views/cms/plugins/prism.css'
content: null,
top: null,
views: null,
status: null,
},
color: "#ffd04b",
status: null
}
}
},
created() {
@@ -69,35 +96,461 @@ import '@/views/cms/plugins/prism.css'
},
methods: {
async getEssayList() {
cmsEssayList(this.queryParams).then(response => {
this.essayList = response.rows;
});
this.loading = true
try {
const response = await cmsEssayList(this.queryParams)
this.essayList = response.rows || []
this.total = Number(response.total) || this.essayList.length
} catch (error) {
this.essayList = []
this.total = 0
} finally {
this.loading = false
}
},
handlePageChange(page) {
this.queryParams.pageNum = page
this.getEssayList().then(() => {
window.scrollTo({ top: 0, behavior: 'smooth' })
})
},
formatDate(value) {
const date = String(value || '').split(/[ T]/)[0]
return date ? date.replace(/-/g, '.') : ''
},
formatTime(value) {
const time = String(value || '').split(/[ T]/)[1]
return time ? time.slice(0, 5) : ''
},
formatIndex(index) {
const position = (this.queryParams.pageNum - 1) * this.queryParams.pageSize + index + 1
return String(position).padStart(2, '0')
}
}
}
</script>
<style scoped>
.el-timeline {
font: 16px/1.5 'Microsoft Yahei', 'PingFang SC', 'Hiragino Sans GB', sans-serif !important;
width: 80%;
.notes-page {
width: min(1180px, calc(100% - 40px));
margin: 0 auto;
padding: 54px 0 84px;
color: #18283e;
}
.el-card {
border-radius: 20px;
box-shadow: 0 0 15px 5px white;
.notes-hero {
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 32px;
margin-bottom: 48px;
padding: 0 4px 30px;
border-bottom: 1px solid rgba(24, 40, 62, .13);
}
@media screen and (max-width: 768px) {
.el-timeline {
width: 98%;
padding: 2px;
.notes-hero::after {
content: '';
width: 76px;
height: 3px;
position: absolute;
bottom: -2px;
left: 4px;
border-radius: 999px;
background: #ef6a3a;
}
.el-timeline /deep/ .el-timeline-item__wrapper {
padding-left: 15px !important;
.eyebrow {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 13px;
color: #d95730;
font: 700 12px/1.2 Consolas, Monaco, monospace;
letter-spacing: .16em;
}
.eyebrow span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ef6a3a;
box-shadow: 0 0 0 5px rgba(239, 106, 58, .12);
}
.notes-hero h1 {
margin: 0;
color: #132238;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.15;
letter-spacing: -.03em;
}
.hero-copy {
margin: 14px 0 0;
color: #697386;
font-size: 15px;
line-height: 1.7;
}
.hero-total {
flex: 0 0 auto;
display: flex;
align-items: baseline;
gap: 7px;
padding-bottom: 3px;
color: #7b8493;
}
.hero-total strong {
color: #132238;
font: 700 34px/1 Georgia, serif;
}
.hero-total span {
font-size: 13px;
}
.notes-content {
position: relative;
}
.notes-timeline {
margin: 0;
padding: 0;
list-style: none;
}
.timeline-item {
display: grid;
grid-template-columns: 154px minmax(0, 1fr);
position: relative;
}
.timeline-item:not(:last-child) {
padding-bottom: 34px;
}
.time-rail {
min-height: 100%;
position: relative;
padding: 21px 30px 0 0;
text-align: right;
}
.time-rail::after {
content: '';
width: 1px;
position: absolute;
top: 31px;
right: 0;
bottom: -34px;
background: linear-gradient(#cfd5dc, #e5e7e9);
}
.timeline-item:last-child .time-rail::after {
display: none;
}
.time-dot {
width: 13px;
height: 13px;
position: absolute;
z-index: 1;
top: 25px;
right: -6px;
border: 3px solid #f4f1e9;
border-radius: 50%;
background: #ef6a3a;
box-shadow: 0 0 0 1px rgba(239, 106, 58, .35);
}
.time-rail time {
display: flex;
flex-direction: column;
gap: 5px;
}
.time-rail strong {
color: #3b485a;
font-size: 13px;
line-height: 1;
}
.time-rail time span,
.undated {
color: #969daa;
font-size: 12px;
}
.note-card {
min-width: 0;
position: relative;
margin-left: 30px;
overflow: hidden;
border: 1px solid rgba(24, 40, 62, .09);
border-radius: 18px;
background: rgba(255, 253, 248, .94);
box-shadow: 0 14px 40px rgba(19, 34, 56, .08);
transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease;
}
.note-card::before {
content: '';
width: 4px;
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: linear-gradient(180deg, #ef6a3a, #f2aa62);
}
.note-card:hover {
border-color: rgba(239, 106, 58, .24);
box-shadow: 0 20px 52px rgba(19, 34, 56, .12);
transform: translateY(-2px);
}
.note-header {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 20px;
align-items: start;
padding: 29px 38px 21px;
border-bottom: 1px solid rgba(24, 40, 62, .08);
}
.note-number {
margin-top: 6px;
color: #d75c35;
font: 700 11px/1.2 Consolas, Monaco, monospace;
letter-spacing: .1em;
}
.note-header h2 {
margin: 0;
color: #17273d;
font-size: 24px;
line-height: 1.4;
letter-spacing: -.01em;
}
.note-body {
padding: 27px 38px 34px 136px;
color: #3e4958;
font-size: 15px;
line-height: 1.85;
overflow-wrap: anywhere;
}
.note-card.without-header .note-body {
padding-left: 38px;
}
.note-body /deep/ > :first-child {
margin-top: 0 !important;
}
.note-body /deep/ > :last-child {
margin-bottom: 0 !important;
}
.note-body /deep/ h1,
.note-body /deep/ h2,
.note-body /deep/ h3,
.note-body /deep/ h4 {
margin: 1.45em 0 .55em;
color: #202d3f;
line-height: 1.45;
}
.note-body /deep/ h1 { font-size: 21px; }
.note-body /deep/ h2 { font-size: 18px; }
.note-body /deep/ h3,
.note-body /deep/ h4 { font-size: 16px; }
.note-body /deep/ p {
margin: .55em 0;
}
.note-body /deep/ ul,
.note-body /deep/ ol {
margin: .55em 0;
padding-left: 1.65em;
}
.note-body /deep/ li + li {
margin-top: .2em;
}
.note-body /deep/ a {
color: #d95730;
text-decoration: none;
border-bottom: 1px solid rgba(217, 87, 48, .3);
}
.note-body /deep/ blockquote {
margin: 1em 0;
padding: 13px 18px;
color: #566274;
border-left: 3px solid #ef6a3a;
background: #fff5ee;
}
.note-body /deep/ img,
.note-body /deep/ video {
max-width: 100%;
height: auto;
border-radius: 10px;
}
.note-body /deep/ pre {
max-width: 100%;
overflow-x: auto;
border-radius: 10px;
}
.note-body /deep/ table {
width: 100%;
display: block;
overflow-x: auto;
border-collapse: collapse;
}
.notes-state {
min-height: 280px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #7a8493;
}
.notes-state i {
color: #ef6a3a;
font-size: 20px;
}
.notes-empty {
flex-direction: column;
padding: 46px;
border: 1px dashed rgba(24, 40, 62, .18);
border-radius: 18px;
background: rgba(255, 253, 248, .72);
text-align: center;
}
.empty-icon {
width: 52px;
height: 52px;
display: grid;
place-items: center;
margin-bottom: 7px;
border-radius: 16px;
background: #fff0e8;
}
.empty-icon i { font-size: 24px; }
.notes-empty h2 { margin: 0; color: #26364c; font-size: 20px; }
.notes-empty p { margin: 0; font-size: 14px; }
.notes-pagination {
margin-top: 44px;
text-align: center;
}
.notes-pagination /deep/ .el-pager li:not(.disabled).active {
background: #ef6a3a;
}
.notes-backtop {
width: 44px;
height: 44px;
color: #fff;
border-radius: 13px;
background: #17273d;
box-shadow: 0 10px 26px rgba(19, 34, 56, .22);
}
.notes-backtop:hover {
background: #ef6a3a;
}
@media (max-width: 820px) {
.notes-page {
width: min(100% - 28px, 1180px);
padding: 38px 0 64px;
}
.notes-hero {
margin-bottom: 34px;
padding-bottom: 24px;
}
.hero-total { display: none; }
.timeline-item {
grid-template-columns: 1fr;
padding-left: 24px;
}
.timeline-item:not(:last-child) {
padding-bottom: 24px;
}
.time-rail {
min-height: 30px;
padding: 0 0 10px 18px;
text-align: left;
}
.time-rail::after {
top: 12px;
right: auto;
bottom: -24px;
left: -24px;
}
.time-dot {
top: 3px;
right: auto;
left: -30px;
}
.time-rail time {
flex-direction: row;
align-items: baseline;
gap: 8px;
}
.note-card { margin-left: 0; }
.note-header {
grid-template-columns: 1fr;
gap: 8px;
padding: 24px 24px 18px;
}
.note-number { margin-top: 0; }
.note-header h2 { font-size: 21px; }
.note-body,
.note-card.without-header .note-body {
padding: 22px 24px 27px;
font-size: 14px;
line-height: 1.8;
}
}
@media (max-width: 480px) {
.notes-page { width: calc(100% - 22px); padding-top: 30px; }
.notes-hero h1 { font-size: 30px; }
.hero-copy { font-size: 14px; }
.timeline-item { padding-left: 18px; }
.time-rail { padding-left: 14px; }
.time-rail::after { left: -18px; }
.time-dot { left: -24px; }
.note-card { border-radius: 14px; }
.notes-backtop { right: 18px !important; bottom: 20px !important; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -72,7 +72,7 @@
likeNum: null,
content: null,
type: null,
blogId: this.$route.query.id,
blogId: this.$route.params.id || this.$route.query.id,
userId: null,
delFlag: null,
createBy: null,
@@ -119,7 +119,7 @@
likeNum: null,
content: null,
type: null,
blogId: this.$route.query.id,
blogId: this.$route.params.id || this.$route.query.id,
userId: null,
delFlag: null,
createBy: null,

View File

@@ -1,75 +1,68 @@
<template>
<el-container>
<div class="background" :style="backgroundImage"></div>
<cmsNavBar></cmsNavBar>
<keep-alive>
<cmsMain></cmsMain>
</keep-alive>
<el-footer height="40px">
<cmsFooter></cmsFooter>
</el-footer>
</el-container>
<div class="cms-shell">
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<cms-nav-bar />
<cms-main />
<cms-footer />
</div>
</template>
<script>
import cmsMain from './main.vue'
import cmsNavBar from './NavBar.vue'
import cmsFooter from './Footer.vue'
// 引入背景图
import backgroundImages from './backgroundImages.js'
export default {
data() {
return {
backgroundImages,
};
},
methods: {
handleSelect(key, keyPath) {
console.log(key, keyPath);
}
},
name: 'CmsLayout',
components: {
cmsMain,
cmsNavBar,
cmsFooter
},
computed: {
backgroundImage() {
// 根据背景图数组的长度随机选择索引
const randIndex = Math.floor(Math.random() * this.backgroundImages.length)
return {
// 获取对应的图片资源并将其设置到`background-image`属性上
backgroundImage: `url(${this.backgroundImages[randIndex]})`
}
}
},
}
</script>
<style scoped>
.el-footer {
background-color: rgba(84, 92, 100, 0.5);
.cms-shell {
--ink: #132238;
--muted: #697386;
--paper: #fffdf8;
--canvas: #f4f1e9;
--accent: #ef6a3a;
--accent-soft: #fff0e8;
min-height: 100vh;
position: relative;
overflow: hidden;
background:
linear-gradient(rgba(19, 34, 56, .025) 1px, transparent 1px),
linear-gradient(90deg, rgba(19, 34, 56, .025) 1px, transparent 1px),
var(--canvas);
background-size: 32px 32px;
color: var(--ink);
}
.background {
background-repeat: no-repeat;
background-size: cover;
margin: 0px;
padding: 0px;
top: 0;
width: 100%;
height: 120vh;
.ambient {
position: fixed;
z-index:-1;
z-index: 0;
border-radius: 50%;
pointer-events: none;
filter: blur(2px);
}
@media screen and (max-width: 768px) {
.title {
width: 100%;
background-position-x: center;
background-position-y: 0;
.ambient-one {
width: 420px;
height: 420px;
top: 80px;
right: -170px;
background: rgba(239, 106, 58, .08);
}
.ambient-two {
width: 320px;
height: 320px;
bottom: 80px;
left: -180px;
background: rgba(40, 109, 116, .08);
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<el-main>
<main class="cms-main">
<router-view :key="key" />
</el-main>
</main>
</template>
<script>
@@ -16,7 +16,9 @@
</script>
<style scoped>
.el-main {
min-height: 650px;
.cms-main {
min-height: calc(100vh - 152px);
position: relative;
z-index: 1;
}
</style>