# mm_os Web API 模型数据传递规范

## 一、概述

在 mm_os 框架中，**type:web** 的 API 负责渲染完整的 HTML 页面。控制器（`index.js`）组装 `model` 对象，通过 `db.tpl.view()` 将数据传递给模板引擎，模板使用 `${viewBag.xxx}` 等语法获取模型数据进行渲染。

与 **type:client**（纯数据 API，返回 JSON）不同，type:web 的核心职责是**组装视图模型 → 渲染模板 → 输出 HTML**。

## 二、完整数据传递链路

```
路由注册 → 事件分发 → 控制器组装model → db.tpl.view()传递 → 模板接收渲染
```

| 阶段 | 位置 | 说明 |
|:-----|:-----|:------|
| **1. 路由注册** | API 的 `api.json` | `type: "web"` 标记为 Web 页面路由，`method: "ALL"` 表示同时支持 GET/POST |
| **2. 事件分发** | `event_api/web/main.js` | 框架事件层创建 `db.tpl` 模板引擎实例，执行公共函数后路由到具体 API 控制器 |
| **3. 控制器组装模型** | API 的 `index.js` → `main()` | 从数据库查询数据，将页面所需全部数据组装到 `model` 对象中 |
| **4. 模板接收渲染** | `static/template/` 下的 `.html` 模板 | 通过 `${viewBag.字段名}` 取出模型数据，渲染 HTML 返回给浏览器 |

## 三、API 控制器（index.js）

### 3.1 标准结构

```javascript
// app/{模块名}/plugin/{插件名}/api_tool_web/{API名}/index.js

async function main(ctx, db) {
  var model = {};

  // 1. 获取用户信息
  var user = await $.user.get({ user_id: ctx.user_id });
  model.user = user;

  // 2. 查询业务数据
  var db_data = db.new('表名', '主键');
  db_data.size = 20;
  db_data.page = ctx.request.query.page || 1;
  var list = await db_data.get({ 条件 }, '排序');
  model.list = list;
  model.count = await db_data.count({ 条件 });

  // 3. 组装导航/分页等辅助数据
  model.nav = {
    previous: db_data.page - 1,
    next: db_data.page + 1,
    page_now: db_data.page,
    page_count: Math.ceil(model.count / db_data.size),
    base: ctx.request.url
  };

  // 4. 传递给模板渲染
  return db.tpl.view('./index.html'.fullname(__dirname), model);
}

exports.main = main;
```

### 3.2 控制器职责

| 职责 | 说明 |
|:-----|:------|
| 接收请求参数 | 从 `ctx.request.query`（GET）或 `ctx.request.body`（POST）获取 |
| 查询数据库 | 使用 `db.new()` / `this.sql.run()` 安全查询 |
| 组装 model 对象 | 将所有页面所需数据放入 model |
| 调用模板渲染 | `db.tpl.view(模板路径, model)` |
| **不负责** | 不直接操作 DOM、不拼接 HTML 字符串、不处理样式 |

## 四、模型数据传递方式

### 4.1 核心方法：`db.tpl.view(path, model)`

```javascript
return db.tpl.view('./index.html'.fullname(__dirname), model);
```

- **path**：模板文件路径。使用 `'./index.html'.fullname(__dirname)` 获取绝对路径
- **model**：包含所有页面数据的普通 JavaScript 对象
- **返回值**：渲染后的 HTML 字符串（框架自动返回给浏览器）

### 4.2 模型数据在模板端的可见性

模型对象的**所有一级属性**在模板中通过 `viewBag.属性名` 访问：

```javascript
// 控制器传入
var model = { user: {...}, list: [...], count: 100 };

// 模板中访问
${viewBag.user.nickname}   // 用户昵称
${viewBag.count}           // 100
${viewBag.list.length}     // 列表长度
```

### 4.3 子视图自动继承模型

通过 `${@view('./xxx.html')}` 嵌入子视图时，**子视图自动继承当前 model 数据**，无需手动传递：

```html
<!-- 父模板 -->
${@view('./common/head.html')}
${@view('./components/article_card.html')}

<!-- article_card.html 中可直接使用 viewBag.list -->
```

若需向子视图传递额外参数：
```html
${@view('./components/swiper.html', hookFilter("params", "首页轮播"))}
```

## 五、典型模型字段说明

以 `cms/article_list`（文章列表页）为参考，一个典型的 type:web 页面 model 包含以下字段：

### 5.1 用户信息

| 字段 | 类型 | 说明 |
|:-----|:-----|:------|
| `model.user` | Object | 当前登录用户信息 |
| `model.user.user_id` | number | 用户ID |
| `model.user.nickname` | string | 用户昵称 |
| `model.user.avatar` | string | 头像URL |

### 5.2 导航与面包屑

| 字段 | 类型 | 说明 |
|:-----|:-----|:------|
| `model.paths` | Array | 面包屑导航路径 `[{name, url, active}]` |
| `model.article_type` | Array | 文章分类导航层级 `[[{name, url, active}]]` |
| `model.article_type_tree` | Array/Object | 分类树形结构 |

