# UI 契约 — 界面规范

## 色彩系统

### 主色调（从 adminLayout.css / dxCustom.css 提取）

| 用途 | 色值 | 使用场景 |
|------|------|---------|
| 侧边栏背景 | `#1c2b4a` | `#sidebar` 背景 |
| 侧边栏深色（Header/子菜单） | `#16213c` | `#sidebarHeader`、`.menu-children` |
| 侧边栏文字 | `#cfd6e4` | 默认菜单项文字 |
| **主色（蓝）** | `#0C6C9E` | 链接、Grid 表头文字、输入框文字、主按钮 |
| Grid 表头背景 | `#CCE4F6` | `.dx-header-row` |
| 菜单分组标题 | `#f0a830` | `.menu-group-title`（金黄强调色） |
| Tab 激活底线 | `#1c2b4a` | `.tab-item.active` inset shadow |
| Tab 栏背景 | `#eceff3` | `#tabBar` |
| 内容区背景 | `#fff` | `#contentArea` |
| 通知徽章 | `#f5862a` | `#notifyBadge`（橙色） |
| 危险/关闭 | `#d33` | `.tab-close:hover` |
| 必填标记 | `red` | dxForm 必填项 |

### 辅助色

| 用途 | 色值 |
|------|------|
| 可编辑列头底线 | `#03A9F4`（亮蓝） |
| 必填列头底线 | `#f80a79`（品红） |
| 结果列头渐变 | `#b2e264` → `#8ebb5c`（绿色） |
| 间隔列头 | `#a6e1fc`（淡蓝） |
| 比例条底色 / 未满 / 满 100% | `#dde1e8` / `#33415c` / `#45bf99`（数据单列表明细统计列 `.wash-rate`，文字不足 50% 时用 `#333`） |
| 详情页灰底 / 卡片边框 / 分隔线 | `#f5f7f8` / `#e3e8ec` / `#ecf0f1`（清洗详情页：浅灰底上放白卡片，`washSheet.css` 的 `.wash-detail`） |
| 页头提示条 | 左边框 `#f5862a` + 底色 `#fef4ec`，文字 `#555`（清洗详情页 `.wash-detail-warns`，橙色只做强调边，文字不用橙色） |
| 重复值提示格 | 底色 `#fff4c2`（编码规则码表里同一列编码重复，`proCodeRule.css` 的 `.pcr-dup`） |
| debug 输出背景 | `#272822`（Monokai 暗色） |
| debug 数字/布尔/null | `#6badf7`（浅蓝） |

### 禁止事项

- 不得引入上表之外的新主色，如需扩展先在本文件添加
- 不得用纯黑 `#000` 作为正文色，用 `#333` 或 `#555`
- 渐变色只用于 Grid 结果列头，其他地方不用渐变

---

## 字体

| 属性 | 值 |
|------|----|
| 字体族 | `"Microsoft YaHei", Arial, sans-serif` |
| 正文字号 | `14px`（body 基准） |
| 最小字号 | `11pt`（dxForm 输入框，约 14.67px），**不得低于此值** |
| 弹窗字号 | `11pt`（`.dx-texteditor-input`） |
| 表单标签 | `font-weight: bold`（`.dx-field-item-label-text`） |
| 通知徽章 | `10px` |
| 标题（系统Logo） | `18px` |

---

## 布局尺寸

| 元素 | 尺寸 |
|------|------|
| 侧边栏展开宽度 | `220px` |
| 侧边栏折叠宽度 | `56px` |
| 侧边栏动画 | `transition: width 0.2s ease` |
| Header / Tab栏高度 | `56px` / `40px` |
| Tab 项高度 | `30px`，padding `0 14px` |
| 图标按钮 | `32×32px` |
| 菜单折叠动画 | `transition: max-height 0.2s ease` |

---

## DevExtreme 配置规范

### 主题

```
dx.material.blue.light.compact.min.css
```

