# CMSPRO 应用视图规范

## 1. 目录结构与命名规范

### 1.1 顶层目录

应用视图位于 `app/Apps/{AppName}/Views/` 下，视图命名空间在 ServiceProvider 中通过 `loadViewsFrom()` 注册：

```php
$this->loadViewsFrom(
    base_path('app/Apps/{AppName}/Views'),
    '{namespace}'
);
```

视图引用方式：`view('{namespace}::path.to.view')`

### 1.2 目录命名

| 目录 | 用途 | 大小写 |
|------|------|--------|
| `Admin/` | 后台管理页面 | **首字母大写**（主流规范） |
| `User/` | 用户中心页面 | **首字母大写** |
| `Home/` | 前台展示页面 | **首字母大写** |
| `layouts/` | 布局文件 | **全小写** |

> 全小写模式（如 `admin/`、`front/`）已逐步淘汰，新开发统一使用首字母大写。

### 1.3 功能子目录

`Admin/`、`User/`、`Home/` 下按 Controller 功能模块名命名为**全小写**子目录：

```
Admin/
├── flow/           # 功能模块名：全小写
├── node_app/       # 多单词：下划线分隔
├── template/
└── authorize/
```

### 1.4 视图文件命名

| 文件 | 用途 |
|------|------|
| `index.blade.php` | 列表/首页页面 |
| `form.blade.php` | 表单页面（新增/编辑共用） |
| `detail.blade.php` | 详情页面 |
| `_xxx.blade.php` | 视图片段/包含文件（下划线前缀） |

---

## 2. 后台管理列表页规范

### 2.1 完整页面结构