### 5.3 列表数据

| 字段 | 类型 | 说明 |
|:-----|:-----|:------|
| `model.list` | Array | 主列表数据 `[{article_id, title, tags, types, time, url}]` |
| `model.list_new` | Array | 最新文章列表 |
| `model.list_hot` | Array | 热门文章列表 |
| `model.list_group` | Array | 分组列表 `[{name, url, count, active}]` |

### 5.4 分页信息

| 字段 | 类型 | 说明 |
|:-----|:-----|:------|
| `model.nav` | Object | 分页导航对象 |
| `model.nav.previous` | number | 上一页页码（0 表示无上一页） |
| `model.nav.next` | number | 下一页页码（0 表示无下一页） |
| `model.nav.page_now` | number | 当前页码 |
| `model.nav.page_count` | number | 总页数 |
| `model.nav.base` | string | 分页链接基础路径 |

### 5.5 计数与辅助

| 字段 | 类型 | 说明 |
|:-----|:-----|:------|
| `model.count` | number | 数据总条数 |
| `model.group_count` | number | 当前分组数据条数 |
| `model.search` | string | 搜索接口URL |

### 5.6 模型字段命名约定

| 约定 | 示例 |
|:-----|:------|
| 列表字段用复数或 `list` 前缀 | `list`, `list_new`, `list_hot` |
| 计数用 `count` 或 `xx_count` | `count`, `group_count` |
| 导航用 `paths`（面包屑）/ `nav`（分页） | `paths`, `nav` |
| 分类/分组用 `xx_type` / `xx_group` | `article_type`, `list_group` |

## 六、模板获取模型数据方式

### 6.1 变量输出

```html
<!-- 输出模型字段值 -->
<h1>${viewBag.list.length} 篇文章</h1>
<p>欢迎，${viewBag.user.nickname}</p>

<!-- 输出原始HTML（不转义） -->
<div>${@viewBag.article.content}</div>

<!-- 多语言 -->
<span>${viewBag.lang.article_title}</span>

<!-- URL查询参数 -->
<a href="?page=${viewBag.query.page}">跳转</a>
```

### 6.2 条件判断

```html
<!--{ if(viewBag.list && viewBag.list.length) }-->
  <div class="article-list">...</div>
<!--{ else }-->
  <div class="empty-tip">暂无文章</div>
<!--{ /if }-->

<!--{ if(viewBag.nav.previous) }-->
  <a href="${viewBag.nav.base}?page=${viewBag.nav.previous}">上一页</a>
<!--{ /if }-->
```

### 6.3 循环遍历

```html
<!--{ for(var o of viewBag.list) }-->
  <div class="article-item">
    <a href="${o.url}">
      <h3>${o.title}</h3>
      <span class="time">${o.time}</span>
    </a>
  </div>
<!--{ /for }-->

<!-- 使用 loop 语法（带索引） -->
<!--{ loop viewBag.article_type o idx }-->
  <a href="${o.url}" class="${o.active ? 'active' : ''}">${o.name}</a>
<!--{ /loop }-->
```

### 6.4 视图嵌入

```html
<!-- 嵌入子视图（自动继承 model） -->
${@view('./common/head.html')}
${@view('./common/header.html')}

<!-- 分页组件 -->
${@view('./components/pagination.html')}

<!-- 嵌入公共底部 -->
${@view('./common/footer.html')}
```

### 6.5 钩子函数

```html
<!-- 执行钩子动作 -->
${hookAction('seo', 'title')}

<!-- 带参数的过滤器 -->
${hookFilter("params", "首页轮播")}
```

## 七、与 type:client 的区别

| 维度 | type:web | type:client |
|:-----|:---------|:------------|
| **用途** | 渲染完整 HTML 页面 | 返回 JSON 数据 |
| **返回方式** | `db.tpl.view(path, model)` | `$.ret.obj(data)` |
| **model 对象** | ✅ 必需，传递给模板 | ❌ 不需要（直接返回 JSON） |
| **模板文件** | ✅ 需要 `.html` 模板 | ❌ 不需要 |
| **路由配置** | `api.json` 中 `type: "web"` | `api.json` 中 `type: "client"` |
| **典型场景** | 文章列表页、详情页、用户中心 | 登录接口、数据增删改查、搜索建议 |
| **参数来源** | `ctx.request.query` / `ctx.request.body` | 相同 |
| **文件位置** | `api_tool_web/` 目录 | `api_tool_client/` 目录 |

### 7.1 何时选 type:web，何时选 type:client

| 场景 | 选择 |
|:-----|:-----|
| 需要完整 HTML 页面（含 SEO） | type:web |
| 页面内容需要搜索引擎收录 | type:web |
| 前端 AJAX 调用的数据接口 | type:client |
| 移动 APP 调用的数据接口 | type:client |
| 小程序调用的数据接口 | type:client |
| 表单提交接口 | type:client |