- **不得改用其他主题**，自定义样式统一写在 `dxCustom.css`
- `dxCustom.css` 是唯一允许覆盖 DevExtreme 样式的地方

### DataGrid 标准配置（通过 dxGrid.js 封装）

- 远程操作：`filtering: true, sorting: true, paging: true, grouping: true`
- 列头可编辑标记：`dx-editable-header`（可编辑）/ `dx-editable-must-header`（必填）
- 多列头：`dx-multi-headers`
- 可点击的单元格文字（如点标题打开子页面）：`dx-cell-link`
- 主从页面限定数据范围：`params: { 主表主键: 值 }`，会附加到 grid/save/remove/export 每次请求上，
  由服务端限定范围，不要靠前端 filter 兜底（用户能清掉 filter）
- 工具条「重置」是整体重置：页面自己放在 `params` 里的查询条件（零件列表的号码批量查询、分类筛选）通过 `toolbar.onReset(dataGrid)` 一起清掉，表格随后统一刷新一次；这类条件**不在表格外面另挂「按 xx 查询: n 清除」的提示条**（2026-09-28 用户定，零件列表那块已删）
- 多对多多选列用 `dxMultiSelectColumn(dataField, displayField, caption, dataSource, valueExpr, displayExpr, tagBoxOptions?)`：第 7 个参数合并进 dxTagBox 配置，候选项多（如号码厂商 1400+）时传 `{ searchEnabled: true }`；名称串列不是真实列，要 `$.extend` 上 `allowFiltering / allowHeaderFiltering / allowSorting: false`（商品品牌、角色页做法）
- 业务自定义工具条按钮：`toolbar.custom: [{icon, text, hint, afterBuiltins, onClick(dataGrid)}]`，默认排在左侧内置按钮（刷新 / 重置…）之前，`afterBuiltins: true` 排在它们之后
- 表格相关的上传（如零件详情「图片」「文件」）不在表格上方铺上传区，放成工具条按钮、点了开弹窗做上传和进度；弹窗关掉只隐藏不销毁，免得打断正在传的文件
- **默认排序写在列上**（`sortOrder` / `sortIndex`），不要只靠 Model 的 `gridDefaultOrder()`：远程分页时如果没有任何列排序，
  DevExtreme 会自己补一个「按主键正序」发给服务端，`gridDefaultOrder()` 根本轮不到。
  `gridDefaultOrder()` 只对不经过 dxDataGrid 分页的直接调用（如 `partItemPost('/carVehicle/grid', …)`）生效。
  排序用的字段不想显示时加一列 `visible: false` 的隐藏列（如 ID、排序值），隐藏列上的 `sortOrder` 同样生效；
  多列排序最后一列带上主键，否则同值行的顺序不稳定。工具条「重置」/「恢复布局」会恢复成列上声明的默认排序（`dxGrid.js` 的 `restoreDefaultSorting()`）

### 表格高度铺满可视区

以单个 dxDataGrid 为主、顶部是功能区的页面，**页面本身不能出滚动条**，表格高度铺到可视区底部。
`createDxGrid()` 不传 `gridOptions.height` 时自动做（`dxAutoHeight()`：窗口高度 − 表格顶部 − 表格下外边距 − 各层父元素的下内边距 / 下边框 / 下外边距）：

- DevExtreme 只在初始化时算一次 height，之后 resize、`updateDimensions()` 都不重算，所以 `createDxGrid()` 在下一轮事件循环、每次 `contentReady`、窗口 resize（含后台标签页 iframe 切回显示）时重算并写回，值没变不写
- 页面上方内容可以在 `createDxGrid()` 之后再渲染（零件列表的顶部按钮条就是这样），不用自己补高度
- 表格下方不要再放别的内容；要放就自己传 `gridOptions.height`，自动高度随之关闭

### 分页器

