You need to sign in before continuing.

Commit 787af94f4513caef4bc11db91d9b709197e7ffd4

Authored by “wangming”
1 parent a382d270

完善设备查看会话记录功能,新增客户名称解析,更新统计输出和查询输入结构,优化前端筛选界面以支持设备名称和查看内容的模糊筛选。

docs/md/sbRecords接口测试报告.md 0 → 100644
  1 +# sbRecords 统计接口测试报告
  2 +
  3 +## 测试时间
  4 +2025-02-17
  5 +
  6 +## 一、Statistics 接口
  7 +
  8 +**接口**:`GET /api/Extend/SbRecords/Actions/Statistics`
  9 +
  10 +**参数**:与列表查询一致,支持 addTime、type、addUser、reId、customerName、deviceName、contentName
  11 +
  12 +**返回结构**:
  13 +```json
  14 +{
  15 + "totalCount": 25,
  16 + "completeCount": 24,
  17 + "totalDurationSeconds": 212,
  18 + "avgDurationSeconds": 8.83,
  19 + "byType": [{ "name": "备件支持", "value": 6 }, ...],
  20 + "byEquipment": [{ "name": "设备名称", "value": 3 }, ...],
  21 + "byContent": [{ "name": "查看内容", "value": 1 }, ...],
  22 + "byCustomer": [...],
  23 + "byUser": [...]
  24 +}
  25 +```
  26 +
  27 +**测试结果**:✅ 通过
  28 +- 返回新增字段:completeCount、totalDurationSeconds、avgDurationSeconds、byContent
  29 +- byEquipment 已改为按 F_DeviceName 分组
  30 +- 筛选参数生效
  31 +
  32 +## 二、GetList 接口
  33 +
  34 +**接口**:`GET /api/Extend/SbRecords`
  35 +
  36 +**新增筛选参数**:deviceName、contentName(模糊匹配)
  37 +
  38 +**测试结果**:✅ 通过
  39 +- deviceName=动力、contentName=猫 等筛选正常
  40 +- 返回 deviceName、contentName 字段
  41 +
  42 +## 三、curl 示例
  43 +
  44 +```bash
  45 +# 1. 获取 Token
  46 +TOKEN=$(curl -s -X POST "http://localhost:2011/api/oauth/Login" \
  47 + -H "Content-Type: application/x-www-form-urlencoded" \
  48 + -d "account=admin&password=66762a3ccde2a2cff3060d7a4a0a576b" \
  49 + | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])")
  50 +
  51 +# 2. 调用 Statistics
  52 +curl -s -X GET "http://localhost:2011/api/Extend/SbRecords/Actions/Statistics" \
  53 + -H "Authorization: $TOKEN"
  54 +
  55 +# 3. 带筛选参数
  56 +curl -s -X GET "http://localhost:2011/api/Extend/SbRecords/Actions/Statistics?type=故障排查" \
  57 + -H "Authorization: $TOKEN"
  58 +
  59 +# 4. 列表筛选
  60 +curl -s -X GET "http://localhost:2011/api/Extend/SbRecords?deviceName=动力&contentName=猫" \
  61 + -H "Authorization: $TOKEN"
  62 +```
  63 +
  64 +## 四、结论
  65 +
  66 +| 接口 | 状态 |
  67 +|------|------|
  68 +| Statistics | ✅ 通过 |
  69 +| GetList(deviceName/contentName 筛选) | ✅ 通过 |
  70 +
  71 +## 五、页面 UI 修复验证(2025-02-17)
  72 +
  73 +### 修复项
  74 +
  75 +| 问题 | 修复方案 | 验证要点 |
  76 +|------|----------|----------|
  77 +| 表格重复「序号」列 | NCC-table 默认 `hasNO=true` 会渲染序号列,与自定义 `indexMethod` 列重复;设置 `:has-n-o="false"` 关闭内置序号 | 表头仅有一列「序号」,分页序号正确 |
  78 +| 空值显示不规范 | 项目规范:没有信息的字段显示「无」 | 设备名称、查看内容、查看客户、查看用户、记录类型 为空时显示「无」 |
  79 +| 操作列未左对齐 | 项目规范:操作按钮必须左对齐 | 操作列「删除」按钮左对齐 |
  80 +| 统计卡片内边距 | 项目规范:卡片内边距 12px | 统计卡片 padding 为 12px |
  81 +
  82 +### 验证清单
  83 +
  84 +- [ ] 表格表头无重复「序号」列
  85 +- [ ] 分页切换后序号连续(如第 2 页从 21 开始)
  86 +- [ ] 空值字段显示「无」而非空白或「-」
  87 +- [ ] 操作列「删除」按钮左对齐
  88 +- [ ] 统计卡片布局符合规范(100px 高、12px 内边距、12px 圆角)
  89 +- [ ] 图表展开后主内容区可正常滚动