## 八、挂件（Pendant）独立模型获取方式

### 8.1 挂件与 API 控制器的核心区别

挂件（Pendant）是页面片段组件，通过 `<!-- #位置名 -->` 插入点渲染。**挂件不继承主模板的 model/viewBag**，而是独立获取数据。

| 维度 | API 控制器 (type:web) | 挂件 (Pendant) |
|:-----|:---------------------|:---------------|
| **函数签名** | `main(ctx, db)` | `main(ctx, db, config)` |
| **数据来源** | 自行查询数据库，组装 model | 通过 `config.options` 获取用户配置，自行查询数据库 |
| **模型继承** | 主模板通过 viewBag 获取 model | **不继承**主模板的 viewBag，独立组装 model |
| **渲染方式** | `db.tpl.view(主模板路径, model)` | `db.tpl.view(挂件模板路径, model)` |
| **配置访问** | 无独立配置 | 模板中可直接访问 `config.xxx` |

### 8.2 挂件函数签名

```javascript
// app/{模块名}/pendant/{挂件名}/pendant.js

module.exports = {
  /**
   * 挂件主函数
   * @param {Object} ctx   请求上下文（可获取当前用户、请求参数等）
   * @param {Object} db    数据管理器
   * @param {Object} config 挂件配置（来自 pendant.json 的 options + 用户在后台的设置）
   * @return {Object} 执行结果
   */
  async main(ctx, db, config) {
    var model = { config };    // 通常把 config 也传给模板
    
    // 从配置中获取用户设定的参数
    var op = config.options;
    var { table, field, query, orderby, url, num } = op;
    
    // 查询数据库
    var db1 = db.new(table || 'cms_article');
    db1.size = num || 10;
    db1.page = 1;
    var qy = query ? query.toUrl() : {};
    var list = await db1.get(qy, orderby || "", field || "*");
    
    // 处理数据
    model.list = list.map((o) => {
      o.url = (url || "").replace(`{article_id}`, o.article_id);
      o.time = $.utils.to_time(o.time_create);
      return o;
    });
    
    // 渲染挂件模板
    return db.tpl.view("./pendant.html".fullname(__dirname), model);
  }
};
```

### 8.3 config 对象结构

挂件的 `config` 对象包含：

| 字段 | 说明 |
|:-----|:------|
| `config.name` | 挂件标识名 |
| `config.title` | 挂件标题 |
| `config.options` | 用户配置的参数（从 pendant.json 的 options 定义） |
| `config.options.table` | 要查询的数据表名 |
| `config.options.field` | 查询字段 |
| `config.options.query` | 查询条件 |
| `config.options.orderby` | 排序规则 |
| `config.options.num` | 显示数量 |
| `config.options.url` | 跳转URL模板 |
| `config.options.title` | 挂件显示标题 |
| `config.diy` | 自定义模板路径（如果配置了DIY模板） |
| `config.id` | 挂件实例ID |
| `config.layout` | 布局样式 |
| `config.class` | CSS类名 |
| `config.style` | 自定义样式 |

### 8.4 挂件模板数据获取

挂件模板中，数据来源有两个：

```html
<!-- 1. 从 model 获取数据（挂件main()函数传入） -->
<!--{ loop list o idx }-->
  <a href="${o.url}">
    <span class="time">${o.time}</span>
    <span class="title">${@o.title}</span>
  </a>
<!--{ /loop }-->

<!-- 2. 从 config 获取配置数据（框架自动注入） -->
<h5>${config.options.title}</h5>
<div id="${config.id}" class="${' ' + config.class}">
```

### 8.5 挂件与主模板的关系

```
API 控制器                           主模板                      挂件
main(ctx, db)                    template.html              pendant.js
      │                               │                        │
      ├── 组装 model ──→ db.tpl.view('主模板', model)         │
      │                               │                        │
      │                          <!-- #sidebar --> ────────── main(ctx, db, config)
      │                               │                        │
      │                          viewBag.xxx              model = { config }
      │                          可访问主模板数据               │
      │                                                    db.tpl.view('pendant.html', model)
      │                                                       │
      │                                                  config.xxx
      │                                                  viewBag.xxx
      │                                                  独立渲染，不共享主模板的 viewBag
```

> **要点**：挂件和主模板的模型**完全隔离**。挂件不能直接访问主模板通过 `db.tpl.view()` 传入的 model 数据。挂件通过 `config` 获取后台配置参数，通过 `ctx` 获取当前请求上下文（如用户信息、URL参数等）。

## 九、禁止行为

- ❌ 禁止在模板中直接查询数据库（应通过控制器传入 model）
- ❌ 禁止在控制器中拼接 HTML 字符串
- ❌ 禁止 model 中包含未序列化的复杂对象（如数据库连接实例）
- ❌ 禁止在模板中使用 `eval()` 或执行任意 JS 代码
- ❌ 禁止 model 对象中嵌套过深（建议最多 3 层）
- ❌ 禁止直接在模板中修改 model 数据（模板为只读渲染）