# MVC v2 重构想法

> 随便写，不用拘泥格式，想到哪写到哪，我们之后再一起梳理成可执行的方案。

## 1. 为什么要重写（不满意 v1.0 的地方）

-

## 2. 核心理念 / 设计原则

- 整体代码以 **PHP 8** 版本编写（`declare(strict_types=1)`、类型声明等新语法可用）
- 命名规则统一遵循 **PSR-1**：
  - **类文件**：文件名 = 类名，PascalCase 首字母大写，如 `Mvc.php`、`ExceptionHandler.php`
  - **类名**：PascalCase，如 `ExceptionHandler`、`DemoAction`
  - **方法名 / 函数名**：camelCase 首字母小写，如 `bindParams()`、`parseRequest()`、`dump()`
  - **变量名**：camelCase 首字母小写，如 `$classBase`、`$uriValues`
  - **常量**：全大写，下划线分隔，如 `DEBUG`
  - **非类的过程式函数库文件**（纯函数集合，无类）：camelCase 首字母小写，如 `mvcFunction.php`，用于和类文件区分
- 代码排版格式遵循 **PSR-12**（4空格缩进、大括号位置、可见性修饰符显式声明等）

现有文件 `Mvc.php`、`ExceptionHandler.php`、`mvcFunction.php` 均已符合上述规则，无需改动。

## 2.1 已落地的框架文件

- [mvcFunction.php](mvcFunction.php) — 全局函数库，目前有 `dump()`（打印不中断）、`dd()`（打印后中断）
- [ExceptionHandler.php](ExceptionHandler.php) — 全局异常处理：
  - `MvcException` 作为框架统一异常基类（后续可派生 `RouteNotFoundException` 等子类）
  - `ExceptionHandler::register()` 注册为 PHP 的 `set_exception_handler`
  - DEBUG 模式下用 `dump()` 打印异常详情；非 DEBUG 模式记录日志 + 输出友好提示
  - 当前类/方法找不到时，先统一 `throw` 全局异常兜底，后续再细化异常类体系
- [Mvc.php](Mvc.php) — 路由核心调度类
  - `init($cfg)`：保存配置、注册全局异常处理、解析当前请求
  - `parseRequest()` / `splitPathInfo()`：解析 PATH_INFO，支持 `/v/`（纯值）`/k/`（键值）传参风格、`.html` 伪静态后缀
  - 路由映射：除最后两段外都是目录，倒数第二段是文件/类名（自动加 `Action` 后缀），最后一段是方法名；空路径默认 `index`
  - `buildParams()`：合并 GET / POST / php://input(JSON) / `/k/` 传参为统一 `Mvc::$req`
  - `dispatch()`：require 路由文件 → 实例化类 → 反射绑定参数（按方法形参名匹配 `$req`，找不到时按 `/v/` 位置兜底，再找不到用默认值，否则抛 `MvcException`）→ 调用方法
  - 已验证：`pdc/admin/action/demo.php` 的 `demoAction::list($a,$b)`，访问 `admin/action/demo/list?a=12&b=all`
- [Action.php](Action.php) — 控制器基类
  - `protected array $request`：当前请求参数，由 `Mvc::dispatch()` 通过 `setRequest()` 注入（不再直接读全局静态属性，方便测试）
  - `success($data, $msg)` / `error($code, $msg)` / `json($code, $data, $msg)`：统一响应结构 `{code, data, msg}`，相比 v1.0 去掉了和分页强绑定的 page/total/records 字段，分页数据放进 `data` 里即可
  - `callBefore(string $method, array $params)` / `callEnd(string $method, array $params)`：可选钩子约定，子类按需实现为 **public** 方法，`Mvc::dispatch()` 用 `method_exists` 检测并调用
- [Model.php](Model.php) — 模型基类
  - 构造函数 `__construct(?Db $db = null)`：支持注入指定的 `Db` 连接（便于测试替换 mock 或读写分离场景下传 read/write 实例）；不传时 `$this->db ??= Db::instance()`，自动用 default 配置的单例连接，已验证
  - `paging()`：固定签名（不再像 v1.0 用 variadic 参数猜测重载），支持可选 `$join`/`$column` 关联查询场景与 `GROUP BY` 分组计数场景
  - 是全站共享的基类，具体业务 Model（如 `UserModel`）放在 `pdc/model/`，继承此类
- `Mvc::dispatch()` 已接入：实例化 Action 后自动 `setRequest()`，方法调用前后自动触发 `callBefore`/`callEnd`（如果子类定义了）
- 已用 CLI 模拟请求验证：`callBefore` 钩子 → 参数绑定 → `$this->request` 注入 → `success()` 统一 JSON 输出，全链路打通

## 7. 已生成的站点结构（pdc/admin 为示例站点）

```
pdc/
├── config/
│   ├── database.php   # 占位
│   └── admin.php      # 占位
├── model/              # 空，待补充
├── cache/               # 空，待补充
├── log/                 # 空，待补充
└── admin/
    ├── action/
    │   ├── .htaccess    # 重写规则，全部转发到 index.php
    │   ├── index.php    # 入口，引入 Mvc.php 并 init+dispatch
    │   └── demo.php     # 示例控制器 demoAction::list($a,$b)
    └── view/            # 空，待补充
```

mvc/v2 下新增 `lib/`（空目录，占位，后续按功能分子目录存放类库）。

**待验证**：需要实际起一个支持 .htaccess（mod_rewrite）的 PHP 服务器/虚拟主机，访问
`http://127.0.0.1/.../admin/action/demo/list?a=12&b=all` 确认路由与参数绑定按预期工作。

## 3. 目录结构设想

两大部分分离：MVC 框架本身 与 站点应用，物理路径都已确定。

### MVC 框架本身 — `E:\www\mdm\mvc\v2`

```
mvc/v2/
├── lib/              # 所有类库都放这里（后续新增类库统一进此目录）
└── (框架文件)         # 框架本身的文件放在 v2 主目录下
```

### 站点应用 — `E:\www\mdm\pdc`

应用下有多个子站点：`admin`（后台）、`home`（前台）、`homeen`（英文版前台）。
`view` 模板按子站点单独存放（每个子站点有自己的 view）。

```
pdc/
├── config/
│   ├── database.php   # 共享配置（多站点通用的放这一类，文件名不带站点前缀）
│   ├── admin.php       # admin 子站点专属配置
│   ├── home.php        # home 子站点专属配置
│   └── homeen.php      # homeen 子站点专属配置
├── model/              # 全站共享一份，不分子站点
├── cache/
├── log/
├── admin/              # 子站点：自己的 action + view + static
│   ├── action/
│   ├── view/
│   └── static/          # 静态文件（css/js/images），如 DevExtreme 等第三方资源
├── home/               # 子站点：自己的 action + view + static
│   ├── action/
│   ├── view/
│   └── static/
└── homeen/             # 子站点：自己的 action + view + static
    ├── action/
    ├── view/
    └── static/
```

规则总结：
- **model**：全站共享一份，放顶层 `model/`，不区分子站点
- **action**：每个子站点各自独立，放 `[子站点]/action/`
- **view**：每个子站点各自独立，放 `[子站点]/view/`
- **static**：每个子站点各自独立，放 `[子站点]/static/`（如 `pdc/admin/static/`），存放该子站点自己的静态文件（css/js/图片/第三方前端库等）
- **config**：共享配置用通用文件名（如 `database.php`），子站点专属配置以子站点名命名（`admin.php` / `home.php` / `homeen.php`）
- **cache / log**：全站共享一份，不按子站点拆分

### lib 目录（框架内，按功能分子目录）

稍后处理。

## 4. 关键模块想法

### 路由 / 调度

**入口方式（已调整）**：`index.php`/`.htaccess` 放在**子站点根目录**（如 `pdc/admin/`），不再放在 `action/` 子目录下——这样网址里就不会出现 `action` 这一段。控制器文件本身仍然物理存放在 `action/` 子目录（`actionDir` 配置成 `__DIR__.'/action'`），只是这个目录名不再出现在 URL 里，纯粹是文件组织上的约定。把所有找不到实际文件/目录的请求都交给 `index.php` 处理：

```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
```

`index.php` 里 include 主框架文件（mvc.php），由框架接管解析 PATH_INFO 并分发。

**路由映射规则**（示例）：

文件 `admin/action/pic.php` 定义类 `picAction`，类中有方法 `function list($a, $b)`。