... ...
docs/md/移动端设备查看埋点方案.md 0 → 100644
  1 +# 移动端设备查看埋点方案
  2 +
  3 +## 一、需求与目标
  4 +
  5 +- **需求**:记录用户**在哪个板块**看了**哪个设备**,以及**停留时长**
  6 +- **对接接口**:`StartViewRecord`(开始会话)、`EndViewRecord`(结束会话)
  7 +- **数据落库**:`sb_records` 表(F_ReId、F_Type、F_ContentName、F_DeviceName、F_AddTime、F_LeaveTime、F_DurationSeconds)
  8 +
  9 +---
  10 +
  11 +## 二、首页 8 个板块 vs 需埋点 6 个
  12 +
  13 +| 序号 | 板块名称 | 路由/页面 | 是否埋点 | 说明 |
  14 +|------|----------|-----------|----------|------|
  15 +| 1 | 故障排查 | `/pages/new/gzpc/gzpc` → `detail` | ✅ | 有设备/故障关联 |
  16 +| 2 | 知识库 | `/pages/new/zsk/zsk` → `detail` | ✅ | 有产品/设备关联 |
  17 +| 3 | 我的设备 | `/pages/myDevice/myDevice` → `detail` | ✅ | 直接是设备 |
  18 +| 4 | 资料管理 | `/pages/new/zlgl/zlgl` → `detail` | ✅ | 有设备/资料关联 |
  19 +| 5 | 备件支持 | `/pages/new/bjzc/bjzc` → `detail` | ✅ | 有产品/设备关联 |
  20 +| 6 | 培训展示 | `/pages/new/pxzs/pxzs` → `detail` | ✅ | 有产品关联 |
  21 +| 7 | 信息推送 | `/pages/new/xxts/xxts` | ❌ | 无设备维度 |
  22 +| 8 | 用户反馈 | `/pages/new/yhfk/yhfk` | ❌ | 无设备维度 |
  23 +
  24 +---
  25 +
  26 +## 三、埋点粒度与 reId 约定
  27 +
  28 +**原则**:只在**详情页**埋点(列表页无具体“设备/资料”对象)。
  29 +**reId**:当前详情对应的**主实体 id**(用于 sb_records 的 F_ReId)。
  30 +**type**:板块名称,用于区分来源。
  31 +
  32 +| 板块 | 详情页路径 | reId 来源 | type 值 | deviceName(设备名称) | contentName(查看内容) |
  33 +|------|------------|-----------|---------|------------------------|-------------------------|
  34 +| 故障排查 | `gzpc/detail` | `info.id` | 故障排查 | `info.sbmc` | `info.code`(故障代码) |
  35 +| 知识库 | `zsk/detail` | `detail.id` | 知识库 | 后端根据 sssb 查 cpgl | `detail.sbm` |
  36 +| 我的设备 | `myDevice/detail` | `options.id` | 我的设备 | `detail.sbmc` | `detail.sbmc` |
  37 +| 资料管理 | `zlgl/detail` | `info.id` | 资料管理 | `info.zlm` | `info.zlm` |
  38 +| 备件支持 | `bjzc/detail` | `info.id` | 备件支持 | `info.glcpmc` | `info.mc` |
  39 +| 培训展示 | `pxzs/detail` | `info.id` | 培训展示 | `info.sbmc` | `info.pxmc` |
  40 +
  41 +> **说明**:sb_records 通过 **记录类型 + 设备名称 + 查看内容** 三字段定位。F_Type、F_DeviceName、F_ContentName 均可由前端传入,未传时后端根据 type+reId 从对应表自动解析。
  42 +
  43 +---
  44 +
  45 +## 四、埋点时机
  46 +
  47 +| 时机 | 动作 | 说明 |
  48 +|------|------|------|
  49 +| 进入详情页 | 调用 `StartViewRecord` | `onLoad` 或 `onShow` 中,拿到 reId、type 后调用 |
  50 +| 离开详情页 | 调用 `EndViewRecord` | `onUnload` 或 `onHide` 中,传入之前保存的 recordId |
  51 +
  52 +**推荐**:
  53 +- **Start**:在详情数据加载完成后(如 `gzpcxq`、`fetchDetail` 等接口返回后)调用,确保有 reId
  54 +- **End**:在 `onUnload` 中调用(页面销毁时),`onHide` 可能因切后台未真正离开,可选补充
  55 +
  56 +---
  57 +
  58 +## 五、各详情页数据流与埋点接入点
  59 +
  60 +### 5.1 故障排查详情 `gzpc/detail.vue`
  61 +
  62 +- **进入**:`onLoad` 从 `uni.getStorageSync("detail")` 取 `info`,调用 `gzpcxq({ id: info.id })`
  63 +- **reId**:`this.info.id`(故障 id)或 `this.info.sssbId`(若有)
  64 +- **type**:`"故障排查"`
  65 +- **埋点**:`gzpcxq` 成功后 `StartViewRecord`;`ht()` 返回 / `onUnload` 时 `EndViewRecord`
  66 +
  67 +### 5.2 知识库详情 `zsk/detail.vue`
  68 +
  69 +- **进入**:`onLoad(options)` 取 `options.id`,调用 `fetchDetail` → `zskxq({ id })`
  70 +- **reId**:`this.id` 或 `this.detail.id`
  71 +- **type**:`"知识库"`
  72 +- **埋点**:`fetchDetail` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord`
  73 +
  74 +### 5.3 我的设备详情 `myDevice/detail.vue`
  75 +
  76 +- **进入**:`onLoad(options)` 取 `options.id`,调用 `fetchDeviceDetail(deviceId)`
  77 +- **reId**:`options.id` 或 `this.detail.id`
  78 +- **type**:`"我的设备"`
  79 +- **埋点**:`fetchDeviceDetail` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord`
  80 +
  81 +### 5.4 资料管理详情 `zlgl/detail.vue`
  82 +
  83 +- **进入**:`onLoad(options)` 取 `options.id`,调用详情接口
  84 +- **reId**:`options.id` 或 `this.info.id`
  85 +- **type**:`"资料管理"`
  86 +- **埋点**:详情加载成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord`
  87 +
  88 +### 5.5 备件支持详情 `bjzc/detail.vue`
  89 +
  90 +- **进入**:`onLoad` 从 `uni.getStorageSync("detail")` 取 info,调用 `bjzcxq`
  91 +- **reId**:`this.info.id`(备件 id)
  92 +- **type**:`"备件支持"`
  93 +- **埋点**:`bjzcxq` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord`
  94 +
  95 +### 5.6 培训展示详情 `pxzs/detail.vue`
  96 +
  97 +- **进入**:`onLoad` 从 `uni.getStorageSync("detail")` 取 info,调用 `pxzsxqs`
  98 +- **reId**:`info.id`(培训 id)
  99 +- **type**:`"培训展示"`
  100 +- **埋点**:`pxzsxqs` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord`
  101 +
  102 +---
  103 +
  104 +## 六、前端实现要点
  105 +
  106 +### 6.1 API 封装
  107 +
  108 +在 `uniapp_jiju/apis/modules/oauth.js`(或新建 `sbRecords.js`)中增加:
  109 +
  110 +```javascript
  111 +// 开始设备查看会话(进入详情页时调用)
  112 +startViewRecord(data) {
  113 + return request.post('/api/Extend/SbRecords/Actions/StartViewRecord', data, {
  114 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  115 + })
  116 +},
  117 +// 结束设备查看会话(离开详情页时调用)
  118 +endViewRecord(data) {
  119 + return request.post('/api/Extend/SbRecords/Actions/EndViewRecord', data, {
  120 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  121 + })
  122 +}
  123 +```
  124 +
  125 +> 注意:项目规范 GET 用 data 传参;POST 表单可用 `application/x-www-form-urlencoded`,参数为 `reId`、`type`、`recordId`。
  126 +
  127 +### 6.2 参数格式
  128 +
  129 +- **StartViewRecord**:`reId`(必填)、`type`(必填)、`contentName`(可选,查看内容)、`deviceName`(可选,设备名称)、`item1`(可选)、`item2`(可选)
  130 +- **EndViewRecord**:`recordId`(必填,Start 返回的 id)
  131 +
  132 +> contentName、deviceName 未传时,后端根据 type+reId 从对应表自动解析。
  133 +
  134 +### 6.3 通用逻辑(可抽成 mixin)
  135 +
  136 +```javascript
  137 +// 伪代码
  138 +data() {
  139 + return { viewRecordId: null }
  140 +},
  141 +methods: {
  142 + async startViewTracking(reId, type, contentName, deviceName) {
  143 + if (!reId || !type) return
  144 + try {
  145 + const data = { reId, type }
  146 + if (contentName) data.contentName = contentName
  147 + if (deviceName) data.deviceName = deviceName
  148 + const res = await this.API.startViewRecord(data)
  149 + async endViewTracking() {
  150 + if (!this.viewRecordId) return
  151 + try {
  152 + await this.API.endViewRecord({ recordId: this.viewRecordId })
  153 + } catch (e) { console.warn('EndViewRecord failed', e) }
  154 + this.viewRecordId = null
  155 + }
  156 +},
  157 +onUnload() {
  158 + this.endViewTracking()
  159 +}
  160 +```
  161 +
  162 +各详情页在数据加载成功后调用 `this.startViewTracking(reId, type, contentName, deviceName)`,在 `onUnload` 中调用 `this.endViewTracking()`。
  163 +
  164 +---
  165 +
  166 +## 七、实现顺序建议
  167 +
  168 +1. **API 封装**:在 `oauth.js` 或新建模块中增加 `startViewRecord`、`endViewRecord`
  169 +2. **Mixin 或工具函数**:抽 `startViewTracking`、`endViewTracking`,避免重复代码
  170 +3. **按页面接入**:依次在 6 个详情页接入埋点(建议顺序:我的设备 → 故障排查 → 知识库 → 资料管理 → 备件支持 → 培训展示)
  171 +4. **联调与验证**:真机/模拟器操作,查 `sb_records` 表确认 F_Type、F_DeviceName、F_ContentName、F_DurationSeconds 正确
  172 +
  173 +---
  174 +
  175 +## 八、注意事项
  176 +
  177 +- **未登录**:若接口需 Token,未登录用户可能调用失败,可静默失败或跳过埋点
  178 +- **快速返回**:用户进入后立即返回,End 仍会执行,Duration 可能为 0 或很小,属正常
  179 +- **页面栈**:`navigateBack` 会触发 `onUnload`,可正常执行 End;`reLaunch`、`redirectTo` 同理
  180 +- **recordId 存储**:必须存在组件 data 中,`onUnload` 时能访问到,避免用全局变量导致多页面冲突
... ...
docs/sql/add_sb_records_content_name.sql 0 → 100644
  1 +-- 为 sb_records 表新增字段,实现「记录类型+设备名称+查看内容」三字段定位
  2 +-- 执行时间:按需执行(若 F_ContentName 已存在,可跳过第一条)
  3 +
  4 +-- 1. 查看内容(故障代码、知识名、资料名、备件名、培训名等)
  5 +ALTER TABLE `sb_records`
  6 + ADD COLUMN `F_ContentName` varchar(200) NULL COMMENT '查看内容(故障代码、知识名、资料名等)' AFTER `F_Type`;
  7 +
  8 +-- 2. 设备名称(所属设备、关联产品/设备名)
  9 +ALTER TABLE `sb_records`
  10 + ADD COLUMN `F_DeviceName` varchar(200) NULL COMMENT '设备名称(所属设备、关联产品名)' AFTER `F_Type`;
... ...
docs/sql/add_sb_records_customer_name.sql 0 → 100644
  1 +-- 为 sb_records 表新增 F_CustomerName 字段
  2 +-- 用于存储「查看客户」,根据记录类型+ReId 从 Khsb/Zlgl/Gzcx 等表解析
  3 +-- 新记录由 StartViewRecord 自动填充;历史记录可保留空,列表接口会回退到 Khsb 子查询(仅对「我的设备」有效)
  4 +
  5 +ALTER TABLE `sb_records`
  6 + ADD COLUMN `F_CustomerName` varchar(200) NULL COMMENT '查看客户(所属客户)' AFTER `F_DeviceName`;
... ...
docs/sql/add_sb_records_device_name.sql 0 → 100644
  1 +-- 为 sb_records 表新增 F_DeviceName 字段(若已执行 add_sb_records_content_name.sql 可单独执行本脚本)
  2 +-- 实现「记录类型+设备名称+查看内容」三字段定位
  3 +
  4 +ALTER TABLE `sb_records`
  5 + ADD COLUMN `F_DeviceName` varchar(200) NULL COMMENT '设备名称(所属设备、关联产品名)' AFTER `F_Type`;
... ...
uniapp_jiju/apis/modules/oauth.js
... ... @@ -236,4 +236,12 @@ export default {
236 236 hqlogo(data){
237 237 return request.get('/api/Extend/Logogl?lx=' + '移动端')
238 238 },
  239 + // 开始设备查看会话(进入详情页时调用,用于记录停留时长)
  240 + startViewRecord(data) {
  241 + return request.postFormData('/api/Extend/SbRecords/Actions/StartViewRecord', data)
  242 + },
  243 + // 结束设备查看会话(离开详情页时调用,用于计算停留时长)
  244 + endViewRecord(data) {
  245 + return request.postFormData('/api/Extend/SbRecords/Actions/EndViewRecord', data)
  246 + },
239 247 }
... ...
uniapp_jiju/common/mixins/viewRecordMixin.js 0 → 100644
  1 +/**
  2 + * 设备查看记录埋点 mixin
  3 + * 用于详情页:进入时 StartViewRecord,离开时 EndViewRecord
  4 + * 使用:在详情页 mixins: [viewRecordMixin],数据加载成功后调用 this.startViewTracking(reId, type, contentName, deviceName)
  5 + * 记录类型、设备名称、查看内容 三字段便于定位
  6 + */
  7 +export default {
  8 + data() {
  9 + return {
  10 + viewRecordId: null
  11 + }
  12 + },
  13 + methods: {
  14 + async startViewTracking(reId, type, contentName, deviceName) {
  15 + if (!reId || !type) return
  16 + try {
  17 + const data = { reId: String(reId), type }
  18 + if (contentName) data.contentName = String(contentName)
  19 + if (deviceName) data.deviceName = String(deviceName)
  20 + const res = await this.API.startViewRecord(data)
  21 + if (res && res.data) {
  22 + this.viewRecordId = res.data
  23 + }
  24 + } catch (e) {
  25 + console.warn('StartViewRecord failed', e)
  26 + }
  27 + },
  28 + async endViewTracking() {
  29 + if (!this.viewRecordId) return
  30 + try {
  31 + await this.API.endViewRecord({ recordId: this.viewRecordId })
  32 + } catch (e) {
  33 + console.warn('EndViewRecord failed', e)
  34 + }
  35 + this.viewRecordId = null
  36 + }
  37 + },
  38 + onUnload() {
  39 + this.endViewTracking()
  40 + }
  41 +}
... ...
机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecords/SbRecordsStatisticsOutput.cs
... ... @@ -10,12 +10,24 @@ namespace NCC.Extend.Entitys.Dto.SbRecords
10 10 /// <summary>总记录数</summary>
11 11 public int TotalCount { get; set; }
12 12  
  13 + /// <summary>完整记录数(有离开时间的记录)</summary>
  14 + public int CompleteCount { get; set; }
  15 +
  16 + /// <summary>总停留时长(秒)</summary>
  17 + public long TotalDurationSeconds { get; set; }
  18 +
  19 + /// <summary>平均停留时长(秒,仅统计完整记录)</summary>
  20 + public double AvgDurationSeconds { get; set; }
  21 +
13 22 /// <summary>按记录类型统计 [{ name, value }]</summary>
14 23 public List<SbRecordsStatisticsItem> ByType { get; set; }
15 24  
16   - /// <summary>按关联设备统计(前 N 条)</summary>
  25 + /// <summary>按设备名称统计(前 N 条,使用 F_DeviceName)</summary>
17 26 public List<SbRecordsStatisticsItem> ByEquipment { get; set; }
18 27  
  28 + /// <summary>按查看内容统计(前 N 条,故障代码、知识名、培训名等)</summary>
  29 + public List<SbRecordsStatisticsItem> ByContent { get; set; }
  30 +
19 31 /// <summary>按客户统计(前 N 条)</summary>
20 32 public List<SbRecordsStatisticsItem> ByCustomer { get; set; }
21 33  
... ...
机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecordsListQueryInput.cs
... ... @@ -53,6 +53,15 @@ namespace NCC.Extend.Entitys.Dto.SbRecords
53 53 /// 记录类型
54 54 /// </summary>
55 55 public string type { get; set; }
56   -
  56 +
  57 + /// <summary>
  58 + /// 设备名称(模糊筛选)
  59 + /// </summary>
  60 + public string deviceName { get; set; }
  61 +
  62 + /// <summary>
  63 + /// 查看内容(模糊筛选)
  64 + /// </summary>
  65 + public string contentName { get; set; }
57 66 }
58 67 }
... ...
机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/SbRecordsEntity.cs
... ... @@ -82,6 +82,12 @@ namespace NCC.Extend.Entitys
82 82 /// </summary>
83 83 [SugarColumn(ColumnName = "F_DeviceName")]
84 84 public string DeviceName { get; set; }
  85 +
  86 + /// <summary>
  87 + /// 查看客户(所属客户,根据记录类型+ReId 从 Khsb/Zlgl/Gzcx 等表解析)
  88 + /// </summary>
  89 + [SugarColumn(ColumnName = "F_CustomerName")]
  90 + public string CustomerName { get; set; }
85 91  
86 92 }
87 93 }
88 94 \ No newline at end of file
... ...
机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/NCC.Extend.Entitys.xml
... ... @@ -3737,11 +3737,23 @@
3737 3737 <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.TotalCount">
3738 3738 <summary>总记录数</summary>
3739 3739 </member>
  3740 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.CompleteCount">
  3741 + <summary>完整记录数(有离开时间的记录)</summary>
  3742 + </member>
  3743 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.TotalDurationSeconds">
  3744 + <summary>总停留时长(秒)</summary>
  3745 + </member>
  3746 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.AvgDurationSeconds">
  3747 + <summary>平均停留时长(秒,仅统计完整记录)</summary>
  3748 + </member>
3740 3749 <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.ByType">
3741 3750 <summary>按记录类型统计 [{ name, value }]</summary>
3742 3751 </member>
3743 3752 <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.ByEquipment">
3744   - <summary>按关联设备统计(前 N 条)</summary>
  3753 + <summary>按设备名称统计(前 N 条,使用 F_DeviceName)</summary>
  3754 + </member>
  3755 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.ByContent">
  3756 + <summary>按查看内容统计(前 N 条,故障代码、知识名、培训名等)</summary>
3745 3757 </member>
3746 3758 <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsStatisticsOutput.ByCustomer">
3747 3759 <summary>按客户统计(前 N 条)</summary>
... ... @@ -3879,9 +3891,19 @@
3879 3891 记录类型
3880 3892 </summary>
3881 3893 </member>
  3894 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsListOutput.contentName">
  3895 + <summary>
  3896 + 查看内容(故障代码、知识名、资料名、备件名、培训名等)
  3897 + </summary>
  3898 + </member>
  3899 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsListOutput.deviceName">
  3900 + <summary>
  3901 + 设备名称(所属设备、关联产品名;优先 F_DeviceName,无则根据 reId 查表)
  3902 + </summary>
  3903 + </member>
3882 3904 <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsListOutput.equipmentName">
3883 3905 <summary>
3884   - 关联设备(原关联记录,展示设备名称
  3906 + 关联设备(兼容旧逻辑,展示设备名称;优先使用 deviceName
3885 3907 </summary>
3886 3908 </member>
3887 3909 <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsListOutput.customerName">
... ... @@ -3949,6 +3971,16 @@
3949 3971 记录类型
3950 3972 </summary>
3951 3973 </member>
  3974 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsListQueryInput.deviceName">
  3975 + <summary>
  3976 + 设备名称(模糊筛选)
  3977 + </summary>
  3978 + </member>
  3979 + <member name="P:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsListQueryInput.contentName">
  3980 + <summary>
  3981 + 查看内容(模糊筛选)
  3982 + </summary>
  3983 + </member>
3952 3984 <member name="T:NCC.Extend.Entitys.Dto.SbRecords.SbRecordsUpInput">
3953 3985 <summary>
3954 3986 查看历史记录更新输入参数
... ... @@ -8763,6 +8795,21 @@
8763 8795 记录类型
8764 8796 </summary>
8765 8797 </member>
  8798 + <member name="P:NCC.Extend.Entitys.SbRecordsEntity.ContentName">
  8799 + <summary>
  8800 + 查看内容(故障代码、知识名、资料名、备件名、培训名等)
  8801 + </summary>
  8802 + </member>
  8803 + <member name="P:NCC.Extend.Entitys.SbRecordsEntity.DeviceName">
  8804 + <summary>
  8805 + 设备名称(所属设备、关联产品名,便于「记录类型+设备名称+查看内容」三字段定位)
  8806 + </summary>
  8807 + </member>
  8808 + <member name="P:NCC.Extend.Entitys.SbRecordsEntity.CustomerName">
  8809 + <summary>
  8810 + 查看客户(所属客户,根据记录类型+ReId 从 Khsb/Zlgl/Gzcx 等表解析)
  8811 + </summary>
  8812 + </member>
8766 8813 <member name="T:NCC.Extend.Entitys.SbwhryEntity">
8767 8814 <summary>
8768 8815 设备维护人员
... ...
机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend/SbRecordsService.cs
... ... @@ -65,6 +65,7 @@ namespace NCC.Extend.SbRecords
65 65 var now = DateTime.Now;
66 66 var resolvedContentName = !string.IsNullOrWhiteSpace(contentName) ? contentName : await ResolveContentNameAsync(type, reId);
67 67 var resolvedDeviceName = !string.IsNullOrWhiteSpace(deviceName) ? deviceName : await ResolveDeviceNameAsync(type, reId);
  68 + var resolvedCustomerName = await ResolveCustomerNameAsync(type, reId);
68 69  
69 70 var entity = new SbRecordsEntity
70 71 {
... ... @@ -79,7 +80,8 @@ namespace NCC.Extend.SbRecords
79 80 Item2 = item2,
80 81 Type = type,
81 82 ContentName = resolvedContentName,
82   - DeviceName = resolvedDeviceName
  83 + DeviceName = resolvedDeviceName,
  84 + CustomerName = resolvedCustomerName
83 85 };
84 86  
85 87 var isOk = await _db.Insertable(entity).IgnoreColumns(ignoreNullColumn: true).ExecuteCommandAsync();
... ... @@ -196,6 +198,59 @@ namespace NCC.Extend.SbRecords
196 198 }
197 199  
198 200 /// <summary>
  201 + /// 根据 type 和 reId 从对应业务表解析查看客户(所属客户)。
  202 + /// 我的设备:ReId=Khsb.Id;故障排查/知识库/资料管理/备件支持/培训展示:通过产品关联 Khsb.Fl 获取 Sskh。
  203 + /// </summary>
  204 + private async Task<string> ResolveCustomerNameAsync(string type, string reId)
  205 + {
  206 + if (string.IsNullOrWhiteSpace(reId)) return null;
  207 + var t = (type ?? "").Trim();
  208 +
  209 + if (t.Contains("我的设备") || t.Contains("查看设备"))
  210 + {
  211 + var sskh = await _db.Queryable<KhsbEntity>().Where(x => x.Id == reId).Select(x => x.Sskh).FirstAsync();
  212 + return sskh;
  213 + }
  214 + if (t.Contains("故障排查") || t.Contains("故障查询"))
  215 + {
  216 + var zlFl = await _db.Queryable<GzcxEntity, ZlglEntity>((g, z) => g.Sssb == z.Id)
  217 + .Where((g, z) => g.Id == reId)
  218 + .Select((g, z) => z.Fl)
  219 + .FirstAsync();
  220 + if (string.IsNullOrEmpty(zlFl)) return null;
  221 + return await _db.Queryable<KhsbEntity>().Where(k => k.Fl == zlFl && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync();
  222 + }
  223 + if (t.Contains("知识库"))
  224 + {
  225 + var sssb = await _db.Queryable<ZskEntity>().Where(x => x.Id == reId).Select(x => x.Sssb).FirstAsync();
  226 + if (string.IsNullOrEmpty(sssb)) return null;
  227 + return await _db.Queryable<KhsbEntity>().Where(k => (k.Fl == sssb) && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync();
  228 + }
  229 + if (t.Contains("资料管理"))
  230 + {
  231 + var zlFl = await _db.Queryable<ZlglEntity>().Where(x => x.Id == reId).Select(x => x.Fl).FirstAsync();
  232 + if (string.IsNullOrEmpty(zlFl)) return null;
  233 + return await _db.Queryable<KhsbEntity>().Where(k => k.Fl == zlFl && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync();
  234 + }
  235 + if (t.Contains("备件支持"))
  236 + {
  237 + var zlFl = await _db.Queryable<BjxxEntity, ZlglEntity>((b, z) => b.Sssb == z.Id)
  238 + .Where((b, z) => b.Id == reId)
  239 + .Select((b, z) => z.Fl)
  240 + .FirstAsync();
  241 + if (string.IsNullOrEmpty(zlFl)) return null;
  242 + return await _db.Queryable<KhsbEntity>().Where(k => k.Fl == zlFl && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync();
  243 + }
  244 + if (t.Contains("培训展示"))
  245 + {
  246 + var sssb = await _db.Queryable<PxzsEntity>().Where(x => x.Id == reId).Select(x => x.Sssb).FirstAsync();
  247 + if (string.IsNullOrEmpty(sssb)) return null;
  248 + return await _db.Queryable<KhsbEntity>().Where(k => k.Fl == sssb && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync();
  249 + }
  250 + return null;
  251 + }
  252 +
  253 + /// <summary>
199 254 /// 结束一条设备查看会话记录(离开页面时调用),并计算停留时长。
200 255 /// </summary>
201 256 /// <param name="recordId">开始会话时返回的记录 Id。</param>
... ... @@ -270,35 +325,60 @@ namespace NCC.Extend.SbRecords
270 325 .WhereIF(queryAddTime != null, p => p.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59))
271 326 .WhereIF(!string.IsNullOrEmpty(input.addUser), p => p.AddUser.Equals(input.addUser))
272 327 .WhereIF(!string.IsNullOrEmpty(input.type), p => p.Type.Contains(input.type))
  328 + .WhereIF(!string.IsNullOrEmpty(input.deviceName), p => p.DeviceName != null && p.DeviceName.Contains(input.deviceName))
  329 + .WhereIF(!string.IsNullOrEmpty(input.contentName), p => p.ContentName != null && p.ContentName.Contains(input.contentName))
273 330 .WhereIF(!string.IsNullOrEmpty(input.customerName), p => SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == p.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any());
274 331  
275 332 var total = await baseQuery.CountAsync();
276 333 const int topN = 10;
  334 +
  335 + var completeQuery = baseQuery.Where(p => p.LeaveTime != null);
  336 + var completeCount = await completeQuery.CountAsync();
  337 + var totalDuration = completeCount > 0 ? await completeQuery.SumAsync(p => p.DurationSeconds) : 0L;
  338 + var avgDuration = completeCount > 0 ? (double)totalDuration / completeCount : 0;
  339 +
277 340 var byTypeList = await baseQuery.GroupBy(p => p.Type ?? "")
278 341 .Select(g => new { Name = g.Type ?? "未分类", Value = SqlFunc.AggregateCount(g.Id) })
279 342 .ToListAsync();
280 343 var byType = byTypeList.OrderByDescending(x => x.Value).Select(x => new SbRecordsStatisticsItem { Name = x.Name ?? "未分类", Value = x.Value }).ToList();
281   - var byEquipmentRawList = await baseQuery.GroupBy(p => p.ReId)
282   - .Select(g => new { ReId = g.ReId, Value = SqlFunc.AggregateCount(g.Id) })
  344 +
  345 + var byEquipmentRawList = await baseQuery.GroupBy(p => p.DeviceName ?? "")
  346 + .Select(g => new { Name = g.DeviceName ?? "未记录", Value = SqlFunc.AggregateCount(g.Id) })
283 347 .ToListAsync();
284   - var byEquipmentRaw = byEquipmentRawList.OrderByDescending(x => x.Value).Take(topN).ToList();
285   - var reIds = byEquipmentRaw.Select(x => x.ReId).Distinct().ToList();
286   - var eqNames = await _db.Queryable<ZsbtzEntity>().Where(t => reIds.Contains(t.Id)).Select(t => new { t.Id, t.Sbmc }).ToListAsync();
287   - var khsbNames = await _db.Queryable<KhsbEntity>().Where(k => reIds.Contains(k.Id)).Select(k => new { k.Id, k.Sbmc }).ToListAsync();
288   - var byEquipment = byEquipmentRaw.Select(x => new SbRecordsStatisticsItem
289   - {
290   - Name = eqNames.FirstOrDefault(e => e.Id == x.ReId)?.Sbmc ?? khsbNames.FirstOrDefault(k => k.Id == x.ReId)?.Sbmc ?? x.ReId ?? "-",
291   - Value = x.Value
292   - }).ToList();
293   - var byUserRawList = await baseQuery.GroupBy(p => p.AddUser)
294   - .Select(g => new { AddUser = g.AddUser, Value = SqlFunc.AggregateCount(g.Id) })
  348 + var byEquipment = byEquipmentRawList
  349 + .Where(x => !string.IsNullOrEmpty(x.Name))
  350 + .OrderByDescending(x => x.Value)
  351 + .Take(topN)
  352 + .Select(x => new SbRecordsStatisticsItem { Name = x.Name ?? "未记录", Value = x.Value })
  353 + .ToList();
  354 +
  355 + var byContentRawList = await baseQuery.GroupBy(p => p.ContentName ?? "")
  356 + .Select(g => new { Name = g.ContentName ?? "未记录", Value = SqlFunc.AggregateCount(g.Id) })
  357 + .ToListAsync();
  358 + var byContent = byContentRawList
  359 + .Where(x => !string.IsNullOrEmpty(x.Name))
  360 + .OrderByDescending(x => x.Value)
  361 + .Take(topN)
  362 + .Select(x => new SbRecordsStatisticsItem { Name = x.Name ?? "未记录", Value = x.Value })
  363 + .ToList();
  364 + // 按查看用户统计:LEFT JOIN UserEntity 直接获取 RealName,避免 GroupBy 分组键与后续查表不一致
  365 + var byUserQuery = _db.Queryable<SbRecordsEntity, UserEntity>((r, u) => new JoinQueryInfos(JoinType.Left, r.AddUser == u.Id))
  366 + .WhereIF(!string.IsNullOrEmpty(input.reId), (r, u) => r.ReId.Equals(input.reId))
  367 + .WhereIF(queryAddTime != null, (r, u) => r.AddTime >= new DateTime(startAddTime.ToDate().Year, startAddTime.ToDate().Month, startAddTime.ToDate().Day, 0, 0, 0))
  368 + .WhereIF(queryAddTime != null, (r, u) => r.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59))
  369 + .WhereIF(!string.IsNullOrEmpty(input.addUser), (r, u) => r.AddUser.Equals(input.addUser))
  370 + .WhereIF(!string.IsNullOrEmpty(input.type), (r, u) => r.Type != null && r.Type.Contains(input.type))
  371 + .WhereIF(!string.IsNullOrEmpty(input.deviceName), (r, u) => r.DeviceName != null && r.DeviceName.Contains(input.deviceName))
  372 + .WhereIF(!string.IsNullOrEmpty(input.contentName), (r, u) => r.ContentName != null && r.ContentName.Contains(input.contentName))
  373 + .WhereIF(!string.IsNullOrEmpty(input.customerName), (r, u) => SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == r.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any());
  374 + var byUserRawList = await byUserQuery
  375 + .GroupBy((r, u) => r.AddUser ?? "")
  376 + .Select((r, u) => new { AddUser = r.AddUser ?? "", RealName = SqlFunc.AggregateMax(u.RealName), Value = SqlFunc.AggregateCount(r.Id) })
295 377 .ToListAsync();
296   - var byUserRaw = byUserRawList.OrderByDescending(x => x.Value).Take(topN).ToList();
297   - var userIds = byUserRaw.Select(x => x.AddUser).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList();
298   - var userNames = await _db.Queryable<UserEntity>().Where(u => userIds.Contains(u.Id)).Select(u => new { u.Id, u.RealName }).ToListAsync();
  378 + var byUserRaw = byUserRawList.Where(x => !string.IsNullOrEmpty(x.AddUser)).OrderByDescending(x => x.Value).Take(topN).ToList();
299 379 var byUser = byUserRaw.Select(x => new SbRecordsStatisticsItem
300 380 {
301   - Name = string.IsNullOrEmpty(x.AddUser) ? "-" : (userNames.FirstOrDefault(u => u.Id == x.AddUser)?.RealName ?? x.AddUser),
  381 + Name = string.IsNullOrEmpty(x.RealName) ? x.AddUser : x.RealName,
302 382 Value = x.Value
303 383 }).ToList();
304 384 var byCustomerRawList = await _db.Queryable<SbRecordsEntity, KhsbEntity>((r, k) => r.ReId == k.Id)
... ... @@ -317,8 +397,12 @@ namespace NCC.Extend.SbRecords
317 397 return new SbRecordsStatisticsOutput
318 398 {
319 399 TotalCount = total,
  400 + CompleteCount = completeCount,
  401 + TotalDurationSeconds = totalDuration,
  402 + AvgDurationSeconds = Math.Round(avgDuration, 2),
320 403 ByType = byType.Select(x => new SbRecordsStatisticsItem { Name = x.Name, Value = x.Value }).ToList(),
321 404 ByEquipment = byEquipment,
  405 + ByContent = byContent,
322 406 ByCustomer = byCustomer,
323 407 ByUser = byUser
324 408 };
... ... @@ -375,6 +459,8 @@ namespace NCC.Extend.SbRecords
375 459 .WhereIF(queryAddTime != null, p => p.AddTime >= new DateTime(startAddTime.ToDate().Year, startAddTime.ToDate().Month, startAddTime.ToDate().Day, 0, 0, 0))
376 460 .WhereIF(queryAddTime != null, p => p.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59))
377 461 .WhereIF(!string.IsNullOrEmpty(input.addUser), p => p.AddUser.Equals(input.addUser))
  462 + .WhereIF(!string.IsNullOrEmpty(input.deviceName), p => p.DeviceName != null && p.DeviceName.Contains(input.deviceName))
  463 + .WhereIF(!string.IsNullOrEmpty(input.contentName), p => p.ContentName != null && p.ContentName.Contains(input.contentName))
378 464 .WhereIF(!string.IsNullOrEmpty(input.customerName), p => SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == p.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any())
379 465 .WhereIF(!string.IsNullOrEmpty(input.item1), p => p.Item1.Contains(input.item1))
380 466 .WhereIF(!string.IsNullOrEmpty(input.item2), p => p.Item2.Contains(input.item2))
... ... @@ -386,7 +472,7 @@ namespace NCC.Extend.SbRecords
386 472 contentName = it.ContentName,
387 473 deviceName = it.DeviceName,
388 474 equipmentName = SqlFunc.IsNull(it.DeviceName, SqlFunc.IsNull(SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == it.ReId).Select(k => k.Sbmc), SqlFunc.Subqueryable<ZsbtzEntity>().Where(t => t.Id == it.ReId).Select(t => t.Sbmc))),
389   - customerName = SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == it.ReId).Select(k => k.Sskh),
  475 + customerName = SqlFunc.IsNull(it.CustomerName, SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == it.ReId).Select(k => k.Sskh)),
390 476 addTime = it.AddTime,
391 477 addUser = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == it.AddUser).Select(u => u.RealName),
392 478 enable = it.Enable,
... ... @@ -429,6 +515,8 @@ namespace NCC.Extend.SbRecords
429 515 .WhereIF(queryAddTime != null, p => p.AddTime >= new DateTime(startAddTime.ToDate().Year, startAddTime.ToDate().Month, startAddTime.ToDate().Day, 0, 0, 0))
430 516 .WhereIF(queryAddTime != null, p => p.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59))
431 517 .WhereIF(!string.IsNullOrEmpty(input.addUser), p => p.AddUser.Equals(input.addUser))
  518 + .WhereIF(!string.IsNullOrEmpty(input.deviceName), p => p.DeviceName != null && p.DeviceName.Contains(input.deviceName))
  519 + .WhereIF(!string.IsNullOrEmpty(input.contentName), p => p.ContentName != null && p.ContentName.Contains(input.contentName))
432 520 .WhereIF(!string.IsNullOrEmpty(input.customerName), p => SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == p.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any())
433 521 .WhereIF(!string.IsNullOrEmpty(input.item1), p => p.Item1.Contains(input.item1))
434 522 .WhereIF(!string.IsNullOrEmpty(input.item2), p => p.Item2.Contains(input.item2))
... ... @@ -440,7 +528,7 @@ namespace NCC.Extend.SbRecords
440 528 contentName = it.ContentName,
441 529 deviceName = it.DeviceName,
442 530 equipmentName = SqlFunc.IsNull(it.DeviceName, SqlFunc.IsNull(SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == it.ReId).Select(k => k.Sbmc), SqlFunc.Subqueryable<ZsbtzEntity>().Where(t => t.Id == it.ReId).Select(t => t.Sbmc))),
443   - customerName = SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == it.ReId).Select(k => k.Sskh),
  531 + customerName = SqlFunc.IsNull(it.CustomerName, SqlFunc.Subqueryable<KhsbEntity>().Where(k => k.Id == it.ReId).Select(k => k.Sskh)),
444 532 addTime = it.AddTime,
445 533 addUser = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == it.AddUser).Select(u => u.RealName),
446 534 enable = it.Enable,
... ...
机具(管理端)/src/views/sbRecords/index.vue
1 1 <template>
2   - <div class="NCC-common-layout">
  2 + <div class="NCC-common-layout sb-records-page">
3 3 <div class="NCC-common-layout-center">
4   - <!-- 统计与图表 -->
5   - <section class="sb-records-stats">
6   - <div class="stats-total">
7   - <span class="stats-total-label">记录总数</span>
8   - <span class="stats-total-value">{{ statistics.totalCount }}</span>
9   - </div>
10   - <el-row :gutter="16" class="charts-row">
11   - <el-col :span="12">
12   - <div class="chart-card">
13   - <div class="chart-title">按记录类型</div>
14   - <div ref="chartByType" class="chart-dom"></div>
15   - </div>
16   - </el-col>
17   - <el-col :span="12">
18   - <div class="chart-card">
19   - <div class="chart-title">按关联设备(Top10)</div>
20   - <div ref="chartByEquipment" class="chart-dom"></div>
21   - </div>
22   - </el-col>
23   - <el-col :span="12">
24   - <div class="chart-card">
25   - <div class="chart-title">按客户(Top10)</div>
26   - <div ref="chartByCustomer" class="chart-dom"></div>
27   - </div>
28   - </el-col>
29   - <el-col :span="12">
30   - <div class="chart-card">
31   - <div class="chart-title">按查看用户(Top10)</div>
32   - <div ref="chartByUser" class="chart-dom"></div>
33   - </div>
34   - </el-col>
35   - </el-row>
36   - </section>
37   -
38   - <el-row class="NCC-common-search-box" :gutter="16">
39   - <el-form @submit.native.prevent>
40   - <el-col :span="6">
41   - <el-form-item label="关联设备">
42   - <el-select v-model="query.reId" placeholder="请选择关联设备" clearable filterable>
43   - <el-option v-for="item in reIdOptions" :key="item.id" :label="item.fullName" :value="item.id" />
44   - </el-select>
45   - </el-form-item>
46   - </el-col>
47   - <el-col :span="6">
48   - <el-form-item label="查看客户">
49   - <el-input v-model="query.customerName" placeholder="客户名称" clearable />
50   - </el-form-item>
51   - </el-col>
52   - <el-col :span="6">
53   - <el-form-item label="查看用户">
54   - <userSelect v-model="query.addUser" placeholder="请选择查看用户" />
55   - </el-form-item>
56   - </el-col>
57   - <el-col :span="6">
58   - <el-form-item label="记录类型">
59   - <el-input v-model="query.type" placeholder="记录类型" clearable />
60   - </el-form-item>
61   - </el-col>
62   - <el-col :span="6">
63   - <el-form-item>
64   - <el-button type="primary" icon="el-icon-search" @click="search()">查询</el-button>
65   - <el-button icon="el-icon-refresh-right" @click="reset()">重置</el-button>
66   - <el-button type="text" icon="el-icon-arrow-down" @click="showAll=true" v-if="!showAll">展开</el-button>
67   - <el-button type="text" icon="el-icon-arrow-up" @click="showAll=false" v-else>收起</el-button>
68   - </el-form-item>
69   - </el-col>
70   - <template v-if="showAll">
71   - <el-col :span="6">
72   - <el-form-item label="记录时间">
73   - <el-date-picker v-model="query.addTime" type="datetimerange" value-format="timestamp" format="yyyy-MM-dd HH:mm:ss" start-placeholder="开始日期" end-placeholder="结束日期" style="width:100%" />
  4 + <!-- 1. 筛选区:紧凑卡片 -->
  5 + <section class="sb-records-filter">
  6 + <el-form @submit.native.prevent class="sb-records-form">
  7 + <el-row :gutter="16">
  8 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  9 + <el-form-item label="关联设备">
  10 + <el-select v-model="query.reId" placeholder="请选择" clearable filterable style="width:100%">
  11 + <el-option v-for="item in reIdOptions" :key="item.id" :label="item.fullName" :value="item.id" />
  12 + </el-select>
  13 + </el-form-item>
  14 + </el-col>
  15 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  16 + <el-form-item label="查看客户">
  17 + <el-input v-model="query.customerName" placeholder="客户名称" clearable />
74 18 </el-form-item>
75 19 </el-col>
76   - </template>
  20 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  21 + <el-form-item label="查看用户">
  22 + <userSelect v-model="query.addUser" placeholder="请选择" />
  23 + </el-form-item>
  24 + </el-col>
  25 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  26 + <el-form-item label="记录类型">
  27 + <el-input v-model="query.type" placeholder="记录类型" clearable />
  28 + </el-form-item>
  29 + </el-col>
  30 + <template v-if="showAll">
  31 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  32 + <el-form-item label="设备名称">
  33 + <el-input v-model="query.deviceName" placeholder="设备名称" clearable />
  34 + </el-form-item>
  35 + </el-col>
  36 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  37 + <el-form-item label="查看内容">
  38 + <el-input v-model="query.contentName" placeholder="故障代码、知识名等" clearable />
  39 + </el-form-item>
  40 + </el-col>
  41 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  42 + <el-form-item label="记录时间">
  43 + <el-date-picker v-model="query.addTime" type="datetimerange" value-format="timestamp" format="yyyy-MM-dd HH:mm:ss" start-placeholder="开始" end-placeholder="结束" style="width:100%" />
  44 + </el-form-item>
  45 + </el-col>
  46 + </template>
  47 + <el-col :xs="24" :sm="12" :md="8" :lg="6">
  48 + <el-form-item label-width="0" class="sb-records-form-actions">
  49 + <el-button type="primary" icon="el-icon-search" @click="search()">查询</el-button>
  50 + <el-button icon="el-icon-refresh-right" @click="reset()">重置</el-button>
  51 + <el-button type="text" @click="showAll=!showAll">
  52 + {{ showAll ? '收起' : '展开' }}
  53 + <i :class="showAll ? 'el-icon-arrow-up' : 'el-icon-arrow-down'" style="margin-left:4px" />
  54 + </el-button>
  55 + </el-form-item>
  56 + </el-col>
  57 + </el-row>
77 58 </el-form>
78   - </el-row>
  59 + </section>
79 60  
80   - <div class="NCC-common-layout-main NCC-flex-main">
81   - <div class="NCC-common-head">
82   - <div>
83   - <el-button type="text" icon="el-icon-download" @click="exportData()">导出</el-button>
84   - <el-button type="text" icon="el-icon-delete" @click="handleBatchRemoveDel()">批量删除</el-button>
  61 + <!-- 2. 主内容区:统计 + 列表 -->
  62 + <div class="sb-records-scroll-wrap">
  63 + <section class="sb-records-content">
  64 + <!-- 统计卡片 -->
  65 + <div class="sb-records-summary">
  66 + <el-row :gutter="16">
  67 + <el-col :xs="12" :sm="12" :md="6">
  68 + <div class="stat-card stat-card-primary">
  69 + <div class="stat-card-label">记录总数</div>
  70 + <div class="stat-card-value">{{ statistics.totalCount }}</div>
  71 + </div>
  72 + </el-col>
  73 + <el-col :xs="12" :sm="12" :md="6">
  74 + <div class="stat-card stat-card-success">
  75 + <div class="stat-card-label">完整记录</div>
  76 + <div class="stat-card-value">{{ statistics.completeCount }}</div>
  77 + </div>
  78 + </el-col>
  79 + <el-col :xs="12" :sm="12" :md="6">
  80 + <div class="stat-card stat-card-warning">
  81 + <div class="stat-card-label">总停留时长</div>
  82 + <div class="stat-card-value">{{ formatDuration(statistics.totalDurationSeconds) }}</div>
  83 + </div>
  84 + </el-col>
  85 + <el-col :xs="12" :sm="12" :md="6">
  86 + <div class="stat-card stat-card-info">
  87 + <div class="stat-card-label">平均停留</div>
  88 + <div class="stat-card-value">{{ formatDuration(Math.round(statistics.avgDurationSeconds || 0)) }}</div>
  89 + </div>
  90 + </el-col>
  91 + </el-row>
85 92 </div>
86   - <div class="NCC-common-head-right">
87   - <el-tooltip effect="dark" content="刷新" placement="top">
88   - <el-link icon="icon-ym icon-ym-Refresh NCC-common-head-icon" :underline="false" @click="reset()" />
89   - </el-tooltip>
90   - <screenfull isContainer />
  93 +
  94 + <!-- 列表区 -->
  95 + <div class="sb-records-list">
  96 + <h3 class="sb-records-list-title">查看记录列表</h3>
  97 + <div class="sb-records-table-wrap">
  98 + <NCC-table v-loading="listLoading" :data="list" :has-n-o="false" class="sb-records-table">
  99 + <el-table-column type="index" label="序号" width="60" align="center" :index="indexMethod" />
  100 + <el-table-column label="记录类型" align="left" width="100">
  101 + <template slot-scope="scope">{{ scope.row.type || '无' }}</template>
  102 + </el-table-column>
  103 + <el-table-column label="设备名称" align="left" min-width="120" show-overflow-tooltip>
  104 + <template slot-scope="scope">{{ scope.row.deviceName || scope.row.equipmentName || scope.row.reId || '无' }}</template>
  105 + </el-table-column>
  106 + <el-table-column label="查看内容" align="left" min-width="120" show-overflow-tooltip>
  107 + <template slot-scope="scope">{{ scope.row.contentName || '无' }}</template>
  108 + </el-table-column>
  109 + <el-table-column label="查看客户" align="left" min-width="100" show-overflow-tooltip>
  110 + <template slot-scope="scope">{{ scope.row.customerName || '无' }}</template>
  111 + </el-table-column>
  112 + <el-table-column label="查看用户" align="left" min-width="100">
  113 + <template slot-scope="scope">{{ scope.row.addUser || '无' }}</template>
  114 + </el-table-column>
  115 + <el-table-column prop="addTime" label="记录时间" align="left" width="160" :formatter="ncc.tableDateFormat" />
  116 + </NCC-table>
  117 + </div>
  118 + <pagination :total="total" :page.sync="listQuery.currentPage" :limit.sync="listQuery.pageSize" @pagination="initData" />
91 119 </div>
92   - </div>
93   - <NCC-table v-loading="listLoading" :data="list" has-c @selection-change="handleSelectionChange">
94   - <el-table-column type="index" label="序号" width="60" align="center" :index="indexMethod" />
95   - <el-table-column prop="type" label="记录类型" align="left" width="100" />
96   - <el-table-column label="设备名称" align="left" min-width="120" show-overflow-tooltip>
97   - <template slot-scope="scope">{{ scope.row.deviceName || scope.row.equipmentName || scope.row.reId || '-' }}</template>
98   - </el-table-column>
99   - <el-table-column prop="contentName" label="查看内容" align="left" min-width="120" show-overflow-tooltip />
100   - <el-table-column prop="customerName" label="查看客户" align="left" min-width="100" show-overflow-tooltip />
101   - <el-table-column prop="addUser" label="查看用户" align="left" min-width="100" />
102   - <el-table-column prop="addTime" label="记录时间" align="left" width="160" :formatter="ncc.tableDateFormat" />
103   - <el-table-column label="操作" fixed="right" width="100">
104   - <template slot-scope="scope">
105   - <el-button type="text" @click="handleDel(scope.row.id)" class="NCC-table-delBtn">删除</el-button>
106   - </template>
107   - </el-table-column>
108   - </NCC-table>
109   - <pagination :total="total" :page.sync="listQuery.currentPage" :limit.sync="listQuery.pageSize" @pagination="initData" />
  120 + </section>
  121 +
  122 + <!-- 3. 统计图表:可折叠 -->
  123 + <section class="sb-records-charts">
  124 + <el-collapse v-model="chartsExpanded" @change="handleChartsExpand">
  125 + <el-collapse-item name="charts">
  126 + <template slot="title">
  127 + <span class="charts-collapse-title">
  128 + <i class="el-icon-data-analysis"></i>
  129 + 统计图表
  130 + </span>
  131 + </template>
  132 + <el-row :gutter="16">
  133 + <el-col :xs="24" :sm="24" :md="12">
  134 + <div class="chart-card">
  135 + <div class="chart-title">按记录类型</div>
  136 + <div ref="chartByType" class="chart-dom"></div>
  137 + </div>
  138 + </el-col>
  139 + <el-col :xs="24" :sm="24" :md="12">
  140 + <div class="chart-card">
  141 + <div class="chart-title">按设备名称(Top10)</div>
  142 + <div ref="chartByEquipment" class="chart-dom"></div>
  143 + </div>
  144 + </el-col>
  145 + <el-col :xs="24" :sm="24" :md="12">
  146 + <div class="chart-card">
  147 + <div class="chart-title">按查看内容(Top10)</div>
  148 + <div ref="chartByContent" class="chart-dom"></div>
  149 + </div>
  150 + </el-col>
  151 + <el-col :xs="24" :sm="24" :md="12">
  152 + <div class="chart-card">
  153 + <div class="chart-title">按客户(Top10)</div>
  154 + <div ref="chartByCustomer" class="chart-dom"></div>
  155 + </div>
  156 + </el-col>
  157 + <el-col :xs="24" :sm="24" :md="12">
  158 + <div class="chart-card">
  159 + <div class="chart-title">按查看用户(Top10)</div>
  160 + <div ref="chartByUser" class="chart-dom"></div>
  161 + </div>
  162 + </el-col>
  163 + </el-row>
  164 + </el-collapse-item>
  165 + </el-collapse>
  166 + </section>
110 167 </div>
111 168 </div>
112 169 <NCC-Form v-if="formVisible" ref="NCCForm" @refresh="refresh" />
113   - <ExportBox v-if="exportBoxVisible" ref="ExportBox" @download="download" />
114 170 </div>
115 171 </template>
116 172  
117 173 <script>
118 174 import request from '@/utils/request'
119 175 import NCCForm from './Form'
120   -import ExportBox from './ExportBox'
121 176 import echarts from 'echarts'
122 177  
123 178 export default {
124   - components: { NCCForm, ExportBox },
  179 + components: { NCCForm },
125 180 data() {
126 181 return {
127 182 showAll: false,
... ... @@ -130,11 +185,12 @@ export default {
130 185 addTime: undefined,
131 186 reId: undefined,
132 187 customerName: undefined,
133   - type: undefined
  188 + type: undefined,
  189 + deviceName: undefined,
  190 + contentName: undefined
134 191 },
135 192 list: [],
136 193 listLoading: true,
137   - multipleSelection: [],
138 194 total: 0,
139 195 listQuery: {
140 196 currentPage: 1,
... ... @@ -143,26 +199,19 @@ export default {
143 199 sidx: ''
144 200 },
145 201 formVisible: false,
146   - exportBoxVisible: false,
  202 + chartsExpanded: [], // 默认折叠;['charts'] 则展开统计图表
147 203 statistics: {
148 204 totalCount: 0,
  205 + completeCount: 0,
  206 + totalDurationSeconds: 0,
  207 + avgDurationSeconds: 0,
149 208 byType: [],
150 209 byEquipment: [],
  210 + byContent: [],
151 211 byCustomer: [],
152 212 byUser: []
153 213 },
154 214 charts: {},
155   - columnList: [
156   - { prop: 'index', label: '序号' },
157   - { prop: 'customerName', label: '客户' },
158   - { prop: 'addUser', label: '用户' },
159   - { prop: 'type', label: '记录类型' },
160   - { prop: 'deviceName', label: '设备名称' },
161   - { prop: 'contentName', label: '查看内容' },
162   - { prop: 'equipmentName', label: '设备' },
163   - { prop: 'addTime', label: '时间' },
164   - { prop: 'countNum', label: '次数' }
165   - ],
166 215 reIdOptions: []
167 216 }
168 217 },
... ... @@ -193,13 +242,17 @@ export default {
193 242 method: 'GET',
194 243 data: query
195 244 }).then(res => {
196   - const data = res.data || {}
  245 + const d = res.data || {}
197 246 this.statistics = {
198   - totalCount: data.totalCount != null ? data.totalCount : 0,
199   - byType: data.byType || [],
200   - byEquipment: data.byEquipment || [],
201   - byCustomer: data.byCustomer || [],
202   - byUser: data.byUser || []
  247 + totalCount: d.totalCount != null ? d.totalCount : (d.TotalCount != null ? d.TotalCount : 0),
  248 + completeCount: d.completeCount != null ? d.completeCount : (d.CompleteCount != null ? d.CompleteCount : 0),
  249 + totalDurationSeconds: d.totalDurationSeconds != null ? d.totalDurationSeconds : (d.TotalDurationSeconds != null ? d.TotalDurationSeconds : 0),
  250 + avgDurationSeconds: d.avgDurationSeconds != null ? d.avgDurationSeconds : (d.AvgDurationSeconds != null ? d.AvgDurationSeconds : 0),
  251 + byType: d.byType || d.ByType || [],
  252 + byEquipment: d.byEquipment || d.ByEquipment || [],
  253 + byContent: d.byContent || d.ByContent || [],
  254 + byCustomer: d.byCustomer || d.ByCustomer || [],
  255 + byUser: d.byUser || d.ByUser || []
203 256 }
204 257 this.$nextTick(() => {
205 258 this.renderCharts()
... ... @@ -208,6 +261,9 @@ export default {
208 261 this.$nextTick(() => this.renderCharts())
209 262 })
210 263 },
  264 + handleChartsExpand() {
  265 + this.$nextTick(() => this.renderCharts())
  266 + },
211 267 buildQuery() {
212 268 const _query = { ...this.listQuery, ...this.query }
213 269 const query = {}
... ... @@ -221,10 +277,23 @@ export default {
221 277 return query
222 278 },
223 279 renderCharts() {
224   - this.renderPie('chartByType', this.statistics.byType, '记录类型')
225   - this.renderBar('chartByEquipment', this.statistics.byEquipment, '关联设备')
226   - this.renderBar('chartByCustomer', this.statistics.byCustomer, '客户')
227   - this.renderBar('chartByUser', this.statistics.byUser, '查看用户')
  280 + if (!this.chartsExpanded.includes('charts')) return
  281 + this.$nextTick(() => {
  282 + this.renderPie('chartByType', this.statistics.byType, '记录类型')
  283 + this.renderBar('chartByEquipment', this.statistics.byEquipment, '设备名称')
  284 + this.renderBar('chartByContent', this.statistics.byContent, '查看内容')
  285 + this.renderBar('chartByCustomer', this.statistics.byCustomer, '客户')
  286 + this.renderBar('chartByUser', this.statistics.byUser, '查看用户')
  287 + })
  288 + },
  289 + formatDuration(seconds) {
  290 + if (seconds == null || seconds < 0) return '0秒'
  291 + if (seconds < 60) return seconds + '秒'
  292 + if (seconds < 3600) return Math.floor(seconds / 60) + '分' + (seconds % 60 ? seconds % 60 + '秒' : '')
  293 + const h = Math.floor(seconds / 3600)
  294 + const m = Math.floor((seconds % 3600) / 60)
  295 + const s = seconds % 60
  296 + return h + '时' + (m ? m + '分' : '') + (s ? s + '秒' : '')
228 297 },
229 298 renderPie(refName, data, title) {
230 299 const el = this.$refs[refName]
... ... @@ -238,7 +307,7 @@ export default {
238 307 this.charts[refName] = chart
239 308 const option = {
240 309 tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
241   - series: [{ type: 'pie', radius: '60%', data: data.map(d => ({ name: d.name || '-', value: d.value })) }]
  310 + series: [{ type: 'pie', radius: '60%', data: data.map(d => ({ name: d.name || d.Name || '-', value: d.value != null ? d.value : (d.Value != null ? d.Value : 0) })) }]
242 311 }
243 312 chart.setOption(option)
244 313 },
... ... @@ -248,8 +317,8 @@ export default {
248 317 if (this.charts[refName]) this.charts[refName].dispose()
249 318 const chart = echarts.init(el)
250 319 this.charts[refName] = chart
251   - const names = (data || []).map(d => (d.name || '-').substring(0, 8))
252   - const values = (data || []).map(d => d.value)
  320 + const names = (data || []).map(d => ((d.name || d.Name || '-') + '').substring(0, 12))
  321 + const values = (data || []).map(d => d.value != null ? d.value : (d.Value != null ? d.Value : 0))
253 322 const option = {
254 323 tooltip: { trigger: 'axis' },
255 324 grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
... ... @@ -285,53 +354,10 @@ export default {
285 354 this.initData()
286 355 this.loadStatistics()
287 356 },
288   - handleDel(id) {
289   - this.$confirm('此操作将永久删除该数据, 是否继续?', '提示', { type: 'warning' }).then(() => {
290   - request({ url: `/api/Extend/SbRecords/${id}`, method: 'DELETE' }).then(res => {
291   - this.$message.success(res.msg)
292   - this.initData()
293   - this.loadStatistics()
294   - })
295   - }).catch(() => {})
296   - },
297   - handleSelectionChange(val) {
298   - this.multipleSelection = (val || []).map(item => item.id)
299   - },
300   - handleBatchRemoveDel() {
301   - if (!this.multipleSelection.length) {
302   - this.$message.error('请选择一条数据')
303   - return
304   - }
305   - this.$confirm('您确定要删除这些数据吗, 是否继续?', '提示', { type: 'warning' }).then(() => {
306   - request({ url: '/api/Extend/SbRecords/batchRemove', method: 'POST', data: this.multipleSelection }).then(res => {
307   - this.$message.success(res.msg)
308   - this.initData()
309   - this.loadStatistics()
310   - })
311   - }).catch(() => {})
312   - },
313 357 addOrUpdateHandle(id, isDetail) {
314 358 this.formVisible = true
315 359 this.$nextTick(() => this.$refs.NCCForm.init(id, isDetail))
316 360 },
317   - exportData() {
318   - this.exportBoxVisible = true
319   - this.$nextTick(() => this.$refs.ExportBox.init(this.columnList))
320   - },
321   - download(data) {
322   - const query = { ...data, ...this.listQuery, ...this.query }
323   - const q = {}
324   - for (const key in query) {
325   - if (Array.isArray(query[key])) q[key] = query[key].join()
326   - else q[key] = query[key]
327   - }
328   - request({ url: '/api/Extend/SbRecords/Actions/Export', method: 'GET', data: q }).then(res => {
329   - if (!res.data || !res.data.url) return
330   - window.location.href = this.define.comUrl + res.data.url
331   - this.$refs.ExportBox.visible = false
332   - this.exportBoxVisible = false
333   - })
334   - },
335 361 refresh(isrRefresh) {
336 362 this.formVisible = false
337 363 if (isrRefresh) {
... ... @@ -344,26 +370,141 @@ export default {
344 370 </script>
345 371  
346 372 <style lang="scss" scoped>
347   -.sb-records-stats {
  373 +/* 页面整体:Data-Dense Dashboard 风格 */
  374 +.sb-records-page .NCC-common-layout-center {
  375 + background: #F8FAFC;
  376 + padding: 16px;
  377 +}
  378 +
  379 +/* 1. 筛选区 */
  380 +.sb-records-filter {
  381 + background: #fff;
  382 + border-radius: 12px;
  383 + padding: 20px;
  384 + margin-bottom: 16px;
  385 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
  386 + flex-shrink: 0;
  387 +}
  388 +.sb-records-form {
  389 + .el-form-item { margin-bottom: 16px; }
  390 + .el-form-item__label { padding-right: 12px; }
  391 +}
  392 +.sb-records-form-actions {
  393 + .el-button + .el-button { margin-left: 8px; }
  394 +}
  395 +
  396 +/* 2. 可滚动内容区 */
  397 +.sb-records-scroll-wrap {
  398 + flex: 1;
  399 + min-height: 0;
  400 + overflow-y: auto;
  401 + overflow-x: hidden;
  402 +}
  403 +
  404 +/* 3. 主内容区:统计 + 列表 */
  405 +.sb-records-content {
  406 + background: #fff;
  407 + border-radius: 12px;
  408 + padding: 20px;
348 409 margin-bottom: 16px;
349   - .stats-total {
350   - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
351   - color: #fff;
352   - padding: 16px 24px;
353   - border-radius: 8px;
354   - margin-bottom: 16px;
355   - .stats-total-label { font-size: 14px; opacity: 0.9; }
356   - .stats-total-value { font-size: 28px; font-weight: 700; margin-left: 12px; }
  410 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
  411 +}
  412 +
  413 +/* 统计卡片 */
  414 +.sb-records-summary {
  415 + margin-bottom: 20px;
  416 + .el-row { margin: 0 -8px; }
  417 + .el-col { padding: 0 8px; margin-bottom: 16px; }
  418 + .stat-card {
  419 + height: 100px;
  420 + padding: 12px;
  421 + border-radius: 12px;
  422 + display: flex;
  423 + flex-direction: column;
  424 + justify-content: center;
  425 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  426 + transition: box-shadow 0.2s ease, transform 0.2s ease;
  427 + cursor: default;
  428 + &:hover {
  429 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
  430 + }
  431 + .stat-card-label {
  432 + font-size: 13px;
  433 + color: rgba(255, 255, 255, 0.9);
  434 + margin-bottom: 6px;
  435 + letter-spacing: 0.3px;
  436 + }
  437 + .stat-card-value {
  438 + font-size: 22px;
  439 + font-weight: 600;
  440 + color: #fff;
  441 + letter-spacing: 0.5px;
  442 + }
  443 + &.stat-card-primary { background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%); }
  444 + &.stat-card-success { background: linear-gradient(135deg, #67C23A 0%, #85ce61 100%); }
  445 + &.stat-card-warning { background: linear-gradient(135deg, #E6A23C 0%, #ebb563 100%); }
  446 + &.stat-card-info { background: linear-gradient(135deg, #909399 0%, #a6a9ad 100%); }
357 447 }
358   - .charts-row { margin-top: 12px; }
359   - .chart-card {
360   - background: #fff;
361   - border-radius: 8px;
362   - padding: 16px;
363   - margin-bottom: 16px;
364   - box-shadow: 0 1px 4px rgba(0,0,0,0.08);
365   - .chart-title { font-size: 14px; color: #303133; margin-bottom: 12px; font-weight: 500; }
366   - .chart-dom { height: 260px; }
  448 +}
  449 +
  450 +/* 列表区 */
  451 +.sb-records-list {
  452 + .sb-records-list-title {
  453 + font-size: 16px;
  454 + font-weight: 600;
  455 + color: #303133;
  456 + margin: 0 0 16px;
  457 + padding-bottom: 12px;
  458 + border-bottom: 1px solid #ebeef5;
  459 + letter-spacing: 0.3px;
367 460 }
368 461 }
  462 +.sb-records-table-wrap {
  463 + margin-bottom: 16px;
  464 +}
  465 +::v-deep .sb-records-table .el-table__row:hover {
  466 + background-color: #f5f7fa;
  467 +}
  468 +
  469 +/* 4. 统计图表 */
  470 +.sb-records-charts {
  471 + background: #fff;
  472 + border-radius: 12px;
  473 + padding: 0;
  474 + margin-bottom: 16px;
  475 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
  476 + overflow: hidden;
  477 +}
  478 +.sb-records-charts .charts-collapse-title {
  479 + font-size: 14px;
  480 + font-weight: 600;
  481 + letter-spacing: 0.3px;
  482 + i { margin-right: 8px; color: #409EFF; }
  483 +}
  484 +.sb-records-charts .el-row { margin: 0 -8px; }
  485 +.sb-records-charts .el-col { padding: 0 8px; margin-bottom: 16px; }
  486 +.sb-records-charts .chart-card {
  487 + background: #fafbfc;
  488 + border-radius: 8px;
  489 + padding: 16px 20px;
  490 + border: 1px solid #ebeef5;
  491 + .chart-title {
  492 + font-size: 14px;
  493 + color: #303133;
  494 + margin-bottom: 12px;
  495 + font-weight: 500;
  496 + }
  497 + .chart-dom { height: 260px; }
  498 +}
  499 +
  500 +::v-deep .el-collapse-item__header {
  501 + height: 52px;
  502 + font-size: 14px;
  503 + padding: 0 20px;
  504 + background: #fff;
  505 +}
  506 +::v-deep .el-collapse-item__content {
  507 + padding: 20px;
  508 + background: #fff;
  509 +}
369 510 </style>
... ...