作为一个博客系统,更换主题的功能几乎是必不可少的。该功能的实现参考了tale开源项目,非常感谢!

具体实现

项目结构

├── java
│   └── cc
│       └── ryanc
│           └── halo
│               ├── Application.java
│               ├── model
│               ├── repository
│               ├── service
│               ├── util
│               └── web
│                   ├── controller   //控制器
│                   └── interceptor  //拦截器
└── resources
    ├── static
    └── templates                    //模板目录
        └── themes                    //主题目录
            ├── anatole
            ├── halo
            └── material

更换主题

由于使用了freemarker模板引擎,换主题这个功能就变得非常简单了,在Controller里面渲染页面的时候,只需要修改主题存在路径就可以了,具体实现方式如下:

BaseController:

public abstract class BaseController {

    /**
     * 定义默认主题
     */
    public static String THEME = "halo";
    /**
     * 根据主题名称渲染页面
     *
     * @param pageName pageName
     * @return 返回拼接好的模板路径
     */
    public String render(String pageName){
        StringBuffer themeStr = new StringBuffer("themes/");
        themeStr.append(THEME);
        themeStr.append("/");
        return themeStr.append(pageName).toString();
    }
}

IndexController(页面控制器),继承BaseController:

@GetMapping(value = "page/{page}")
public String index(Model model,
                    @PathVariable(value = "page") Integer page){
    Sort sort = new Sort(Sort.Direction.DESC,"postDate");
    //默认显示10条
    Integer size = 10;
    //所有文章数据,分页
    Pageable pageable = new PageRequest(page-1,size,sort);
    Page<Post> posts = postService.findPostByStatus(0,pageable);
    model.addAttribute("posts",posts);
    return this.render("index");
}

仔细看上面所示代码可知,在BaseController里面定义了一个静态变量作为主题名称(和主题文件夹名一致),然后在rander方法里面拼接好主题完整路径返回即可,如/themes/halo,需要注意的是:render方法是有一个参数的,这个参数就是freemarker的模板文件名称,完整拼接如:/themes/halo/index,然后在IndexController的方法里面就可以调用该方法,并传入响应的模板文件名,就可以完成渲染了。

如果需要切换主题,那么只需要在后台管理对BaseController里面的THEME变量重新赋值便可以实现切换主题了。

主题管理界面

上面说到了在后台管理对BaseController里面的THEME变量重新赋值,那么既然要切换主题,那就得把所有主题展示出来吧!实现的方法也不是太难,只需要扫描theme文件夹下的所有目录就行了。

具体代码:

Theme实体类:

public class Theme implements Serializable {
    /**
     * 主题名称
     */
    private String themeName;

    /**
     * 是否支持设置
     */
    private boolean hasOptions;
}

本来是不需要创建这个实体类的,但考虑到要确定该主题是否支持设置,所有建一个实体类来传输数据会比较方便。

HaloUtil(工具类):

/**
 * 获取所有主题
 * @return list
 */
public static List<Theme> getThemes(){
    List<Theme> themes = new ArrayList<>();
    try {
        //获取项目根路径
        File basePath = new File(ResourceUtils.getURL("classpath:").getPath());
        //获取主题路径
        File themesPath = new File(basePath.getAbsolutePath(),"templates/themes");
        File[] files = themesPath.listFiles();
        if(null!=files) {
            Theme theme = null;
            for (File file : files) {
                if (file.isDirectory()) {
                    theme = new Theme();
                    theme.setThemeName(file.getName());
                    File optionsPath = new File(themesPath.getAbsolutePath(), file.getName() + "/module/options.ftl");
                    //判断是否存在options.ftl模板
                    if (optionsPath.exists()) {
                        theme.setHasOptions(true);
                    } else {
                        theme.setHasOptions(false);
                    }
                    themes.add(theme);
                }
            }
        }
    }catch (Exception e){
        log.error("主题获取失败:"+e.getMessage());
    }
    return themes;
}

这里返回的themes就是所有主题的List集合了。

ThemeController:

/**
 * 渲染主题设置页面
 *
 * @return String
 */
@GetMapping
public String themes(Model model){
    model.addAttribute("activeTheme",BaseController.THEME);
    if(null!=HaloConst.THEMES){
        model.addAttribute("themes",HaloUtil.getThemes());
    }
    return "admin/admin_theme";
}

页面上:

<#list themes as theme>
    <div class="col-md-3">
        <div class="box box-solid">
            <div class="box-body theme-thumbnail" style="background-image: url(/${theme.themeName?if_exists}/screenshot.png)"></div>
            <div class="box-footer">
                <span class="theme-title">${theme.themeName?if_exists?upper_case}</span>
                <#if theme.hasOptions==true>
                    <button class="btn btn-primary btn-sm pull-right btn-flat" onclick="openSetting('${theme.themeName?if_exists}')">设置</button>
                </#if>
                <#if activeTheme == "${theme.themeName}">
                    <button class="btn btn-primary btn-sm pull-right btn-flat" disabled>已启用</button>
                <#else>
                    <button onclick="setTheme('${theme.themeName?if_exists}')" class="btn btn-primary btn-sm pull-right btn-flat">启用</button>
                </#if>
            </div>
        </div>
    </div>
</#list>

效果:

后台上传主题

这个功能可以实现,在后台管理可以上传主题压缩包并解压到themes目录,实现起来也不是太难,实现代码如下:

ThemeController:

@RequestMapping(value = "/upload", method = RequestMethod.POST)
@ResponseBody
public boolean uploadTheme(@RequestParam("file") MultipartFile file,
                           HttpServletRequest request){
    try {
        if(!file.isEmpty()) {
            //获取项目根路径
            File basePath = new File(ResourceUtils.getURL("classpath:").getPath());
            File themePath = new File(basePath.getAbsolutePath(), new StringBuffer("templates/themes/").append(file.getOriginalFilename()).toString());
            file.transferTo(themePath);
            log.info("上传主题成功,路径:" + themePath.getAbsolutePath());
            logsService.saveByLogs(
                new Logs(LogsRecord.UPLOAD_THEME,file.getOriginalFilename(),HaloUtil.getIpAddr(request),HaloUtil.getDate())
            );
            //调用方法解压该压缩包到themes目录
            HaloUtil.unZip(themePath.getAbsolutePath(),new File(basePath.getAbsolutePath(),"templates/themes/").getAbsolutePath());
            //移除压缩包
            HaloUtil.removeFile(themePath.getAbsolutePath());
            HaloConst.THEMES.clear();
            HaloConst.THEMES = HaloUtil.getThemes();
            return true;
        }else{
            log.error("上传失败,没有选择文件");
        }
    }catch (Exception e){
        log.error("上传失败:"+e.getMessage());
    }
    return false;
}

unZip:

public static void unZip(String zipFilePath,String descDir){
    File zipFile=new File(zipFilePath);
    File pathFile=new File(descDir);
    if(!pathFile.exists()){
        pathFile.mkdirs();
    }
    ZipFile zip=null;
    InputStream in=null;
    OutputStream out=null;
    try {
        zip=new ZipFile(zipFile);
        Enumeration<?> entries=zip.entries();
        while(entries.hasMoreElements()){
            ZipEntry entry=(ZipEntry) entries.nextElement();
            String zipEntryName=entry.getName();
            in=zip.getInputStream(entry);

            String outPath=(descDir+"/"+zipEntryName).replace("\\*", "/");
            File file=new File(outPath.substring(0, outPath.lastIndexOf('/')));
            if(!file.exists()){
                file.mkdirs();
            }
            if(new File(outPath).isDirectory()){
                continue;
            }
            out=new FileOutputStream(outPath);
            byte[] buf=new byte[4*1024];
            int len;
            while((len=in.read(buf))>=0){
                out.write(buf, 0, len);
            }
            in.close();
        }
    } catch (Exception e) {
        log.error("解压失败:"+e.getMessage());
    }finally{
        try {
            if(zip!=null)
                zip.close();
            if(in!=null)
                in.close();
            if(out!=null)
                out.close();
        } catch (IOException e) {
            log.error("未知错误:"+e.getMessage());
        }
    }
}

主题的设置

在整个主题系统中,在某些情况下是需要对主题进行单独设置的。比如社交选项,样式选项等,其实要存储这些设置是非常简单的,和上一篇文章其实是一样的,将各个设置选项和值以key,value的方式存储在数据表中就可以了,在这里就不多讲了。

效果图:

注:这个弹出层是使用的layer实现的,非常感谢该框架!

总结

整个主题系统的完善还是花了不少时间的,这里只是讲了核心的实现方法,如果有朋友对此感兴趣的话,可以去github上看具体实现的代码:https://github.com/ruibaby/halo。如果对你有帮助的话,请给个Star,也欢迎大家提pull request。