`createDxGrid()` 默认用紧凑分页（`pager.displayMode: 'compact'`）：每页条数下拉（10 / 20 / 30 / 40 / 50 / 100 / 200 / 300 / 500 / 1000，默认 20）+「第 x 页，共 y 页 (n 个项目)」+ 输入页码跳转 + 上下页箭头，不铺一排页码按钮。个别页面要不同的条数选项就在 `gridOptions.pager` / `gridOptions.paging` 里覆盖

### 单元格模板动态改高度

开了 `columnFixing` 之后，固定列（勾选框、操作列）和内容区是**两张独立的 table**，
DevExtreme 只在渲染和 resize 时对齐两边行高。单元格模板在渲染之后自己改高度
（展开/收起、异步插内容）会让两边错位——左边勾选框和右边内容对不上。

`createDxGrid()` 已经在点击后自动补一次对齐（`dxSyncRowHeights()`），**模板里不用写任何东西**。
不走 `createDxGrid()` 的场景，改完高度后自己调一次 `dxSyncRowHeights(dataGrid, selector)`。

不要用 `ResizeObserver` / `requestAnimationFrame` 做这件事：后台标签页的 iframe 处于
`display:none`，不渲染的文档里这两者不派发回调，切回来就已经错位了。

修的永远是 `dxGrid.js`，不是 DevExtreme 源码（见根契约「第三方代码不可改」）。

### 后台顶部标签：鼠标拖动排序

`adminLayout.js` 的多标签支持按住标签左右拖动换位置（2026-09-22）。

- **用 mousedown / mousemove / mouseup 自己做，不用 HTML5 拖放**：内容区是 iframe，原生拖放的事件会掉进 iframe 里收不到
- 拖动中给 `body` 加 `.tabs-dragging`，靠 `pointer-events: none` 盖住 iframe，鼠标滑到内容区上也收得到事件
- 位移不到 4px 不算拖动，正常点击切换标签不受影响；拖完那一下的 click 不当成切换
- 首页标签既不能拖，也不能被插到它前面
- 拖完要把 `tabs` 数组按 DOM 顺序同步回来，「关闭左侧 / 右侧选项卡」这类按顺序算的操作才不会错

标签标题可能是业务数据（数据单标题、零件号），**必须截断**，否则一个标签就能把别的标签挤出可视区
（实测 44 字的数据单标题撑到 727px）：

- 截断写在 CSS（`.tab-title` 的 `max-width` + `text-overflow: ellipsis`），非当前标签 160px、当前标签 240px，
  **不要在 JS 里按字符数截**——中英文宽度差一倍，按字数截出来的标签宽度参差不齐
- 完整标题挂在 `.tab-item` 的 `title` 属性上，鼠标悬停能看全
- 只截显示：存进 `localStorage` 的标题保持完整，整页刷新 / 切换语言后要用它重开标签

### 多分页详情页：一个保存按钮 + 未保存圆点

零件详情这类「页头 + dxTabPanel」的详情页，**不给每个分页各放一个保存按钮**——
用户改了两个分页只点了一个保存，另一个就悄悄丢了。约定：

- 保存按钮只放页头一个，管所有分页；同时绑 `Ctrl/Cmd + S`
- 各分页把自己的改动打上脏标记（`markDirty('key')`），页头按钮在没有脏标记时禁用
- 程序回填控件值（初始加载、保存后刷新）要包在「不记脏」的开关里，否则一进页面就显示有改动
- 保存时先把所有脏表单校验一遍，不合法就切到那个分页再提示，不要保存到一半才报错
- **分页标题上不加未保存标记**（原来是个橙色圆点，2026-09-21 去掉）：DevExtreme 的 `.dx-tab-text` 是纵向 flex 容器，
  挂上去的角标不管用行内还是绝对定位都会把标签顶歪；有没有改动看页头保存按钮是否可用即可
- 行内编辑的表格（dxDataGrid）自己管保存，不进这套脏标记

