Appearance
模板目录结构与页面路由约定
本文档详细说明主题文件系统的标准目录布局、每个目录和关键文件的职责、页面路由的命名约定。
标准目录树
一个完整的 PC 端主题包含以下目录结构(以 default_pc 为例,实际可按需增删):
<theme_name>/
├── info.json # [必需] 主题元数据
├── README.md # [可选] 主题说明文档
│
├── config/ # [必需] 主题配置
│ ├── design.json # 页面装修组件定义(声明主题提供了哪些可拖拽组件)
│ └── index.json # 简单配置项(键值对列表)
│
├── data/ # [必需] 运行时数据
│ ├── Design.json # 实际保存的装修布局数据
│ ├── config.js # 主题能力声明(侧边栏、组件、链接、表单)
│ ├── DynamicPage.js # 动态页面定义
│ └── design_bak.json # [可选] 装修数据的备份
│
├── utils/ # [可选] 模板工具函数
│ └── common.js # 注入模板的自定义 JS 函数(通过 common.xxx() 访问)
│
└── view/ # [必需] EJS 模板文件
├── layout/ # [必需] 页面布局
│ ├── default.ejs # 默认布局(主站 HTML 骨架)
│ └── <custom>.ejs # 自定义布局
│
├── common/ # 公共组件(在 layout 中引用)
│ ├── header.ejs # 头部导航栏
│ ├── footer.ejs # 页脚
│ ├── aside.ejs # 侧边栏
│ ├── public.ejs # CSS 资源与全局变量注入
│ ├── script.ejs # JavaScript 引入入口
│ └── pagination.ejs # 分页组件
│
├── components/ # 可复用业务组件(通过 include() 引用)
│ ├── comment/
│ │ └── index.ejs # 评论组件
│ └── grid-hot/
│ └── index.ejs # 排行榜组件
│
├── index/ # 首页模块
│ ├── index.ejs # 首页
│ └── components/ # 首页专属组件(装修系统可拖拽的组件放这里)
│ ├── block-home-top.ejs # 推荐区块示例
│ └── block-special.ejs # 歌单区块示例
│
├── music/ # 音乐模块(示例)
│ ├── detail.ejs # 详情页
│ └── type.ejs # 列表页
├── user/ # 用户模块
├── order/ # 订单模块
├── ... # 按需添加更多业务模块
│
├── error/ # [推荐] 错误页
│ ├── 404.ejs
│ └── index.ejs
└── ajax/
└── index.ejs # AJAX 加载更多返回的 HTML 片段
---
## 关键文件详解
### `config/design.json` — 装修组件定义
声明主题向后端管理面板提供了哪些可拖拽的页面组件。
```json
{
"components": [
{
"name": "header",
"title": "头部",
"icon": "el-icon-menu",
"options": {}
},
{
"name": "banner",
"title": "轮播图",
"icon": "el-icon-menu",
"options": {}
}
],
"page": [
{
"title": "首页",
"name": "index",
"options": {}
}
]
}| 字段 | 说明 |
|---|---|
components[].name | 组件唯一标识 |
components[].title | 后台显示名称 |
components[].icon | Element UI 图标名 |
components[].options | 组件额外选项(保留) |
page[].title | 可装修的页面名称 |
page[].name | 页面标识(用于 design.index 的 key) |
config/index.json — 简单配置项
提供后台可视化配置表单中的简单字段。
json
[
{
"title": "默认语言",
"name": "default",
"value": "zh-CN",
"type": "input"
},
{
"title": "默认颜色",
"name": "theme",
"value": "light",
"type": "input"
},
{
"title": "加载封面",
"name": "square",
"value": "0",
"type": "image"
}
]| 字段 | 类型 | 说明 |
|---|---|---|
title | String | 配置项显示标题 |
name | String | 配置键名 |
value | String | 默认值 |
type | String | 表单控件类型(input、image) |
data/Design.json — 装修布局数据
存储实际的装修数据,由后台管理面板的拖拽操作生成。在模板中通过 design 变量访问。
json
{
"config": {
"theme_mode": "system",
"theme_language": "chinese_simplified",
"themeColor": "#E0842C",
"progressColor": "#90EE90"
},
"sidebar": [
{
"title": "发现",
"children": [
{
"title": "首页",
"icon": "fa fa-home",
"path": "/",
"id": "item_xxx"
}
],
"id": "group_xxx"
}
],
"index": {
"subject": [
{
"component": "../index/components/swiper-index-full",
"title": "首页轮播",
"type": "subject",
"models": [],
"param": {
"condition": {
"query": { "config_name": "index_swiper" },
"cachetime": 3600
}
},
"options": {},
"id": "comp_xxx"
}
],
"right": [
{
"component": "../index/components/right-rank-list",
"title": "排行榜",
"type": "right",
"models": [],
"param": {},
"options": {},
"id": "comp_xxx"
}
]
}
}数据结构说明:
| 路径 | 类型 | 说明 |
|---|---|---|
config | Object | 可视化配置项的值(由 data/config.js 的 formBuilder 定义表单) |
config.theme_mode | String | 主题模式:light / dark / system |
config.theme_language | String | 默认语言:chinese_simplified / english / korean |
config.themeColor | String | 主题色(CSS 变量 --theme-color) |
config.progressColor | String | 播放进度条颜色 |
sidebar | Array | 侧边栏菜单配置(分组结构) |
sidebar[].title | String | 菜单分组标题 |
sidebar[].children | Array | 子菜单项列表 |
sidebar[].children[].title | String | 菜单项标题(支持 HTML) |
sidebar[].children[].icon | String | Font Awesome 图标类名 |
sidebar[].children[].path | String | 路由路径(传递给 url() 函数) |
index.subject | Array | 首页主体区域的装修组件列表 |
index.right | Array | 首页右侧栏的装修组件列表 |
组件实例(subject/right 数组元素):
| 字段 | 类型 | 说明 |
|---|---|---|
component | String | 组件模板路径(相对于 view/,不含 .ejs 后缀) |
title | String | 组件标题 |
type | String | 组件类型:subject(主内容区)或 right(右侧栏) |
models | Array | 组件关联的数据模型名称 |
param | Object | 传递给组件的参数 |
param.condition | Object | 数据查询条件(orderby, limit, cachetime, query 等) |
param.modelName | String | 指定数据模型名称 |
options | Object | 组件额外选项(保留) |
id | String | 组件实例的唯一标识 |
渲染方式(首页 view/index/index.ejs):
ejs
<%
let { subject, right } = design.index;
%>
<% if (subject) { %>
<% for (let item of subject) { %>
<%- await include(item.component, {...item.param, title: item.title, model: model}); %>
<% } %>
<% } %>组件通过 include() 加载,参数展开后传入子模板。
data/config.js — 主题能力声明
定义主题的功能边界和后端管理面板的配置表单。
js
export default {
// 是否支持侧边栏
is_sidebar: true,
// 是否支持右侧栏
is_right: true,
// 提供的可用组件列表(首页装修时可选)
components: [
{
component: "../index/components/swiper-index-full",
title: "首页轮播",
type: "subject",
models: [],
param: {
condition: {
query: { config_name: "index_swiper" },
cachetime: 60 * 60
}
},
options: {}
},
// ... 更多组件
],
// 可用的页面链接列表(用于后台菜单配置等)
links: [
{ title: "首页", path: "/" },
{ title: "登录", path: "User/login" },
{ title: "注册", path: "User/register" },
{ title: "充值", path: "Recharge/index" },
{ title: "用户中心", path: "User/index" },
{ title: "购物车", path: "Cart/index" },
{ title: "订单中心", path: "Order/index" },
// ... 更多链接
],
// 后台可视化配置表单定义
formBuilder: [
{
type: "radio",
field: "theme_mode",
title: "默认模式",
value: "light",
props: { placeholder: "请选择主题模式" },
col: { span: 13 },
options: [
{ label: "白色模式", value: "light" },
{ label: "暗黑模式", value: "dark" },
{ label: "随系统", value: "system" }
]
},
{
type: "radio",
field: "theme_language",
title: "默认语言",
value: "chinese_simplified",
options: [
{ label: "中文", value: "chinese_simplified" },
{ label: "英文", value: "english" },
{ label: "韩文", value: "korean" }
]
},
{
type: "ColorPicker",
field: "themeColor",
title: "主题颜色",
value: "#4258ff",
props: {
predefine: [
"#4258ff", "#ff8c00", "#ffd700",
"#90ee90", "#00ced1", "#1e90ff", "#c71585"
]
}
},
{
type: "ColorPicker",
field: "progressColor",
title: "波形颜色",
value: "#4258ff",
props: {
predefine: [
"#4258ff", "#ff8c00", "#ffd700",
"#90ee90", "#00ced1", "#1e90ff", "#c71585"
]
}
}
]
}formBuilder 表单项字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
type | String | 表单控件类型:radio、ColorPicker 等 |
field | String | 字段键名(保存到 design.config.<field>) |
title | String | 表单标签 |
value | Any | 默认值 |
props | Object | 额外属性(placeholder、predefine 等) |
col | Object | 栅格布局({ span: 13 }) |
options | Array | 选项列表(用于 radio/select 类型) |
options[].label | String | 选项显示文本 |
options[].value | String | 选项值 |
data/DynamicPage.js — 动态页面定义
定义系统中哪些页面是「动态页面」——即通过 URL 规则自动匹配的列表/聚合页。
js
export default [
{
type_ids: null,
diyname: "header",
title: "专辑",
seotitle: "最新音乐专辑、合辑、大碟",
keywords: ["专辑", "合辑", "大碟", "音乐"],
description: "提供热门专辑展示",
data: {},
flag: [],
content: "",
image: "",
route: "/albums.html",
model_name: "MusicSong",
tpl: "dynamic/albums",
theme: "default_pc",
sort: 0,
isguest: 1,
status: 1
},
// ... 更多动态页面
]动态页面字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
diyname | String | 页面分组标识(header 顶部导航 / event 活动类 / agreement 协议类) |
title | String | 页面标题 |
seotitle | String | SEO 标题 |
keywords | Array | SEO 关键词列表 |
description | String | SEO 描述 |
route | String | 访问路径 |
model_name | String | 关联的数据模型名(如 MusicSong) |
tpl | String | 对应的 EJS 模板路径(相对于 view/,不含 .ejs) |
theme | String | 所属主题名 |
sort | Number | 排序值 |
isguest | Number | 是否允许游客访问(1=允许) |
status | Number | 状态(1=启用) |
动态页面在导航栏中的加载方式(view/common/header.ejs):
ejs
<%
const navbar = await model("dynamic_page", {
query: { diyname: "header", status: 1 },
orderway: -1,
orderby: "sort",
limit: "20",
cachetime: 600
});
%>
<% navbar.forEach((item, key) => { %>
<a class="navbar-item" href="<%= item.url %>"><%= item.title %></a>
<% }); %>页面路由命名约定
路由与模板文件的映射关系
路由配置 routes/index.js 定义了「URL 路径 → 控制器方法」的映射,控制器方法中调用 this.fetch('模板路径') 渲染对应 EJS 文件。
| 路由示例 | 控制器 | 方法 | 模板路径 |
|---|---|---|---|
GET / | Index | index | view/index/index.ejs |
GET /song/:id | Music | detail | view/music/detail.ejs |
GET /album/:id | Album | detail | view/album/detail.ejs |
GET /video/:id | Video | detail | view/video/detail.ejs |
GET /author/:id | Author | detail | view/author/detail.ejs |
GET /author/creator | Author | creator | view/author/creator.ejs |
GET /music/type | Music | type | view/music/type.ejs |
GET /dynamic | Dynamic | index | 动态匹配 dynamic/<tpl> |
GET /user/login | User | login | view/user/login.ejs |
GET /user/register | User | register | view/user/register.ejs |
GET /user/index | User | index | view/user/index.ejs |
GET /cart/index | Cart | index | view/cart/index.ejs |
GET /order/index | Order | index | view/order/index.ejs |
GET /recharge/index | Recharge | index | view/recharge/index.ejs |
GET /search/index | Search | index | view/search/index.ejs |
GET /vip/index | Vip | index | view/vip/index.ejs |
GET /index/sitemap | Index | sitemap | view/index/sitemap.ejs |
GET /rss/* | Rss | baidu/google/bing 等 | view/rss/*.ejs |
GET /signin/index | Signin | index | view/signin/index.ejs |
POST /ajax/index | Ajax | index | view/ajax/index.ejs(返回 HTML 片段) |
URL 生成函数
在模板中通过 url() 函数生成路由 URL,无需手动拼接路径:
ejs
<%= url('User/login') %> <!-- /user/login -->
<%= url('User/login', { redirect: '/home' }) %> <!-- /user/login?redirect=/home -->
<%= url('Music/detail', { id: song._id }) %> <!-- /song/abc123 -->
<%= url('Author/detail', { id: author._id }) %> <!-- /author/abc123 -->
<%= url('Search/index') %> <!-- /search/index?wd=xxx -->AJAX 请求的模板渲染
当请求头部包含 x-requested-with: XMLHttpRequest 时,Base.fetch() 仍然正常渲染模板,但前端通常通过 fetch API 获取 HTML 片段并插入 DOM。例如:
js
// 前端
fetch('/ajax/index?modename=music_song&orderby=createtime&page=1&tplid=music-cell', {
headers: { 'x-requested-with': 'XMLHttpRequest' }
})
.then(res => res.text())
.then(html => {
document.getElementById('list-target').innerHTML = html;
});对应的服务端模板 view/ajax/index.ejs:
ejs
<%
let { list: __list__, page } = await model(modeName, {
orderway, orderby, pageNum, limit: limit,
paging: true, query, cachetime: 600
});
%>
<% for (let [index, item] of __list__.entries()) { %>
<% if (tplid == 'item-dynamic') { %>
<%- await include('../components/item-dynamic', { item }); %>
<% } else if (tplid == 'hot-cell') { %>
<%- await include('../components/grid-hot/hot-cell', { item }); %>
<% } else if (tplid == 'music-cell') { %>
<%- await include('../components/music-cell', { item }); %>
<% } %>
<% } %>布局切换
控制器中通过 this.layout('layoutName') 切换布局。系统默认为 default,也可指定自定义布局:
js
// 使用 default 布局
this.fetch('music/detail');
// 使用自定义布局
this.layout('author');
this.fetch('author/creator');
// 完全禁用布局(用于 AJAX 片段返回)
this.layout(false);
this.fetch('ajax/index');对应的布局文件必须存在于 view/layout/<name>.ejs。
模板中的 <script> 标签约定
每个页面模板的末尾应声明 pageName 变量,用于前端 JS 按页面执行初始化:
ejs
<script>
var pageName = "music/detail";
</script>JS 入口据此分发:
js
const pages = {
"music/detail": initMusicDetail,
"index/index": initIndex,
};
document.addEventListener("DOMContentLoaded", () => {
if (window.pageName && pages[window.pageName]) {
pages[window.pageName]();
}
});约定:pageName 格式为 "模块/页面",与控制器 fetch 的模板路径一致。这是 JS 与模板之间的唯一命名约定,其余选择器、class 名等由开发者自定。
公共组件(view/common/)说明
| 文件 | 用途 | 引用方式 |
|---|---|---|
public.ejs | 引入 CSS 资源(Bulma、FontAwesome、主题样式),注入全局 JS 变量 AOD | 在 layout 的 <head> 中 <%- await include('../common/public') %> |
script.ejs | 引入 JS 资源,区分开发/生产模式 | 在 layout 的 </body> 前 <%- await include('../common/script') %> |
header.ejs | 全站导航栏(含搜索框、用户菜单、动态页面导航) | 在 layout 中 <%- await include('../common/header') %> |
footer.ejs | 全站页脚(友链、证书、版权信息) | 在 layout 中 <%- await include('../common/footer') %> |
aside.ejs | 左侧边栏(菜单、主题切换、语言设置) | 在 layout 中 <%- await include('../common/aside') %> |
pagination.ejs | 分页导航组件(上一页/下一页/页码) | 在列表页中 <%- await include('../common/pagination') %> |
block.ejs | 通用区块占位(空模板,仅输出 title) | 可选 |
empty.ejs | 空数据占位提示 | 可选 |
业务模块模板的通用页面
每个业务模块通常包含以下类型的页面:
| 页面类型 | 命名约定 | 示例 | 说明 |
|---|---|---|---|
| 列表页 | type.ejs / index.ejs | music/type.ejs | 分类/聚合列表 |
| 详情页 | detail.ejs | music/detail.ejs | 单条数据详情 |
| 编辑页 | update.ejs | author/update.ejs | 数据编辑表单 |
| 创建页 | create.ejs | special/create.ejs | 数据创建表单 |
| AJAX 片段 | ajax_*.ejs | user/ajax_login.ejs | 弹窗中加载的 HTML 片段 |
utils/common.js — 模板工具函数
主题下的 utils/common.js 是一个 ES Module 文件,导出可在 EJS 模板中调用的工具函数。这些函数通过 templateUtils[theme] 机制注入到模板上下文中,在模板中通过 common.函数名() 访问:
ejs
<%= common.formatTime(obj.duration) %>
<%= common.bytesToKbps(obj.bitrate) %> kbps
<%= common.formatBytes(obj.size) %>
<%= common.formatUtcStrin(obj.createtime) %>
<%= common.paytypeText(order.paytype) %>
<%= common.productType(item.type) %>完整导出函数列表(基于真实代码 utils/common.js):
| 函数 | 参数 | 返回值 | 说明 |
|---|---|---|---|
formatTime(seconds) | number | "MM:SS" 或 "HH:MM:SS" | 秒数转时间格式 |
randomString(len) | number(默认 32) | string | 生成随机字符串 |
hideMobileOrEmail(str) | string | string | 脱敏手机号/邮箱 |
filterUrl(url, param, keys, query) | string, object, array, object | string | 动态构建 URL(合并参数过滤) |
formatUtcStrin(time, format) | string, string(默认 YYYY-MM-DD HH:mm:ss) | string | UTC 时间转本地格式化 |
paytypeText(paytype) | string | string | 支付方式中文映射 |
productType(type) | string | string | 模型名中文映射 |
htmlFilter(html) | string | string | 过滤 HTML 危险标签(script/iframe/video/audio) |
fileIcon(url, fileName) | string, string | string(SVG HTML) | 生成文件类型图标(含云盘识别) |
formatBytes(bytes, decimals) | number, number(默认 2) | string | 字节转 KB/MB/GB |
analyzeRichTextLines(richText) | string | number | 分析富文本行数 |
getSecurityParameter(request, method, model) | object, string, string | object | 安全过滤请求参数 |
formNumber(num) | number | string | 数字转中文单位(千/万/亿) |
bytesToKbps(bytes, decimalPlaces) | number, number(默认 2) | string | 字节转 kbps |
omit(request, keys) | object, string|array | object | 移除对象中指定属性 |
couponTypeText(value) | string | string | 优惠券类型中文映射 |
跨模块引用约定
子模板引用(include)
使用相对路径引用组件,路径相对于 view/ 目录:
ejs
<%- await include('../components/comment/index', { id: obj._id, type: 'music' }) %>
<%- await include('../components/grid-hot/index', { modelName: 'music_song', condition: {...} }) %>
<%- await include('../common/pagination') %>include 传参
第二个参数是一个对象,子模板中可直接访问对象中的属性:
ejs
<%# 父模板调用 %>
<%- await include('../components/comment/index', {
id: obj._id,
type: 'music',
source_uid: obj.author.user_id
}) %>
<%# 子模板 components/comment/index.ejs 中可直接使用 %>
<%= id %>
<%= type %>
<%= source_uid %>