后台列表页必须包含完整的 HTML 结构，参照 [CmsproFlow\Admin\flow\index.blade.php](file:///e:\wwwroot\cmspro\code\app\Apps\CmsproFlow\Views\Admin\flow\index.blade.php) 和 [CmsproForum\Admin\categories\index.blade.php](file:///e:\wwwroot\cmspro\code\app\Apps\CmsproForum\Views\Admin\categories\index.blade.php)：

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>页面标题</title>
    <!-- 必须引入的公共 CSS -->
    <link rel="stylesheet" href="{{ asset('CmsProUi/component/pear/css/pear.css') }}">
    <link rel="stylesheet" href="{{ asset('CmsProUi/font-awesome/4.7.0/css/font-awesome.min.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/admin.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/variables.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/reset.css') }}">
</head>
<body>
<div class="pear-container">
    {{-- 搜索条件卡 --}}
    <div class="layui-card">
        <div class="layui-card-body">
            {{-- 搜索表单 --}}
        </div>
    </div>

    {{-- 表格卡 --}}
    <div class="layui-card">
        <div class="layui-card-body">
            {{-- Layui 表格 --}}
            <table id="table" lay-filter="table"></table>
        </div>
    </div>

    {{-- Layui 模板（必须用 @verbatim 包裹） --}}
    @verbatim
    <script type="text/html" id="statusTpl">
        {{# if(d.status === 1){ }}
        <span class="layui-badge layui-bg-green">启用</span>
        {{# } else { }}
        <span class="layui-badge layui-bg-gray">禁用</span>
        {{# } }}
    </script>
    @endverbatim
</div>

{{-- JS 资源 --}}
<script src="{{ asset('CmsProUi/component/layui/layui.js') }}"></script>
<script src="{{ asset('CmsProUi/component/pear/pear.js') }}"></script>
<script>
layui.use(['table', 'form', 'layer', 'jquery'], function () {
    var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.jquery;
    $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' } });
    // ... 表格渲染逻辑
});
</script>
</body>
</html>
```

### 2.2 错误的写法（当前 CmsproFlow 模板列表的问题）

❌ 直接裸露 Layui 模板代码，缺少完整 HTML 页面结构：

```html
@verbatim
{{# layui.each(d.items, function(index, item){ }}
<tr>
    <td>{{ item.id }}</td>
    <td>{{ item.name }}</td>
</tr>
{{# }); }}
@endverbatim
```

**这个文件只是一个模板片段，缺少页面结构，无法独立渲染。**

> **注意：** `template/index.blade.php` 当前内容不完整——它只有被 `@verbatim` 包裹的一段纯 Layui 模板语法，但缺少 `<html>`、`<head>`、CSS 引用、表格容器、JS 初始化等必要结构。它既不是独立页面，也不是通过 `include` 引入的片段（没有下划线 `_` 前缀），导致无法正确渲染。

---

## 3. 后台表单页规范

### 3.1 完整页面结构（iframe 弹窗中使用）

表单页作为 `layer.open(type: 2)` 的内容时，需要完整 HTML 结构，并采用**固定底部按钮栏 + 内容区自适应滚动**的布局。标准模板参照 [Finance\Admin\currency_type\form.blade.php](file:///e:\wwwroot\cmspro\code\app\Apps\Finance\Views\Admin\currency_type\form.blade.php)：

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>表单标题</title>
    <link rel="stylesheet" href="{{ asset('CmsProUi/component/pear/css/pear.css') }}">
    <link rel="stylesheet" href="{{ asset('CmsProUi/font-awesome/4.7.0/css/font-awesome.min.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/admin.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/variables.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/reset.css') }}">
    <style>
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            overflow: hidden;
        }
        *, *::before, *::after {
            box-sizing: border-box;
        }
        .layui-form {
            height: 100%;
        }
        /* 内容区：总高度减去底部按钮栏高度，内容超出时自身滚动 */
        .layui-form > .layui-row {
            height: calc(100vh - 52px);
            overflow-y: auto;
            padding: 15px;
            margin: 0;
        }
        /* 底部按钮栏：固定 52px 高度，始终贴底不参与滚动 */
        .bottom {
            height: 52px;
            background: #fff;
            border-top: 1px solid #e6e6e6;
            text-align: right;
        }
        .bottom .button-container {
            display: inline-block;
        }
        .bottom .button-container button {
            margin-left: 10px;
        }
    </style>
</head>
<body>
    <form class="layui-form" lay-filter="xxxForm">
        <input type="hidden" id="id" name="id" value="">
        {{-- 滚动内容区：所有表单字段放在 .layui-row 内 --}}
        <div class="layui-row">
            <div class="layui-col-md12">
                <div class="layui-card-body layui-row">
                    <div class="layui-col-md12">
                        <div class="layui-form-item">
                            <label class="layui-form-label">名称</label>
                            <div class="layui-input-block">
                                <input type="text" id="name" name="name" lay-verify="required" placeholder="请输入名称" class="layui-input" autocomplete="off">
                            </div>
                        </div>
                        {{-- 其余表单字段 --}}
                    </div>
                </div>
            </div>
        </div>
        {{-- 固定底部按钮栏：与滚动内容区 .layui-row 同级 --}}
        <div class="bottom">
            <div class="button-container">
                <button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit lay-filter="save">
                    <i class="layui-icon layui-icon-ok"></i> 提交
                </button>
                <button type="reset" class="layui-btn layui-btn-primary layui-btn-sm">
                    <i class="layui-icon layui-icon-refresh"></i> 清空
                </button>
            </div>
        </div>
    </form>

    <script src="{{ asset('CmsProUi/component/layui/layui.js') }}"></script>
    <script src="{{ asset('CmsProUi/component/pear/pear.js') }}"></script>
    <script>
    layui.use(['form', 'jquery'], function () {
        var form = layui.form, $ = layui.jquery;
        $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' } });

        var editId = new URLSearchParams(window.location.search).get('id');
        var isEdit = !!editId;

        // 编辑：回填数据
        if (isEdit) {
            $('#code').attr('readonly', true).css('background', '#f2f2f2');
            $.ajax({ url: '/admin/xxx/api/xxx/' + editId, type: 'GET', dataType: 'json',
                success: function (res) {
                    if (res.code === 0 && res.data) {
                        // 逐字段回填后调用 form.render()
                        form.render();
                    }
                }
            });
        }

        // 提交
        form.on('submit(save)', function (data) {
            var url = isEdit ? '/admin/xxx/api/xxx/' + editId : '/admin/xxx/api/xxx';
            var method = isEdit ? 'PUT' : 'POST';
            data.field.status = data.field.status ? 1 : 0;
            $.ajax({ url: url, type: method, dataType: 'json', contentType: 'application/json',
                data: JSON.stringify(data.field),
                success: function (res) {
                    if (res.code === 0) {
                        layer.msg(isEdit ? '编辑成功' : '新增成功', { icon: 1, time: 1000 }, function () {
                            var index = parent.layer.getFrameIndex(window.name);
                            parent.layer.close(index);
                            parent.table.reload('xxxTable');
                        });
                    } else {
                        layer.msg(res.message || '操作失败', { icon: 2, time: 1500 });
                    }
                },
                error: function () { layer.msg('请求失败', { icon: 2, time: 1500 }); }
            });
            return false;
        });
    });
    </script>
</body>
</html>
```

关键点：

- 必须引入 `CmsProUi/component/layui/layui.js` 和 `CmsProUi/component/pear/pear.js`
- 必须设置 `$.ajaxSetup` 注入 CSRF Token
- 表单提交后通过 `parent.layer.getFrameIndex(window.name)` 获取并关闭父层弹窗
- 保存成功后调用 `parent.table.reload('xxxTable')` 刷新父页面表格

### 3.2 固定底部按钮栏布局规范（必须遵循）

表单在 iframe 弹窗中打开时，**"提交/清空"按钮必须始终固定在右下角**，内容超长时只滚动内容区、按钮栏不随滚动移动。实现要点：

| 元素 | 关键样式 | 作用 |
|------|---------|------|
| `html, body` | `height: 100%; margin: 0; overflow: hidden;` | 锁定视口高度，禁止 body 层滚动 |
| `.layui-form` | `height: 100%;` | 撑满 iframe 视口 |
| 内容区（`.layui-row`） | `height: calc(100vh - 52px); overflow-y: auto;` | 高度 = 视口 - 按钮栏，内容超出时自身滚动 |
| 按钮栏（`.bottom`） | `height: 52px;`（不加 `padding`） | 固定高度贴底，与内容区高度互补 |

> **踩坑记录：**
> - 按钮栏高度（`52px`）必须与内容区 `calc(100vh - 52px)` 的减数**保持一致**，否则会出现整页滚动，按钮被挤出视口，需滚动才能看到。
> - `.bottom` **不要加 `padding`**（如 `padding: 8px 20px`），否则实际高度超过 `52px`，破坏高度互补关系。内边距由内部 `.button-container` 控制。
> - 弹窗 `layer.open` 必须指定固定高度，如 `area: ['550px', '450px']`，`height: 100%` 才有参照。
> - 不要用 `position: fixed` / `sticky` 固定按钮栏——在 iframe + flex 环境下易失效，改用上述"内容区 calc 高度 + 按钮栏固定高度"方案最稳定。
> - **不要覆盖 Layui 表单组件的默认尺寸样式**（如 `.layui-form-label` 的 `width`、`.layui-input-block` 的 `margin-left`），Layui 的 `form.render('select')` 依赖这些值计算下拉面板定位，覆盖会导致 select 下拉框位置偏移。

**典型故障案例（CmsproChildrehab 应用 form 视图批量修复，2026-07-17）：**

**现象**：表单页打开后取消/保存（或提交/清空）按钮被挤出可视区域，只能看到一小块按钮边沿，必须滚动才能完整看到。

**根因**：表单页 `<style>` 块的 `.bottom` 写法违反规范——同时使用了 `line-height: 52px;` 和 `padding-right: 15px;`，且按钮未用 `.button-container` 包装。结果 `.bottom` 实际渲染高度 > `52px`，与内容区 `calc(100vh - 52px)` 的减数不再互补，导致整页滚动把按钮栏顶出视口。

**违规写法（禁止）**：
```css
.bottom { height: 52px; background: #fff; border-top: 1px solid #e6e6e6; text-align: right; line-height: 52px; padding-right: 15px; }
```
```html
<div class="bottom">
    <button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit lay-filter="save"><i class="layui-icon layui-icon-ok"></i> 提交</button>
    <button type="reset" class="layui-btn layui-btn-primary layui-btn-sm"><i class="layui-icon layui-icon-refresh"></i> 清空</button>
</div>
```

**正确写法**：
```css
html, body { height: 100%; margin: 0; overflow: hidden; }
*, *::before, *::after { box-sizing: border-box; }
.layui-form { height: 100%; }
.layui-form > .layui-row { height: calc(100vh - 52px); overflow-y: auto; padding: 15px; margin: 0; }
/* 底部按钮栏：固定 52px 高度，不加 padding，内边距由 .button-container 控制 */
.bottom { height: 52px; background: #fff; border-top: 1px solid #e6e6e6; text-align: right; }
.bottom .button-container { display: inline-block; line-height: 52px; padding-right: 15px; }
.bottom .button-container button { margin-left: 10px; }
```
```html
<div class="bottom">
    <div class="button-container">
        <button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit lay-filter="save"><i class="layui-icon layui-icon-ok"></i> 提交</button>
        <button type="reset" class="layui-btn layui-btn-primary layui-btn-sm"><i class="layui-icon layui-icon-refresh"></i> 清空</button>
    </div>
</div>
```

**修复检查清单**（编写或审查 form 视图时逐项核对）：
- [ ] `.bottom` 的 CSS 中**不出现** `padding`、`line-height`（这两个属性应写在 `.button-container` 上）
- [ ] `.bottom` 内部必须有 `<div class="button-container">` 包装按钮
- [ ] `.layui-form > .layui-row` 与 `.bottom` 是 `.layui-form` 的同级直接子元素
- [ ] `.layui-form` 设 `height: 100%`，`.layui-row` 设 `calc(100vh - 52px)` + `overflow-y: auto`
- [ ] 包含 `*, *::before, *::after { box-sizing: border-box; }` 盒模型声明
- [ ] `layer.open` 弹窗指定固定高度（如 `area: ['550px', '450px']`）

### 3.3 表单标签不换行规范（必须遵循）

**问题现象**：表单中较长的标签名称（如"默认时长(分钟)"、"关联管理员ID"、"微信AppID"、"主治医生"等）会自动换行显示为两行，破坏表单视觉对齐。

**根因**：Layui 的 `.layui-form-label` 默认 `width: 80px`（固定值），中文标签超过 4-5 个字符时宽度不足，浏览器自动换行。

**禁止方案**：

```css
/* ❌ 禁止：width: unset 会让 label 宽度变 auto，破坏与 .layui-input-block 的对齐 */
.layui-form-label { width: unset; }
```

`width: unset` 的危害：
- `.layui-input-block` 默认 `margin-left: 110px`（与 label `width: 80px` 配套），label 变 `auto` 后两者不再匹配
- 短标签（如"名称"）：label 收缩，与 input 之间出现大间隙
- 长标签（如"默认时长(分钟)"）：label 撑宽，与 input 发生重叠
- 同时违反本规范第 3.2 节"不要覆盖 Layui 表单组件的默认尺寸样式"约束，select 下拉框定位依赖 label width

**正确方案**：

```css
/* ✅ 推荐：仅设 white-space: nowrap，不覆盖任何尺寸样式 */
.layui-form-label { white-space: nowrap; }
```

**原理**：
- `.layui-form-label` 默认 `text-align: right; float: left`，标签文字右对齐
- 加 `white-space: nowrap` 后标签强制单行显示，不再换行
- 超长内容因 `text-align: right` 向**左**溢出，不会挤压右侧 input 区域
- 不覆盖 `width` 和 `margin-left`，select 下拉框定位完全不受影响
- form-section 的 `padding: 20px` 足以容纳向左溢出的几个字符，视觉上不突兀

**完整样式块示例**（与其他 form 样式规整在一起）：

```css
html, body { height: 100%; margin: 0; overflow: hidden; }
*, *::before, *::after { box-sizing: border-box; }
.layui-form { height: 100%; }
.layui-form > .layui-row { height: calc(100vh - 52px); overflow-y: auto; padding: 15px; margin: 0; }
.bottom { height: 52px; background: #fff; border-top: 1px solid #e6e6e6; text-align: right; }
.bottom .button-container { display: inline-block; line-height: 52px; padding-right: 15px; }
.bottom .button-container button { margin-left: 10px; }
/* 表单标签单行显示，防止长标签换行；超长内容向左溢出，不覆盖 Layui 默认 width */
.layui-form-label { white-space: nowrap; }
```

**检查清单**（追加到 form 视图审查项）：
- [ ] `<style>` 块中包含 `.layui-form-label { white-space: nowrap; }`
- [ ] **不出现** `.layui-form-label { width: unset; }` 或对 label width 的覆盖
- [ ] **不出现**对 `.layui-input-block` 的 `margin-left` 覆盖



---

## 4. iframe 嵌套弹窗规范

### 4.1 场景：iframe 内嵌页面中再次弹出 iframe 选择器

**问题描述：**

表单本身通过 `layer.open` 以 iframe 方式弹出，表单内的选择按钮（如头像选择、附件选择）需要再次弹出选择器 iframe。若直接使用 `layer.open`，新弹窗会在当前 iframe 内部打开，导致层级错误、无法最大化覆盖整个页面。

**解决方案：**

使用 `parent.layer.open` 和 `parent.layer.full` 代替 `layer.open` 和 `layer.full`，将弹窗提升到父页面层级。

**代码示例：**

```javascript
// 在 iframe 内嵌页面中弹出附件选择器
$('#chooseAvatarBtn').on('click', function() {
    parent.layer.open({
        type: 2,
        title: '选择头像',
        area: ['90%', '80%'],
        maxmin: true,
        success: function(layero, idx){
            parent.layer.full(idx);
        },
        content: '/admin/attachment?picker=1',
        end: function() {
            var selectedUrl = sessionStorage.getItem('selectedAttachmentUrl');
            if (selectedUrl) {
                $('#avatar').val(selectedUrl);
                $('#avatarPreview').attr('src', selectedUrl).addClass('active');
                $('#removeAvatarBtn').show();
                sessionStorage.removeItem('selectedAttachmentUrl');
            }
        }
    });
});
```

**关键要点：**

| 项目 | 说明 |
|------|------|
| `parent.layer.open` | 在父页面层级打开弹窗，确保覆盖当前 iframe |
| `parent.layer.full` | 在父页面层级执行最大化，避免 iframe 内最大化受限 |
| `maxmin: true` | 启用最大化/最小化按钮，配合 `layer.full` 确保可靠最大化 |
| `sessionStorage` | 同源 iframe 共享 sessionStorage，用于选择器回传选中文件 URL |
| `end` 回调 | 弹窗关闭时触发，在当前 iframe 上下文中操作 DOM 回填数据 |

**适用场景：**

- iframe 表单内需要弹出附件选择器
- iframe 表单内需要弹出其他 iframe 类型的选择器（如商品选择、用户选择等）
- 任何在 iframe 内需要突破当前 iframe 层级弹窗的情况

---

## 5. Layui 模板语法规范

### 5.1 `@verbatim` 的使用

Blade 和 Layui 都使用 `{{ }}` 语法，Layui 的模板语法（`{{# }}`、`{{ var }}`、`{{ d.field }}`）必须用 `@verbatim`/`@endverbatim` 包裹：

```html
{{-- 正确 --}}
@verbatim
<script type="text/html" id="statusTpl">
    {{# if(d.status === 1){ }}
    <span class="layui-badge layui-bg-green">启用</span>
    {{# } }}
</script>
@endverbatim

{{-- 错误：缺少 @verbatim --}}
<script type="text/html" id="statusTpl">
    {{# if(d.status === 0){ }}      {{-- Blade 会解析报错 --}}
    <span>待支付</span>
    {{# } }}
</script>
```

### 5.2 `@verbatim` 作用域

- 只包裹 `<script type="text/html">` 内的模板内容
- 不要包裹整个 `<script>` 标签
- 不要在 `@verbatim` 块内编写 Blade 语法（如 `{{ csrf_token() }}`、`{{ $var }}`）

```html
{{-- 正确：@verbatim 只包裹模板内容 --}}
<script type="text/html" id="statusTpl">
@verbatim
    {{# if(d.status === 1){ }}
    <span class="layui-badge layui-bg-green">启用</span>
    {{# } }}
@endverbatim
</script>

{{-- 错误：@verbatim 扩大了范围，Blade 语法不会被执行 --}}
@verbatim
<script type="text/html" id="statusTpl">
    {{# if(d.status === 1){ }}
    <span>启用</span>
    {{# } }}
</script>
@endverbatim
```

### 5.3 `@verbatim` 的位置

`@verbatim` 必须紧贴模板内容，避免多余空白：

```html
{{-- 推荐格式 --}}
<script type="text/html" id="tpl">
@verbatim
{{# if(d.status === 1){ }}
<span class="layui-badge layui-bg-green">启用</span>
{{# } }}
@endverbatim
</script>
```

---

## 6. 公共资源引用规范

### 6.1 必须引入的公共 CSS（后台/用户端页面）

```html
<link rel="stylesheet" href="{{ asset('CmsProUi/component/pear/css/pear.css') }}">
<link rel="stylesheet" href="{{ asset('CmsProUi/font-awesome/4.7.0/css/font-awesome.min.css') }}">
<link rel="stylesheet" href="{{ asset('Admin/css/admin.css') }}">
<link rel="stylesheet" href="{{ asset('Admin/css/variables.css') }}">
<link rel="stylesheet" href="{{ asset('Admin/css/reset.css') }}">
```

### 6.2 必须引入的公共 JS

```html
<script src="{{ asset('CmsProUi/component/layui/layui.js') }}"></script>
<script src="{{ asset('CmsProUi/component/pear/pear.js') }}"></script>
```

### 6.3 每个页面必须做的 AJAX 配置

```javascript
$.ajaxSetup({
    headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
    statusCode: {
        401: function () {
            layer.msg('登录已过期，请重新登录', { icon: 2, time: 1500 }, function () {
                top.location.href = '/admin/login';
            });
        }
    }
});
```

### 6.4 权限脚本注入（后台列表页必须）

后台列表页是通过 iframe 加载的独立 HTML 页面，**不会**继承父页面 `layouts/admin.blade.php` 中的 `window.hasPermission` 函数。必须在 `</head>` 之前注入权限脚本：

```html
@include('admin.partials.permission-script')
```

> **位置要求**：必须在 `</style>` 之后、`</head>` 之前。若放在 `<style>` 块内部，`<script>` 标签会被浏览器当作 CSS 文本而不执行，导致权限函数未定义。

该片段注入了 `window.CMSPRO_PERMISSIONS`（当前用户权限列表，超管为 `['*']`）和 `window.hasPermission(code)` 函数，供页面内的按钮权限控制使用。

---

## 7. 布局文件使用规范

### 7.1 布局来源

- **后台管理**：复用 `resources/views/layouts/admin.blade.php`，通过 `@extends('layouts.admin')` 使用
  - 提供 `@yield('content')` 和 `@yield('script')` 两个区块
- **用户中心**：复用 `resources/views/layouts/user.blade.php`，通过 `@extends('layouts.user')` 使用
  - 与 admin 布局结构相同，但认证守卫不同（`auth:web`）

### 7.2 何时使用布局

| 场景 | 推荐方式 |
|------|---------|
| 独立整页页面 | 使用 `@extends('layouts.admin')` 或 `@extends('layouts.user')` |
| iframe 弹窗内容（form） | 不使用布局，独立完整 HTML |
| 前台展示页面 | 应用自建 `layouts/home.blade.php` |

### 7.3 使用布局的推荐写法

```html
{{-- 后台列表页使用布局 --}}
@extends('layouts.admin')

@section('content')
<div class="pear-container">
    {{-- 页面内容 --}}
</div>
@endsection

@section('script')
<script>
layui.use([...], function () {
    // JS 逻辑
});
</script>
@endsection
```

> **注意：** 使用 `@extends('layouts.admin')` 时不需要在页面中重复引入 CSS 和 JS，布局文件已包含。但很多现有应用为了保持独立性和兼容 iframe 加载，仍然采用完整 HTML 结构。

---

## 7. 常见问题排查

### 7.1 页面空白或语法错误

**原因：** Blade 引擎解析 `{{ }}` 时遇到 Layui 模板语法报错。

**解决：** 检查所有 `<script type="text/html">` 中的 `{{# }}` 是否被 `@verbatim` 包裹。

### 8.2 Layui 模板不渲染

**原因 1：** `@verbatim` 包裹了整个文件，导致 Blade 的 `{{ csrf_token() }}`、`{{ $var }}` 等不执行。

**检查方法：** 打开浏览器开发者工具，查看页面源码，如果看到 `{{ csrf_token() }}` 原样输出、未被替换为 token 值，说明 `@verbatim` 范围过大。

**原因 2（典型问题）：** 视图文件只有 Layui 模板片段，缺少完整的页面结构（HTML、CSS、JS）。

**检查方法：** 如果页面 URL 直接返回的是空或只有 HTML 片段内容，说明视图文件本身不完整。

### 7.3 Layui 下拉框位置偏移

**原因：** 自定义 CSS 覆盖了 Layui 表单组件的尺寸样式，导致下拉面板定位计算错误。

**典型场景：**

```css
/* ❌ 错误：覆盖 Layui 默认宽度，破坏 select 下拉面板定位 */
.layui-form-label {
    width: unset;         /* 或 width: 90px; 等非默认值 */
}
.layui-input, .layui-textarea {
    width: 98%;
}
.layui-form-item .layui-form-label { width: 90px; }
.layui-form-item .layui-input-block { margin-left: 100px; }
```

Layui 的 `form.render('select')` 基于 `.layui-form-label` 的宽度和 `.layui-input-block` 的 `margin-left` 计算下拉面板的位置。当这些值被 CSS 覆盖后，下拉面板会出现偏移。

**解决：** 非必要不要重新定义 Layui 的表单组件样式。如需调整，务必：

1. 给自定义样式加上**作用域限定选择器**，避免影响全局 Layui 组件（如 `#propertyContent .layui-form-item .layui-form-label`）
2. 在不涉及 Layui select 组件的容器内使用自定义宽度，不影响整体页面布局
3. 对于 iframe 表单弹窗，如需调整 label 宽度，保留 `width` 默认值，仅调整 `padding`

```css
/* ✅ 正确：作用域限定，不影响全局 Layui 组件 */
#propertyContent .layui-form-item .layui-form-label {
    width: 90px;
}
#propertyContent .layui-form-item .layui-input-block {
    margin-left: 100px;
}
```


### 7.4 表单提交 500 错误

**原因：** 缺少 CSRF Token。

**解决：** 确保页面中有 `$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' } })`。

### 7.5 Layui 表格表头与数据列不对齐

**原因：** 以下三个因素共同作用会导致表头与数据列不对齐：

**① 所有列固定 `width`，缺少弹性列（最常见）**

当列宽总和超过表格容器实际宽度时，Layui 自动压缩各列，导致表头和数据列的计算偏移、错位。

**② 缺少 `skin: false` 配置**

Layui 默认的 `layui-table` 样式会给 `th` 和 `td` 设置不同的 `padding`/`line-height`，导致固定宽度下表头与数据行的盒模型不一致出现错位。设置 `skin: false` 取消默认行样式后，`th` 和 `td` 盒模型统一，消除对齐偏差。

**③ `page: true` 简写代替完整配置对象**

`page: true` 使用 Layui 默认分页配置，分页组件渲染时可能影响表格总宽度计算。使用完整的 `page` 配置对象（明确指定 `layout`、`groups`、`limit`、`limits`）可避免此问题。

**典型场景：**

```js
// ❌ 错误：三个问题同时存在
// 1. 所有列固定宽度
// 2. 缺少 skin: false
// 3. page: true 简写
table.render({
    elem: '#table',
    url: '/api/xxx/list',
    page: true,
    limit: 15,
    limits: [15, 30, 50, 100],
    cols: [[
        { field: 'name', title: '名称', width: 150 },
        { field: 'type', title: '类型', width: 100 },
        { title: '操作', width: 200, toolbar: '#actionTpl', fixed: 'right' }
    ]]
});
```

**解决：**

同时满足以下三点：

**1. 至少保留一列不设 `width`**（通常选择文本内容较长的列或次要信息列），让其作为弹性列自动撑满剩余空间：

**2. 设置 `skin: false`**，取消默认行样式，统一 `th`/`td` 盒模型：

**3. 使用完整 `page` 配置对象**，替代 `page: true` 简写：

```js
// ✅ 正确：三个问题全部解决
var cols = [[
    { type: 'checkbox', fixed: 'left' },
    { field: 'name', title: '名称', minWidth: 120 },             // 弹性列（无 width）
    { field: 'type', title: '类型', width: 100 },
    { title: '操作', width: 200, toolbar: '#actionTpl', fixed: 'right' }
]];

table.render({
    elem: '#table',
    url: '/api/xxx/list',
    page: {
        layout: ['count', 'prev', 'page', 'next', 'limit'],      // 完整 page 配置
        groups: 5,
        limit: 15,
        limits: [15, 30, 50, 100]
    },
    cols: cols,
    skin: false,                                                  // 取消默认行样式
    parseData: function(res) {                                    // 明确数据解析
        return {
            "code": res.code === 0 ? 0 : 1,
            "msg": res.msg || "",
            "count": res.count || 0,
            "data": res.data || []
        };
    }
});
```

> **注意：**
> - 弹性列不要放在 `fixed: 'right'` 的列上，右固定列必须有固定宽度
> - 如果所有列确实都需要固定宽度，可以将最后一列的 `width` 去掉作为弹性列
> - 使用 `templet` 渲染的列同样适用此规则，不影响弹性列的正常工作
> - `parseData` 虽非对齐必需，但能确保 API 响应格式明确，建议统一添加
> - `cols` 定义为独立变量（`var cols = [[...]]`）可提高可读性，非必需但推荐

### 8.4 `{{ $id }}` 变量未定义

**原因：** Controller 没有传递变量到视图。

**排查步骤：**
1. 检查 Controller 方法：`return view('xxx', ['id' => $id])`
2. 检查路由文件是否正确配置
3. 检查视图命名空间是否正确注册（ServiceProvider 中 `loadViewsFrom()`）

---

## 9. 权限控制规范

后台列表页应实现前端按钮权限控制，隐藏/拦截无权限的操作按钮。前端控制仅为 UX 优化，后端 `CheckPermission` 中间件是安全兜底。

### 9.1 权限脚本注入

所有后台列表页（iframe 独立页面）**必须**在 `</head>` 之前注入权限脚本（详见 6.4 节）：

```html
    @include('admin.partials.permission-script')
</head>
```

> **关键**：子页面不继承父页面 `window` 变量。未注入则 `window.hasPermission` 为 `undefined`，`if (!window.hasPermission || ...)` 恒为 `true`，所有按钮被错误隐藏（包括超管）。

### 9.2 工具栏按钮权限控制

给 `<button>` 添加 `data-permission` 属性，JS 中遍历隐藏：

```html
<script type="text/html" id="toolbar">
    <button class="layui-btn layui-btn-sm" lay-event="add" data-permission="admin.role.store">
        <i class="layui-icon layui-icon-add-1"></i> 新增
    </button>
</script>
```

```javascript
var hidePermButtons = function() {
    $('button[data-permission]').each(function() {
        if (!window.hasPermission || !window.hasPermission($(this).data('permission'))) {
            $(this).hide();
        }
    });
};
hidePermButtons();
```

> **注意**：`data-permission` 只加在 `<button>` 上，不加在 `<a>` dropdown 触发器上。

### 9.3 dropdown 菜单权限过滤

给 dropdown 菜单项添加 `permission` 属性，渲染前用 `.filter()` 过滤：

```javascript
dropdown.render({
    data: [
        { title: '编辑', id: 'Edit', permission: 'admin.role.update' },
        { title: '删除', id: 'Delete', permission: 'admin.role.destroy' }
    ].filter(function(item) {
        return !item.permission || !window.hasPermission || window.hasPermission(item.permission);
    }),
    click: function(menuData) { /* ... */ }
});
```

### 9.4 行内按钮权限控制

行内操作按钮（在 `table.render` 的 `done` 回调中隐藏）：

```html
<script type="text/html" id="row-bar">
    <a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete" data-permission="admin.role.destroy">删除</a>
</script>
```

```javascript
table.render({
    // ...
    done: function() {
        $('td [data-permission]').each(function() {
            if (!window.hasPermission || !window.hasPermission($(this).data('permission'))) {
                $(this).hide();
            }
        });
    }
});
```

### 9.5 操作拦截

在执行操作前检查权限（适用于动态生成的按钮、switch 开关等）：

```javascript
if (!window.hasPermission || !window.hasPermission('admin.role.destroy')) {
    layer.msg('无操作权限', { icon: 2, time: 1000 });
    return;
}
```

### 9.6 非标准列表页

对于非标准列表页（如配置管理页，无 Layui table 工具栏），在操作函数开头检查权限：

```javascript
$('#addBtn').on('click', function() {
    if (!window.hasPermission || !window.hasPermission('admin.config-group.store')) {
        layer.msg('无操作权限', { icon: 2, time: 1000 });
        return;
    }
    // ... 执行操作
});
```

并在渲染完成后隐藏无权限的操作元素：

```javascript
if (window.hasPermission && !window.hasPermission('admin.config-group.store')) {
    $('#addBtn').hide();
}
```

### 9.7 权限码规范

- 系统权限格式：`admin.{模块}.{操作}`（如 `admin.user.store`）
- 应用权限格式：`{app_id}.{功能点}`（如 `blog.post.create`）
- 权限码必须在 `admin_permissions` 表中注册，否则前端 `hasPermission` 返回 `false` 会隐藏按钮（后端中间件对未注册权限码会放行）

---

## 10. 视图文件检查清单

编写新的应用视图时，对照以下清单检查：

- [ ] 文件扩展名为 `.blade.php`
- [ ] 目录结构遵循 `Admin/功能模块/视图文件.blade.php`
- [ ] 列表页有完整的 `<html>`、`<head>`、`<body>` 结构
- [ ] 引入了必要的公共 CSS（pear.css、admin.css 等）
- [ ] 引入了必要的公共 JS（layui.js、pear.js）
- [ ] 设置了 CSRF Token（`$.ajaxSetup`）
- [ ] Layui 模板语法已用 `@verbatim` 包裹
- [ ] `@verbatim` 不会"溢出"导致 Blade 语法不被解析
- [ ] Layui 模板放在 `<script type="text/html">` 标签中
- [ ] 表单页作为 iframe 弹窗时，有完整的 HTML 结构
- [ ] 表单页提交后能正确关闭弹窗并刷新父页面表格
- [ ] 未覆盖 Layui 表单组件的默认尺寸样式（如 `.layui-form-label` 的 `width`、`.layui-input-block` 的 `margin-left`），避免下拉面板偏移
- [ ] 后台列表页已注入权限脚本 `@include('admin.partials.permission-script')`（在 `</style>` 之后、`</head>` 之前）
- [ ] 操作按钮已添加 `data-permission` 属性并实现 `hidePermButtons` 隐藏逻辑
- [ ] dropdown 菜单项已添加 `permission` 属性并用 `.filter()` 过滤
- [ ] 行内操作按钮在 `table.render` 的 `done` 回调中隐藏无权限项
- [ ] 动态生成的按钮、switch 开关等已添加 `hasPermission` 操作拦截