只读态的禁用也不要写成一长串 `if (xxx) { xxx.option('disabled', ...) }`：
各分页渲染时登记一个 `apply(editable)` 回调，状态变化时统一遍历执行。

**记住用户最后选的分页**（2026-09-22，零件详情）：刷新页面、打开另一个零件都停在上次那个分页，直到用户自己换。
- 存 `localStorage`（键 `partItemDetailActiveTab`），**存分页的 `key`，不存下标**——分页增删、调顺序不会停错；记的 key 不存在了就回到第一个分页
- 读写都包 `try/catch`，存储不可用时就从第一个分页开始，不报错
- 只记用户自己点的：保存校验不过、程序跳到出错分页（`selectTab()`）那一下不记
- 各分页 `deferRendering`，初始停在别的分页时「基本信息」等还没建，相关代码要允许这些实例为 `null`

### 零件分类选择：统一用弹窗选择器

分类有几百个末级项，下拉（dxSelectBox / dxDropDownBox）要翻很久。**凡是选零件分类的地方一律用
`lib/partCategoryPicker.js` 的 `createPartCategoryPicker($host, options)`**，不要再写分类下拉或分类树下拉：

- 表现：只读输入框（显示完整路径 `大类 / 中类 / 小类`），点一下开弹窗
- 弹窗两个分页：「选择分类」按层级把分类全铺开打钩（大类一块、中类一行、末级横排），「查询分类」输关键词找，结果带完整路径
- `multiple: true` 值是 ID 数组，多选时多一个「全选/全不选」和已选计数；单选是 radio
- `leafOnly: true`（默认）只有末级能选、上级的打钩框是「整块全选」；`leafOnly: false` 时任意一级都能选
- `cascade: true`（默认 false，只对 `leafOnly: false` 的多选有意义）勾一个分类连它下面的全勾上、取消全取消，
  上级按「下面是不是全勾了」自动打钩 / 半选。零件列表按分类筛选用它；
  **统计页（中国车型统计）不能开**——那里勾一个大类是「加一列覆盖率」，级联下去会变成几十列
- 文案统一由 `view/public/partCategoryPickerText.view.php` 注入（它同时引入对应的 css/js），页面只要 `viewLoad('public/partCategoryPickerText')`

放进 `dxForm` 时它是**模板项**（`template`），所以：`validationRules` 和 `readOnly` 都管不到它——
必填要在保存前自己判（`picker.getValue()` 空就 `picker.markInvalid(...)`），标签上的星号用 `isRequired: true`，
只读态用 `picker.setDisabled(true)`，值改了要自己写回 `form.option('formData')`。

### 零件列表页：顶部功能按钮 + 左侧分类面板

页面上下两块：表格区顶上单独一行放功能按钮（`#partTopBar` 里的 `#partToolbar`，dxToolbar），左边是可开关的分类面板。

- **功能按钮单独成一行**：分类 / 号码批量查询 / 批量导入图片 / 批量审核 / 批量修改。
  表格自己的工具条只剩通用按钮（刷新、列选择器……）——**列表页没有「新增」**，零件只能从数据单转换（`washSheetItem/toPart`）。
  按钮的 `onClick` 要把表格实例传进去（`openXxxPopup(grid)`），和它们还在表格工具条里时拿到的 `dataGrid` 是同一个
- **分类面板默认隐藏**（`#partCategoryPanel` 初始就带 `collapsed`，`display: none`），顶部「分类」按钮开关它。
  开关后必须调 `grid.updateDimensions()` —— dxDataGrid 不会自己发现可用宽度变了
- 面板里是「全部分类 + 多选」一行、多选结果、分类树
- **多选**：复用 `createPartCategoryPicker`（`multiple: true`、`leafOnly: false`，口径和点分类树一致，选了大类就含下级），
  宿主输入框藏起来（`#partCategoryPickerHost { display: none }`，dxTextBox 自带的 display 压得过 `hidden` 属性），只借它的弹窗