访问（网址不含 `action`，见上方"入口方式"调整说明）：
```
http://127.0.0.1/mdm/pdc/admin/pic/list?a=12&b=all
```

映射关系：
- URL 路径 `pic` → 文件 `pic.php` → 类名 `picAction`
- URL 路径 `list` → 调用方法 `list($a, $b)`
- 查询字符串 `?a=12&b=all` → 自动绑定到方法形参 `$a` `$b`（沿用 v1.0 反射绑定参数的思路）

**多级目录映射规则**（已采纳）：URL 路径中，除最后两段外都是目录路径，倒数第二段是文件名/类名，最后一段是方法名。

```
admin/action/pic/category/list
              └─ 目录 pic/ ─┘  └文件 category.php / 类 categoryAction┘ └方法 list┘
```

单层情况（`pic/list`）等价于目录深度为 0：文件 `pic.php`，类 `picAction`，方法 `list`。

**异常处理**：方法/类找不到时，先统一抛全局异常，后续再设计专门的异常类体系。

**URL 传参风格**：保留 v1.0 的 `/v/`（纯值）与 `/k/`（键值对）风格，与 query string 并存。

**伪静态**：保留 `.html` 后缀路由支持（对应 v1.0 的单例模式 `_CallHtml`）。

### 控制器 (Action)

-

### 模型 (Model) / 数据库层

