Files
shanxia2.0/openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md
T
2026-08-23 09:47:38 +08:00

1670 lines
107 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## ADDED Requirements
### Requirement: Web 管理端以 Fresh Mint 规范为唯一视觉权威
`web-admin` 工程的界面实现 SHALL 以 `docs/web-admin-ui-spec-fresh-mint.md` 及其中引用的 `docs/fresh-mint-green.html` 为权威来源;主色链 SHALL 使用文档定义的 Mint 绿系(如 `--primary` `#10b981` 等),不得擅自替换为小程序品牌色。
#### Scenario: 新增列表页
- **WHEN** 在 `web-admin` 新增标准列表/表单页
- **THEN** 页面结构 SHALL 对齐规范中的布局顺序(筛选 → 工具栏 → 列表 → 分页等)及模板页(如 `#/demo/unit`、`#/demo/order`)所体现的模式
### Requirement: 小程序以三墙 Demo 体系为唯一视觉权威
`miniprogram` 工程的界面 SHALL 以 `docs/miniprogram-ui-spec-sanqiang.md` 及其中列出的 `mobile-ui/sanqiang-*.html` 为权威来源;品牌绿、大麦黄与中性色 SHALL 采用规范 §2 Token,不得直接复用 Web 端 Fresh Mint 色板作为主色。
#### Scenario: 新增小程序页面
- **WHEN** 新增客户端/供应商/配送端页面
- **THEN** 页面 SHALL 遵循规范中的统一骨架(Header 渐变、卡片圆角节奏、底部固定区等)及状态标签语义色
### Requirement: 禁止跨端调色板混用
除非产品书面要求,Web 管理端 SHALL NOT 使用小程序规范中的 `#0a9f6d` 等作为主色;小程序 SHALL NOT 使用 `#10b981` 作为主色替代品牌绿。
#### Scenario: Code review
- **WHEN** PR 修改全局主题或主按钮色
- **THEN** 审查者 SHALL 对照对应端 UI 规范文档中的 Token 表拒绝跨端拷贝色值
### Requirement: 树形主数据展示规则
涉及树形主数据(分类、库位、组织等)时,Web 端 SHALL 遵循:下拉/树节点展示**本级名称**,选中后在输入框、表格、导出与查看弹窗中展示**全路径**;路径分隔符 SHALL 与工程约定一致(当前为 ` / `,见 `web-admin/src/utils/tree-path.ts` 与规范 §5.6)。
#### Scenario: 树选择与表格列
- **WHEN** 用户在树形选择器中选择末级节点
- **THEN** 表格与只读视图 SHALL 展示完整祖先路径而非仅末级名称
### Requirement: 弹窗与遮罩
Web 管理端弹窗 SHALL 遵循规范:表单 `label-position="top"`;遮罩 SHALL NOT 使用背景模糊(`backdrop-filter: none` 等与仓库既有策略一致)。
#### Scenario: 新增编辑弹窗
- **WHEN** 使用 Element Plus `el-dialog` 承载表单
- **THEN** 表单标签位置与遮罩样式 SHALL 符合 `docs/web-admin-ui-spec-fresh-mint.md` 中 `el-dialog` 小节
### Requirement: 小程序媒体与触控
小程序列表缩略图、轮播与详情图 SHALL 遵循 `docs/miniprogram-ui-spec-sanqiang.md` §7.0 的宽高比与 `mode` 约定;可点击控件 SHALL 满足最小触控区域(规范中的 44×44 等要求)。
#### Scenario: 商品列表缩略图
- **WHEN** 展示商品列表缩略图
- **THEN** 图片区域 SHALL 为 1:1 且使用 `aspectFill`(与规范 §7.0.1 一致)
### Requirement: 页面不展示顶部 page-head 大标题与描述
`web-admin` 所有左侧菜单对应的页面 **SHALL NOT** 包含顶部 `<header class="page-head">`(或 `<div class="page-head">`)区块,即不在页面顶部展示 `<h1>` 大标题和 `<p class="page-desc">` 描述文字。页面内容 SHALL 直接从 `<section class="panel">`(或 `<div class="panel">`)开始。
**理由**:左侧菜单已提供当前页面名称,顶部大标题和描述属于冗余信息,移除后页面更简洁、内容区更大。
#### Scenario: 新增菜单页面无 page-head
- **WHEN** 新增一个左侧菜单对应的页面
- **THEN** 该页面 SHALL NOT 包含 `<header class="page-head">` 或 `<div class="page-head">` 元素
- **THEN** 页面模板 SHALL 直接以 `<section class="panel">` 或 `<div class="panel">` 作为第一个子内容块
#### Scenario: Code review 检查 page-head
- **WHEN** PR 包含新增或修改的页面文件(`src/views/**/*.vue`)
- **THEN** 审查者 SHALL 确认该页面不包含 `class="page-head"` 元素
### Requirement: 列表面板主标题装饰
`web-admin` 中列表面板 `panel-top` 下的主标题(`h2.title-bar-heading`)SHALL 在标题文案前展示 **主色竖条**(与全局 `app.scss` 中 `h2.title-bar-heading::before` 一致);含「租户」「用户」等易歧义文案的标题 SHALL 通过 `App.vue` 中 `panelTitleBarTextRules` 显式纳入竖条样式,避免子串误匹配为其它图标。
**重要**:`App.vue` 中 `panelTitleBarTextRules` 是标题竖条样式的唯一触发入口。新增任何包含 `panel-top > h2` 标题的页面时,开发者 SHALL 将该标题文案追加到 `panelTitleBarTextRules` 正则表达式的 `|` 分隔列表中。遗漏将导致标题无绿色竖条而显示为无样式或错误图标。
#### Scenario: 租户列表标题
- **WHEN** 打开 `#/system/tenant` 租户管理页
- **THEN** 「租户列表」标题 SHALL 显示绿色竖条装饰,且 SHALL NOT 因「租户」含「用户」子串而显示用户类图标
#### Scenario: 新增页面标题竖条
- **WHEN** 新增包含 `panel-top > h2` 标题的列表页(如标题为"XXX列表")
- **THEN** 开发者 SHALL 在 `App.vue` 的 `panelTitleBarTextRules` 正则中追加该标题文案(用 `|` 分隔)
- **THEN** 标题 SHALL 显示绿色竖条装饰
#### Scenario: Code review 新页面标题
- **WHEN** PR 包含新增页面的 `panel-top > h2` 标题
- **THEN** 审查者 SHALL 确认 `App.vue` 的 `panelTitleBarTextRules` 已同步更新
### Requirement: 弹窗与面板内子区域标题装饰
`web-admin` 中弹窗(`el-dialog`)和面板内的子区域标题(如「选择部门」「已选用户」「基本信息」「参数配置」等区块标题)SHALL 在文字左侧展示主色竖条装饰(`border-left: 3px solid #10b981`,`padding-left: 10px`),与列表面板主标题的竖条风格保持一致。子区域标题与字段标签(`el-form-item` label 或 `dt`)SHALL 形成明显视觉区分:子区域标题使用深色文字 + 竖条,字段标签使用辅助灰色、无竖条。
此规则适用于以下场景的区块标题:
1. **弹窗内分组标题**:如编辑弹窗中的「基本信息」「参数配置」等将表单字段分组的标题。
2. **多栏选择器的列标题**:如「选择部门」「选择用户」等 `data-scope-col-title` 类标题。
3. **已选摘要的分组标题**:如「已选部门」「已选用户」等 `data-scope-summary-heading` 类标题。
4. **查看弹窗的区块标题**:`SystemViewDialog` 的 section title(`h3`)。
5. **设计器/工具面板的分组标题**:如「节点类型」「属性」等 `panel-section-title` 类标题。
#### Scenario: 新增弹窗包含子区域分组
- **WHEN** 新增编辑弹窗内使用 `section-title`、`col-title`、`summary-heading` 等类名作为区块标题
- **THEN** 标题 SHALL 使用 `border-left: 3px solid #10b981` + `padding-left: 10px` 展示主色竖条
#### Scenario: Code review 弹窗子区域标题
- **WHEN** PR 包含弹窗内新增或修改的区块标题样式
- **THEN** 审查者 SHALL 确认标题包含主色竖条装饰,且与字段标签有明确视觉区分
### Requirement: 新增与编辑弹窗布局与视口留白
使用 Element Plus `el-dialog` 承载新增/编辑表单时,系统 SHALL 满足:
1. **水平栅格**:默认使用 **两列** 栅格(`.form-grid-2`,`grid-template-columns: repeat(2, minmax(0, 1fr))`,`gap: 12px 20px`);字段较多时 SHALL 通过加宽 `width` 并选用 `.form-grid-3`、`.form-grid-4` 或 `.form-grid-5` 扩展列数,**列数上限为 5**;跨整行长字段 SHALL 使用 `.form-col-span-2`(`grid-column: 1 / -1`)。
2. **窄屏回退**:视口或表单区过窄时,栅格 SHALL 回退为更少列或单列(与全局 `app.scss` 中 `@media (max-width: 900px)`、`(max-width: 560px)` 规则一致)。
3. **定位**:弹窗 SHALL 相对视口水平与垂直居中(依赖 `ElConfigProvider` 的 `dialog.alignCenter` 及 Element Plus 默认遮罩层居中行为)。
4. **高度与滚动**:使用 `.dialog-form-layout` 类名的弹窗壳层 SHALL `max-height: calc(100vh - 100px)`,保证相对视口上下各 **至少约 50px** 可视留白;弹窗 SHALL NOT 以固定高度占满视口;当内容超出时 **仅** `.el-dialog__body` 区域出现纵向滚动条,页头与底栏固定不滚动。
#### Scenario: 租户新增弹窗
- **WHEN** 在租户管理页打开新增或编辑租户弹窗
- **THEN** 表单 SHALL 以两列栅格展示常规字段、备注跨两列;弹窗 SHALL 带 `dialog-form-layout` 且正文区在内容过多时可滚动
#### Scenario: 高字段数弹窗
- **WHEN** 某业务弹窗字段超过两列舒适排布
- **THEN** 实现 SHALL 增加 `el-dialog` 的 `width` 并选用 `form-grid-3` 至 `form-grid-5` 之一,且 SHALL NOT 使用超过 5 列的栅格类
### Requirement: 图标组件使用 Element Plus 图标
`web-admin` 中的图标 SHALL 优先使用 Element Plus 内置图标(`@element-plus/icons-vue`),通过 `ArtSvgIcon` 组件的 EP 图标映射(`src/utils/ui/ep-icon-map.ts`)渲染;SHALL NOT 在新增代码中直接使用 Iconify 远程图标名(如 `ri:xxx`、`fluent:xxx`)。仅在 Element Plus 无等价图标时,方可保留 Iconify 图标名作为例外。
#### Scenario: 新增菜单路由
- **WHEN** 在 `src/router/modules/` 中新增路由并设置 `meta.icon`
- **THEN** `icon` 值 SHALL 使用 `ep-icon-map.ts` 中已注册的名称(如 `setting`、`grid`),SHALL NOT 使用 `ri:` 前缀的 Iconify 名
### Requirement: Web 管理端侧栏菜单须同步注册静态路由
在默认 **`VITE_ACCESS_MODE=frontend`** 下,侧栏菜单与可访问路由来自 `web-admin/src/router/modules/sqxp.ts`(`asyncRoutes`),**不是**仅写入数据库 `sys_menu` 即可展示。完整注册流程、字段对齐、超管/非超管可见性及 `sys_menu` + `sqxp.ts` + `backend/app/api/menu.py` 三件套同步规则,SHALL 以 `permission-conventions` 中 **「Web 管理端菜单注册与侧栏展示(三件套同步)」** 为准。
#### Scenario: 新增 CRM 类侧栏菜单项
- **WHEN** 为 CRM 等业务模块新增「客户管理」「公海池」等侧栏入口
- **THEN** 除 `docs/sql/*-menu.sql` 外,SHALL 在 `sqxp.ts` 的父级 `children` 与 `menu.py` 的 `STATIC_MENU_TREE` 同步追加节点;`meta.menuCode` SHALL 等于 `sys_menu.perms`;子路由 `path` SHALL 为相对路径(如 `customer`、`customer-pool`)
#### Scenario: 菜单变更 PR 评审(UI 视角)
- **WHEN** PR 仅包含 `docs/sql` 菜单脚本而无 `sqxp.ts` 变更
- **THEN** 在 frontend 模式下 SHALL 判定为不完整,要求补全静态路由后再合并
#### Scenario: 新增界面图标
- **WHEN** 在页面或组件中新增图标
- **THEN** SHALL 使用 `<ArtSvgIcon icon="ep-icon-name" />` 或 `<el-icon><XxxIcon /></el-icon>`,SHALL NOT 使用 `<ArtSvgIcon icon="ri:xxx" />` 除非 Element Plus 无等价图标
#### Scenario: 新增 Element Plus 图标
- **WHEN** Element Plus 提供了新的所需图标但 `ep-icon-map.ts` 尚未注册
- **THEN** 开发者 SHALL 先在 `ep-icon-map.ts` 中注册映射条目,再在业务代码中使用(参见反例 **EC-UI-002**)
### Requirement: 表格操作列固定在右侧
`web-admin` 中**保留操作列**的业务列表页(系统管理、业务模块等,不含 Demo 参考页)的 `el-table` 操作列 SHALL 使用 `fixed="right"` 固定在表格右侧。宽度规则:2 个按钮时 `min-width="260"`,3 个按钮时 `min-width="320"`,4 个及以上按钮时 `min-width="400"`。全局 CSS 通过 `.el-table-fixed-column--right` 选择器(注意:Element Plus 使用单短横线,非双下划线)提供固定列样式。
**例外**:遵循下文「列表主标识列链接查看与编辑删除外置」、且列表动作仅为常规「编辑」「删除」的标准 CRUD 页 **SHALL NOT** 设置操作列,此时本规则不适用。
#### Scenario: 新增系统管理列表页
- **WHEN** 在系统管理模块下新增列表页面(如用户管理、角色管理等),且页面保留操作列
- **THEN** 表格操作列 SHALL 设置 `fixed="right"`,宽度按按钮数设置:≤ 2 按钮用 `min-width="260"`,3 按钮用 `min-width="320"`,≥ 4 按钮用 `min-width="400"`
#### Scenario: 标准 CRUD 列表无外置操作列
- **WHEN** 新增列表页仅含常规「编辑」「删除」,并采用工具栏 + 查看弹窗 Hero 外置模式
- **THEN** 表格 **SHALL NOT** 包含 `label="操作"` 列
- **THEN** 本「操作列固定右侧」规则 **SHALL NOT** 适用
#### Scenario: Code review 表格列
- **WHEN** PR 包含新增或修改的 `el-table-column` 且 `label="操作"`
- **THEN** 审查者 SHALL 确认该列包含 `fixed="right"` 属性,且宽度与按钮数量匹配(2 按钮 260px,3+ 按钮 320px)
- **THEN** 若页面属于标准 CRUD + 查看弹窗且仅编辑/删除,审查者 SHALL 要求移除外置至工具栏与 `#hero-actions`,而非保留操作列
### Requirement: 表格列宽使用 min-width 实现等比缩放
`web-admin` 中 `el-table` 的**业务数据列与操作列**的列宽属性 SHALL 使用 `min-width` 替代 `width`,使浏览器视口宽度变化时这些列按基础比例等比伸缩。
**原理**:Element Plus 的布局逻辑是,所有 `min-width` 列按各自 `min-width` 值的比例分配容器可用宽度;当容器宽度小于所有 `min-width` 之和时,触发横向滚动。若部分列使用 `width`(固定宽度),则多余空间全部由 `min-width` 列吸收,导致视口加宽时只有 `min-width` 列变宽而 `width` 列不变。
**规则**:
1. 业务数据列、操作列 SHALL 使用 `min-width` 设定基础宽度,SHALL NOT 使用 `width`。
2. `min-width` 值即为该列的最小可读宽度,也是等比分配的权重基数。
3. `el-table` 本身的 `width` 属性(如弹窗内嵌表格的固定总宽)不受此规则约束。
4. **例外(固定窄列)**:`type="selection"`(多选框)与 `type="index"`(序号)列 SHALL 使用固定 `width`(建议多选约 `48`~`55`、序号约 `60`~`68`),SHALL NOT 使用 `min-width`,避免宽屏下被等比拉得过宽。公共组件 `ListIndexColumn` SHALL 遵循同一例外。
#### Scenario: 新增列表页
- **WHEN** 新增列表页包含 `el-table`
- **THEN** 业务数据列与操作列的宽度属性 SHALL 使用 `min-width`,SHALL NOT 使用 `width`;多选列与序号列 SHALL 使用固定 `width`
#### Scenario: 浏览器窗口宽度变化
- **WHEN** 用户调整浏览器窗口宽度
- **THEN** 业务数据列与操作列宽度 SHALL 按 `min-width` 设定的基础比例等比伸缩;多选列与序号列宽度 SHALL 保持固定,不随视口加宽而膨胀
#### Scenario: Code review 表格列宽
- **WHEN** PR 包含新增或修改的业务数据列 / 操作列 `el-table-column` 且使用 `width` 属性
- **THEN** 审查者 SHALL 要求改为 `min-width`
#### Scenario: Code review 多选与序号列宽
- **WHEN** PR 包含 `type="selection"` 或 `type="index"` 的 `el-table-column` 且使用 `min-width`
- **THEN** 审查者 SHALL 要求改为固定 `width`
### Requirement: 侧边栏菜单图标层级规则
`web-admin` 左侧菜单区域的图标展示 SHALL 遵循以下层级规则:
1. **一级菜单**(顶层菜单项)SHALL 在菜单名称前展示图标(`meta.icon`)。
2. **二级及以下菜单**(子菜单项)SHALL NOT 展示图标,仅展示文字名称。
3. 双列菜单模式(`DualMenu`)左侧一级图标栏 SHALL 为每个一级菜单项渲染图标,与侧边栏一级规则一致。
4. 水平菜单(`HorizontalMenu`)和混合菜单(`MixedMenu`)如使用图标,SHALL 与侧边栏保持相同的层级规则。
#### Scenario: 侧边栏一级菜单图标
- **WHEN** 用户打开 `web-admin` 主页,查看左侧菜单区域的一级菜单项
- **THEN** 每个一级菜单项 SHALL 在文字名称左侧展示其 `meta.icon` 对应的图标
#### Scenario: 侧边栏二级菜单无图标
- **WHEN** 用户展开某个一级菜单,查看其下的二级(及更深层级)菜单项
- **THEN** 二级及以下菜单项 SHALL NOT 展示任何图标,仅显示菜单文字名称
#### Scenario: Code review 菜单图标
- **WHEN** PR 修改了 `SidebarSubmenu.vue` 或其他菜单组件中图标渲染逻辑
- **THEN** 审查者 SHALL 确认 `level === 0` 条件判断保持不变,仅一级菜单渲染图标
### Requirement: 标签页(WorkTab)不展示图标
`web-admin` 顶部标签页区域(WorkTab strip)的每个标签 SHALL 仅展示文字标题,SHALL NOT 展示图标。路由 `meta.icon` 仅用于侧边栏菜单,SHALL NOT 传递到标签页渲染。
#### Scenario: 标签页仅显示文字
- **WHEN** 用户打开任意页面,顶部标签页区域出现对应标签
- **THEN** 标签 SHALL 仅展示页面标题文字,SHALL NOT 在标题前展示任何图标
#### Scenario: Code review 标签页图标
- **WHEN** PR 修改了 `art-work-tab/index.vue` 或 `utils/navigation/worktab.ts`
- **THEN** 审查者 SHALL 确认标签页组件不渲染图标,且 `setWorktab` 不向 `openTab` 传递 `icon` 字段
### Requirement: 表单字段注释格式
`web-admin` 中弹窗表单(`el-form-item`)如需对某字段添加辅助说明文字(如"数值越小越靠前"),该注释 SHALL 以小字形式直接显示在 label 文字后方,SHALL NOT 放置在输入控件旁边或下方。实现方式为使用 `el-form-item` 的 `#label` 插槽,在 label 文字后追加 `<span class="form-label-tip">注释内容</span>`。样式类 `form-label-tip` 定义在全局 `app.scss` 中(字号 12px、`font-weight: 400`、使用 `--mint-text-muted` 颜色)。
#### Scenario: 排序字段注释
- **WHEN** 弹窗表单中包含排序字段(`orderNum`)
- **THEN** label SHALL 显示为"排序",其后紧跟小字注释"数值越小越靠前",使用 `.form-label-tip` 样式类,SHALL NOT 使用 `.sort-field` + `.form-tip` 的旧模式
#### Scenario: 新增带注释的表单字段
- **WHEN** 新增弹窗表单中的某字段需要辅助说明
- **THEN** SHALL 使用 `<template #label>字段名<span class="form-label-tip">注释内容</span></template>` 格式,SHALL NOT 将注释放在输入控件旁
### Requirement: 列表主标识列链接查看与编辑删除外置
`web-admin` 中带只读查看弹窗(或等价只读详情态)的 `el-table` 列表页 SHALL 遵循:
1. **主标识列链接**:承载业务主标识的列(通常为「名称」「单号」「编号」「主题」等,如 `productName`、`customerName`、`orderNo`、`applyCode`、`planNo`)SHALL 使用 `<el-link type="primary">` 包裹展示文案,点击 SHALL 调用打开只读详情的同一函数(如 `handleView(row)`、`openView(row)`、`handleDetail(row)` 等,与 `@row-dblclick` 共用)。
2. **操作列去重(查看)**:当主标识列已提供上述链接查看入口时,操作列 **SHALL NOT** 再包含仅用于打开同一只读详情/查看弹窗的「查看」「查看详情」等按钮。
3. **编辑/删除外置(标准 CRUD)**:对于列表行级动作**仅含**常规「编辑」「删除」(及同类 CRUD,无审批、状态变更、安排运输等行内复合业务按钮)的标准列表页,表格 **SHALL NOT** 设置「操作」列;「编辑」「删除」SHALL 外置至以下两处:
- **列表工具栏**:位于「已选 N 项」与「新增」主按钮(`type="primary"` / `btn-main`)之后;**编辑** SHALL 在 `selectedRows.length === 1` 时可用,点击 SHALL 对选中行调用与行内编辑相同的 handler(如 `handleUpdate(selectedRows[0])`);**删除** SHALL 支持批量删除,在 `selectedRows.length === 0` 时禁用,且 SHALL 复用既有创建人校验(如 `allRowsDeletableByCreator` / `canDeleteByCreator` + `CREATOR_DELETE_TIP`);两按钮 SHALL 使用 `btn-ghost` 描边样式,**SHALL NOT** 与「新增」同列使用主色实心。
- **查看弹窗 Hero**:通过 `SystemViewDialog` 的 `#hero-actions` 插槽,在 Hero 区右侧展示「编辑」「删除」,对**当前查看行**执行与列表相同的 handler;删除成功后 SHALL 关闭查看弹窗并刷新列表;从详情进入编辑并保存成功后,若查看弹窗仍打开,SHALL 刷新当前详情数据。
4. **操作列保留场景**:存在审批、状态变更、安排运输、复合主操作等**行级、状态依赖**业务按钮的列表页 **SHALL** 保留操作列,并仍遵循「表格操作列固定在右侧」;此类页面 MAY 在 `#hero-actions` 放置通用动作,但 SHOULD 避免与操作列完全重复同一入口。
5. **行双击**:`el-table` SHALL 继续绑定 `@row-dblclick`,双击行 SHALL 触发与主标识列链接相同的查看函数。
6. **条件按钮场景**:当操作列按钮随状态/Tab 变化(如待确认 Tab 显示「确认收款」,已确认 Tab 显示「查看确认信息」),`@row-dblclick` handler SHALL 仅在「查看」类按钮可见时触发对应查看函数,其余状态下可忽略或调用对应主操作函数。
7. **复合主操作例外**:主操作本身为复合业务入口、不仅打开只读弹窗的按钮(如订单「查看/运营」、待办「审批」)SHALL NOT 因本规则被删除;此时仍 SHALL 在主标识列提供链接以打开只读查看/详情。
8. **工具栏查看**:列表工具栏中的「查看」类按钮在表格主标识列已提供链接时 **SHALL NOT** 保留;单选后查看 SHALL 改由点击行内主标识链接或双击行触发。
9. **弹窗组件与字段**:查看弹窗 SHALL 优先使用 `SystemViewDialog`(`web-admin/src/components/business/system-view-dialog/index.vue`);样式对齐 Hero 渐变头图 + 分区 KV 信息区 + 底部「关闭」按钮(详见下文「查看弹窗 KV 布局与组件选型」)。KV 信息区 SHALL 使用 `ViewKvGrid`(上下堆叠栅格)或 `ViewKvTable`(多组多列表格);**SHALL NOT** 使用 `el-descriptions` 或历史 `.dlg-kv` 布局承载列表行查看详情。查看弹窗 SHALL 展示该行记录的所有业务字段(含状态映射、字典翻译),不展示 `id`、`del_flag` 等技术字段。
10. **区块标题样式**:查看弹窗中各区块标题 SHALL 与字段标签形成明显视觉区分。区块标题 SHALL 使用深色文字(`#1e293b`)配合左侧主色竖条装饰(`border-left: 3px solid #10b981`),字号 14px、`font-weight: 600`;字段标签 SHALL 为 13px、`#64748b`、`font-weight: 400`。
权威参考:
- 列表无外置操作列 + 工具栏编辑/删除:`web-admin/src/views/system/user/index.vue`
- 主标识列链接 + 工具栏编辑/删除 + 查看弹窗 Hero 编辑/删除:`web-admin/src/views/crm/product/index.vue`
- 查看弹窗 Hero 动作区:`web-admin/src/views/crm/customer/CustomerDetailDialog.vue`(`#hero-actions`)
#### Scenario: 新增标准 CRUD 列表页
- **WHEN** 在 `web-admin` 新增带只读查看弹窗的标准 CRUD 列表页,且行级动作仅为编辑/删除
- **THEN** 主标识列 SHALL 使用 `el-link type="primary"` 触发 `handleView`
- **THEN** 表格 **SHALL NOT** 包含「操作」列
- **THEN** 工具栏 SHALL 在「新增」之后提供「编辑」(单选可用)与「删除」(批量可用)按钮
- **THEN** `SystemViewDialog` SHALL 通过 `#hero-actions` 提供「编辑」「删除」
- **THEN** `el-table` SHALL 绑定 `@row-dblclick="handleView"`(或等价 handler)
#### Scenario: 现有页面补充查看弹窗
- **WHEN** 为现有列表页补充查看弹窗,且行级动作仅为编辑/删除
- **THEN** SHALL 在主标识列添加 `el-link` 与 `@row-dblclick` 双击事件,两者调用同一个查看函数
- **THEN** SHALL NOT 在操作列再添加「查看」按钮
- **THEN** SHOULD 移除「操作」列,将「编辑」「删除」迁移至工具栏与 `#hero-actions`
#### Scenario: 保留操作列的复杂业务列表
- **WHEN** 列表页存在审批、状态变更、安排运输等行级状态依赖按钮
- **THEN** **SHALL** 保留操作列并设置 `fixed="right"`
- **THEN** 主标识列仍 SHALL 提供 `el-link` 查看入口,操作列 **SHALL NOT** 包含冗余「查看」按钮
#### Scenario: Code review 列表查看与 CRUD 入口
- **WHEN** PR 修改列表页面且页面包含 `el-table` 与只读查看能力
- **THEN** 审查者 SHALL 确认主标识列含 `el-link` 查看入口
- **THEN** 审查者 SHALL 确认 `el-table` 绑定了 `@row-dblclick` 事件
- **THEN** 若为标准 CRUD(仅编辑/删除),审查者 SHALL 确认无「操作」列,且工具栏与 `#hero-actions` 均提供编辑/删除
- **THEN** 审查者 SHALL 确认操作列无冗余「查看」/「查看详情」按钮(复合主操作例外除外)
### Requirement: 查看弹窗 KV 布局与组件选型
`web-admin` 列表行【查看】弹窗及同类只读详情弹窗的键值(KV)信息区 SHALL 采用下列两种**规范布局之一**,**SHALL NOT** 使用 Element Plus `el-descriptions`,**SHALL NOT** 使用已废弃的 `.dlg-kv` 四列「label|value|label|value」横向布局,**SHALL NOT** 在业务页手写无统一样式的 `<table>` 承载主体 KV。
| 布局 | 说明 | 默认场景 |
|------|------|----------|
| **上下堆叠栅格** | label 在上、value 在下,多列 grid | 字段较多、需 3~4 列密度;`SystemViewDialog` 默认 |
| **多组多列表格** | 每行左 label(`th`)、右 value(`td`);短字段一行可排多组 | 字段较少(通常 ≤10)、需表格对齐感;如报销备用金申请「基础信息」 |
权威视觉细节见 `docs/web-admin-ui-spec-fresh-mint.md` §9.5 / §9.8;交互 Demo 见 `docs/fresh-mint-view-wide-dialog-demos.html`(上下堆叠见主区;**多组多列表格**见 `#table-section`,模式 M~R)。
#### 1. 组件选型
| 场景 | 组件 | 路径 |
|------|------|------|
| 门户 / CRM / 系统管理等(默认) | `SystemViewDialog` | `web-admin/src/components/business/system-view-dialog/index.vue` |
| KV 栅格渲染(上下堆叠,被上述组件复用) | `ViewKvGrid` | `web-admin/src/components/business/system-view-dialog/ViewKvGrid.vue` |
| KV 表格渲染(多组多列,被上述组件复用) | `ViewKvTable` | `web-admin/src/components/business/system-view-dialog/ViewKvTable.vue` |
| 报销管理(`bx` 模块,Hero 自定义壳) | `BxViewKvGrid` / `BxViewKvTable` | `web-admin/src/components/bx/base/BxViewKvGrid.vue`、`BxViewKvTable.vue`(内部分别复用 `ViewKvGrid` / `ViewKvTable`) |
- 新增业务查看弹窗 **SHALL** 优先 `SystemViewDialog` + `ViewField[]` / `ViewSection[]`。
- 字段极多、需 Tab / KPI / 分栏的复杂查看 **SHALL** 采用界面模式 **P10**(独立 `*ViewDialog`,内部仍复用 `ViewKvGrid` / `ViewKvTable` 或 `SystemViewDialog` 的 section 结构)。
- `bx` 模块保留 `.dlg-view-hero` 等既有 Hero 壳时,KV 区 **SHALL** 使用 `BxViewKvGrid` 或 `BxViewKvTable`,**SHALL NOT** 继续手写 `.dlg-kv` 或裸 `<table>`。
#### 2. 弹窗宽度与默认列数
- `SystemViewDialog` 默认宽度 **SHALL** 为 **`1040px`**(`width` prop 可覆盖;合同/报价等宽表场景可用 `calc(100vw - Npx)` 或更大固定宽)。
- 上下堆叠栅格默认列数 **SHALL** 为 **4 列**(`gridColumns: 4`);紧凑模式(`compact`)降为 **2 列**。
- 多组多列表格 **SHALL** 在 **`1040px`** 弹窗内使用;少字段(≤8)可选用 **`720px`~`920px`**(见 Demo Q/R)。
- `bx` 模块 `.dialog-view-xl` 宽度 **SHALL** 与上述一致(`min(1040px, calc(100vw - 32px))`)。
#### 2.5 查看弹窗 KV 字号(14px / 13px 模式)
查看弹窗内所有 KV 信息区 **SHALL** 统一采用 **value 14px / label 13px** 字号模式,与全站列表正文(14px)对齐,label 略小一级以区分层级:
| 元素 | 字号 | 字重 | 说明 |
|------|------|------|------|
| **value**(`ViewKvGrid` 的 `dd`、`ViewKvTable` 的 `td`) | **14px** | 600 | 字段值正文 |
| **label**(`ViewKvGrid` 的 `dt`、`ViewKvTable` 的 `th`、`.view-field-label`) | **13px** | 400(表格 `th` 可为 500) | 字段名 / 次级分组标签 |
- 实现权威来源:`ViewKvGrid.vue`、`ViewKvTable.vue`,以及 `app.scss` 内 `:is(.view-kv, [class*='-view-kv'], .pl-plan-kv)` 全局规则。
- `SystemViewDialog` 的 **`compact`** 模式 **SHALL** 降为 value **13px** / label **12px**,以保持紧凑弹窗内的层级差。
- **SHALL NOT** 在业务页对 KV 单元单独覆写为其它字号(除上述 compact 降级外)。
#### 3. KV 单元格结构(上下堆叠栅格)
每个字段渲染为 `<dl>` 内的 `.view-kv` 单元:
- **dt(label)**:上,13px,`#64748b`,`font-weight: 400`
- **dd(value)**:下,14px,`font-weight: 600`,`word-break: break-word`
- **SHALL NOT** 在同一 grid cell 内使用左右并排的 label|value(历史 `el-descriptions` / `.dlg-kv .k/.v` 模式)
#### 4. 四列栅格跨列规则
栅格容器为 `.view-kv-grid.view-kv-grid--cols-4`,`gap: 12px 18px`。跨列逻辑 **SHALL** 由 `web-admin/src/utils/view-field-layout.ts` 与 `ViewField` 属性共同决定:
| 类型 | 每行个数 | 触发条件 | CSS class |
|------|----------|----------|-----------|
| 短值 | **4 个/行** | 默认;显示文本 &lt; 17 字且非长 label 启发式 | (无 span) |
| 长值 | **2 个/行** | `long: true` 或 `span: true`;或自动:文本 ≥17 字、含 ` / ` 路径、label 匹配备注/地址/说明等 | `view-kv-span-2` |
| 整行 | **1 个/行** | `fullSpan: true` 或 `span: 'full'`;或自动:文本 ≥80 字 | `view-kv-span-full` |
常量:`VIEW_KV_LONG_MIN_LEN = 17`,`VIEW_KV_FULL_MIN_LEN = 80`(见 `view-field-layout.ts`)。
响应式:**SHALL** 在 ≤1100px 降为 3 列、≤900px 降为 2 列、≤640px 降为 1 列(`ViewKvGrid.vue` 内 media query);长值在单列下 **SHALL** 占满整行。
#### 5. ViewField 定义约定
类型定义见 `web-admin/src/components/business/system-view-dialog/types.ts`。
```typescript
interface ViewField {
label: string
value?: string | number | boolean | null
items?: string[] // 无编号列表,与 value 二选一
span?: boolean | 'full' // true=长值(2列);'full'=整行
long?: boolean // 显式长值,等同 span:true
fullSpan?: boolean // 显式整行
tagType?: TagType // 状态/类型 → el-tag
money?: boolean // 金额红色 .money-display
}
```
- 空值 **SHALL** 展示为 `—`(`formatViewValue`)。
- 字典/状态 **SHALL** 预先翻译为 label 并视情况设 `tagType`(见「查看弹窗中编码字段展示为可读标签」「状态、结论与类型字段使用 Tag 标签展示」)。
- 金额 **SHALL** 设 `money: true` 并使用 `¥` 前缀(见「价格字段展示规范」)。
- **`long: false`**:显式禁用 `view-field-layout.ts` 自动长值检测;**SHALL** 用于 `ViewKvTable` 的 **quad compact** section(见 §8「紧凑八列模式」),避免字段因值 ≥17 字被拆成单行一组。
#### 6. 弹窗骨架(SystemViewDialog)
标准只读查看弹窗 **SHALL** 按以下顺序组织:
1. **Hero**(`.view-hero`):图标 + 主标题 + 可选 `meta` / `code` + 状态 Tag;**标准 CRUD 列表页**的「编辑」「删除」SHALL 通过 `#hero-actions` 插槽置于 Hero 区右侧(见「列表主标识列链接查看与编辑删除外置」),Footer 仍仅保留「关闭」类按钮
2. **分区**(`.view-section`):区块标题 + `ViewKvGrid` / `ViewKvTable` 或具名插槽(子表、附件、审批流等)
3. **Footer**:仅「关闭」类按钮(带 `Close` 图标),**SHALL NOT** 在查看态混入提交语义
子表、附件、审批历史等 **SHALL** 作为独立 section 或 slot 置于 KV 区之后,**SHALL NOT** 嵌入 KV 栅格 cell 或 KV 表格 `<td>`。
#### 7. 模块辅助工具(推荐)
| 模块 | 工具 | 用途 |
|------|------|------|
| CRM | `web-admin/src/utils/crm/view-fields.ts` | `crmField` / `crmLongField` / `crmDictField` / `crmTagField` 构建 `ViewField`;宽表「基本信息」八列 compact 见各 `*DetailDialog` 内 `viewQuadTableSection`(如 `QuoteDetailDialog.vue`) |
| 报销 bx | `web-admin/src/utils/bx/view-kv.ts` | `bxViewKv` / `bxViewKvLong` / `bxViewKvMoney` |
#### 8. KV 表格模式(多组多列)
当业务选用**多组多列表格**展示 KV 时,**SHALL** 使用 `ViewKvTable`(或 `BxViewKvTable`),数据源仍为 `ViewField[]`;跨行/长值逻辑 **SHALL** 复用 `web-admin/src/utils/view-field-layout.ts`(`isViewFieldLong` / `isViewFieldFullSpan` / `buildViewKvTableDualRows` / `buildViewKvTableTripleRows` / `buildViewKvTableQuadRows`)。
**模式(`mode` prop / `ViewSection.tableMode`)**
| mode | 每行组数 | 列结构 | 典型场景 |
|------|----------|--------|----------|
| `pair` | **1 组** | `th` + `td`(两列) | 少字段、需逐行阅读;Demo **Q** |
| `dual` | **2 组** | `th` + `td` × 2(四列) | 中等字段量;Demo **N** / **R** |
| `triple` | **3 组** | `th` + `td` × 3(六列) | 少字段高密度;Demo 扩展;参考 `bx/advance/apply` 基础信息 |
| `quad` | **4 组** | `th` + `td` × 4(**八列**) | 宽弹窗(≥1680px 或 1040px+)、字段较多且需表格对齐;CRM 报价单/合同「基本信息」等 |
**行分组规则**
- 短字段 **SHALL** 按 mode 尽可能填满一行(`dual` 最多 2 组、`triple` 最多 3 组、`quad` 最多 4 组);末行不足时 **SHALL** 留空 `th`/`td` 占位,**SHALL NOT** 强行与长字段同组。
- 长值 / 整行字段(`long` / `fullSpan` 或 `view-field-layout.ts` 自动检测:显示文本 ≥17 字、含 ` / ` 路径、label 匹配备注/地址/说明等)**SHALL** 独占整行:`pair` 为单行 `th`+`td`;`dual` 为 `td colspan="3"`;`triple` 为 `td colspan="5"`;`quad` 为 `td colspan="7"`。
- 字段顺序 **SHOULD** 将长文本(申请原因、备注等)置于数组末尾,以便短字段先组成完整多组行;**备注、条款等超长文本 SHOULD 独立 section**,与「基本信息」分区展示。
**紧凑八列模式(quad compact)**
当 section 选用 **`quad`(八列)** 且业务要求**每行尽量排满 4 组**(不因字段值略长而拆成「一行仅 1 组」)时:
1. **SHALL** 在 `SystemViewDialog` 的 `ViewSection` 上设置 `fieldLayout: 'table'`、`tableMode: 'quad'`。
2. **SHALL** 对该 section 内所有常规短字段统一设置 `long: false`、`fullSpan: false`,以禁用 `view-field-layout.ts` 的自动长值拆行;较长内容 **SHALL** 在 `td` 内换行展示(`ViewKvTable` 已支持 `word-break`),**SHALL NOT** 为此单独占整行。
3. **SHALL NOT** 在「基本信息」等 quad compact section 内混入 `long: true` / `span: true` / `fullSpan: true` 字段;若确需整行展示,**SHALL** 拆至独立 section(如「报价条款」「备注」)。
4. 推荐封装:在 `*DetailDialog` 内提供辅助函数,对传入 `ViewField[]` 映射 `{ ...field, long: false, fullSpan: false }` 后返回 `ViewSection`;参考 `web-admin/src/views/crm/quote/components/QuoteDetailDialog.vue` 的 `viewQuadTableSection`。
5. 宽表弹窗宽度 **SHOULD** 与八列密度匹配(如报价单查看 `1680px`);字段数量 **SHOULD** 为 4 的倍数或接受末行留空占位。
**表格样式(Fresh Mint)**
- 外层 **SHALL** 使用 `.view-kv-table-wrap`(圆角 8px、边框 `#d9e8e2`)。
- 表头单元格 **`th`(label)**:背景 `var(--table-head)`,13px、`#64748b`;**SHALL** 支持换行(`white-space: normal`、`word-break: break-word`、`overflow-wrap: anywhere`),**SHALL NOT** 使用 `white-space: nowrap` 导致长 label(如「实际发放金额(元)」)溢出。
- 数据单元格 **`td`(value)**:14px、`font-weight: 600`;金额 **SHALL** 使用 `.money-display`;状态/类型 **SHALL** 使用 `el-tag`(`ViewKvTableCell`)。
- `triple` 模式 label 列宽约 **10%**、value 列宽约 **23.33%**(三组合计六列);`quad` 模式 label 列宽约 **8%**、value 列宽约 **17%**(四组合计八列);具体比例以 `ViewKvTable.vue` 为准。
**选型建议**
- 字段 **≤8** 且弹窗显空 → 优先 **`pair`** 或 **`triple`** + 适当缩窄弹窗宽度。
- 字段 **9~18** 且需表格感 → 优先 **`dual`**(1040px)。
- 字段 **≥12** 且宽弹窗(报价单、合同等)→ 优先 **`quad` compact**(见上「紧凑八列模式」);「基本信息」**SHALL** 每行排满 4 组,**SHALL NOT** 因报价主题、客户名称等值略长而逐字段独占一行。
- 同一弹窗内 **SHALL NOT** 混用 `ViewKvGrid` 与 `ViewKvTable` 于**同一 section**;不同 section 可按需分别选用(如「基础信息」用表格、「扩展信息」用栅格)。
#### 9. 禁止与废弃
- **SHALL NOT** 在新代码中使用 `el-descriptions` 实现列表行【查看】弹窗主体 KV。
- **SHALL NOT** 使用 `.dlg-kv`、`.dlg-kv--triple` 横向 KV(`web-admin/src/styles/bx/index.scss` 内已标记 deprecated,仅兼容旧页)。
- **SHALL NOT** 手写 `<table class="...">` 实现 KV 主体区;**SHALL** 使用 `ViewKvTable` / `BxViewKvTable`。
- 迁移存量页时 **SHALL** 保留 Hero / 子表 / 附件 / 审批流结构,**仅替换** KV 区为 `ViewKvGrid` / `ViewKvTable` / `BxViewKvGrid` / `BxViewKvTable` / `SystemViewDialog`。
#### Scenario: 新增 CRM 列表查看弹窗
- **WHEN** 在 CRM 模块新增带只读查看能力的标准 CRUD 列表页(行级动作仅为编辑/删除)
- **THEN** 主标识列 SHALL 使用 `el-link` 触发查看,表格 **SHALL NOT** 含「操作」列
- **THEN** 工具栏与 `SystemViewDialog` `#hero-actions` SHALL 提供「编辑」「删除」
- **THEN** SHALL 使用 `SystemViewDialog`(或独立 `*DetailDialog` 封装该组件)
- **THEN** KV 字段 SHALL 通过 `ViewSection[]` + `ViewField[]` 定义,默认 4 列上下堆叠
- **THEN** SHALL NOT 使用 `el-descriptions`
#### Scenario: 报销模块查看弹窗(上下堆叠)
- **WHEN** 在 `web-admin/src/views/bx/**` 实现或改造查看弹窗,且 KV 区采用上下堆叠栅格
- **THEN** KV 区 SHALL 使用 `BxViewKvGrid`(columns 默认 4)
- **THEN** 弹窗宽度 SHALL 为 1040px 级(`.dialog-view-xl`)
#### Scenario: 报销模块查看弹窗(多组多列表格)
- **WHEN** 在 `web-admin/src/views/bx/**` 的查看弹窗中,某 section(如「基础信息」)采用多组多列表格
- **THEN** SHALL 使用 `BxViewKvTable` + `ViewField[]`,并显式声明 `mode`(`pair` / `dual` / `triple`)
- **THEN** 长 label SHALL 在 `th` 内换行展示,**SHALL NOT** 溢出列宽
- **THEN** 参考实现:`web-admin/src/views/bx/advance/apply/index.vue`(`mode="triple"`)
#### Scenario: CRM 报价单查看弹窗(紧凑八列基本信息)
- **WHEN** 在 CRM 报价单管理(`/#/crm/quote`)打开【查看】弹窗,且「基本信息」section 采用八列表格
- **THEN** SHALL 使用 `SystemViewDialog` + `fieldLayout: 'table'` + `tableMode: 'quad'`
- **THEN** 「基本信息」内各字段 SHALL 设置 `long: false`、`fullSpan: false`,使每行排满 4 组(八列);末行不足 4 组时留空占位
- **THEN** 较长字段值(报价主题、客户名称、有效期等)SHALL 在单元格内换行,**SHALL NOT** 单独占整行
- **THEN** 备注、条款等长文本 SHALL 置于独立 section(如「报价条款」),**SHALL NOT** 与基本信息混在同一 quad section
- **THEN** 参考实现:`web-admin/src/views/crm/quote/components/QuoteDetailDialog.vue`(`viewQuadTableSection`)
#### Scenario: 长文本与备注字段
- **WHEN** 查看弹窗包含申请原因、备注、条款等长文本
- **THEN** SHALL 为对应 `ViewField` 设置 `long: true` 或 `fullSpan: true`(或依赖 `view-field-layout.ts` 自动检测)
- **THEN** 4 列栅格下 SHALL 占 2 列或整行,**SHALL NOT** 与短字段同一行挤 4 个
#### Scenario: Code review KV 布局
- **WHEN** PR 新增或修改查看弹窗
- **THEN** 审查者 SHALL 确认 KV 区为 `ViewKvGrid`(上下堆叠 + 声明的 `gridColumns`)或 `ViewKvTable`(多组多列 + 声明的 `mode`)
- **THEN** SHALL 确认未使用 `el-descriptions` / `.dlg-kv` / 手写 KV `<table>` 作为主体字段区
- **THEN** 状态/金额/字典字段 SHALL 符合 Tag、`money`、字典翻译等关联 Requirement
- **THEN** 若使用 `ViewKvTable`,SHALL 确认长 label 可换行、长 value 在 compact quad section 内换行而非误拆行;非 compact section 中长 value 可独占整行
### Requirement: 查看弹窗中编码字段展示为可读标签
`web-admin` 中所有只读查看弹窗(`SystemViewDialog` 及类似组件)中,存储为编码(code / value)的字段 SHALL 展示为对应的人类可读标签(label),SHALL NOT 直接展示原始编码值。这包括但不限于:字典编码→字典标签、状态编码→状态文本、站点类型编码→类型名称等。
对于多值编码字段(如逗号分隔的编码串),SHALL 逐个编码转换为标签后以顿号(`、`)连接展示;无匹配标签时 SHALL 回退展示原始编码值。
#### Scenario: 站点管理查看弹窗
- **WHEN** 用户点击站点管理列表中主标识列(站点名称)链接
- **THEN** 「经营范围」字段 SHALL 展示为编码对应的标签文本(如"蔬菜、水果"),SHALL NOT 展示原始编码串(如"1,2")
#### Scenario: 新增包含字典编码字段的查看弹窗
- **WHEN** 新增列表页的查看弹窗中包含字典编码、状态编码等字段
- **THEN** 实现 SHALL 使用对应字典选项(`businessScopeOptions`、`statusOptions` 等)将编码翻译为标签后展示
#### Scenario: Code review 查看弹窗编码展示
- **WHEN** PR 修改查看弹窗中涉及编码类字段的展示
- **THEN** 审查者 SHALL 确认字段值已翻译为可读标签,SHALL NOT 出现原始编码值
### Requirement: 业务字典下拉统一使用 useDictStore
`web-admin` 中凡选项来源于系统字典(`sys_dict_type` / `sys_dict_data`,以 `dict_type` 编码区分,如 `crm_customer_level`、`crm_clue_source`、`crm_customer_type`)的下拉类控件(`el-select`、`el-radio-group` 等),在**列表筛选区**、**新增/编辑弹窗**、**子弹窗**及同类表单中 SHALL 统一通过 Pinia **`useDictStore`**(`web-admin/src/store/modules/dict.ts`)获取选项数据,SHALL NOT 在单文件内硬编码 `el-option` 的 label/value,SHALL NOT 对同一 `dict_type` 在页面内重复调用 `fetchDictDataOptions` 等分散接口(全量字典已由 `GET /web/admin/dict-data/all` 经 `dictStore.loadAllDict()` 加载)。
实现约定:
1. **加载时机**:页面或弹窗首次需要字典时调用 `await dictStore.loadAllDict()`(`loaded === true` 时 Store 内部跳过重复请求)。
2. **选项绑定**:使用 `computed(() => dictStore.getOptions('<dict_type>'))` 生成选项列表,`el-option` 的 `:label="opt.label"`、`:value="opt.value"` 与 Store 项一致。
3. **同页一致**:同一业务模块的列表筛选与新增/编辑弹窗 SHALL 对同一业务字段使用**相同**的 `dict_type` 与 Store 数据源,保证筛选条件、表单提交值、列表展示编码一致。
4. **只读展示**:表格列、查看弹窗中字典字段 SHALL 使用 `dictStore.getLabel(dictType, value)`(或 `getTagType` 配合 `el-tag`)翻译,与上文「查看弹窗中编码字段展示为可读标签」一致。
5. **字典类型登记**:新增业务字段所用 `dict_type` SHALL 在 `docs/sql/` 中提供对应字典类型/数据脚本(或说明沿用既有类型),并在需求/表字段注释中标明编码。
**参考实现**:CRM 客户管理 — 列表 `web-admin/src/views/crm/customer/index.vue`(`crm_clue_source`、`crm_customer_level`、`crm_customer_industry`);表单弹窗 `web-admin/src/views/crm/customer/CustomerFormDialog.vue`(另含 `crm_customer_type`、`crm_customer_park`、`crm_cert_type` 等,与列表共用 Store)。
#### Scenario: 新增业务模块列表与表单弹窗
- **WHEN** 新增含字典下拉的列表页及对应新增/编辑弹窗
- **THEN** 筛选区与弹窗 SHALL 均通过 `useDictStore` + `getOptions` 绑定选项
- **THEN** 弹窗打开时 SHALL 调用 `loadAllDict()`(或依赖父页已加载的全局字典)
- **THEN** SHALL NOT 在模板中写死业务字典选项
#### Scenario: CRM 客户管理字典下拉
- **WHEN** 用户打开 `#/crm/customer` 并打开「新增客户」弹窗
- **THEN** 客户类型、级别、来源、行业、园区、证件类型等下拉 SHALL 与列表筛选使用同一 `dict_type` 编码体系,且数据来自 `useDictStore`
#### Scenario: Code review 禁止硬编码字典选项
- **WHEN** PR 在 `web-admin` 新增或修改含 `el-select` 的业务表单/筛选
- **AND** 字段语义为系统字典(状态、类型、来源、级别等)
- **THEN** 审查者 SHALL 确认使用 `useDictStore.getOptions`,SHALL 要求移除硬编码 `el-option` 及重复的 per-type 字典 API 调用
### Requirement: 排序字段不在界面默认展示
`web-admin` 中所有列表页的表格列、新增弹窗表单、编辑弹窗表单 SHALL NOT 默认包含排序字段(`sortOrder`、`orderNum`、`排序`等)。排序字段仅在产品明确要求时按需添加,不应作为默认 UI 元素。
#### Scenario: 新增标准列表页
- **WHEN** 新增标准 CRUD 列表页(含新增/编辑弹窗)
- **THEN** 表格列和弹窗表单中 SHALL NOT 包含排序相关字段,除非产品需求明确要求
#### Scenario: Code review 排序字段
- **WHEN** PR 新增列表页或弹窗中包含排序字段
- **THEN** 审查者 SHALL 确认产品需求中有明确排序要求,否则 SHALL 要求移除
### Requirement: 筛选区关键词字段排第一
`web-admin` 列表页筛选区(`.filter-block` 内 `.filter-grid`)若包含**关键词**模糊检索字段(`label` 为「关键词」或等效语义、`el-input` 绑定 `keyword` 等),该字段 SHALL 作为 `.filter-grid` 中**第一个** `el-form-item`,位于下拉筛选、状态筛选、日期范围等条件字段之前。落地参考:`#/demo/unit`、`#/demo/user` 及质检模块「入库放行审核」页。
#### Scenario: 新增含关键词的列表页
- **WHEN** 在列表页筛选区同时提供关键词输入与下拉/日期等筛选条件
- **THEN** 「关键词」对应的 `el-form-item` SHALL 位于 `.filter-grid` 首位
#### Scenario: 入库放行审核筛选区
- **WHEN** 用户打开质检模块「入库放行审核」列表页
- **THEN** 筛选区 `.filter-grid` 中第一个字段 SHALL 为「关键词」,其后依次为协议类别、审核结论、入库状态等条件
#### Scenario: Code review 筛选区字段顺序
- **WHEN** PR 新增或修改列表页 `.filter-block` 筛选字段
- **THEN** 审查者 SHALL 确认存在关键词字段时其位于 `.filter-grid` 第一位
### Requirement: 筛选区多行条件默认折叠与展开更多
`web-admin` 列表页筛选区(`.filter-block`)当检索条件在桌面端 **超过一行**(即 `.filter-grid` 默认 **四列** 栅格下字段数 **大于 4**)时,SHALL 提供「展开更多 / 收起更多」切换,**默认仅展示首行常用条件**(首段 `.filter-grid` 内 **最多 4 个** `el-form-item`);其余条件 SHALL 放入 **`.filter-extra`** 区域,默认隐藏(`v-show` 或等效),由用户主动展开。视觉与结构 SHALL 对齐 `docs/web-admin-ui-spec-fresh-mint.md` §5.3 及 `docs/fresh-mint-green.html` 任务列表示例;落地参考 **`#/demo/order`**、**`#/crm/customer`**(`web-admin/src/views/crm/customer/index.vue`)。
#### 结构与样式
1. **首行常用条件**:`.filter-block` → `.filter-form` → **首段** `.filter-grid`(桌面 `repeat(4, 1fr)`,与全局 `app.scss` 一致),放置最常用的 4 个筛选项。
2. **扩展条件**:`.filter-extra`(建议 `id` 供 `aria-controls` 引用)→ 内嵌第二段 `.filter-grid`,承载超出首行的其余 `el-form-item`;默认 **`filterExpanded === false`** 时不展示。
3. **操作行**:使用 **`.filter-actions`**(非 `.filter-actions-row` 单独承载按钮时):左侧 **`.btn-link.btn-filter-toggle`**(链式文字按钮);右侧 **`.filter-actions-end`** 内「重置」「搜索」按钮(带 loading,见按钮图标规范)。
4. **筛选区内部**:首段 `.filter-grid`、`.filter-extra` 与 `.filter-actions` 之间 **SHALL NOT** 使用横向分割线(`border-top` 虚线/实线);仅用 `gap` / `padding-top` / `margin-top` 做间距(与 Fresh Mint §5.3 一致)。
5. **全局样式**:`.filter-extra`、`.filter-actions`、`.btn-link` / `.btn-filter-toggle` SHALL 在 `web-admin/src/assets/styles/core/app.scss` 的 `.page-container` / `.console-container` 作用域内统一定义,页面 **SHALL NOT** 重复 scoped 覆盖。
#### 交互与文案
1. **切换按钮文案**:收起态 **「展开更多」**;展开态 **「收起更多」**(SHALL NOT 与订单 demo 历史文案「展开更多条件」混用同一产品内的新页)。
2. **图标**:切换按钮文字后方 SHALL 携带 `<el-icon>`:**收起态** `ArrowDownBold`,**展开态** `ArrowUpBold`(从 `@element-plus/icons-vue` 显式 import)。
3. **无障碍**:切换按钮 SHALL 设置 `aria-expanded`(与 `filterExpanded` 同步)及 `aria-controls`(指向 `.filter-extra` 的 `id`)。
4. **重置行为**:用户点击「重置」时,除清空表单字段外,SHALL 将 **`filterExpanded` 置为 `false`**,恢复仅首行展示。
5. **条件不足一行**:当筛选项总数 **≤ 4**(桌面四列下一行可容纳)时,SHALL **不渲染** 展开/收起按钮(无 `.filter-extra` 亦可)。
#### 实现模式
```vue
<p class="filter-hint">…</p>
<div class="filter-grid"><!-- 首行最多 4 项 --></div>
<div v-show="filterExpanded" id="xxx-filter-extra" class="filter-extra">
<div class="filter-grid"><!-- 其余项 --></div>
</div>
<div class="filter-actions">
<button
v-if="showFilterToggle"
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="filterExpanded"
aria-controls="xxx-filter-extra"
@click="filterExpanded = !filterExpanded"
>
{{ filterExpanded ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="filterExpanded" />
<ArrowDownBold v-else />
</el-icon>
</button>
<div class="filter-actions-end">…</div>
</div>
```
```typescript
const filterExpanded = ref(false)
const showFilterToggle = computed(() => totalFilterFieldCount > 4)
function handleReset() {
// …清空 searchForm / dateRange
filterExpanded.value = false
pagination.page = 1
loadData()
}
```
#### Scenario: CRM 客户管理筛选区
- **WHEN** 用户打开 `#/crm/customer`
- **THEN** 默认 SHALL 仅展示首行四个条件(客户名称、客户编号、客户类型、客户级别)
- **AND** SHALL 显示「展开更多」按钮
- **WHEN** 用户点击「展开更多」
- **THEN** SHALL 展示客户来源、客户状态、成交状态、所在园区、创建时间等扩展条件,按钮文案变为「收起更多」
#### Scenario: 重置后收起扩展条件
- **WHEN** 用户已展开筛选扩展区并点击「重置」
- **THEN** 扩展区 SHALL 收起且 `filterExpanded` 为 false
#### Scenario: 单行筛选无需展开按钮
- **WHEN** 列表页筛选区仅包含 4 个及以下 `el-form-item`(桌面四列下一行)
- **THEN** SHALL NOT 渲染「展开更多 / 收起更多」按钮
#### Scenario: Code review 筛选区折叠
- **WHEN** PR 新增或修改 `.filter-block`,且筛选项数量大于 4
- **THEN** 审查者 SHALL 确认采用首行 + `.filter-extra` + `.filter-actions` 结构
- **AND** SHALL 确认默认折叠、重置收起、按钮文案与图标符合本 Requirement
- **AND** SHALL 拒绝将全部筛选项堆叠在单一 `.filter-grid` 且无展开入口的实现
### Requirement: 列表筛选自动触发搜索
`web-admin` 所有列表页的筛选表单(`.filter-block` 内 `searchForm` / `filterForm`)SHALL 使用 `watchDebounced`(来自 `@vueuse/core`,已 auto-import)对筛选表单做 deep watch,当任意字段值变更后 **300ms** 防抖自动触发搜索(重置页码为 1 并调用数据加载函数),无需用户手动点击「搜索」按钮。「搜索」按钮 SHALL 保留作为手动触发入口。
#### 实现模式
```typescript
watchDebounced(
searchForm, // 或 filterForm
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
```
对于含有**独立日期范围选择器**(`dateRange` ref,不属于 searchForm)的页面(如 `material-stock-log`、`qc-material-usage`),SHALL 额外添加 `watch(dateRange, ...)` 监听,在日期变更时同样触发搜索。
#### Scenario: 单字段筛选自动搜索
- **WHEN** 用户在筛选区关键词输入框中键入内容
- **THEN** 停止输入 300ms 后 SHALL 自动执行搜索,无需点击「搜索」按钮
#### Scenario: 多字段筛选自动搜索
- **WHEN** 列表页筛选区包含关键词、下拉选择、日期选择等多个筛选控件
- **AND** 用户修改其中任意一个筛选字段的值
- **THEN** 变更后 300ms SHALL 自动执行搜索
#### Scenario: 日期范围筛选
- **WHEN** 页面使用独立的 `dateRange` ref(非 searchForm 内字段)进行日期范围筛选
- **THEN** SHALL 额外使用 `watch(dateRange, ...)` 监听变更并触发搜索
#### Scenario: 保留手动搜索
- **WHEN** 用户点击筛选区的「搜索」按钮
- **THEN** SHALL 立即执行搜索(无防抖),与自动搜索行为并行可用
#### Scenario: Code review 自动搜索
- **WHEN** PR 新增或修改列表页筛选区
- **THEN** 审查者 SHALL 确认筛选表单已添加 `watchDebounced` 自动搜索,且 `debounce` 不超过 500ms
### Requirement: 附件上传卡片化布局
`web-admin` 弹窗表单中的附件上传与展示 SHALL 采用**卡片化布局**,实现与交互以 `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` 中「协议附件」为权威参考;储存证等业务弹窗(如 `web-admin/src/views/cell-storage/storage-cert/index.vue`)SHALL 复用同一套 DOM 结构、类名与样式规则,仅调整 `el-form-item` 的 `label` 与上传 `folder` 等业务参数。
#### 布局与视觉
1. **结构**:`el-form-item`(建议 `class="form-col-span-2"` 占满弹窗栅格宽)→ `.attachment-upload` → `.attachment-list`(flex 行)→ 上传卡片 + 若干 `.attachment-item`;`.upload-hint` 在 flex 行**下方**独立一行。
2. **上传卡片**:宽 **140px**、高 **96px**;`border: 1px dashed #a7f3d0`;`background: #f0fdf4`;`border-radius: 8px`;居中 `Plus`(size 28,`#10b981`)+ 文案「上传附件」(`.upload-card-text`,12px `#10b981` font-weight 500)。hover:`border-color: #10b981`、`border-style: solid`、`background: #ecfdf5`。
3. **附件卡片**:宽 **140px**;预览区高 **96px**;`border: 1px solid #e2e8f0`;`border-radius: 8px`;hover:`border-color: #10b981`、`box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1)`。
4. **预览区**(`.attachment-preview`):背景 `#f8fafc`;图片已上传且 `fileUrl` 存在时显示缩略图(`object-fit: cover`),可点击预览;图片上传中无 URL 时显示 `Loading`;视频显示 `.attachment-preview__icon--video`(`#ede9fe` / `#7c3aed` + `VideoPlay`);其他文件显示 `.attachment-preview__icon--doc`(`#e0f2fe` / `#0284c7` + `Document`)。
5. **卡片底部**(`.attachment-footer`):文件名 `.attachment-name`(11px `#334155`,最多 2 行省略,`title` 为完整文件名);`.attachment-actions` 居中排列 **下载**(`Download`)、**删除**(`Delete`,`.action-btn--danger` hover 红色)。
6. **上传中**:`.attachment-item.is-uploading` → `opacity: 0.6`、`pointer-events: none`。
7. **提示**:`.upload-hint` — 11px `#94a3b8`,`margin-top: 8px`;默认文案:「支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB」。
8. **色板**:SHALL 使用 Fresh Mint(`#10b981`、`#a7f3d0`、`#f0fdf4`、`#ecfdf5`);SHALL NOT 使用小程序主色 `#0a9f6d`。
9. **禁止**:SHALL NOT 使用 `el-upload` 的 `list-type="picture-card"`、默认 `file-list` 展示,或单独一行纯文字「选择文件」按钮替代上传卡片。
#### `el-upload` 配置
| 属性 | 值 |
|------|-----|
| `:auto-upload` | `false` |
| `:show-file-list` | `false` |
| `:multiple` | `true` |
| `accept` | `image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar` |
| `@change` | 选中后立即走上传逻辑(见下) |
上传卡片 SHALL 作为 `.attachment-list` 的**第一个子元素**,且始终渲染(不因已有附件数量而隐藏)。
#### 数据模型与上传逻辑
附件列表 SHALL 为响应式数组,元素至少包含:`id`、`fileName`、`fileUrl`、`fileType`(可选)、`_uploading`(可选布尔)。
1. **选择文件后**:校验单文件 ≤ **20MB**;超限则 `ElMessage.error` 并 `clearFiles()`。
2. **立即上传**:向数组 push 占位项(`_uploading: true`、`fileUrl: ''`);`FormData` 调用 `fetchUploadAttachment`(`@/api/business`),`fd.append('folder', '<业务目录>')`(如 `protocol`、`storage-cert`、`protocol-payment`);成功后回填 `fileUrl` 等字段并 `_uploading: false`;失败则移除该项并提示。
3. **收尾**:`nextTick` 后 `uploadRef.clearFiles()`,避免同一文件无法重复选择。
4. **删除**:从数组 `splice` 移除,不单独调删除接口(除非产品另有要求)。
5. **下载**:使用 `@/utils/download-attachment` 的 `downloadAttachmentFile(file, { fullFileUrl })`;`fullFileUrl` 需拼接 `VITE_MINIO_BASE_URL`(与参考页一致)。
6. **预览**:图片/视频已上传项点击预览区,打开独立 `el-dialog`(宽 800px,`destroy-on-close`);图片用 `<img>`,视频用 `<video controls>`。
7. **提交**:若存在 `_uploading === true` 的项,SHALL 阻止提交并提示「附件上传中,请稍候」;业务字段若存逗号分隔 URL,提交前 SHALL 从数组中过滤 `fileUrl` 非空且非上传中的项拼接。
缴费明细等嵌套场景可使用 `.upload-card--sm`(宽 96px、高 76px),其余规则不变。
#### 标准模板(Vue)
```vue
<el-form-item label="协议附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="attachmentUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, index) in attachments"
:key="file.id || index"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeAttachment(index)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span>
</div>
</el-form-item>
```
> 说明:`attachments` 为业务侧附件数组变量名;图标 `Plus`、`Loading`、`VideoPlay`、`Document`、`Download`、`Delete` SHALL 在 `<script setup>` 中从 `@element-plus/icons-vue` 显式 import。
#### 标准样式(SCSS,弹窗 `scoped` 内)
完整类名与尺寸 SHALL 与参考页一致,核心片段:
```scss
.attachment-upload { width: 100%; }
.attachment-list { display: flex; flex-wrap: wrap; gap: 12px; }
.upload-card {
width: 140px; height: 96px;
border: 1px dashed #a7f3d0; border-radius: 8px; background: #f0fdf4;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; cursor: pointer;
&:hover { border-color: #10b981; border-style: solid; background: #ecfdf5; }
}
.upload-card-text { font-size: 12px; color: #10b981; font-weight: 500; line-height: 1; }
.upload-hint { display: block; font-size: 11px; color: #94a3b8; margin-top: 8px; line-height: 1; }
.attachment-item {
width: 140px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; overflow: hidden;
&.is-uploading { opacity: 0.6; pointer-events: none; }
&:hover { border-color: #10b981; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1); }
}
.attachment-preview {
width: 100%; height: 96px; display: flex; align-items: center; justify-content: center;
background: #f8fafc; overflow: hidden; cursor: pointer;
img { width: 100%; height: 100%; object-fit: cover; display: block; }
}
.attachment-preview__icon {
width: 48px; height: 48px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
&--video { background: #ede9fe; color: #7c3aed; }
&--doc { background: #e0f2fe; color: #0284c7; }
}
.attachment-footer { display: flex; flex-direction: column; padding: 6px 8px; }
.attachment-name {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; font-size: 11px; color: #334155; line-height: 1.4; word-break: break-all;
}
.attachment-actions {
display: flex; justify-content: center; gap: 8px; margin-top: 4px;
}
.action-btn {
width: 22px; height: 22px; border-radius: 4px; background: #f1f5f9; color: #64748b;
cursor: pointer;
&:hover { background: #10b981; color: #fff; }
&--danger:hover { background: #ef4444; color: #fff; }
}
```
只读查看态 SHALL 使用 `web-admin/src/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue`(无上传卡片、无删除,可保留下载/预览)。
#### Scenario: 新增弹窗包含附件上传
- **WHEN** 在弹窗表单中新增附件上传
- **THEN** SHALL 使用上述 DOM 结构、类名与 140×96 卡片尺寸
- **THEN** 上传卡片 SHALL 位于 `.attachment-list` 第一项且始终可见
- **THEN** `.upload-hint` SHALL 位于卡片行下方
#### Scenario: 附件卡片预览与操作
- **WHEN** 附件为已上传图片
- **THEN** 预览区 SHALL 显示缩略图,点击 SHALL 打开预览弹窗
- **WHEN** 附件为视频或其他文档
- **THEN** 预览区 SHALL 分别显示视频/文档图标(见布局与视觉 §4)
- **WHEN** 用户点击下载或删除
- **THEN** SHALL 分别调用 `downloadAttachmentFile` 与数组移除逻辑
#### Scenario: 附件上传中状态
- **WHEN** 文件正在上传
- **THEN** 对应卡片 SHALL 为 `is-uploading`(半透明、不可点),预览区显示 `Loading`
- **WHEN** 用户提交表单且仍有 `_uploading` 项
- **THEN** SHALL 阻止提交并提示等待上传完成
#### Scenario: Code review 附件上传
- **WHEN** PR 新增或修改附件上传 UI
- **THEN** 审查者 SHALL 对照 `ProtocolFormDialog.vue` 确认卡片化布局与立即上传逻辑
- **THEN** SHALL 拒绝 `picture-card`、`show-file-list` 默认列表、与规范不一致的尺寸/色值
---
### Requirement: 小程序拍照凭证上传(基线)
`miniprogram` 业务附件在当前产品范围内 **仅支持拍照/相册图片凭证**(如取货、送达、备货、现场确认等),**SHALL NOT** 实现 PDF/Word/Excel 等文档类附件上传(文档类仍以 `web-admin`「附件上传卡片化布局」为准)。实现与交互以叁强鲜配 `sqxp-wx` 配送/供应商凭证上传为权威参考(如 `pages/deliverer/task/detail/detail.vue`、`pages/supplier/delivery/detail/detail.vue`、`pages/supplier/settings/avatar.vue`);山峡侧后续落地时 SHALL 抽公共工具(建议 `miniprogram/utils/upload.js`),业务页只负责数量上限、`folder` 与提交字段。
#### 范围与禁止项
1. **允许来源**:`uni.chooseImage`(或等价的 `uni.chooseMedia` 且仅 `mediaType: ['image']`),`sourceType` 含 `album` 与 `camera`,`sizeType` 优先 `compressed`。
2. **禁止**:SHALL NOT 使用 `wx.chooseMessageFile` / `uni.chooseFile` 选聊天文件或任意文档;SHALL NOT 复用 Web 端 `el-upload`、`FormData` + `uni.request`/`fetch` 上传二进制;SHALL NOT 把图片转 Base64 塞进 JSON 业务接口(除非产品书面要求且接口单独评审)。
3. **色板与触控**:上传入口、删除热区 SHALL 遵守小程序 Token(品牌绿 `#0a9f6d` 系)与最小触控 44×44;SHALL NOT 使用 Web Fresh Mint `#10b981` 作为小程序主色。
#### 两步流程(强制)
1. **选图并立即上传**:用户选择临时路径后,逐张(或受控并发)调用 `uni.uploadFile` 上传到图片上传接口,成功后将返回的 **URL 字符串** 推入本地数组(如 `imageUrls`)。
2. **业务提交**:用户确认业务动作(取货/送达/保存等)时,再调用业务 API,请求体携带已上传的 URL 列表(字段名建议 `imageUrls`,camelCase);服务端再关联业务并(如需要)写入附件表。SHALL NOT 在业务提交接口中直接接收 multipart 文件流(与叁强「先 URL、后业务」一致)。
#### `uni.uploadFile` 约定
| 项 | 要求 |
|----|------|
| 调用方式 | `uni.uploadFile`(微信小程序原生 multipart) |
| `url` | 图片上传接口绝对或与 `API_BASE` 拼接后的完整 URL(见 `api-conventions`「小程序拍照凭证上传接口」) |
| `filePath` | `chooseImage` / `chooseMedia` 返回的临时路径 |
| `name` | 固定为 `file`(与后端 `UploadFile` 字段名一致) |
| `header.Authorization` | `Bearer ${token}`;token 来自本地存储(与登录态同一 key) |
| `formData.folder` | 业务目录字符串(如 `delivery-evidence`、`avatars`、山峡业务自定义 kebab/短路径) |
| 响应解析 | `uploadFile` 的 `data` 多为 **字符串**,SHALL `JSON.parse` 后再读 `code` / `data.url`;成功条件与统一响应一致(`code === 200` 且存在 `data.url`) |
| Loading | 上传过程 SHALL `uni.showLoading`,结束 `uni.hideLoading`;失败 SHALL `uni.showToast` 提示 |
上传逻辑 **SHALL NOT** 走会做 AES/包体加密的通用 `request()` 封装(multipart 无法套 JSON 加密封装);鉴权仅靠 Header Bearer。
#### UI 与数量
1. **展示**:已上传项以缩略图网格/横向列表展示;点击预览 SHALL 使用 `uni.previewImage`(`urls` 为当前列表,`current` 为点击项)。
2. **删除**:未提交业务前可从本地数组移除;已随业务提交锁定的凭证按业务只读展示,SHALL NOT 在只读态提供删除。
3. **数量上限**:单次业务凭证默认最多 **6** 张(与叁强配送凭证一致);若业务需要其他上限,SHALL 在页面文案与选图 `count` 中写死同一常量。选图前 `count = 上限 - 已有张数`,已满则 toast「最多上传 N 张」。
4. **上传入口文案**:建议「+ 上传」或「选择图片并上传」;入口热区 ≥ 44×44。
#### 参考实现骨架(工具函数形态)
```javascript
// miniprogram/utils/upload.js(后续落地时抽取;业务页传入 folder / maxCount)
export async function chooseAndUploadImages({ folder, maxCount, currentUrls, token, uploadUrl }) {
const remain = maxCount - currentUrls.length
if (remain <= 0) {
uni.showToast({ title: `最多上传${maxCount}张`, icon: 'none' })
return []
}
const choose = await new Promise((resolve) => {
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => resolve(res),
fail: () => resolve(null)
})
})
if (!choose?.tempFilePaths?.length) return []
const urls = []
uni.showLoading({ title: '上传中...' })
try {
for (const filePath of choose.tempFilePaths) {
const uploadRes = await new Promise((ok, fail) => {
uni.uploadFile({
url: uploadUrl,
filePath,
name: 'file',
header: { Authorization: token ? `Bearer ${token}` : '' },
formData: { folder },
success: ok,
fail
})
})
const raw = typeof uploadRes.data === 'string' ? uploadRes.data : String(uploadRes.data || '')
let body = {}
try {
body = JSON.parse(raw)
} catch {
body = {}
}
if (body.code !== 200 || !body.data?.url) {
uni.showToast({ title: body.message || '上传失败', icon: 'none' })
break
}
urls.push(String(body.data.url))
}
} finally {
uni.hideLoading()
}
return urls
}
```
业务页在确认动作时示例:
```javascript
await request({
url: `/mini/<role>/tasks/${taskId}/pickup`,
method: 'POST',
data: { remark: '已取货', imageUrls: pickupImages.value }
})
```
#### Scenario: 配送/现场凭证上传
- **WHEN** 用户在小程序业务页点击「上传」且未达上限
- **THEN** SHALL 调起相册或拍照选图,并对每张临时文件调用 `uni.uploadFile`
- **THEN** 成功后本地列表增加可预览的图片 URL,业务提交时携带 `imageUrls`
#### Scenario: 禁止文档类附件
- **WHEN** 需求表述为「附件」但场景为小程序端
- **THEN** 实现 SHALL 仅提供拍照凭证能力;若确需 PDF 等文档,SHALL 先走独立 OpenSpec 变更扩展本 Requirement,不得静默加入 `chooseMessageFile`
#### Scenario: Code review 小程序凭证上传
- **WHEN** PR 新增或修改 `miniprogram` 图片/凭证上传
- **THEN** 审查者 SHALL 确认使用 `uni.chooseImage`(或仅图片的 `chooseMedia`)+ `uni.uploadFile`,且业务提交为 URL 列表
- **THEN** SHALL 拒绝 `FormData`+`request`、Base64 塞业务 JSON、以及未排除加解密的上传路径误用
### Requirement: 按钮须携带图标
`web-admin` 中所有带文字标签的 `el-button`(或 `ElButton`)SHALL 在文字**前方**包含 `<el-icon>` 子元素,使用 `@element-plus/icons-vue` 提供的图标组件;SHALL NOT 出现仅有文字而无图标的按钮。该规则适用于列表工具栏、表格操作列、`el-dialog` / 抽屉的 `#footer` 底栏、表单内 `link` / `plain` / `size="small"` 等**凡渲染可见文案的按钮**,不限于 `type="primary"`。图标的引入方式为在文件 `<script setup>` 中显式 `import` 所需图标组件(`@element-plus/icons-vue` 不属于 auto-import 范围)。图标与文字间距由全局样式保证(与 Fresh Mint 弹窗底栏约定一致,约 6px)。
#### 常用按钮图标映射
| 按钮语义 | 图标组件 | 适用场景 |
|---|---|---|
| 新增 | `Plus` | 工具栏「新增 XXX」按钮 |
| 搜索 | `Search` | 筛选区「搜索」按钮 |
| 重置 | `RefreshLeft` | 筛选区「重置」按钮 |
| 展开更多 / 收起更多 | `ArrowDownBold` / `ArrowUpBold` | 筛选区 `.btn-filter-toggle`(默认折叠多行条件时) |
| 确定 / 确认 / 提交 / 保存 | `Check` | 弹窗确认类按钮 |
| 取消 / 关闭 | `Close` | 弹窗取消、关闭按钮 |
| 编辑 / 修改 | `Edit` | 工具栏「编辑」、查看弹窗 `#hero-actions`「编辑」、操作列「编辑」按钮 |
| 删除 / 批量删除 | `Delete` | 工具栏「删除」、查看弹窗 `#hero-actions`「删除」、操作列「删除」按钮 |
| 查看 / 详情 | `View` | 工具栏「查看审批流」等非列表行主入口;**列表行查看由主标识列 `el-link` 或双击触发,操作列与工具栏不再放「查看」** |
| 审批 / 审核 | `EditPen` | 操作列「审批」按钮 |
| 配置 / 设置 | `Setting` 或 `SetUp` | 操作列「配置」按钮 |
| 导入 / 上传 | `Upload` | 工具栏「导入」、表单「上传」按钮 |
| 折叠 / 展开 | `ArrowDown` / `ArrowUp` | 工具栏「展开/收起」按钮 |
| 充值 / 发起充值 | `Wallet` | 渠道服务订单等预付款充值操作 |
| 退款 / 申请退款 | `Sell` | 退款、退费类操作 |
| 购买产品 | `ShoppingCart` | 购买、下单类操作 |
| 退订 | `Remove` | 退订、减少持有类操作 |
| 作废 | `Delete` | 作废流水/订单(与删除同图标,可用 `op-warn` 样式区分) |
| 操作流水 / 记录列表 | `List` | 「操作流水」等子列表入口 |
| 返回 | `Close` | 详情页、表单页「返回」按钮(与取消同图标) |
| 选择 / 挑选 | `Select` | 输入框 append「选择」、挑选合同/客户等 |
| 安排运输 | `Van` | 运输计划「安排运输」操作 |
| 归档 / 批量归档 | `FolderChecked` | 计划归档、批量归档 |
| 取消计划 | `CircleClose` | 取消运输计划等业务取消 |
| 二维码 | `Share` | 生成/查看运输二维码 |
| 提交结算 | `Promotion` | 运费结算「提交」 |
| 审核通过 | `CircleCheck` | 运费结算「审核」、证件「处理」等确认类操作 |
#### Scenario: 新增列表页工具栏按钮
- **WHEN** 在列表页筛选区添加「搜索」和「重置」按钮
- **THEN** 按钮 SHALL 包含 `<el-icon><Search /></el-icon>` 和 `<el-icon><RefreshLeft /></el-icon>`,SHALL NOT 仅有文字
#### Scenario: 新增弹窗底部按钮
- **WHEN** 在 `el-dialog` 的 `#footer` 插槽中添加「取消」和「确定」按钮
- **THEN** 取消按钮 SHALL 包含 `<el-icon><Close /></el-icon>`,确定按钮 SHALL 包含 `<el-icon><Check /></el-icon>`
#### Scenario: 新增表格操作列按钮
- **WHEN** 在表格操作列添加「编辑」「删除」等业务操作按钮
- **THEN** 每个按钮 SHALL 包含对应的 `<el-icon>` 子元素(如 `<el-icon><Edit /></el-icon>`),SHALL NOT 仅有文字
#### Scenario: 新增工具栏主操作按钮
- **WHEN** 在列表工具栏添加「新增 XXX」主操作按钮
- **THEN** 按钮 SHALL 包含 `<el-icon><Plus /></el-icon>`
#### Scenario: Code review 按钮图标
- **WHEN** PR 包含新增或修改的 `el-button`
- **THEN** 审查者 SHALL 确认该按钮包含 `<el-icon>` 子元素,且图标组件已从 `@element-plus/icons-vue` 显式导入
#### Scenario: 运输计划模块(web-admin/src/views/pl)
- **WHEN** 实现或修改 `web-admin/src/views/pl/**` 下运输计划菜单页面(承运商管理、车辆管理、司机管理、运输计划、运输管理、运费结算、库存台账、证件提醒及其子页 form/detail/arrange)
- **THEN** 每个带可见文案的 `el-button` SHALL 在文字前方包含 `<el-icon>`,并按上表选用语义图标(如搜索 `Search`、重置 `RefreshLeft`、新增 `Plus`、查看 `View`、安排运输 `Van`、归档 `FolderChecked`、提交 `Promotion`、审核/处理 `CircleCheck`、选择 `Select` 等)
- **THEN** 图标组件 SHALL 在 `<script setup>` 中从 `@element-plus/icons-vue` 显式 `import`,SHALL NOT 依赖隐式全局注册
- **THEN** Code review SHALL 拒绝仅有文字、无 `<el-icon>` 的按钮
#### Scenario: 渠道服务订单与财务确认页
- **WHEN** 实现或修改 `web-admin/src/views/pilot-service/channel-service-order/**` 或 `channel-service-order-finance/**` 中的按钮(含弹窗底栏、表格操作列、行内 link 删除)
- **THEN** 每个带文案的 `el-button` SHALL 按上表携带图标(如查看 `View`、确认 `Check`、取消 `Close`、作废 `Delete`、发起充值 `Wallet` 等)
- **THEN** 弹窗底栏取消/确定 SHALL 使用 `btn-ghost` / `btn-main` 类名并与 Demo 页图标约定一致
#### 标准写法示例
```html
<!-- 工具栏 -->
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增单位
</el-button>
<!-- 筛选区 -->
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<!-- 操作列 -->
<el-button class="btn-op-item primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
<!-- 弹窗底部 -->
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
```
```typescript
// <script setup> 中显式导入
import { Plus, Close, Check, Search, RefreshLeft, Edit, Delete, View } from '@element-plus/icons-vue'
```
### Requirement: 状态、结论与类型字段使用 Tag 标签展示
`web-admin` 中所有列表表格和查看弹窗中的**状态**、**结论**、**类型**等枚举类字段 SHALL 使用 Element Plus `<el-tag>` 组件以彩色标签形式展示,SHALL NOT 以纯文本形式展示。具体规则如下:
#### 1. 表格列中的 Tag 展示
`el-table` 中所有状态/结论/类型列 SHALL 在 `<template #default="{ row }">` 内使用 `<el-tag>` 渲染,并通过 `:type` 属性区分语义色。SHALL NOT 使用自定义 `<span class="tag">` 或纯文本。
**标准写法:**
```html
<el-table-column prop="status" label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
{{ row.status === '1' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
```
**多状态映射标准写法(使用状态标签注册表):**
```html
<el-table-column prop="workOrderStatus" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="workOrderStatusTagType(row.workOrderStatus)" size="small">
{{ workOrderStatusLabel(row.workOrderStatus) }}
</el-tag>
</template>
</el-table-column>
```
```typescript
import { workOrderStatusLabel, workOrderStatusTagType } from '@/utils/status-tag-registry'
```
#### 2. 查看弹窗(SystemViewDialog)中的 Tag 展示
使用 `SystemViewDialog` 组件(`src/components/business/system-view-dialog/index.vue`)时,状态/结论/类型字段 SHALL 在 `ViewField` 定义中传入 `tagType` 属性。组件检测到 `tagType` 后自动以 `<el-tag>` 渲染该字段值。
**ViewField 接口扩展(已实现):**
```typescript
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface ViewField {
label: string
value: string | number | boolean | null | undefined
span?: boolean
/** 传入后以 el-tag 渲染,值为 el-tag 的 type 属性 */
tagType?: TagType
}
```
**标准写法:**
```typescript
import { taskStatusLabel, taskStatusTagType, qcTaskConclusionLabel, qcTaskConclusionTagType } from '@/utils/status-tag-registry'
const viewSections = computed<ViewSection[]>(() => [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo },
{ label: '状态', value: taskStatusLabel(d.status), tagType: taskStatusTagType(d.status) },
{ label: '检测结论', value: qcTaskConclusionLabel(d.conclusion), tagType: qcTaskConclusionTagType(d.conclusion) },
// 普通字段不加 tagType,仍以纯文本展示
{ label: '备注', value: d.remark || '—', span: true },
]
}
])
```
#### 3. 自定义查看弹窗中的 Tag 展示
不使用 `SystemViewDialog` 的自定义查看弹窗(如 `ProtocolViewDialog`、`DonationViewDialog` 等)中的状态/结论字段 SHALL 同样使用 `<el-tag>` 展示。
**标准写法:**
```html
<dd>
<el-tag :type="acceptConclusionTagType(row.acceptConclusion)" size="small">
{{ acceptConclusionLabel(row.acceptConclusion) }}
</el-tag>
</dd>
```
```typescript
import { acceptConclusionLabel, acceptConclusionTagType } from '@/utils/status-tag-registry'
```
#### 4. Tag 类型语义色映射规范
`web-admin` 中语义 `el-tag`(含列表、查看弹窗、Teleport 弹层):`success` / `warning` / `danger` / `primary` SHALL 使用 **深色底 + 白字**;`info` SHALL 使用 **透明无背景 + 标准正文色**(`var(--mint-text)` / `#1e293b`),SHALL NOT 使用浅底彩字或 Element 默认灰 `#909399`。全局样式权威实现:`web-admin/src/assets/styles/core/el-ui.scss`。视觉权威:`docs/web-admin-ui-spec-fresh-mint.md` §7.7。
| Tag Type | 背景色 | 文字 | 适用语义 | 示例 |
|---|---|---|---|---|
| `success` | `#059669` | `#fff` | 正常、启用、已完成、生效中、通过 | 正常/启用/已完成/接收/通过 |
| `warning` | `#d97706` | `#fff` | 进行中、待确认、已过期、预警 | 进行中/已排配/已过期/待确认 |
| `danger` | `#dc2626` | `#fff` | 异常、已作废、拒绝、不通过 | 异常/已作废/拒绝/不通过 |
| `info` | 透明 | `#1e293b` | 待执行、待生效、未知、停用、默认 | 待执行/待生效/停用/无结论 |
| `primary` | `#059669` | `#fff` | 特殊分类、来源类型、转出 | 转科研/转细胞存储/来源类型/菜单类型 |
**对于启用/停用的状态字段**,SHALL 使用 `success`(启用)和 `info`(停用),SHALL NOT 使用 `danger` 表示停用状态。
#### 5. 状态标签注册表(集中定义 label + tagType)
`web-admin` 中所有状态、结论、类型等枚举字段的 **文案(label)与 Tag 语义色(tagType)SHALL 在单一注册表中集中定义**,权威文件为 `web-admin/src/utils/status-tag-registry.ts`。
**核心原则:**
1. **同一枚举值全局一致**:同一枚举 code 在列表、查看弹窗、导出、嵌套子列表等任何展示位置,SHALL 使用相同的 label 与 tagType;SHALL NOT 在页面内用三元表达式或局部 `Record` 重复定义映射。
2. **成对导出**:每个枚举域 SHALL 导出 registry 对象及配套的 `xxxLabel(code)`、`xxxTagType(code)` 两个 getter;未知 code 时 label 回退为原值、`tagType` 回退为 `'info'`。
3. **新增枚举先注册后使用**:新增业务枚举时,SHALL 先在 `status-tag-registry.ts` 追加 registry 与 getter,再在页面/组件中 import 使用;SHALL NOT 直接在 `.vue` 中写 `if/else` 或内联映射。
4. **域内分组**:registry 内按业务域分区维护(协议/存储、质检、渠道、订单、系统通用等),便于检索与 code review。
**注册表结构(已实现):**
```typescript
/** el-tag 的 type 属性值 */
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface StatusItem {
label: string
tagType: TagType
}
export type StatusRegistry = Record<string, StatusItem>
export const protocolStatus: StatusRegistry = {
dsx: { label: '待生效', tagType: 'info' },
sxz: { label: '生效中', tagType: 'success' },
ygq: { label: '已过期', tagType: 'warning' },
yzf: { label: '已作废', tagType: 'danger' },
zky: { label: '转科研', tagType: 'primary' }
}
export const protocolStatusLabel = createLabelGetter(protocolStatus)
export const protocolStatusTagType = createTagTypeGetter(protocolStatus)
```
**页面使用方式:**
```typescript
import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
```
```html
<el-tag :type="protocolStatusTagType(row.status)" size="small">
{{ protocolStatusLabel(row.status) }}
</el-tag>
```
**兼容 re-export(非新增入口):**
`utils/protocol-payment-labels.ts`、`utils/qc-labels.ts` 等历史文件 MAY 继续 re-export 注册表中的 getter 以保持既有 import 路径;**新增枚举 SHALL 直接写入 `status-tag-registry.ts`**,SHALL NOT 再新建独立的 label/tag 映射文件。
**禁止写法:**
- 页面 `<script setup>` 内定义 `function xxxTagType()` / `const xxxMap = { ... }` 映射已在或应在注册表中的枚举
- 查看弹窗 `ViewField.tagType` 使用内联三元表达式(如 `d.status === 'ywc' ? 'success' : ...`)替代 registry getter
- 同一枚举在不同页面使用不同 tagType
#### Scenario: 新增业务枚举的状态展示
- **WHEN** 新增页面需展示新的状态/结论/类型枚举字段
- **THEN** 开发者 SHALL 先在 `status-tag-registry.ts` 注册该枚举的 label 与 tagType,并在页面中 import 对应的 `xxxLabel` / `xxxTagType` getter 使用
#### Scenario: 复用已有枚举的状态展示
- **WHEN** 页面需展示已在注册表中定义的枚举(如 `protocolStatus`、`taskStatus`、`enableStatus`)
- **THEN** 页面 SHALL 直接从 `@/utils/status-tag-registry` import 并使用,SHALL NOT 在页面内重复定义映射
#### Scenario: 新增列表页包含状态列
- **WHEN** 新增列表页的表格中包含状态、结论或类型字段
- **THEN** 该列 SHALL 使用 `<el-tag>` 渲染,通过 `:type` 绑定语义色,SHALL NOT 使用纯文本或自定义 `<span class="tag">`
#### Scenario: 新增查看弹窗包含状态字段
- **WHEN** 新增 `SystemViewDialog` 查看弹窗,且 fields 中包含状态/结论/类型字段
- **THEN** 该字段 SHALL 传入 `tagType` 属性,组件自动以 `<el-tag>` 渲染
#### Scenario: Code review Tag 展示
- **WHEN** PR 修改列表页或查看弹窗中的状态/结论/类型字段展示
- **THEN** 审查者 SHALL 确认使用 `<el-tag>` 组件(表格列)或 `tagType` 属性(SystemViewDialog),SHALL NOT 出现纯文本状态展示或自定义 `<span class="tag">`;若枚举已在或应进入注册表,SHALL 确认映射来自 `status-tag-registry.ts` 而非页面内联定义
### Requirement: 嵌套弹窗与子弹窗内列表风格一致
`web-admin` 中所有界面(含主列表页、编辑弹窗、查看弹窗、子弹窗、侧栏详情面板等)内的 `el-table` 列表 SHALL 与标准列表页(如 `#/demo/unit`、`#/demo/order`)保持一致的表格视觉规格,**不得**因处于弹窗或嵌套区域而缩小字号、压缩行高或改用 `size="small"` 等紧凑样式。
具体规则:
1. **字号与行高**:表格正文 SHALL 使用规范默认 **14px** 字号与标准行高(与 `.list-table` / 主列表一致);SHALL NOT 为弹窗内表格单独设置更小 `font-size` 或通过 `size="small"` / `size="mini"` 缩小表格。
2. **表格类名**:业务列表(含弹窗内嵌列表)SHALL 使用 `.list-table`(可与 `.inner-table` 等场景类叠加),以复用全局表格 Token 与行高,而非在 scoped 样式中覆盖为紧凑尺寸。
3. **只读查看**:查看详情、任务详情等只读场景中的子列表 SHALL 以纯文本/Tag 展示数据,SHALL NOT 保留可编辑控件;同时 SHALL NOT 隐藏操作列后改用更小表格尺寸补偿空间。
4. **高度**:弹窗内列表可通过 `height` / `max-height` 控制可视区域高度以便滚动,但 SHALL NOT 以缩小字体或行高换取更多行展示。
#### Scenario: 任务详情弹窗内嵌列表
- **WHEN** 用户在生产任务「查看详情」弹窗中查看原材料、产出或步骤详情中的试剂/耗材等子列表
- **THEN** 表格字号与行高 SHALL 与主列表页一致(14px 标准规格)
- **THEN** 只读模式下 SHALL NOT 显示添加、删除等编辑操作按钮
#### Scenario: 新增弹窗内嵌业务列表
- **WHEN** 在 `el-dialog` 或子弹窗中新增 `el-table` 展示业务数据
- **THEN** 实现 SHALL 使用 `.list-table` 且 SHALL NOT 设置 `size="small"` 或自定义更小字号/行高以适配弹窗
#### Scenario: Code review 弹窗内表格
- **WHEN** PR 在弹窗、侧栏详情或嵌套面板中新增或修改 `el-table`
- **THEN** 审查者 SHALL 确认未使用 `size="small"` / 紧凑字号覆盖,且列表风格与主列表页一致
### Requirement: web-admin 界面模式索引与参考页选型
`web-admin` 新增或改造业务页面时,开发者 SHALL 在编码前按 **功能特征 → 模式编号(P01–P27)→ 参考页** 完成界面选型;模式定义、决策表、各模式标准写法与全站页面映射的**详细说明**以 `docs/web-admin-ui-pattern-index.md` 为权威索引,本 Requirement 给出 SHALL 级门禁与摘要。
**选型流程(SHALL 按序执行):**
1. 对照下方「功能特征 → 模式」决策表或索引文档 §二,确定模式编号(如 P01、P12)。
2. 打开该模式对应的 **Demo 模板页** 或 **业务参考页**(Hash + 源码路径见索引文档 §三)。
3. 复制页面骨架与交互模式,替换 API、字段、`menuCode`;**SHALL NOT** 从零设计布局或另起一套类名习惯。
4. 枚举字段 SHALL 注册至 `status-tag-registry.ts`(见本 spec「状态标签注册表」Requirement)。
**官方 Demo 模板(永远优先对照,SHALL 优先于业务页):**
| 用途 | Hash | 源码 |
|------|------|------|
| 标准列表 CRUD | `/#/demo/unit` | `web-admin/src/views/system/unit/index.vue` |
| 复杂业务列表 | `/#/demo/order` | `web-admin/src/views/system/order/index.vue` |
| 商品类 + 查看弹窗 | `/#/demo/product` | `web-admin/src/views/system/product/index.vue` |
| 树形主数据 | `/#/demo/category` | `web-admin/src/views/system/category/index.vue` |
| 用户类 CRUD | `/#/demo/user` | `web-admin/src/views/system/user/index.vue` |
| 工作台说明 | `/#/demo/console` | `web-admin/src/views/dashboard/console/index.vue` |
**功能特征 → 模式编号(决策表):**
| 功能特征 | 模式 | 首选参考 |
|----------|------|----------|
| 单表主数据,增删改查 + 分页 + 关键词筛选 | **P01** | `/#/demo/unit` → `basic-info/customer` |
| 列表多状态,操作按钮随状态变化 | **P02** | `/#/demo/order` → `cell-storage/protocol-audit` |
| 树形结构,父子级联,无分页 | **P03** | `/#/demo/category` → `system/dept` |
| 左侧组织/分类树 + 右侧分页列表 | **P04** | `/#/demo/user` → `system/user` |
| 同页上下两块表(类型 + 明细) | **P05** | `system/dict` |
| 主表列表 + 弹窗内维护子表 | **P06** | `basic-info/collection-region` |
| 批量生成编号/编码 + 下发/收回 | **P07** | `basic-info/sample-code` |
| 列表顶部 Tab 按状态切换(非路由 Tab) | **P08** | `cell-storage/payment-info` |
| 只读查看,字段分 Section(≤20 字段) | **P09** | `SystemViewDialog` → `system/express` |
| 只读查看,多 Tab/KPI/审核历史/附件 | **P10** | `ProtocolViewDialog` / `DonationViewDialog` |
| 简单审核/状态变更(单表单 ~560px) | **P11** | `ProtocolAuditDialog` |
| 多分区大表单(Tab 锚点 + 行内子表) | **P12** | `ProtocolFormDialog` / `DonationFormDialog` |
| 财务/业务确认(只读明细 + 上传附件) | **P13** | `cell-storage/payment-confirm` |
| 主表 + 嵌套配置大弹窗(子资源 CRUD) | **P14** | `basic-info/insurance-company` |
| 主表 + 多 Tab 子模块(渠道/协议等) | **P15** | `pilot-service/channel` |
| 三栏:资源库 \| 步骤列表 \| 步骤详情 | **P16** | `production/process-route` |
| 工单列表 + 派发/执行弹窗链 | **P17** | `production/production-work-order` |
| 任务执行(虚拟步骤 + 步骤状态机) | **P18** | `TaskExecutionDialog` + `useTaskStepEditor` |
| 主数据 + append-to-body 选择器弹窗 | **P19** | `production/process` |
| 库存主数据 + 入库/出库 + 流水只读页 | **P20** | `production/consumable` + `material-stock-log` |
| 质检工单/任务(配置检测项) | **P21** | `quality-inspection/qc-work-order` |
| 审核列表 + 专用审核弹窗 | **P22** | `quality-inspection/storage-release-review` |
| 接收/登记(关联协议样本) | **P23** | `quality-inspection/sample-reception` |
| 工作流配置 / 待办 / 审批 | **P24** | `system/workflow/*` |
| 独立详情页(非弹窗) | **P25** | `system/user/detail` |
| 角色/权限扩展(菜单树/数据范围) | **P26** | `system/role` |
| 衍生列表(跳转主业务查看弹窗) | **P27** | `cell-storage/storage-cert` |
**各模式 SHALL 级要点(摘要):**
- **P01**:`filter-block` → `list-toolbar`(已选 N 项在前)→ `list-table stripe` → 分页;`dialog-form-layout` 编辑 + `SystemViewDialog` 查看。
- **P02**:在 P01 基础上增加状态筛选、操作列多按钮、业务弹窗(审核/状态变更)。
- **P03**:`el-table` 树形、无分页;路径展示遵循「树形主数据展示规则」Requirement。
- **P04**:左 `el-tree` + 右 P01 列表;组织全路径用 `tree-path.ts`。
- **P06**:主从平铺 + 子 API;**SHALL NOT** 误用树形主数据 UI。
- **P07**:`/batch-generate`、`/batch-issue`、`/batch-revoke`;批量请求超时 SHALL ≥ 120s。
- **P08**:`region-tabs` 切换列表筛选;各 Tab 可有不同批量操作。
- **P09**:查看 SHALL 使用 `SystemViewDialog`;状态字段传 `tagType`。
- **P10**:复杂查看 SHALL 使用独立 `*ViewDialog` 组件;与编辑弹窗 **同 Tab/section 命名**;只读附件复用 `ProtocolAttachmentReadonly` 等。
- **P11/P22**:轻量审核弹窗 width ~560px;结论字段用 registry Tag。
- **P12**:重表单 SHALL 拆为独立 `.vue` 组件;`DialogFormBody` + `detail-tabs` + `scroll-to-error`;附件卡片化布局遵循本 spec 附件 Requirement。
- **P16**:三栏布局 SHALL 使用 `_route-config.scss` 的 `.route-config-*` 类;新三栏场景 SHALL 复用 `useTaskStepEditor` + `StepDetailPanel`,**SHALL NOT** 复制 `process-route` 内联逻辑。
- **P17/P18**:工单/任务流 SHALL 对齐 `production-work-order` 弹窗链与 composable 模式。
- **P27**:衍生列表查看 SHALL 打开主业务 ViewDialog(如 `ProtocolViewDialog`),**SHALL NOT** 重复实现完整查看 UI。
**维护约定:**
- 新增页面落地后,SHALL 在 `docs/web-admin-ui-pattern-index.md` §四补充「页面 → 模式」映射。
- 出现现有 P01–P27 无法覆盖的新交互形态时,SHALL 先在索引文档新增模式编号与参考页,再更新本 Requirement 决策表。
#### Scenario: 新增标准主数据页
- **WHEN** 新增单表 CRUD 主数据页(如供应商、配置项)
- **THEN** 开发者 SHALL 选定模式 **P01**,以 `/#/demo/unit` 或 `basic-info/customer` 为骨架参考
- **THEN** PR 描述或设计说明中 SHALL 注明所选模式编号与参考页路径
#### Scenario: 新增复杂业务或工单页
- **WHEN** 新增含多状态操作、工单派发、任务执行或三栏编辑的页面
- **THEN** 开发者 SHALL 从 P02、P16、P17、P18、P21 等决策表中选定模式,并对照索引文档 §三对应参考页实现
- **THEN** SHALL NOT 仅参照 P01 标准列表强行裁剪复杂交互
#### Scenario: 新增重表单或专用查看弹窗
- **WHEN** 新增字段多、需 Tab 分区或 KPI 的业务表单/查看
- **THEN** 编辑 SHALL 采用 **P12**(独立 FormDialog 组件);查看字段极多 SHALL 采用 **P10**(专用 ViewDialog)
- **THEN** SHALL NOT 在 `index.vue` 内堆叠超过 500 行表单/查看模板
#### Scenario: Code review 界面模式选型
- **WHEN** PR 新增或大幅改造 `web-admin` 业务页面
- **THEN** 审查者 SHALL 确认 PR 注明模式编号(Pxx)及参考页
- **THEN** 页面骨架、工具栏顺序、弹窗类名与参考页一致;若偏离 SHALL 有书面理由
#### Scenario: 索引文档与 OpenSpec 同步
- **WHEN** 新增 P28 或更高级别模式,或全站页面映射变更
- **THEN** 维护者 SHALL 同步更新 `docs/web-admin-ui-pattern-index.md` 与本 Requirement 决策表
### Requirement: 金额与单价千分位输入与展示
本 Requirement 适用于**价格、单价、金额、成本价、费用、总额**等以「元」为业务单位的货币类字段(下称「价格类字段」)。数量、份数、排序号、百分比等非货币字段不在此列。
#### 1. 标签须标注单位「(元)」
凡价格类字段,其**可见标签文案** SHALL 在字段名后紧跟半角括号包裹的单位 **`(元)`**(全角括号,与中文排版一致),**不论**出现在何处:
| 场景 | 写法示例 |
|------|----------|
| 新增/编辑弹窗 `el-form-item` | `label="标准单价(元)"` |
| 筛选区 `el-form-item` | `label="价格区间(元)"` |
| 列表 `el-table-column` | `label="销售价(元)"` |
| 查看弹窗 KV(`dt` / `SystemViewDialog` 的 `label`) | `标准单价(元)` |
| 合计/摘要文案中的字段名 | `应付总额(元)` |
- 单位 **`(元)`** 写在 **label 侧**,SHALL NOT 仅写在 placeholder、表头 tooltip 或单元格数值后方。
- 标签已含「元」字时仍 SHALL 使用统一后缀形式,如 `金额(元)`,SHALL NOT 写为 `金额/元` 或 `金额(元)`(半角括号)。
- 输入控件内(含 `AmountInput`)SHALL NOT 再重复显示 `元` 或 `¥`;单位由 label 承担。
#### 2. 只读展示须前缀人民币符号「¥」
价格类字段的**只读数值展示**(列表单元格、查看弹窗、详情 KV、合计行、导出前端的展示列等)SHALL:
1. 使用 `formatMoneyDisplay(value, 2)`(`@/utils/money-format.ts`)做千分位与小数位格式化;
2. 在格式化结果**前面**拼接半角人民币符号 **`¥`**(U+00A5),中间**不加空格**;
3. 数值文本 SHALL 使用全局类名 **`money-display`**(`web-admin/src/assets/styles/core/app.scss`,色值 **`#dc2626`**,`font-weight: 600`),使单价、价格、金额等只读数值统一红色强调。
**标准模板:**
```html
<!-- 列表列 -->
<el-table-column prop="price" label="标准单价(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="money-display">¥{{ formatMoneyDisplay(row.price) }}</span>
</template>
</el-table-column>
<!-- 查看弹窗 KV -->
<dt>成本价(元)</dt>
<dd><span class="money-display">¥{{ formatMoneyDisplay(detail.costPrice) }}</span></dd>
<!-- SystemViewDialog -->
{ label: '销售价(元)', value: `¥${formatMoneyDisplay(d.salePrice)}`, money: true }
```
- `formatMoneyDisplay` **SHALL NOT** 内置 `¥`;符号 SHALL 在模板或拼接字符串侧统一添加,便于与输入态区分。
- 空值、未填:SHALL 展示为 `¥0.00` 或业务约定的 `—`;若用 `—`,SHALL NOT 前缀 `¥`。
#### 3. 弹窗/列表表单控件同宽(栅格内满宽)
在 `dialog-form-layout` 弹窗、`page-container` / `console-container` 内使用 `.form-grid-2`~`.form-grid-5`(或等价栅格)时,**同一行内的所有表单控件**(`el-input`、`el-select`、`el-tree-select`、`AmountInput`、`el-input-number` 等)SHALL **占满该栅格列宽度**(`width: 100%`),SHALL NOT 因使用 `el-input-number` 而保留 Element Plus 默认约 **150px** 窄宽导致与邻列文本框视觉不齐。
- 全局样式权威:`web-admin/src/assets/styles/core/app.scss`(`.el-dialog.dialog-form-layout .el-form-item .el-input-number` 等选择器)。
- 页面 scoped 样式 SHALL NOT 为单个弹窗重复写宽度补丁;优先依赖全局规则。
- 价格类字段 SHALL 优先使用 `AmountInput`(内部已为 `el-input` 且 `width: 100%`),兼顾金额规范与同宽。
#### Scenario: 弹窗栅格内数字字段
- **WHEN** 在 `form-grid-2` 等栅格中放置 `el-input-number`(如排序号、数量)
- **THEN** 控件根节点 SHALL 与同行的 `el-input` 同宽,SHALL NOT 明显窄于邻列
#### Scenario: Code review 弹窗表单控件宽度
- **WHEN** PR 在弹窗表单中新增 `el-input-number` 或 `AmountInput`
- **THEN** 审查者 SHALL 目视确认与同栅格列 `el-input` 等宽,必要时检查是否误用未拉满的 `class="w-full"` alone(对 `el-input-number` 无效,须全局或 `width: 100%` 作用于组件根)
#### 4. 可编辑输入(AmountInput)
价格类可编辑字段 SHALL 使用 `AmountInput`(`@/components/common/amount-input/index.vue`):
| 时机 | 行为 |
|------|------|
| 聚焦输入中 | 纯数字展示,无千分位逗号,如 `1234567.89` |
| 失焦后 | 千分位展示,如 `1,234,567.89`(无 `¥` 前缀) |
| 再次聚焦 | 自动去掉逗号,恢复纯数字便于修改 |
| 提交/API | `number` 类型,不受逗号影响 |
业务 `.vue` 文件 SHALL NOT 复制千分位正则或 `addThousandSeparators` 等函数实现;SHALL 统一引用 `@/utils/money-format.ts`。
#### Scenario: 弹窗表单价格字段
- **WHEN** 在新增/编辑弹窗中新增价格或单价字段
- **THEN** `el-form-item` 的 `label` SHALL 为「字段名(元)」
- **THEN** 控件 SHALL 为 `<AmountInput />`,SHALL NOT 使用 `<el-input-number>`
#### Scenario: 列表价格列
- **WHEN** 在 `el-table` 中展示价格或单价列
- **THEN** 列 `label` SHALL 含「(元)」
- **THEN** 单元格展示 SHALL 为 `<span class="money-display">¥{{ formatMoneyDisplay(row.xxx) }}</span>`,数值列建议 `align="right"`
#### Scenario: 查看弹窗价格字段
- **WHEN** 在查看弹窗或 `SystemViewDialog` 中展示价格
- **THEN** 字段标签 SHALL 含「(元)」,数值 SHALL 以 `¥` + `formatMoneyDisplay` 展示,并 SHALL 使用 `money-display` 红色样式(`ViewField.money: true` 或模板侧 `class="money-display"`)
#### Scenario: Code review 价格类字段
- **WHEN** PR 涉及价格、单价、金额、成本价等字段
- **THEN** 审查者 SHALL 确认 label 含「(元)」、只读值含 `¥` 前缀与 `money-display` 红色样式,且使用 `AmountInput` / `formatMoneyDisplay`,拒绝页面内复制千分位实现或用主色绿展示金额
### Requirement: Web 管理端常见错误案例(反例登记)
以下条目为**已发生过的真实缺陷**,用于 PR 自检与 Code Review;新增类似实现时 SHALL 对照避免重复。
#### 错误案例 EC-UI-001:无分页列表底角被表格白底遮住
| 项 | 说明 |
|----|------|
| **现象** | 列表面板(`.panel`)左下、右下圆角被「削平」或露出方角白块,多见于**树形/全量列表且无分页条**的页面(如 `#/crm/product-category`、组织管理、菜单管理等)。 |
| **根因** | `.list-body.list-wrap` 全局为 `overflow: visible`(避免影响 `fixed="right"` 操作列);`el-table` 单元格白底在面板底部以直角延伸,盖住 `.panel` / `.list-wrap` 的 `border-radius: 0 0 10px 10px`。有分页时由 `.list-pager-bar` 承接底圆角,问题不明显;**无分页时列表区为 panel 末段**,白底直接顶到面板底边。 |
| **错误写法** | 仅给 `.list-wrap` 设底圆角,未裁剪末段列表;或误以为「与有分页列表页相同结构」即可自动正确。 |
| **正确做法** | 依赖全局规则(`web-admin/src/assets/styles/core/app.scss`):当 `.list-body.list-wrap` **后面没有** `.pagination.list-pager-bar` / `.list-footer` 时,对该节点设 `overflow: hidden` 以裁剪底圆角。新增无分页列表页 SHALL 使用 `list-body list-wrap` 包裹表格,**SHALL NOT** 在页面 scoped 样式中单独 `overflow: hidden` 于 `.panel`(会破坏固定列)。 |
| **自检** | 打开页面滚动至列表底部,确认面板左下/右下圆角与 Mint 边框一致,无白色方角溢出。 |
#### Scenario: EC-UI-001 无分页树形列表
- **WHEN** 新增 P03 类树形列表页且 **SHALL NOT** 使用分页条
- **THEN** 表格 SHALL 包在 `.list-body.list-wrap` 内,且其后 **SHALL NOT** 紧跟 `.pagination.list-pager-bar`
- **THEN** 实现 SHALL 依赖全局 `app.scss` 末段裁剪规则,SHALL NOT 仅复制有分页参考页而遗漏底圆角验收
#### Scenario: EC-UI-001 Code review
- **WHEN** PR 新增或改造无分页的 `list-body list-wrap` 列表
- **THEN** 审查者 SHALL 在浏览器中检查面板底部圆角是否被表格白底遮挡
#### 错误案例 EC-UI-002:一级菜单 `meta.icon` 未注册导致侧边栏无图标
| 项 | 说明 |
|----|------|
| **现象** | 左侧一级菜单(如「CRM 管理」)名称前无图标,仅留空槽或占位。 |
| **根因** | `ArtSvgIcon` 约定:图标名**不含** `:` → 查 `src/utils/ui/ep-icon-map.ts` 的 Element Plus 映射;**含** `:` → 走 Iconify。在 `sqxp.ts` / `menu.py` 中设置 `meta.icon: 'goods'` 时,若 `ep-icon-map` 未注册 `goods`,`resolveEpIcon` 返回 `undefined`,组件不渲染图标。`workflow` 等同理,须在 map 中注册(如 `workflow: Connection`)。 |
| **错误写法** | 路由/菜单树写入 `icon: 'goods'` 或任意 EP 图标名,但未在 `ep-icon-map.ts` 增加 `import` 与 `epIconMap` 条目;或误用无 `:` 的字符串指望 Iconify 自动加载。 |
| **正确做法** | 1)在 `@element-plus/icons-vue` 确认有对应组件(如 `Goods`);2)在 `ep-icon-map.ts` 注册 `goods: Goods`;3)再在 `meta.icon` 使用 `'goods'`。新增一级菜单图标 SHALL 与「图标组件使用 Element Plus 图标」Requirement 一并完成。 |
| **参考** | 修复记录:`goods` → `Goods`(CRM 管理);`workflow` → `Connection`(工作流管理)。 |
#### Scenario: EC-UI-002 新增一级菜单图标
- **WHEN** 在 `src/router/modules/` 或 `backend/app/api/menu.py` 为一级菜单设置 `meta.icon`
- **THEN** 图标名 SHALL 已在 `web-admin/src/utils/ui/ep-icon-map.ts` 注册
- **THEN** 刷新后侧边栏一级项 SHALL 显示图标(`SidebarSubmenu` 仅 `level === 0` 渲染图标)
#### Scenario: EC-UI-002 Code review
- **WHEN** PR 修改菜单 `meta.icon` 或新增 EP 风格图标名(无 `:` 前缀)
- **THEN** 审查者 SHALL 打开 `ep-icon-map.ts` 确认存在对应键值,SHALL NOT 仅检查路由/菜单 JSON 是否写了 `icon` 字段
#### 错误案例 EC-UI-003:价格字段未标「(元)」或未展示「¥」
| 项 | 说明 |
|----|------|
| **现象** | 列表列标题为「标准单价」、弹窗 label 为「成本价」,单元格或详情仅显示 `1,234.56` 无货币符号。 |
| **根因** | 仅使用 `formatMoneyDisplay` 满足千分位,未遵守「标签单位 + 展示符号」双轨约定。 |
| **正确做法** | 见本 spec「金额与单价千分位输入与展示」:label/dt/列头一律 `xxx(元)`;只读值一律 `<span class="money-display">¥{{ formatMoneyDisplay(...) }}</span>`。 |
#### Scenario: EC-UI-003 Code review
- **WHEN** PR 新增或修改价格、单价、金额、成本价等字段
- **THEN** 审查者 SHALL 抽查列表列、弹窗表单、查看弹窗三处是否均满足「(元)」、`¥` 前缀与 `money-display` 红色样式
#### 错误案例 EC-UI-003b:价格类字段未使用红色强调
| 项 | 说明 |
|----|------|
| **现象** | 列表/查看弹窗中单价、金额使用主色绿(`#059669`)或默认黑色展示。 |
| **根因** | 误用 `.amount-text`、`.approval-amount` 等历史局部样式,或未使用全局 `.money-display`。 |
| **正确做法** | 只读价格 SHALL 使用 `money-display`(`#dc2626`);`SystemViewDialog` 的 `ViewField.money: true` 自动套用。 |
#### 错误案例 EC-UI-004:弹窗栅格内 `el-input-number` 宽度窄于同行文本框
| 项 | 说明 |
|----|------|
| **现象** | 新增/编辑弹窗两列栅格中,「标准单价」「成本价」「排序号」等 `el-input-number` 明显比「产品名称」等 `el-input` 窄。 |
| **根因** | Element Plus 为 `el-input-number` 根节点设置默认宽度(约 150px);仅加 Tailwind `w-full` 或未覆盖根宽度时无法与同列 `el-input` 对齐。 |
| **正确做法** | 价格字段用 `AmountInput`;其余数字字段依赖 `app.scss` 弹窗/页面表单内 `.el-input-number { width: 100%; }`;SHALL NOT 在单页 scoped 零散补丁。 |
| **参考** | `#/crm/product` 产品新增弹窗。 |
#### Scenario: EC-UI-004 Code review
- **WHEN** PR 在 `dialog-form-layout` + `form-grid-*` 弹窗中新增 `el-input-number`
- **THEN** 审查者 SHALL 确认数字控件与同栅格列文本输入同宽