- **单选（树）和多选（弹窗）互斥**：一边有值就把另一边清掉。树上标不出多选的结果，所以选中的分类在面板上列成一排小标签，每个可单独去掉
- 多选开了 `cascade`，勾大类会连下级一起勾上；面板上的小标签**只列没有被上级覆盖的那些**（上级已经在里面了，下级再列一遍是重复），
  去掉一条时连它下面的一起去掉
- 请求参数：单选是 `categoryId`，多选是 `categoryIds`（见 API.md）。
  **顶部的关键词搜索框 2026-09-22 按用户要求去掉了**，服务端的 `keyword`（全文检索）还在，只是列表页不再传

### 工业风紧凑表单皮肤 `.mdm-industrial`

录入密度高的业务表单（第一处是零件详情的「基本信息」）不用 material 默认的填充式输入，
改挂 `.mdm-industrial`（定义在 `dxCustom.css`）：白底输入框、1px 实线边框 + 3px 小圆角、无下划线和焦点动画、
表单行距 2px、输入区上下留白 1px、控件统一 22px 高、标签右对齐。

压行高要连着压四处，少一处就前功尽弃：material 给非首行的 20px 上边距（选择器得照抄它那串 `:not()`，权重不够压不住）、每个 field item 的 10px 下边距、`.dx-button` 自带的 `height: 28px`、按钮里行内图标撑出来的行框（`.dx-button-content` 要 `line-height: 0`）。

- **按容器 class 生效**，不挂就是 DevExtreme 原样，所以能一个页面一个页面地换，不会牵动别的模块
- 字号不变，仍是 11pt（上面的最小字号），紧凑只靠留白和行高做
- 页面自己的分区排版（段落标题、分组行距）写在该页的 css 里，不要塞进这套皮肤

### 弹窗宽度约定（三档）

| 档位 | 宽度 | 适用场景 |
|------|------|---------|
| 小 | `400px` | 确认框、简单表单 |
| 中 | `700px` | 标准编辑弹窗 |
| 大 | `1000px` | 复杂表单、多字段编辑 |

---

## JS 全局变量

页面通过 `window.APP` 注入，所有 JS 文件只从这里读配置：

```js
window.APP = {
    HOST,          // 站点根路径，如 http://127.0.0.1:8087/mdm/pdc/admin
    STATIC_URL,    // 静态资源根路径
    LOCALE,        // 当前语言，如 'zh-CN'
    GRID_TEXT,     // Grid 工具栏通用文案（由 PHP lg() 翻译后注入）
}
```

- JS 文件里**不直接调用 `lg()`**，文案统一由 PHP 端翻译后通过 `APP.GRID_TEXT` 传入
- 新增 Grid 工具栏文案必须同时在 `header.view.php` 的 `GRID_TEXT` 和语言包里添加

---

## 静态资源引用

视图里引用 `static/` 下的 js/css **一律用 `StaticAsset::url()`**（`pdc/model/StaticAsset.php`），不要直接拼 `Mvc::$cfg['staticUrl']`：

```php
<script src="<?=StaticAsset::url('/js/lib/dxGrid.js')?>"></script>
<link rel="stylesheet" href="<?=StaticAsset::url('/css/partItem.css')?>">
```

它会在网址后面加 `?v=文件修改时间`，文件一改网址就变，浏览器不会继续用缓存里的旧版本（2026-09-23 前改完 dxGrid.js 要 Ctrl+F5 才生效）。
`window.APP.STATIC_URL` 仍是不带版本号的前缀，JS 里如果要动态加载文件，要自己考虑缓存问题。

---

## CSS 书写规范

- 自定义样式写在 `admin/static/css/` 下，不内联在 HTML
- 类名语义化，不用 `div1`、`wrap2` 等无意义名称
- 布局用 Flexbox，不用 float
- 不引入 Bootstrap 或其他 CSS 框架（已有 DevExtreme + 自定义 CSS 足够）