已落地基类见 [2.1 已落地的框架文件](#21-已落地的框架文件) 中的 `Model.php`。

**数据库层选型**：不自己造轮子，复用第三方查询构造器 **Medoo**（API 风格与数组化 where/join 和 v1.0 接近，迁移成本低），在 `Model` 之上包一层自己的接口，方便以后替换底层实现。

- 已用 composer 在 `mvc/v2/composer/` 下安装：`catfan/medoo`、`workerman/workerman`、`textalk/websocket`（composer.json 见 `mvc/v2/composer/composer.json`，vendor 内部代码不分析）
- composer 自动加载已接入框架：`Mvc.php` 顶部新增 `require_once __DIR__ . '/composer/vendor/autoload.php'`，已验证 `Medoo\Medoo`、`Workerman\Worker`、`WebSocket\Client` 均可正常 autoload，且不影响现有路由分发流程
- [lib/Db.php](lib/Db.php)：`Db extends Medoo\Medoo`，补充：
  - `rawQuery(string $sql, array $params = []): array` — 原生 SQL 查询（SELECT），走 `$this->pdo`，返回结果集
  - `rawExecute(string $sql, array $params = []): int` — 原生 SQL 操作（INSERT/UPDATE/DELETE），返回受影响行数
  - `Db::instance(string $name = 'default'): self` — **免传配置**，首次调用时自动 `require [projectDir]/config/database.php` 并缓存到 `self::$config`，之后调用直接复用；按库名 `$name` 缓存各自的连接单例，业务代码里直接 `Db::instance()` 即可，不用每次手动 require 配置文件
  - 常规查询默认走 **Medoo 原生查询语法**（`select()`/`insert()`/`update()` 等数组风格），`rawQuery`/`rawExecute` 仅用于 Medoo 语法表达不了的场景
  - `begin()` / `end()`：支持**嵌套调用**的事务计数。`begin()` 计数 +1，从 0→1 时真正开启事务；`end()` 计数 -1，减到 0 时真正 `commit()`；`end()` 调用次数多于 `begin()` 时抛 `MvcException`
  - 构造函数里 `register_shutdown_function` 注册 `assertTransactionClosed()`：程序结束时检查事务计数是否归零，不归零（忘记调用 `end()`）抛 `RuntimeException`，会被全局 `ExceptionHandler` 捕获处理，已验证
- [pdc/config/database.php](../../pdc/config/database.php)：全站共享数据库配置（Medoo 连接选项），已用真实环境验证可连接成功
  - `host=192.168.1.42, port=3306, database=pdc, username=gspuser, charset=utf8mb4, collation=utf8mb4_unicode_ci`
  - `ENGINE=InnoDB` 是建表选项，不属于 Medoo 连接配置，已在文件注释里记录供建表时参考
  - database.php 改为**多库结构**：返回数组按库名分组（如 `'default' => [...]`），为后续多库场景预留；`Db::instance()` 同步改为 `instance(array $options, string $name = 'default')`，按库名缓存单例（每个库名各自一个连接实例）

## 8. 已建的业务表（pdc 库）

- [pdc/sql/admin_depa.sql](../../pdc/sql/admin_depa.sql) — 部门表，前缀 `admin_depa_`：`id/cn_name/en_name/remark/create_time/update_time`
- [pdc/sql/admin_user.sql](../../pdc/sql/admin_user.sql) — 系统管理用户表，前缀 `admin_`：`username(唯一)/password/name/depa_id(关联admin_depa,带索引)/email/mobile/remark/allow/create_time/update_time`
- 均为 `ENGINE=InnoDB, CHARSET=utf8mb4, COLLATE=utf8mb4_unicode_ci`，字段与表都带中文 COMMENT
- 已通过 `Db::rawExecute`/`pdo->exec` 在真实库 `pdc` 上创建成功并验证字段注释，顺带验证了 `Db.rawQuery()` 在真实环境可用
- `admin_user.admin_lnk_depa_id`（**原名 `admin_depa_id`，已改名**，见下方"字段命名规范"）已加外键 `fk_admin_user_depa` 关联 `admin_depa.admin_depa_id`：**ON UPDATE CASCADE（级联更新）/ ON DELETE RESTRICT（限制删除）**——部门下还有用户时不可删除该部门；sql 文件已同步更新外键定义
- `lib/` 目录已接入自动加载：`Mvc.php` 中 `spl_autoload_register`，按类名查找 `lib/{类名}.php`（**注意**：目前是扁平查找，等 `lib` 按功能分子目录后需要改成递归/映射查找，见 [3. 目录结构设想 - lib 目录](#lib-目录框架内按功能分子目录)）
- 待做：`Model` 基类如何拿到 `Db` 实例（构造注入的具体来源/单例管理，配合 `pdc/config/database.php` 的连接配置）

### 字段命名规范：外键列要和被关联表的主键名区分开

**踩过的坑**：`admin_user.admin_depa_id` 和 `admin_depa.admin_depa_id` 同名，JOIN 查询时任何不带表别名前缀引用这个字段名的地方（比如 dxDataGrid 的 filter）都会被 MySQL 报 `Column 'xxx' is ambiguous`。

**解决方式**：用户把 `admin_user` 里的外键列改名为 `admin_lnk_depa_id`（`lnk` 表示这是一个关联/外键列），避免和 `admin_depa.admin_depa_id` 同名，从根上解决歧义，不需要在查询层做字段名映射这种 workaround。

**以后建表的规则（提醒事项）**：新建表如果某个外键列要关联别的表的主键，**且外键列名会跟被关联表的主键名完全一样**时，要提醒用户改名（比如加 `lnk_`/`ref_` 之类的前缀区分"这是引用别的表的外键"还是"这是本表自己的主键"），避免以后 JOIN 查询/过滤时出现同名歧义问题。

### 多语言：DevExtreme 本地化 + 数据表多语言字段约定

**DevExtreme 组件本地化**（已落地于 [header.view.php](../../pdc/admin/view/public/header.view.php)）：
- 语言包目录 `devextreme@24.1.3/localization/`，文件名**全小写**，如 `dx.messages.zh-cn.js`、`dx.messages.en.js`
- header 里按当前 `Mvc::$cfg['locale']`（如 `zh-CN`）`strtolower()` 拼出文件名加载脚本，再调用 `DevExpress.localization.locale('zh-CN')`（这个调用要用**原始大小写**的 locale 值，跟文件名小写是两件事）
- 找不到对应语言文件时脚本 404，DevExtreme 静默回退到内置默认（英文），不会报错中断

**数据表多语言字段约定**：字段名里 `_cn_`、`_en_`、`_de_` 等表示该字段是哪种语言的内容（如 `admin_depa_cn_name`/`admin_depa_en_name`）。已落地通用机制：
- **[Lang.php](Lang.php)** 新增 `Lang::pickField(array $available, string $fallback='en'): string`：按当前语言选出该用哪个语言段
  - `locale` → 语言段的映射：`zh-CN` 特例映射成 `cn`（因为字段约定是 `_cn_` 不是 `_zh_`），其他语言通用规则是取 locale 的 `-` 前半段小写（如 `de-DE`→`de`，`en`→`en`）
  - 选出来的语言段如果该表没有对应字段（`$available` 数组里没有），回退到 `en`；连 `en` 都没有就用 `$available` 第一个
  - 这是**通用机制**，不挂在某个具体 Model 上，任何表只要按 `_cn_`/`_en_` 命名多语言字段，都可以调用 `Lang::pickField()` 选字段
- **[pdc/model/Users.php](../../pdc/model/Users.php)** 示范用法：`depaNameColumn()` 用 `Lang::pickField(['cn','en'])` 拼出 `admin_depa_cn_name`/`admin_depa_en_name`，`listWithDepa()`/`listDepartments()`/`gridSelect()` 都统一把选中的语言列**别名成固定的 `admin_depa_name`**，前端/调用方完全不用关心当前是中文还是英文，字段名永远一样
- 已用真实 HTTP 测试验证：`?lang=en` 切换后，`users/index` 页面的部门下拉数据和 `users/grid` 接口返回的 `admin_depa_name` 都正确变成英文（`Technology`/`Business`/`Incident`/`Directory`），不传 `lang` 或 `lang=zh-CN` 显示中文

## 9. dxDataGrid 数据层封装（filter→SQL 转换器 + 通用 grid/save/remove）

按之前讨论确定的方案落地：拆成独立的 `grid`/`save`/`remove` 接口（不靠 payload 形状嗅探意图）、filter 完整支持所有讨论过的操作符、封装成所有 Model 可复用。

- **[lib/DxFilter.php](lib/DxFilter.php)**：DevExtreme filter 表达式 → SQL 的递归转换器，`DxFilter::toSql($filter): [string $sql, array $params]`
  - 完整支持 15 个操作符：`=` `<>` `>` `>=` `<` `<=` `startswith` `endswith` `contains` `notcontains` `between` `anyof` `noneof` `isblank` `isnotblank`
  - `!` 取反、`and`/`or` 组合（可混用、可任意嵌套，按数组顺序左折叠加括号，不依赖 SQL 的 AND/OR 优先级）、两元素简写（默认 `=`）、`null` 值特殊处理（`=null`→`IS NULL`，`<>null`→`IS NOT NULL`）
  - `contains`/`notcontains`/`startswith`/`endswith` 转 `LIKE`，对 `%`/`_`/`\` 做转义，避免用户输入里的通配符破坏匹配
  - `noneof` 转 `NOT IN` 时做了 NULL 安全处理（加 `OR 字段 IS NULL`），避免列里有 NULL 导致 `NOT IN` 判定为 UNKNOWN
  - 已用全部 15 种场景 + 混合 and/or 嵌套写了单测脚本验证，全部正确
- **[Model.php](Model.php)** 新增：`$table`/`$key` 属性（子类声明表名和主键）、`gridFrom()`/`gridSelect()`（默认 `SELECT * FROM $table`，需要 JOIN 展示时子类覆盖）、`grid(loadOptions)`（用 `DxFilter` 转 filter，`sort`→`ORDER BY`，`skip`/`take`→`LIMIT`，`requireTotalCount`→额外查总数）、`gridInsert()`/`gridUpdate()`/`gridRemove()`（直接用 Medoo 的 `insert()`/`update()`/`delete()`，全部 Model 共用，不用每个 Model 各自实现）
- **[GridActions.php](GridActions.php)**：trait，给 `Action` 子类提供 `grid()`/`save()`/`remove()` 三个方法，对应 dxDataGrid CustomStore 的 `load`/`insert`+`update`/`remove`；子类只需要 `use GridActions;` 并实现 `gridModel(): Model`。`save()` 按请求体有没有 `key` 区分插入/更新（这是显式字段判断，不是嗅探 SQL，符合之前讨论的原则）
- **[pdc/model/Users.php](../../pdc/model/Users.php)**：声明 `$table='admin_user'`/`$key='admin_id'`；覆盖 `gridFrom()`/`gridSelect()` 关联 `admin_depa` 显示部门中文名；覆盖 `gridInsert()`/`gridUpdate()` 做密码 `password_hash()`（更新时没填密码不清空）、并防御性剔除 `admin_depa_cn_name`（联表展示字段，不是 `admin_user` 表的真实列，写入前要 unset 掉）；新增 `listDepartments()` 给前端部门下拉用
- **[pdc/admin/action/users.php](../../pdc/admin/action/users.php)**：`usersAction`，`use GridActions`，`gridModel()` 返回 `new Users()`；`index()` 渲染页面并把部门列表传给前端做 lookup
- **[pdc/admin/static/js/lib/dxGrid.js](../../pdc/admin/static/js/lib/dxGrid.js)**：`createDxGrid(selector, options)` 通用封装，内部建 `DevExpress.data.CustomStore`，`load`/`insert`/`update`/`remove` 统一走 `{host}/grid`、`{host}/save`、`{host}/remove`；统一拆 `{code,data,msg}` 响应包，`code!=0` 时让 Promise reject 以便 dxDataGrid 正确显示错误；默认开启 `filterRow`（行过滤）+ `filterPanel`（过滤器面板）+ `remoteOperations`（服务端分页/排序/过滤）
- **[pdc/admin/view/users/index.view.php](../../pdc/admin/view/users/index.view.php)**：用 `createDxGrid()` 渲染用户列表，部门列用 `lookup` 关联 `admin_depa`，密码列用 popup 编辑表单 + `editorOptions.mode='password'`，新增/编辑用弹窗模式（`editing.mode:'popup'`）
- 语言包补充：`密码`/`备注`/`更新时间`/`用户信息`
- `demoAction::users()`（`demo/users` 路由）改为复用 `users/index.view.php`（`View::put('users/index')` 指定视图路径），渲染同一套完整增删改查 dxDataGrid，数据接口仍走真实的 `usersAction`（`host` 固定指向 `/users`，跟哪个 Action 渲染页面无关）；旧的只读表格模板 `demo/users.view.php` 已删除
- **已用 PHP 内置开发服务器（`php -S` + 模拟 `.htaccess` 重写的 router）做真实 HTTP 测试**（CLI SAPI 不支持 `php://input`，无法在纯 CLI 下测这部分，所以用内置服务器代替）：
  - `grid`：`filter`（contains）、`sort`（desc）、`requireTotalCount` 全部验证正确
  - `save`：新增（密码正确哈希、部门 JOIN 正常）、更新（不改密码时不清空、联表展示字段会被正确剔除不报错）均验证通过
  - `remove`：验证通过
  - 测试中发现一个**纯测试工具的坑**：用 `curl -d` 直接在命令行传中文 JSON，在当前 Windows/Git-Bash 环境下 argv 会被截断/乱码导致 `json_decode` 失败，进而让 `save()` 误判成插入空数据触发外键报错——换成 `--data-binary @文件` 方式传同样的中文内容就完全正常。**这是 shell 环境的坑，不是框架代码的 bug**，真实浏览器 `JSON.stringify()+ajax` 发送不会有这个问题，已确认数据现场已还原

### 前端技术栈

- UI 组件库：**DevExtreme**（jQuery / JS 版本），用于 admin 后台界面
  - 当前版本：**24.1.3**
  - 已安装在 `E:\www\mdm\pdc\admin\static\js\plugins\devextreme@24.1.3`（按版本号命名目录，方便以后升级版本共存/切换）
  - 官方文档：https://js.devexpress.com/jQuery/Documentation/（具体组件用法需要时可实时查询确认，不完全依赖训练记忆）
  - 已落地公共布局模板：[pdc/admin/view/public/header.view.php](../../pdc/admin/view/public/header.view.php) / [footer.view.php](../../pdc/admin/view/public/footer.view.php)，用 `viewLoad('public/header')`/`viewLoad('public/footer')` 引入，header 里加载 jQuery 3.7.1 + DevExtreme CSS/JS（用 `dx.light.min.css` + `dx.all.min.js`）
  - 站点配置新增 `staticUrl`（如 `/mdm/pdc/admin/static`）和 `host`（如 `/mdm/pdc/admin/action`，action 入口前缀，供前端 ajax 拼 URL 用），模板里用 `Mvc::$cfg[...]` 取值，不写死域名/路径
  - **共享 JS 配置**：不是单独的静态 `.js` 文件，而是在 `header.view.php` 里用 PHP 直接注入一段 `<script>window.APP = {...}</script>`（`HOST`/`STATIC_URL`/`LOCALE`），放在所有业务脚本之前加载——因为这些值依赖当前环境/站点配置，必须由 PHP 动态生成，静态文件做不到按环境区分。前端代码统一用 `APP.HOST`/`APP.STATIC_URL`/`APP.LOCALE` 取值
  - 已验证：`demoAction::users()` 改为渲染 `dxDataGrid`（替换掉之前手写的 HTML `<table>`），数据通过 `json_encode($rows)` 传给 `dataSource`，列定义用 `lg()` 做多语言表头，`admin_allow` 转 `bool` 配合 `dataType: 'boolean'`；HTML 输出结构正确，浏览器实际渲染效果待用户确认

### 视图 (View)

已落地 [View.php](View.php)：

- **原生 PHP 模板**，渲染方式是 `extract(变量) + require 模板文件`，直接交给 PHP 引擎执行，不做 v1.0 那种危险函数黑名单扫描——以性能为先（按用户明确要求去掉这层开销）
- `View::set($key, $value)` / `viewSet()`：给模板赋值，支持单个键值或数组批量
- `View::put($view = '')` ：输出视图；`$view` 为空时，按当前路由（`Mvc::$class` 去掉 `Action` 后缀 + `Mvc::$cfg['routeDir']` + `Mvc::$method`）推导默认模板路径，**不用 `debug_backtrace`**（v1.0 是用回溯取调用者类名/方法名，这里直接读 `Mvc` 当前路由状态，更快）
- `View::load($view)` / `viewLoad()`：加载子模板，供模板内部 `<?=viewLoad('public/header')?>` 这样调用
- 模板路径规则：`[site]/view/[routeDir][classBase]/[method].view.php`，与 action 文件的目录结构一一对应
- 站点配置新增 `viewDir`（如 `pdc/admin/view`），在各站点 `index.php` 的 `Mvc::init()` 里传入
- 已验证：`demoAction::page($a,$b)` 渲染 `pdc/admin/view/demo/page.view.php`，变量正确传入，且与 `callBefore`/`callEnd` 钩子协同正常
- 已验证：`demoAction::users()` 改为调用 `pdc/model/Users.php`（`Users extends Model`）的 `listWithDepa()` 方法（内部用 `$this->db->select()` Medoo 原生 join 语法），渲染 `pdc/admin/view/demo/users.view.php` 输出 HTML 表格，4 个用户及部门中文名正确显示
- `Mvc.php` 新增 `pdc/model/` 目录的自动加载（`spl_autoload_register`，类名 = 文件名，路径取自 `Mvc::$cfg['projectDir']`），与 `lib/` 自动加载并存
- **重要发现**：模板里不能依赖 `<?`/`<?...?>` 短标签（需要 php.ini 开 `short_open_tag` 才能解析），只有 `<?=...?>` 短回显标签是 PHP 始终内置支持的。模板里的控制结构（`foreach`/`if` 等）统一用标准 `<?php ... ?>` 标签，不依赖服务器配置

### 依赖注入 / 容器

-

### 多语言

已落地 [Lang.php](Lang.php)：

- 语言包是**纯 PHP 数组文件**（`key => 译文`），不用 gettext，与"性能优先、PHP原生"的取向一致：`pdc/lang/zh-CN.php`、`pdc/lang/en.php`
- `Lang::load($locale, $langDir)`：加载语言包到内部静态字典，`Mvc::init($cfg)` 里如果传了 `locale`/`langDir` 会自动调用
- `Lang::get($key)` / 全局函数 `lg($key)`：取译文，**找不到 key 时原样返回 key 本身**兜底，不会因漏翻译而报错或空白
- 各站点在自己的 `index.php` 的 `Mvc::init()` 里指定 `'locale' => 'zh-CN'`（或 `'en'`）和 `'langDir' => .../pdc/lang`，目前 `admin` 站点固定 `zh-CN`；`homeen` 之类的英文站点以后固定传 `'en'` 即可，简单场景不需要 session 级别的语言切换
- 已验证：`demo/users.view.php` 用 `lg('用户列表')` 等包文案，切换 `locale` 为 `zh-CN`/`en` 输出正确切换
- **网址传参 `lang=` 切换语言**（已落地于 `Mvc::resolveLocale()`）：
  - 访问时带 `?lang=en`：写入 `$_SESSION['lang']`，本次请求立即生效
  - 之后的请求不带 `lang` 参数：取 `$_SESSION['lang']` 上次记录的值，直到再次传 `lang` 改变它
  - 都没有（首次访问、无 session 记录）：默认 `zh-CN`（`Mvc::DEFAULT_LOCALE`）
  - `index.php` 里不再固定写 `'locale' => 'zh-CN'`，改成由 `resolveLocale()` 动态决定，只需配置 `langDir`
  - 浏览器实测通过（CLI 模拟测试中因 Windows 环境下 PHP CLI 的 session 文件行为异常，出现误判的"未持久化"现象，已确认是测试环境artifact，非代码问题，以浏览器实测为准）

### 配置管理

已落地 **通用配置 `pdc/config/common.php`**：

- 用途：不同开发人员本地路径不一定相同（比如 `mvc` 框架目录、composer 目录位置），不应该写死在 `index.php` 里
- `common.php` 提供默认值（`mvcDir`、`composerDir`），**会提交到版本库**
- 个人本地路径覆盖：建 `pdc/config/common.local.php`（**不提交版本库**，已提供 `common.local.php.example` 模板），各站点 `index.php` 加载时如果存在该文件就 `array_merge` 覆盖默认值
- 改造影响：`Mvc.php` 顶部不再硬编码 `require_once __DIR__.'/composer/vendor/autoload.php'`，改成 `Mvc::init($cfg)` 内部按 `$cfg['composerDir']` 动态 require（没传时回退到 `__DIR__.'/composer'` 默认值）
- 各站点 `index.php` 改造模式：先 require `common.php`（+ 可选的 `common.local.php` 覆盖）→ 用其中的 `mvcDir` 来 require 框架的 `Mvc.php` → `Mvc::init()` 时把 `composerDir` 也传进去
- 已在 `pdc/admin/action/index.php` 落地并回归测试通过

## 10. 后台 UI 框架（左侧菜单 + 多标签 iframe 浏览）

参考 GSP 系统截图实现：深色侧边栏 + 多级菜单 + 顶部多标签（每个标签内容是一个 iframe）+ 工具条右侧（消息/用户/语言/标签操作）。手写 HTML/CSS/JS 实现壳层（不用 DevExtreme 组件），DevExtreme 只在具体业务页面（如 dxDataGrid）里用。

- **HOST 常量**（[Mvc.php](Mvc.php) `init()` 里 `define('HOST', $cfg['host'])`）：PHP 端（Action/View）直接用全局常量 `HOST`，和 JS 端 `window.APP.HOST` 是同一个值，两边风格统一
- **菜单数据**：[pdc/config/admin.php](../../pdc/config/admin.php) 里的 `menu` 键，多级数组（`title`/`icon`/`children`），`url` 是相对路径，前端拼 `APP.HOST` 后用于打开标签
- **壳层页面**：[pdc/admin/action/index.php](../../pdc/admin/action/index.php)（`indexAction::index()`，对应默认路由 `/`）渲染 [pdc/admin/view/index/index.view.php](../../pdc/admin/view/index/index.view.php) —— 这是唯一一个不走 `viewLoad('public/header')` 套路的页面，因为它是壳层本身，不是被装进 iframe 的内容页
- **样式**：[static/css/adminLayout.css](../../pdc/admin/static/css/adminLayout.css)
- **交互逻辑**：[static/js/lib/adminLayout.js](../../pdc/admin/static/js/lib/adminLayout.js)
  - 点 LOGO（`#sidebarHeader`）切换 `#appShell.collapsed`，CSS 控制侧边栏收拢/展开
  - 菜单分组点击展开/收起（`.menu-group.open`），点子项 `openTab(url, title)`：已打开则激活已有标签，否则新建标签 + 对应 `<iframe src="HOST+url">`，标签间用绝对定位的 `.tab-pane` 叠放（`display:none/block` 切换，不重新加载 iframe，切换速度快、保留各标签内的状态）
  - 标签操作下拉（关闭/关闭其他/关闭右侧/关闭左侧/关闭全部）固定作用于**当前激活的标签**；首页标签 `id='home'` 始终不可关闭
  - 刷新按钮：重新设置当前激活标签 iframe 的 `src`（强制刷新，不影响其他标签）
  - 语言切换：整页带 `?lang=` 刷新（标签栏/菜单这些壳层文案需要重新渲染；已打开的标签页内容暂不会跟着变，需要用户自己刷新该标签，这是已知的简化处理）
  - 用户名取 `$_SESSION['user']['admin_username'] ?? 'test.admin'`（**目前没有做登录/鉴权**，这只是占位，先把 UI 框架搭起来；退出按钮链接到 `/login/logout`，这个路由目前也还不存在，留作以后接入登录系统）
- **FontAwesome**：选择本地安装（不用 CDN），引用路径固定为 `static/js/plugins/fontawesome/css/all.min.css`，**这个目录现在是空的，需要用户自己下载 FontAwesome 包放进去**，否则图标不会显示（不影响功能，只是图标空着）
- **联动验证的两个真实业务页**：复用之前做好的 dxDataGrid CRUD 模式，新增了 [pdc/model/Depa.php](../../pdc/model/Depa.php) + [pdc/admin/action/depa.php](../../pdc/admin/action/depa.php) + [pdc/admin/view/depa/index.view.php](../../pdc/admin/view/depa/index.view.php)（部门管理，对应菜单"系统用户→部门"），和已有的"系统用户→用户"（`users/index`）放在同一个菜单分组下
- 已用 PHP 内置服务器做真实 HTTP 测试：壳层首页渲染正确（菜单/用户名/语言下拉都对）、`depa/index` 页面和 `depa/grid` 接口都正常返回数据
- **待验证**（CLI/curl 测不出来，需要浏览器实测）：侧边栏收拢/展开动画、标签切换与关闭交互、下拉菜单的点击外部关闭、iframe 实际加载效果
- **菜单已支持任意层级嵌套**（不止两级）：`index.view.php` 里 `renderAdminMenu()` 递归渲染，有 `children` 的是分组、没有的是叶子菜单项；CSS 用 `.menu-root`（顶层，始终可见）区分于 `.menu-children`（嵌套分组内，默认折叠），已验证 `系统→工具→语言包` 三级菜单渲染正确

## 11. lg() 扫描工具：把文案抽取做成可维护的工程化流程

**前提规则（影响所有以后的代码）**：`lg()` 必须传**字面量字符串**（`lg('中文原文')`），不能传变量（`lg($var)`），否则静态扫描工具找不到。之前 `admin.php` 菜单数据用 `lg($group['title'])` 这种动态调用就是反例——已改成在 `config/admin.php` 里直接 `'title' => lg('系统用户')`，数据定义时就把文案翻译好，存进数组的是**翻译结果**，视图里直接输出，不再二次包 `lg()`。这意味着 **config 目录也要纳入扫描范围**（因为它现在也包含字面量 `lg()` 调用了）。

- **[lib/LangScanner.php](lib/LangScanner.php)**：
  - `scan(array $dirs): array` — 递归扫描 `.php`/`.js` 文件，正则 `/\blg\(\s*([\'"])((?:(?!\1).)*)\1\s*\)/u` 提取 `lg('xxx')`/`lg("xxx")` 里的字面量，去重排序
  - `run(array $dirs, string $langDir)` — 扫描 + 写 `scan.php`（纯列表，自动生成不要手改）+ 同步语言目录下所有语言文件（含 zh-CN.php）：缺的 key 补上（默认值=key本身，即中文原文占位待翻译），多余的 key（代码里已经不再用 `lg()` 引用的）删掉
  - `editableLangFiles($langDir, $includeZhCn)` — 列出语言文件名，UI 下拉默认排除 `scan.php` 和 `zh-CN.php`
  - `loadFileRows()`/`updateFileValue()` — 给 dxDataGrid 用的查/改，**只能改已存在的 key 的翻译值，不能新增/删除**（新增删除只能靠扫描）
  - 安全：`assertEditableFile()` 用 `basename()` + 白名单校验文件名，防止路径穿越或读写任意文件（已用 `../../../etc/passwd` 和 `zh-CN.php` 验证拦截生效）
- **扫描目录**（[pdc/admin/action/langTool.php](../../pdc/admin/action/langTool.php) 里 `SCAN_DIRS` 常量）：`admin/view`、`admin/static/js/lib`、`model`、`config`（按本次讨论决定加上 config，因为菜单配置现在也有字面量 `lg()` 调用了）
- **菜单**：`系统` → `工具` → `语言包`（三级嵌套，验证了菜单支持任意深度）
- **UI**（[pdc/admin/view/langTool/index.view.php](../../pdc/admin/view/langTool/index.view.php)）：扫描按钮（显示新增/删除统计）+ 语言文件下拉（排除 scan.php/zh-CN.php，目前只有 en.php）+ dxDataGrid（key 列只读、value 列可编辑，`allowAdding:false, allowDeleting:false`，编辑后 `onRowUpdated` 立即保存）
- **已用真实 HTTP 测试验证完整流程**：跑了一次真实扫描（36 个 key），结果显示已有翻译全部保留、新发现 1 个 key（`用户`，自动补占位）、自动清掉 2 个不再使用的 key（`是`/`否`，因为允许列早就改成 DevExtreme 的 boolean 勾选框渲染，代码里已经没有文字判断了）——这次扫描顺带验证了之前所有页面的 `lg()` 调用都写对了（字面量形式），没有漏网的动态调用

## 12. dxDataGrid 工具条扩展 + 通用导出（Excel）

**全局自定义样式**：[pdc/admin/static/css/dxCustom.css](../../pdc/admin/static/css/dxCustom.css)，在 `header.view.php` 里跟在 DevExtreme 主题 CSS 之后加载，对全站所有用 dxDataGrid 的页面生效。注意里面 `.dx-editable-header`/`.dx-editable-must-header`/`.dx-jiange-header`/`.dx-multi-headers` 这几个 class 不是 DevExtreme 自动打的，是约定的标记 class，以后定义需要这种视觉区分的列时要自己在 `cssClass` 里加上才会生效。

**页面布局规则**：所有 grid 页面去掉了 `<h1>` 标题（标签本身已经显示标题，不需要重复），grid 的高度统一由 [dxGrid.js](../../pdc/admin/static/js/lib/dxGrid.js) 里的 `dxAutoHeight(selector)` 算：取该容器到可视区底部的剩余高度（天然适配容器前面有别的 DOM 的情况，因为算的是 `offset().top` 之后的剩余空间），小于 200 时取 200；视图自己在 `gridOptions.height` 里显式传了值就优先用视图的。不走 `createDxGrid()` 的页面（比如 langTool 的语言文件编辑表格）也可以直接调 `dxAutoHeight()`。

**`createDxGrid()` 新增的工具条能力**（[dxGrid.js](../../pdc/admin/static/js/lib/dxGrid.js) 的 `onToolbarPreparing`，通过 `options.toolbar` 开关）：
- 左侧：刷新（默认开）、已选（`selectToggle`，默认关，开启后 `selection.mode` 强制为多选，按钮只切换"显示全部/只显示已勾选"，已选过滤用 `anyof` 转成服务端过滤条件，和用户自己设的过滤条件 AND 组合）、重置（默认开，清过滤/排序/分组/已选状态）、批量查询（`batchSearch:{fields:[...]}`，默认关，弹出字段下拉+多行文本框，字段下拉的显示文字直接从 `options.columns` 里找对应 `caption`，不用单独再维护一份翻译；提交后用 `dataGrid.filter()` 设置 `anyof` 条件）
- 右侧（在原生列选择按钮之前，从左到右）：导出（默认开，下拉"导出全部/导出已选"）→ 导入（默认开，下拉占位，目前只有"批量导入"一项，无实际逻辑；部门视图用 `toolbar:{import:false}` 关掉）→ 编辑方式切换（默认开，下拉对应 DevExtreme 原生 `editing.mode` 五种值 row/popup/cell/form/batch，默认 popup）→ 批量保存按钮（只在编辑方式=batch 时显示，调 `dataGrid.saveEditData()`；没有依赖 DevExtreme 原生的批量保存 UI，是单独做的按钮，位置和显隐都自己控制）
- 工具条按钮的通用文案（刷新/已选/重置/批量查询/导入/导出/...）没有走 JS 里硬编码中文，是 `header.view.php` 里用字面量 `lg()` 翻译好放进 `window.APP.GRID_TEXT`，`dxGrid.js` 运行时从 `APP.GRID_TEXT` 取，这样语言包扫描工具能扫到、也不用给纯 JS 文件单独搞一套翻译机制

**导出走服务端**（用户已装好 `phpoffice/phpspreadsheet ^5.8`，决定不用 DevExtreme 自带的前端导出，因为那个需要额外装 exceljs+jszip，而且只能导出当前已加载到表格里的数据）：
- [lib/ExcelExporter.php](lib/ExcelExporter.php) — 通用 `download(columns, rows, filename)`，按 `columns` 给的 `dataField`+`caption` 顺序生成表头和每行数据，`Content-Disposition: attachment` 直接输出下载
- [GridActions.php](GridActions.php) 新增 `export()`：复用 `Model::grid()`（不传 `take` 就是不分页，查全部），"导出已选"传 `keys` 转成 `[主键字段,'anyof',keys]` 过滤，"导出全部"遵循前端当前过滤条件（`dataGrid.getCombinedFilter()` 拿到的表达式，加上"已选"叠加过滤如果当时开着）；`Model` 加了 `getKey()` 公开方法给这里用主键名
- 前端导出请求是**表单 POST**（不是 JSON body），因为要触发浏览器原生下载行为：`dxGrid.js` 里 `postDownload()` 动态拼一个隐藏 `<form target="_blank">` 提交，`columns`/`filter`/`keys` 几个字段的值是 JSON 字符串，服务端 `export()` 手动 `json_decode`（这几个字段不会被 `Mvc::buildParams()` 自动解析，因为那只在 `Content-Type: application/json` 时整体解析 body，表单 POST 这里走的是 `$_POST` 直接合并，单个字段的值还是原始字符串）
- **已用真实 HTTP 测试验证**：导出全部、导出已选（按 key 过滤）、导出全部+筛选条件（`contains`）三种场景都生成了合法的 `.xlsx`（用 `file` 命令确认签名、用 PhpSpreadsheet 读回校验了表头和数据行内容都对）；过程中踩了一个纯测试环境的坑——curl 命令行里直接传中文会因为 shell 参数编码问题导致 PHP 收到空字符串（不是代码 bug），用 `--data-urlencode field@文件` 从文件读内容能绕开

**后续小修**：
- 浏览器刷新 / 切换中英文（整页刷新）后自动重新打开刷新前的那个标签：[adminLayout.js](../../pdc/admin/static/js/lib/adminLayout.js) 里 `activateTab()` 每次都把当前标签 `{url,title}` 存进 `localStorage`（home 标签不存），页面 `$(function(){...})` 启动时 `restoreActiveTab()` 读出来重新 `openTab()`；只记"当前打开的是哪个标签"，不是整份标签列表
- 导出/导入下拉面板加了 `dropDownOptions:{width:180}`，避免按钮文字被截断
- 导出 Excel 表头样式：[lib/ExcelExporter.php](lib/ExcelExporter.php) 表头行背景 `#2A3C63`、白色加粗字体，并对整个数据区设置自动筛选（`setAutoFilter`）
- 导出时排除隐藏列：[dxGrid.js](../../pdc/admin/static/js/lib/dxGrid.js) 的 `exportData()` 先用 `columns.filter(c => c.visible !== false)` 过滤掉 `visible:false` 的列再发给后端，不依赖服务端去猜哪些列该导出
- 导出时 lookup 列（比如用户列表的部门）显示当前名称而不是原始 ID：`exportData()` 把列的 `lookup.dataSource/valueExpr/displayExpr` 跟着 `columns` 一起传给后端，[ExcelExporter::applyLookups()](lib/ExcelExporter.php) 拿这份配置在写入单元格前把原始值替换成对应的显示名称——对任何 lookup 列都通用，不是只认部门这一个字段。已用真实请求验证：部门列导出结果是"技术部/业务部/事件部/目录部"而不是 1/2/3/4
- 导出时排除"当前实时被隐藏"的列：`exportData()` 读 `dataGrid.option('columns')`（实时状态，含列选择器手动切换过的可见性），不读 `options.columns`（那是页面初始配置，用户手动改过可见性后就不准了）

## 12. 登录系统 + 找回密码 + 每用户 dxDataGrid 布局保存

**新增两张表**：[sql/admin_remember_token.sql](../../pdc/sql/admin_remember_token.sql)（"记住我"持久登录令牌）、[sql/admin_password_reset.sql](../../pdc/sql/admin_password_reset.sql)（找回密码令牌）。两张表都用 selector/validator 模式：cookie/链接里只带明文 selector+validator，库里只存 validator 的 sha256 哈希，防止表数据泄露后能直接冒充登录或重置任意密码，也防时间侧通道（用 `hash_equals` 比较）。

- **[lib/Auth.php](lib/Auth.php)**：登录态管理。`Auth::user()/id()` 读 SESSION（key 是 `$_SESSION['user']`，跟 indexAction 原有约定一致）；`Auth::login($admin,$remember)` 登录成功调用，`$remember=true` 时额外发"记住我" cookie（30天，httpOnly）；`Auth::logout()` 清 SESSION 并吊销对应的"记住我"记录（彻底退出，不会被自动登录捡回来）；`Auth::tryRememberLogin()` SESSION 没登录态时用 cookie 自动登录，**每次用过 validator 就轮换一次**（同一个 selector，新 validator+新过期时间），降低 cookie 截获后长期重放的风险；validator 不匹配时直接吊销那条记录（疑似被盗用）。
- **[Action.php](Action.php) 的 `callBefore()`**：全站登录态校验的挂载点（`Mvc::dispatch()` 本来就会在绑定参数后自动调用 `callBefore`，之前没用上，现在用来做登录拦截）。默认 `requiresLogin()` 返回 true，没登录时：ajax 请求（jQuery 默认带 `X-Requested-With: XMLHttpRequest`，不用前端额外处理）返回 401 JSON；普通页面请求 302 跳登录页。`loginAction` 覆盖 `requiresLogin()` 返回 false（登录/验证码/找回密码不需要登录态）。
- **[lib/Captcha.php](lib/Captcha.php)**：个位数加减法验证码，GD 画图，答案存 `$_SESSION['captchaAnswer']`，校验后立即清除（一次性，防止同一道题被重复提交复用）。已用真实 HTTP 测试验证：拿图片建立 session → 直接读服务器本地的 session 文件取出答案 → 提交登录验证通过。
- **[lib/Mailer.php](lib/Mailer.php)**：PHPMailer 包了一层，配置读 `config/common.php` 的 `mailSendServer`。**当前状态：SMTP 握手/STARTTLS 都正常，卡在 `535 Error: authentication failed, system busy`**——腾讯企业邮箱通常需要后台单独生成的"SMTP 授权码"而不是登录密码，且要在管理后台开启 SMTP 服务，这是凭据/账号配置问题，不是代码问题，等拿到正确凭据后重测即可。
- **[lib/PasswordPolicy.php](lib/PasswordPolicy.php)**：密码强度校验（长度>8 且同时包含大写、小写、数字、特殊符号），**只用在找回密码流程**，后台用户管理模块改密码不受此限制（已按用户要求在 `Users::gridUpdate()` 那条路径不调用这个校验）。
- **[model/PasswordReset.php](../../pdc/model/PasswordReset.php)**：对应 admin_password_reset 表，`createForUser()` 生成令牌（30分钟有效），`findValidUser()` 校验令牌（过期/已用/validator不匹配都返回 null），`markUsed()` 改密成功后标记，防止同一个链接重复使用。
- **[admin/action/login.php](../../pdc/admin/action/login.php)**：`index`（登录页，已登录直接跳首页）、`captcha`（验证码图片）、`doLogin`（**普通 form POST 而不是 ajax**，这样浏览器原生的"保存密码"提示才会触发——ajax 提交大多数浏览器不会弹保存密码）、`logout`、`forgotPassword`（不暴露用户名/邮箱是否存在，找到了才真的发邮件）、`resetPassword`（展示页面，链接失效会提示重新申请）、`doResetPassword`。
- **[admin/view/login/index.view.php](../../pdc/admin/view/login/index.view.php)**：独立简洁登录页（不套用 admin 后台框架的 header/菜单），中英文切换、记住我勾选框、验证码图片点击刷新、忘记密码用 dxPopup 弹窗输入用户名/邮箱。
- **已用真实 HTTP 测试验证完整登录链路**：未登录访问页面→302 跳登录页；未登录 ajax→401 JSON；验证码答案校验通过→登录成功→session 改变（`session_regenerate_id`）→带 session 访问受保护页面 200；退出登录→即使带着"记住我" cookie 也无法自动登录（cookie 已被吊销，符合"退出就是彻底退出"的预期）；另外单独验证了"记住我"在保留 cookie、丢弃 session cookie（模拟浏览器重启）的情况下能自动登录成功。

**每用户 dxDataGrid 布局保存**：新增 [sql/sysDxDataGrid.sql](../../pdc/sql/sysDxDataGrid.sql)（字段前缀 `sysDxDataGrid_`，按用户要求保留这个驼峰表名，跟其它表的 snake_case 不一致，是例外）。核心思路是直接用 **dxDataGrid 自带的 `instance.state()`**（不依赖 `stateStoring` 自动持久化选项，手动在按钮点击时调用）拿到/还原列宽、列顺序、显示隐藏、分组、过滤条件的完整状态，不用自己一项一项拼。
- **[model/GridLayout.php](../../pdc/model/GridLayout.php)** + **[admin/action/gridLayout.php](../../pdc/admin/action/gridLayout.php)**：save/load/list/remove 四个接口，跟具体业务 Model 完全无关，所有 grid 共用同一套接口，按 `Auth::id()` + `gridName`（前端默认用 `window.location.pathname`，同一个用户在同一个 gridName 下最多一个 `default` 方案 + 任意多个命名方案）区分。
- **[dxGrid.js](../../pdc/admin/static/js/lib/dxGrid.js)**：列选择按钮前新增"布局"下拉（保存布局/恢复布局/更多布局）。"保存布局"存为 `default` 方案；"恢复布局"先删除 `default` 方案再把 `columns` 还原成 `createDxGrid()` 调用时传入的原始配置（创建时就深拷贝了一份存起来，不受运行时改动影响）；"更多布局"弹窗管理命名方案（另存为新名称/加载/删除，列表不显示 `default`）。**grid 创建后会自动尝试加载 `default` 方案**，这样用户上次调整过的布局下次打开页面就直接生效，不用每次手动点恢复。
- **已用真实 HTTP 测试验证**：登录态下保存/加载/另存为新方案/列出方案/删除方案全部测通；未登录访问 gridLayout 接口返回 401，跟其它受保护接口行为一致。

## 13. 内置超级管理员 / 验证码动态要求 / IP 锁定 / 用户自助改资料 / 密码90天过期提示

- **内置超级管理员**：用户名 `test.admin`，密码 `TEST@2026@2026`，**写死在 [admin/action/login.php](../../pdc/admin/action/login.php) 的常量里，不入库**。登录态里用 `admin_id = 0`（真实表是 AUTO_INCREMENT 从 1 开始，0 这个值在 admin_user 表里不可能出现，安全地区分"这是内置账号不是数据库用户"）。`profileAction` 检测到 `Auth::id() === 0` 就直接显示"系统内置账号不支持修改资料"，不碰数据库。
- **验证码动态要求**：第一次登录不强制验证码，密码错误一次后 `$_SESSION['requireCaptcha']=true`，**之后每次登录都强制要求**，直到下一次登录成功才清零。登录页根据这个 SESSION 标记决定要不要渲染验证码输入框（不是"渲染了但不校验"，是真的不渲染，符合"第一次不用输入"的字面意思）。
- **IP 锁定**：新表 [sql/admin_login_attempt.sql](../../pdc/sql/admin_login_attempt.sql)，[lib/LoginThrottle.php](lib/LoginThrottle.php) 按 IP（不是按用户名）统计连续密码错误次数，达到 6 次锁 1 小时；锁定期间**任何账号**（包括内置超级管理员）从这个 IP 登录都直接拒绝，连验证码/密码都不检查。验证码本身错误不计入这个计数器，只有真正的"用户名/密码不匹配"才算一次失败；登录成功清零。已用真实 HTTP 测试验证：6 次错误后第 7 次（即便密码正确）被拒，返回剩余分钟数；超级管理员在同一个被锁 IP 上也被拒。
- **用户自助改资料**：新增 [admin/action/profile.php](../../pdc/admin/action/profile.php) + [view/profile/index.view.php](../../pdc/admin/view/profile/index.view.php)，对应 adminLayout.js 里早就有的用户菜单"修改资料"入口。**只能改邮箱/手机号/密码**，用户名/姓名/部门/允许状态这些字段不在这个接口的处理范围内（要改只能后台管理员通过 `users/grid` 那条管理员专用路径）。改密码套用跟找回密码一样的 `PasswordPolicy`（大写+小写+数字+特殊符号+长度>8），跟后台管理员在用户管理 grid 里改别人密码不受此限制是两条不同的路径，互不影响。
- **密码90天过期提示**：`admin_user` 新增 `admin_password_update_time` 列（ALTER TABLE 用 `DEFAULT CURRENT_TIMESTAMP` 加列，存量记录自动按字面要求补成"加列那一刻的当前时间"，不是按各自原来的创建时间）。`Users::isPasswordStale()` 登录成功后检查，超过90天就把跳转目标从 `/index/index` 改成 `/profile/index?passwordExpired=1`，资料页顶部出现提醒横幅；真正"更新密码修改时间"的时机是用户在资料页**真正提交新密码成功**那一刻（`Users::updatePasswordById()`/`gridInsert`/`gridUpdate` 改密码时都会同步刷新这个时间），不是仅仅触发提示就更新——只是被提示还没改密码的话，下次登录还是会继续提示。
## 14. RBAC：站点/角色/权限/会员 + 部门用户多对多 + 权限合并引擎

9张新表：`sys_site`/`sys_role`/`sys_permission`/`sys_role_permission`/`sys_admin_role`/`sys_member_role`/`sys_depa_role`（RBAC 引擎本身，`sys_` 前缀）+ `admin_user_depa`（用户-部门多对多，`admin_` 前缀，业务关系不算引擎）+ `member`（会员表，业务实体）。`admin_user.admin_lnk_depa_id` 单值字段已迁移并删除。

- **权限码**：5位大写字母+数字，层级由编码派生（[Permission::deriveLevelAndParent()](../../pdc/model/Permission.php)），新增时校验父级必须先存在，`sys_permission_level`/`sys_permission_parent_code` 落库存一份避免每次现场解析。
- **多对多展示**：MySQL 5.7.18 没有 `JSON_ARRAYAGG`，部门/角色这类多对多关系在 `grid()` 里用 `GROUP_CONCAT` 拼逗号字符串，PHP 端再切回数组；[Model.php](Model.php) 加了 `gridGroupBy()` 钩子。**用户表同时联了部门和角色两个多对多关系**，必须在 `GROUP_CONCAT` 里加 `DISTINCT`，否则两个关系做笛卡尔积会重复（已用真实数据验证 2部门×2角色 不会变成4条）。
- **前端多选标签框**：[dxGrid.js](../../pdc/admin/static/js/lib/dxGrid.js) 新增 `dxMultiSelectColumn()`，用 `dxTagBox` 的 `editCellTemplate` 实现"单元格显示逗号名称，编辑时多选"，用户/部门/会员三个 grid 共用。
- **[lib/PermissionResolver.php](lib/PermissionResolver.php)**：有效角色 = 用户直接绑定 ∪ 所属所有部门绑定的角色（多部门按并集），多角色对同一权限码合并：类型取并集，数据域取数值更大（更宽松），额外配置数值取较大值/布尔取真。登录时调用一次缓存进 `$_SESSION['permissions']`（[Auth.php](lib/Auth.php) 的 `refreshPermissions()`），角色/部门变更要下次登录才生效。内置超级管理员（`admin_id=0`）直接标记 `isSuperAdmin=true`，不查表。
- **会员不能绑 admin 站点角色**：[Role::bindToMember()](../../pdc/model/Role.php) 应用层校验，绑之前查角色的站点代码，是 `admin` 就抛异常。
- **后台管理页面**：站点/会员是标准 dxDataGrid CRUD；角色页是 dxDataGrid + 每行"分配权限"按钮弹出 dxTreeList（权限树）+ 侧边表单（类型勾选/数据域下拉/额外配置动态字段），点权限节点加载该角色现有配置，保存调 `role/grant`、撤销调 `role/revoke`；权限管理页是 dxTreeList + 侧边新增/编辑表单（额外配置schema 用 JSON 文本框，不做可视化表单生成器，避免过度设计）。
- **已用真实 HTTP 测试验证完整链路**：权限码父级不存在时正确拒绝、父级存在后新增成功、层级正确派生；角色创建/角色绑站点名联表正确；role/grant→grantsOf→revoke 全部测通；通过 users/save 和 depa/save 的多选标签框字段（`admin_depa_ids`/`admin_role_ids`/`depa_role_ids`）真实绑定部门和角色；最后跑通完整链条——通过 UI 驱动的真实数据调用 `PermissionResolver::resolveForAdmin()`，验证用户直接绑定角色 + 部门继承角色都正确合并出最终权限。

### 14.1 修复：权限树的 JSON 列没解码导致前端类型勾选框显示乱码

[Permission::tree()](../../pdc/model/Permission.php) 从 DB 查出的 `sys_permission_types`/`sys_permission_extra_schema` 是 JSON 列的**原始字符串**（Medoo 不会自动解码 JSON 列），但角色授权弹窗的 JS 直接对这个字符串调用了 `Object.keys()`——对字符串调用相当于按字符下标遍历，于是类型勾选框显示出来的是 JSON 字符串本身的前几个字符（如 `{`/`"`/`a`/`d`）而不是真正的类型键。修复：`tree()` 内部对这两个字段做 `json_decode` 后再返回，前端拿到的就是真对象/数组，不用改任何 JS。

### 14.2 新增"角色与权限"三栏页面 + "角色与系统用户"三栏页面

- **[admin/action/rolePermission.php](../../pdc/admin/action/rolePermission.php)** + **[admin/view/rolePermission/index.view.php](../../pdc/admin/view/rolePermission/index.view.php)**：左栏权限树、中栏角色列表（带站点筛选下拉）、右栏当前"选中角色+选中权限"组合的配置表单（复用已有的类型勾选/数据域/额外配置渲染逻辑），点权限节点高亮拥有它的角色、点角色高亮它拥有的权限码（用纯数据驱动的方式：`highlightedCodes`/`highlightedRoleIds` 两个数组，`dxTreeList` 的 `onRowPrepared` 和 `dxList` 的 `itemTemplate` 分别按这两个数组判断要不要加高亮 class，而不是事后操作 DOM）。新增反查方法 [Role::roleIdsGrantedCode()](../../pdc/model/Role.php) 支持"权限→角色"方向查询。这个页面落地后，**[role/index.view.php](../../pdc/admin/view/role/index.view.php) 里原来每行的"分配权限"弹窗按钮被去掉**，避免两处维护同一份授权逻辑。
- **[admin/action/roleAdmin.php](../../pdc/admin/action/roleAdmin.php)** + **[admin/view/roleAdmin/index.view.php](../../pdc/admin/view/roleAdmin/index.view.php)**：左栏角色列表、中栏系统用户列表，右栏"用户角色"上下两块（上=当前选中用户的角色勾选框、下=当前选中角色的用户勾选框），四处勾选框**勾选即生效**（没有单独的保存按钮）。`Role::bindToAdmin()` 本身是"全量替换"实现，前端在每次勾选/取消时先查出该用户当前完整角色集合、本地加减一个角色ID后整体重新提交，不需要新增"单独加一个/减一个"的后端接口。新增反查方法 [Role::adminIdsOfRole()](../../pdc/model/Role.php) 支持"角色→用户"方向查询。
- 两个新页面都挂到菜单"权限"分组下（[config/admin.php](../../pdc/config/admin.php)）。
- **已用真实 HTTP 测试验证**：`permission/tree` 返回的 `sys_permission_types` 确认是解码后的对象；`rolePermission/rolesOfCode` 在 grant/revoke 前后正确反映角色集合的增减；`roleAdmin/rolesOfAdmin`/`adminsOfRole` 双向反查结果一致；`roleAdmin/bind` 全量替换式绑定测试后已恢复成原始状态，未遗留测试数据。

- **已用真实 HTTP 测试验证整条链路**：首次密码错误不需要验证码→第二次起强制要求验证码→连续6次错误锁定IP（含锁定后超级管理员也被拒）→超级管理员登录成功且能访问受保护页面→把测试用户密码修改时间改成100天前→登录成功后被重定向到资料页带 `passwordExpired=1`→资料页正确显示提示横幅→提交新邮箱/手机号/密码后数据库里全部正确更新、密码修改时间刷新为当前时间→提交弱密码被拒绝→未登录访问资料页 302 跳登录。

## 15. 通用属性系统（attr_type + attr_value）

两张表：`attr_type`（类型定义）+ `attr_value`（所有类型的值，按 `attr_value_lnk_type_id` 区分）。

### 表结构

```sql
attr_type
  attr_type_id, attr_type_code(unique), attr_type_cn_name, attr_type_en_name
  attr_type_is_tree(0=平铺,1=树型), attr_type_use_code(0/1), attr_type_ext_schema(JSON), attr_type_sort

attr_value
  attr_value_id, attr_value_lnk_type_id(FK→attr_type), attr_value_parent_id(NULL=根节点)
  attr_value_cn_name, attr_value_en_name, attr_value_code(nullable), attr_value_sort
  attr_value_ext_values(JSON), attr_value_enabled
  UNIQUE KEY (attr_value_lnk_type_id, attr_value_code)  -- MySQL 5.7 NULL不参与唯一索引，多个NULL共存
```

### attr_type_ext_schema 格式

定义该类型的扩展字段，存为 JSON 字符串，数组套分组套 items：

```json
[
  {
    "group": "finance",
    "caption": "财务属性",
    "items": [
      {"type": "integer",  "caption": "库存数量", "field": "qty",      "required": 1, "min": 0, "max": 9999},
      {"type": "number",   "caption": "对人民币汇率", "field": "cny_rate", "required": 1, "min": 0},
      {"type": "text",     "caption": "备注",     "field": "remark",   "required": 0, "minLength": 0, "maxLength": 200},
      {"type": "checkbox", "caption": "特殊标记", "field": "flag",     "required": 0}
    ]
  }
]
```

**item 字段说明**

| 字段 | 说明 | 默认值 |
|------|------|--------|
| `type` | `integer`/`number`/`text`/`checkbox`，旧的 `input` 自动映射为 `number` | `text` |
| `caption` | 显示名称 | 取 `field` |
| `field` | 英文键名，对应 `attr_value_ext_values` JSON 里的 key | 必填 |
| `required` | 1=必填，前端加 `required` 校验规则 | 0 |
| `min`/`max` | 仅 `integer`/`number` 类型有效，对应 DevExtreme `range` 校验规则 | 无限制 |
| `minLength`/`maxLength` | 仅 `text` 类型有效，对应 `stringLength` 校验规则 | 0 / 100 |

**前端 UI 映射**（attrValue/index.view.php `buildExtColumn()`）

| type | DevExtreme 编辑器 | 校验规则 |
|------|------------------|----------|
| `integer` | dxNumberBox，`format:'#0', step:1` | range(min,max) + 自定义 `Number.isInteger` |
| `number` | dxNumberBox（默认） | range(min,max) |
| `text` | dxTextBox，`maxLength` 限制打字 | `stringLength(minLength,maxLength)` |
| `checkbox` | dxCheckBox | — |

### attr_value_ext_values 存储格式

```json
{"cny_rate": 7.25, "remark": "测试备注", "flag": true}
```

即 `field` 键名 → 对应值，PHP 端 `json_encode`/`json_decode`，前端通过 `flattenExt()` 打平成 `ext__field` 前缀字段供 dxDataGrid 表单直接读写，保存时 `collectExt()` 收回为对象。

### 关键实现要点

- `AttrValue::grid()` **必须重写**父类方法，在返回前 decode `attr_value_ext_values`（父类直接返回原始 JSON 字符串，前端 `flattenExt` 无法按 key 读取）
- `AttrValue::treeByType()` 同样 decode，与 `grid()` 保持一致
- 前端 `flattenExt()` 加了防御性 `JSON.parse`，以防万一收到字符串也能正常处理
- 树型类型切换到 dxTreeList，`onSaving` 拦截统一提交，保存后刷新父节点 lookup 数据
- 切换类型时正确 `dispose()` 旧 widget 再重建（列结构因 use_code 和 ext_schema 不同而变）

### 菜单位置

系统 → 通用属性 → 属性类型（/attrType/index）/ 属性值（/attrValue/index）

## 5. 明确不要的东西（反面想法，从 v1.0 里要去掉的）

-

## 6. 暂时不确定 / 待讨论

-
