diff --git a/docs/md/sbRecords接口测试报告.md b/docs/md/sbRecords接口测试报告.md new file mode 100644 index 0000000..90da1e2 --- /dev/null +++ b/docs/md/sbRecords接口测试报告.md @@ -0,0 +1,89 @@ +# sbRecords 统计接口测试报告 + +## 测试时间 +2025-02-17 + +## 一、Statistics 接口 + +**接口**:`GET /api/Extend/SbRecords/Actions/Statistics` + +**参数**:与列表查询一致,支持 addTime、type、addUser、reId、customerName、deviceName、contentName + +**返回结构**: +```json +{ + "totalCount": 25, + "completeCount": 24, + "totalDurationSeconds": 212, + "avgDurationSeconds": 8.83, + "byType": [{ "name": "备件支持", "value": 6 }, ...], + "byEquipment": [{ "name": "设备名称", "value": 3 }, ...], + "byContent": [{ "name": "查看内容", "value": 1 }, ...], + "byCustomer": [...], + "byUser": [...] +} +``` + +**测试结果**:✅ 通过 +- 返回新增字段:completeCount、totalDurationSeconds、avgDurationSeconds、byContent +- byEquipment 已改为按 F_DeviceName 分组 +- 筛选参数生效 + +## 二、GetList 接口 + +**接口**:`GET /api/Extend/SbRecords` + +**新增筛选参数**:deviceName、contentName(模糊匹配) + +**测试结果**:✅ 通过 +- deviceName=动力、contentName=猫 等筛选正常 +- 返回 deviceName、contentName 字段 + +## 三、curl 示例 + +```bash +# 1. 获取 Token +TOKEN=$(curl -s -X POST "http://localhost:2011/api/oauth/Login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "account=admin&password=66762a3ccde2a2cff3060d7a4a0a576b" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])") + +# 2. 调用 Statistics +curl -s -X GET "http://localhost:2011/api/Extend/SbRecords/Actions/Statistics" \ + -H "Authorization: $TOKEN" + +# 3. 带筛选参数 +curl -s -X GET "http://localhost:2011/api/Extend/SbRecords/Actions/Statistics?type=故障排查" \ + -H "Authorization: $TOKEN" + +# 4. 列表筛选 +curl -s -X GET "http://localhost:2011/api/Extend/SbRecords?deviceName=动力&contentName=猫" \ + -H "Authorization: $TOKEN" +``` + +## 四、结论 + +| 接口 | 状态 | +|------|------| +| Statistics | ✅ 通过 | +| GetList(deviceName/contentName 筛选) | ✅ 通过 | + +## 五、页面 UI 修复验证(2025-02-17) + +### 修复项 + +| 问题 | 修复方案 | 验证要点 | +|------|----------|----------| +| 表格重复「序号」列 | NCC-table 默认 `hasNO=true` 会渲染序号列,与自定义 `indexMethod` 列重复;设置 `:has-n-o="false"` 关闭内置序号 | 表头仅有一列「序号」,分页序号正确 | +| 空值显示不规范 | 项目规范:没有信息的字段显示「无」 | 设备名称、查看内容、查看客户、查看用户、记录类型 为空时显示「无」 | +| 操作列未左对齐 | 项目规范:操作按钮必须左对齐 | 操作列「删除」按钮左对齐 | +| 统计卡片内边距 | 项目规范:卡片内边距 12px | 统计卡片 padding 为 12px | + +### 验证清单 + +- [ ] 表格表头无重复「序号」列 +- [ ] 分页切换后序号连续(如第 2 页从 21 开始) +- [ ] 空值字段显示「无」而非空白或「-」 +- [ ] 操作列「删除」按钮左对齐 +- [ ] 统计卡片布局符合规范(100px 高、12px 内边距、12px 圆角) +- [ ] 图表展开后主内容区可正常滚动 diff --git a/docs/md/移动端设备查看埋点方案.md b/docs/md/移动端设备查看埋点方案.md new file mode 100644 index 0000000..f3d9ab7 --- /dev/null +++ b/docs/md/移动端设备查看埋点方案.md @@ -0,0 +1,180 @@ +# 移动端设备查看埋点方案 + +## 一、需求与目标 + +- **需求**:记录用户**在哪个板块**看了**哪个设备**,以及**停留时长** +- **对接接口**:`StartViewRecord`(开始会话)、`EndViewRecord`(结束会话) +- **数据落库**:`sb_records` 表(F_ReId、F_Type、F_ContentName、F_DeviceName、F_AddTime、F_LeaveTime、F_DurationSeconds) + +--- + +## 二、首页 8 个板块 vs 需埋点 6 个 + +| 序号 | 板块名称 | 路由/页面 | 是否埋点 | 说明 | +|------|----------|-----------|----------|------| +| 1 | 故障排查 | `/pages/new/gzpc/gzpc` → `detail` | ✅ | 有设备/故障关联 | +| 2 | 知识库 | `/pages/new/zsk/zsk` → `detail` | ✅ | 有产品/设备关联 | +| 3 | 我的设备 | `/pages/myDevice/myDevice` → `detail` | ✅ | 直接是设备 | +| 4 | 资料管理 | `/pages/new/zlgl/zlgl` → `detail` | ✅ | 有设备/资料关联 | +| 5 | 备件支持 | `/pages/new/bjzc/bjzc` → `detail` | ✅ | 有产品/设备关联 | +| 6 | 培训展示 | `/pages/new/pxzs/pxzs` → `detail` | ✅ | 有产品关联 | +| 7 | 信息推送 | `/pages/new/xxts/xxts` | ❌ | 无设备维度 | +| 8 | 用户反馈 | `/pages/new/yhfk/yhfk` | ❌ | 无设备维度 | + +--- + +## 三、埋点粒度与 reId 约定 + +**原则**:只在**详情页**埋点(列表页无具体“设备/资料”对象)。 +**reId**:当前详情对应的**主实体 id**(用于 sb_records 的 F_ReId)。 +**type**:板块名称,用于区分来源。 + +| 板块 | 详情页路径 | reId 来源 | type 值 | deviceName(设备名称) | contentName(查看内容) | +|------|------------|-----------|---------|------------------------|-------------------------| +| 故障排查 | `gzpc/detail` | `info.id` | 故障排查 | `info.sbmc` | `info.code`(故障代码) | +| 知识库 | `zsk/detail` | `detail.id` | 知识库 | 后端根据 sssb 查 cpgl | `detail.sbm` | +| 我的设备 | `myDevice/detail` | `options.id` | 我的设备 | `detail.sbmc` | `detail.sbmc` | +| 资料管理 | `zlgl/detail` | `info.id` | 资料管理 | `info.zlm` | `info.zlm` | +| 备件支持 | `bjzc/detail` | `info.id` | 备件支持 | `info.glcpmc` | `info.mc` | +| 培训展示 | `pxzs/detail` | `info.id` | 培训展示 | `info.sbmc` | `info.pxmc` | + +> **说明**:sb_records 通过 **记录类型 + 设备名称 + 查看内容** 三字段定位。F_Type、F_DeviceName、F_ContentName 均可由前端传入,未传时后端根据 type+reId 从对应表自动解析。 + +--- + +## 四、埋点时机 + +| 时机 | 动作 | 说明 | +|------|------|------| +| 进入详情页 | 调用 `StartViewRecord` | `onLoad` 或 `onShow` 中,拿到 reId、type 后调用 | +| 离开详情页 | 调用 `EndViewRecord` | `onUnload` 或 `onHide` 中,传入之前保存的 recordId | + +**推荐**: +- **Start**:在详情数据加载完成后(如 `gzpcxq`、`fetchDetail` 等接口返回后)调用,确保有 reId +- **End**:在 `onUnload` 中调用(页面销毁时),`onHide` 可能因切后台未真正离开,可选补充 + +--- + +## 五、各详情页数据流与埋点接入点 + +### 5.1 故障排查详情 `gzpc/detail.vue` + +- **进入**:`onLoad` 从 `uni.getStorageSync("detail")` 取 `info`,调用 `gzpcxq({ id: info.id })` +- **reId**:`this.info.id`(故障 id)或 `this.info.sssbId`(若有) +- **type**:`"故障排查"` +- **埋点**:`gzpcxq` 成功后 `StartViewRecord`;`ht()` 返回 / `onUnload` 时 `EndViewRecord` + +### 5.2 知识库详情 `zsk/detail.vue` + +- **进入**:`onLoad(options)` 取 `options.id`,调用 `fetchDetail` → `zskxq({ id })` +- **reId**:`this.id` 或 `this.detail.id` +- **type**:`"知识库"` +- **埋点**:`fetchDetail` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord` + +### 5.3 我的设备详情 `myDevice/detail.vue` + +- **进入**:`onLoad(options)` 取 `options.id`,调用 `fetchDeviceDetail(deviceId)` +- **reId**:`options.id` 或 `this.detail.id` +- **type**:`"我的设备"` +- **埋点**:`fetchDeviceDetail` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord` + +### 5.4 资料管理详情 `zlgl/detail.vue` + +- **进入**:`onLoad(options)` 取 `options.id`,调用详情接口 +- **reId**:`options.id` 或 `this.info.id` +- **type**:`"资料管理"` +- **埋点**:详情加载成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord` + +### 5.5 备件支持详情 `bjzc/detail.vue` + +- **进入**:`onLoad` 从 `uni.getStorageSync("detail")` 取 info,调用 `bjzcxq` +- **reId**:`this.info.id`(备件 id) +- **type**:`"备件支持"` +- **埋点**:`bjzcxq` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord` + +### 5.6 培训展示详情 `pxzs/detail.vue` + +- **进入**:`onLoad` 从 `uni.getStorageSync("detail")` 取 info,调用 `pxzsxqs` +- **reId**:`info.id`(培训 id) +- **type**:`"培训展示"` +- **埋点**:`pxzsxqs` 成功后 `StartViewRecord`;`onUnload` 时 `EndViewRecord` + +--- + +## 六、前端实现要点 + +### 6.1 API 封装 + +在 `uniapp_jiju/apis/modules/oauth.js`(或新建 `sbRecords.js`)中增加: + +```javascript +// 开始设备查看会话(进入详情页时调用) +startViewRecord(data) { + return request.post('/api/Extend/SbRecords/Actions/StartViewRecord', data, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) +}, +// 结束设备查看会话(离开详情页时调用) +endViewRecord(data) { + return request.post('/api/Extend/SbRecords/Actions/EndViewRecord', data, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) +} +``` + +> 注意:项目规范 GET 用 data 传参;POST 表单可用 `application/x-www-form-urlencoded`,参数为 `reId`、`type`、`recordId`。 + +### 6.2 参数格式 + +- **StartViewRecord**:`reId`(必填)、`type`(必填)、`contentName`(可选,查看内容)、`deviceName`(可选,设备名称)、`item1`(可选)、`item2`(可选) +- **EndViewRecord**:`recordId`(必填,Start 返回的 id) + +> contentName、deviceName 未传时,后端根据 type+reId 从对应表自动解析。 + +### 6.3 通用逻辑(可抽成 mixin) + +```javascript +// 伪代码 +data() { + return { viewRecordId: null } +}, +methods: { + async startViewTracking(reId, type, contentName, deviceName) { + if (!reId || !type) return + try { + const data = { reId, type } + if (contentName) data.contentName = contentName + if (deviceName) data.deviceName = deviceName + const res = await this.API.startViewRecord(data) + async endViewTracking() { + if (!this.viewRecordId) return + try { + await this.API.endViewRecord({ recordId: this.viewRecordId }) + } catch (e) { console.warn('EndViewRecord failed', e) } + this.viewRecordId = null + } +}, +onUnload() { + this.endViewTracking() +} +``` + +各详情页在数据加载成功后调用 `this.startViewTracking(reId, type, contentName, deviceName)`,在 `onUnload` 中调用 `this.endViewTracking()`。 + +--- + +## 七、实现顺序建议 + +1. **API 封装**:在 `oauth.js` 或新建模块中增加 `startViewRecord`、`endViewRecord` +2. **Mixin 或工具函数**:抽 `startViewTracking`、`endViewTracking`,避免重复代码 +3. **按页面接入**:依次在 6 个详情页接入埋点(建议顺序:我的设备 → 故障排查 → 知识库 → 资料管理 → 备件支持 → 培训展示) +4. **联调与验证**:真机/模拟器操作,查 `sb_records` 表确认 F_Type、F_DeviceName、F_ContentName、F_DurationSeconds 正确 + +--- + +## 八、注意事项 + +- **未登录**:若接口需 Token,未登录用户可能调用失败,可静默失败或跳过埋点 +- **快速返回**:用户进入后立即返回,End 仍会执行,Duration 可能为 0 或很小,属正常 +- **页面栈**:`navigateBack` 会触发 `onUnload`,可正常执行 End;`reLaunch`、`redirectTo` 同理 +- **recordId 存储**:必须存在组件 data 中,`onUnload` 时能访问到,避免用全局变量导致多页面冲突 diff --git a/docs/sql/add_sb_records_content_name.sql b/docs/sql/add_sb_records_content_name.sql new file mode 100644 index 0000000..5db9bfb --- /dev/null +++ b/docs/sql/add_sb_records_content_name.sql @@ -0,0 +1,10 @@ +-- 为 sb_records 表新增字段,实现「记录类型+设备名称+查看内容」三字段定位 +-- 执行时间:按需执行(若 F_ContentName 已存在,可跳过第一条) + +-- 1. 查看内容(故障代码、知识名、资料名、备件名、培训名等) +ALTER TABLE `sb_records` + ADD COLUMN `F_ContentName` varchar(200) NULL COMMENT '查看内容(故障代码、知识名、资料名等)' AFTER `F_Type`; + +-- 2. 设备名称(所属设备、关联产品/设备名) +ALTER TABLE `sb_records` + ADD COLUMN `F_DeviceName` varchar(200) NULL COMMENT '设备名称(所属设备、关联产品名)' AFTER `F_Type`; diff --git a/docs/sql/add_sb_records_customer_name.sql b/docs/sql/add_sb_records_customer_name.sql new file mode 100644 index 0000000..dd2ac3f --- /dev/null +++ b/docs/sql/add_sb_records_customer_name.sql @@ -0,0 +1,6 @@ +-- 为 sb_records 表新增 F_CustomerName 字段 +-- 用于存储「查看客户」,根据记录类型+ReId 从 Khsb/Zlgl/Gzcx 等表解析 +-- 新记录由 StartViewRecord 自动填充;历史记录可保留空,列表接口会回退到 Khsb 子查询(仅对「我的设备」有效) + +ALTER TABLE `sb_records` + ADD COLUMN `F_CustomerName` varchar(200) NULL COMMENT '查看客户(所属客户)' AFTER `F_DeviceName`; diff --git a/docs/sql/add_sb_records_device_name.sql b/docs/sql/add_sb_records_device_name.sql new file mode 100644 index 0000000..c39cd1d --- /dev/null +++ b/docs/sql/add_sb_records_device_name.sql @@ -0,0 +1,5 @@ +-- 为 sb_records 表新增 F_DeviceName 字段(若已执行 add_sb_records_content_name.sql 可单独执行本脚本) +-- 实现「记录类型+设备名称+查看内容」三字段定位 + +ALTER TABLE `sb_records` + ADD COLUMN `F_DeviceName` varchar(200) NULL COMMENT '设备名称(所属设备、关联产品名)' AFTER `F_Type`; diff --git a/uniapp_jiju/apis/modules/oauth.js b/uniapp_jiju/apis/modules/oauth.js index 5b66ab4..b620ebb 100644 --- a/uniapp_jiju/apis/modules/oauth.js +++ b/uniapp_jiju/apis/modules/oauth.js @@ -236,4 +236,12 @@ export default { hqlogo(data){ return request.get('/api/Extend/Logogl?lx=' + '移动端') }, + // 开始设备查看会话(进入详情页时调用,用于记录停留时长) + startViewRecord(data) { + return request.postFormData('/api/Extend/SbRecords/Actions/StartViewRecord', data) + }, + // 结束设备查看会话(离开详情页时调用,用于计算停留时长) + endViewRecord(data) { + return request.postFormData('/api/Extend/SbRecords/Actions/EndViewRecord', data) + }, } diff --git a/uniapp_jiju/common/mixins/viewRecordMixin.js b/uniapp_jiju/common/mixins/viewRecordMixin.js new file mode 100644 index 0000000..c21d45c --- /dev/null +++ b/uniapp_jiju/common/mixins/viewRecordMixin.js @@ -0,0 +1,41 @@ +/** + * 设备查看记录埋点 mixin + * 用于详情页:进入时 StartViewRecord,离开时 EndViewRecord + * 使用:在详情页 mixins: [viewRecordMixin],数据加载成功后调用 this.startViewTracking(reId, type, contentName, deviceName) + * 记录类型、设备名称、查看内容 三字段便于定位 + */ +export default { + data() { + return { + viewRecordId: null + } + }, + methods: { + async startViewTracking(reId, type, contentName, deviceName) { + if (!reId || !type) return + try { + const data = { reId: String(reId), type } + if (contentName) data.contentName = String(contentName) + if (deviceName) data.deviceName = String(deviceName) + const res = await this.API.startViewRecord(data) + if (res && res.data) { + this.viewRecordId = res.data + } + } catch (e) { + console.warn('StartViewRecord failed', e) + } + }, + async endViewTracking() { + if (!this.viewRecordId) return + try { + await this.API.endViewRecord({ recordId: this.viewRecordId }) + } catch (e) { + console.warn('EndViewRecord failed', e) + } + this.viewRecordId = null + } + }, + onUnload() { + this.endViewTracking() + } +} diff --git a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecords/SbRecordsStatisticsOutput.cs b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecords/SbRecordsStatisticsOutput.cs index ac3305d..e49e161 100644 --- a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecords/SbRecordsStatisticsOutput.cs +++ b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecords/SbRecordsStatisticsOutput.cs @@ -10,12 +10,24 @@ namespace NCC.Extend.Entitys.Dto.SbRecords /// 总记录数 public int TotalCount { get; set; } + /// 完整记录数(有离开时间的记录) + public int CompleteCount { get; set; } + + /// 总停留时长(秒) + public long TotalDurationSeconds { get; set; } + + /// 平均停留时长(秒,仅统计完整记录) + public double AvgDurationSeconds { get; set; } + /// 按记录类型统计 [{ name, value }] public List ByType { get; set; } - /// 按关联设备统计(前 N 条) + /// 按设备名称统计(前 N 条,使用 F_DeviceName) public List ByEquipment { get; set; } + /// 按查看内容统计(前 N 条,故障代码、知识名、培训名等) + public List ByContent { get; set; } + /// 按客户统计(前 N 条) public List ByCustomer { get; set; } diff --git a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecordsListQueryInput.cs b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecordsListQueryInput.cs index 5448cc9..57fcabc 100644 --- a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecordsListQueryInput.cs +++ b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/SbRecordsListQueryInput.cs @@ -53,6 +53,15 @@ namespace NCC.Extend.Entitys.Dto.SbRecords /// 记录类型 /// public string type { get; set; } - + + /// + /// 设备名称(模糊筛选) + /// + public string deviceName { get; set; } + + /// + /// 查看内容(模糊筛选) + /// + public string contentName { get; set; } } } diff --git a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/SbRecordsEntity.cs b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/SbRecordsEntity.cs index 0b5268a..d8776a5 100644 --- a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/SbRecordsEntity.cs +++ b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/SbRecordsEntity.cs @@ -82,6 +82,12 @@ namespace NCC.Extend.Entitys /// [SugarColumn(ColumnName = "F_DeviceName")] public string DeviceName { get; set; } + + /// + /// 查看客户(所属客户,根据记录类型+ReId 从 Khsb/Zlgl/Gzcx 等表解析) + /// + [SugarColumn(ColumnName = "F_CustomerName")] + public string CustomerName { get; set; } } } \ No newline at end of file diff --git a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/NCC.Extend.Entitys.xml b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/NCC.Extend.Entitys.xml index bdeea26..3cd717e 100644 --- a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/NCC.Extend.Entitys.xml +++ b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend.Entitys/NCC.Extend.Entitys.xml @@ -3737,11 +3737,23 @@ 总记录数 + + 完整记录数(有离开时间的记录) + + + 总停留时长(秒) + + + 平均停留时长(秒,仅统计完整记录) + 按记录类型统计 [{ name, value }] - 按关联设备统计(前 N 条) + 按设备名称统计(前 N 条,使用 F_DeviceName) + + + 按查看内容统计(前 N 条,故障代码、知识名、培训名等) 按客户统计(前 N 条) @@ -3879,9 +3891,19 @@ 记录类型 + + + 查看内容(故障代码、知识名、资料名、备件名、培训名等) + + + + + 设备名称(所属设备、关联产品名;优先 F_DeviceName,无则根据 reId 查表) + + - 关联设备(原关联记录,展示设备名称) + 关联设备(兼容旧逻辑,展示设备名称;优先使用 deviceName) @@ -3949,6 +3971,16 @@ 记录类型 + + + 设备名称(模糊筛选) + + + + + 查看内容(模糊筛选) + + 查看历史记录更新输入参数 @@ -8763,6 +8795,21 @@ 记录类型 + + + 查看内容(故障代码、知识名、资料名、备件名、培训名等) + + + + + 设备名称(所属设备、关联产品名,便于「记录类型+设备名称+查看内容」三字段定位) + + + + + 查看客户(所属客户,根据记录类型+ReId 从 Khsb/Zlgl/Gzcx 等表解析) + + 设备维护人员 diff --git a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend/SbRecordsService.cs b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend/SbRecordsService.cs index 0bcea7b..5503219 100644 --- a/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend/SbRecordsService.cs +++ b/机具(服务端)/netcore/src/Modularity/Extend/NCC.Extend/SbRecordsService.cs @@ -65,6 +65,7 @@ namespace NCC.Extend.SbRecords var now = DateTime.Now; var resolvedContentName = !string.IsNullOrWhiteSpace(contentName) ? contentName : await ResolveContentNameAsync(type, reId); var resolvedDeviceName = !string.IsNullOrWhiteSpace(deviceName) ? deviceName : await ResolveDeviceNameAsync(type, reId); + var resolvedCustomerName = await ResolveCustomerNameAsync(type, reId); var entity = new SbRecordsEntity { @@ -79,7 +80,8 @@ namespace NCC.Extend.SbRecords Item2 = item2, Type = type, ContentName = resolvedContentName, - DeviceName = resolvedDeviceName + DeviceName = resolvedDeviceName, + CustomerName = resolvedCustomerName }; var isOk = await _db.Insertable(entity).IgnoreColumns(ignoreNullColumn: true).ExecuteCommandAsync(); @@ -196,6 +198,59 @@ namespace NCC.Extend.SbRecords } /// + /// 根据 type 和 reId 从对应业务表解析查看客户(所属客户)。 + /// 我的设备:ReId=Khsb.Id;故障排查/知识库/资料管理/备件支持/培训展示:通过产品关联 Khsb.Fl 获取 Sskh。 + /// + private async Task ResolveCustomerNameAsync(string type, string reId) + { + if (string.IsNullOrWhiteSpace(reId)) return null; + var t = (type ?? "").Trim(); + + if (t.Contains("我的设备") || t.Contains("查看设备")) + { + var sskh = await _db.Queryable().Where(x => x.Id == reId).Select(x => x.Sskh).FirstAsync(); + return sskh; + } + if (t.Contains("故障排查") || t.Contains("故障查询")) + { + var zlFl = await _db.Queryable((g, z) => g.Sssb == z.Id) + .Where((g, z) => g.Id == reId) + .Select((g, z) => z.Fl) + .FirstAsync(); + if (string.IsNullOrEmpty(zlFl)) return null; + return await _db.Queryable().Where(k => k.Fl == zlFl && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync(); + } + if (t.Contains("知识库")) + { + var sssb = await _db.Queryable().Where(x => x.Id == reId).Select(x => x.Sssb).FirstAsync(); + if (string.IsNullOrEmpty(sssb)) return null; + return await _db.Queryable().Where(k => (k.Fl == sssb) && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync(); + } + if (t.Contains("资料管理")) + { + var zlFl = await _db.Queryable().Where(x => x.Id == reId).Select(x => x.Fl).FirstAsync(); + if (string.IsNullOrEmpty(zlFl)) return null; + return await _db.Queryable().Where(k => k.Fl == zlFl && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync(); + } + if (t.Contains("备件支持")) + { + var zlFl = await _db.Queryable((b, z) => b.Sssb == z.Id) + .Where((b, z) => b.Id == reId) + .Select((b, z) => z.Fl) + .FirstAsync(); + if (string.IsNullOrEmpty(zlFl)) return null; + return await _db.Queryable().Where(k => k.Fl == zlFl && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync(); + } + if (t.Contains("培训展示")) + { + var sssb = await _db.Queryable().Where(x => x.Id == reId).Select(x => x.Sssb).FirstAsync(); + if (string.IsNullOrEmpty(sssb)) return null; + return await _db.Queryable().Where(k => k.Fl == sssb && k.Sskh != null && k.Sskh != "").Select(k => k.Sskh).FirstAsync(); + } + return null; + } + + /// /// 结束一条设备查看会话记录(离开页面时调用),并计算停留时长。 /// /// 开始会话时返回的记录 Id。 @@ -270,35 +325,60 @@ namespace NCC.Extend.SbRecords .WhereIF(queryAddTime != null, p => p.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59)) .WhereIF(!string.IsNullOrEmpty(input.addUser), p => p.AddUser.Equals(input.addUser)) .WhereIF(!string.IsNullOrEmpty(input.type), p => p.Type.Contains(input.type)) + .WhereIF(!string.IsNullOrEmpty(input.deviceName), p => p.DeviceName != null && p.DeviceName.Contains(input.deviceName)) + .WhereIF(!string.IsNullOrEmpty(input.contentName), p => p.ContentName != null && p.ContentName.Contains(input.contentName)) .WhereIF(!string.IsNullOrEmpty(input.customerName), p => SqlFunc.Subqueryable().Where(k => k.Id == p.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any()); var total = await baseQuery.CountAsync(); const int topN = 10; + + var completeQuery = baseQuery.Where(p => p.LeaveTime != null); + var completeCount = await completeQuery.CountAsync(); + var totalDuration = completeCount > 0 ? await completeQuery.SumAsync(p => p.DurationSeconds) : 0L; + var avgDuration = completeCount > 0 ? (double)totalDuration / completeCount : 0; + var byTypeList = await baseQuery.GroupBy(p => p.Type ?? "") .Select(g => new { Name = g.Type ?? "未分类", Value = SqlFunc.AggregateCount(g.Id) }) .ToListAsync(); var byType = byTypeList.OrderByDescending(x => x.Value).Select(x => new SbRecordsStatisticsItem { Name = x.Name ?? "未分类", Value = x.Value }).ToList(); - var byEquipmentRawList = await baseQuery.GroupBy(p => p.ReId) - .Select(g => new { ReId = g.ReId, Value = SqlFunc.AggregateCount(g.Id) }) + + var byEquipmentRawList = await baseQuery.GroupBy(p => p.DeviceName ?? "") + .Select(g => new { Name = g.DeviceName ?? "未记录", Value = SqlFunc.AggregateCount(g.Id) }) .ToListAsync(); - var byEquipmentRaw = byEquipmentRawList.OrderByDescending(x => x.Value).Take(topN).ToList(); - var reIds = byEquipmentRaw.Select(x => x.ReId).Distinct().ToList(); - var eqNames = await _db.Queryable().Where(t => reIds.Contains(t.Id)).Select(t => new { t.Id, t.Sbmc }).ToListAsync(); - var khsbNames = await _db.Queryable().Where(k => reIds.Contains(k.Id)).Select(k => new { k.Id, k.Sbmc }).ToListAsync(); - var byEquipment = byEquipmentRaw.Select(x => new SbRecordsStatisticsItem - { - Name = eqNames.FirstOrDefault(e => e.Id == x.ReId)?.Sbmc ?? khsbNames.FirstOrDefault(k => k.Id == x.ReId)?.Sbmc ?? x.ReId ?? "-", - Value = x.Value - }).ToList(); - var byUserRawList = await baseQuery.GroupBy(p => p.AddUser) - .Select(g => new { AddUser = g.AddUser, Value = SqlFunc.AggregateCount(g.Id) }) + var byEquipment = byEquipmentRawList + .Where(x => !string.IsNullOrEmpty(x.Name)) + .OrderByDescending(x => x.Value) + .Take(topN) + .Select(x => new SbRecordsStatisticsItem { Name = x.Name ?? "未记录", Value = x.Value }) + .ToList(); + + var byContentRawList = await baseQuery.GroupBy(p => p.ContentName ?? "") + .Select(g => new { Name = g.ContentName ?? "未记录", Value = SqlFunc.AggregateCount(g.Id) }) + .ToListAsync(); + var byContent = byContentRawList + .Where(x => !string.IsNullOrEmpty(x.Name)) + .OrderByDescending(x => x.Value) + .Take(topN) + .Select(x => new SbRecordsStatisticsItem { Name = x.Name ?? "未记录", Value = x.Value }) + .ToList(); + // 按查看用户统计:LEFT JOIN UserEntity 直接获取 RealName,避免 GroupBy 分组键与后续查表不一致 + var byUserQuery = _db.Queryable((r, u) => new JoinQueryInfos(JoinType.Left, r.AddUser == u.Id)) + .WhereIF(!string.IsNullOrEmpty(input.reId), (r, u) => r.ReId.Equals(input.reId)) + .WhereIF(queryAddTime != null, (r, u) => r.AddTime >= new DateTime(startAddTime.ToDate().Year, startAddTime.ToDate().Month, startAddTime.ToDate().Day, 0, 0, 0)) + .WhereIF(queryAddTime != null, (r, u) => r.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59)) + .WhereIF(!string.IsNullOrEmpty(input.addUser), (r, u) => r.AddUser.Equals(input.addUser)) + .WhereIF(!string.IsNullOrEmpty(input.type), (r, u) => r.Type != null && r.Type.Contains(input.type)) + .WhereIF(!string.IsNullOrEmpty(input.deviceName), (r, u) => r.DeviceName != null && r.DeviceName.Contains(input.deviceName)) + .WhereIF(!string.IsNullOrEmpty(input.contentName), (r, u) => r.ContentName != null && r.ContentName.Contains(input.contentName)) + .WhereIF(!string.IsNullOrEmpty(input.customerName), (r, u) => SqlFunc.Subqueryable().Where(k => k.Id == r.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any()); + var byUserRawList = await byUserQuery + .GroupBy((r, u) => r.AddUser ?? "") + .Select((r, u) => new { AddUser = r.AddUser ?? "", RealName = SqlFunc.AggregateMax(u.RealName), Value = SqlFunc.AggregateCount(r.Id) }) .ToListAsync(); - var byUserRaw = byUserRawList.OrderByDescending(x => x.Value).Take(topN).ToList(); - var userIds = byUserRaw.Select(x => x.AddUser).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList(); - var userNames = await _db.Queryable().Where(u => userIds.Contains(u.Id)).Select(u => new { u.Id, u.RealName }).ToListAsync(); + var byUserRaw = byUserRawList.Where(x => !string.IsNullOrEmpty(x.AddUser)).OrderByDescending(x => x.Value).Take(topN).ToList(); var byUser = byUserRaw.Select(x => new SbRecordsStatisticsItem { - Name = string.IsNullOrEmpty(x.AddUser) ? "-" : (userNames.FirstOrDefault(u => u.Id == x.AddUser)?.RealName ?? x.AddUser), + Name = string.IsNullOrEmpty(x.RealName) ? x.AddUser : x.RealName, Value = x.Value }).ToList(); var byCustomerRawList = await _db.Queryable((r, k) => r.ReId == k.Id) @@ -317,8 +397,12 @@ namespace NCC.Extend.SbRecords return new SbRecordsStatisticsOutput { TotalCount = total, + CompleteCount = completeCount, + TotalDurationSeconds = totalDuration, + AvgDurationSeconds = Math.Round(avgDuration, 2), ByType = byType.Select(x => new SbRecordsStatisticsItem { Name = x.Name, Value = x.Value }).ToList(), ByEquipment = byEquipment, + ByContent = byContent, ByCustomer = byCustomer, ByUser = byUser }; @@ -375,6 +459,8 @@ namespace NCC.Extend.SbRecords .WhereIF(queryAddTime != null, p => p.AddTime >= new DateTime(startAddTime.ToDate().Year, startAddTime.ToDate().Month, startAddTime.ToDate().Day, 0, 0, 0)) .WhereIF(queryAddTime != null, p => p.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59)) .WhereIF(!string.IsNullOrEmpty(input.addUser), p => p.AddUser.Equals(input.addUser)) + .WhereIF(!string.IsNullOrEmpty(input.deviceName), p => p.DeviceName != null && p.DeviceName.Contains(input.deviceName)) + .WhereIF(!string.IsNullOrEmpty(input.contentName), p => p.ContentName != null && p.ContentName.Contains(input.contentName)) .WhereIF(!string.IsNullOrEmpty(input.customerName), p => SqlFunc.Subqueryable().Where(k => k.Id == p.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any()) .WhereIF(!string.IsNullOrEmpty(input.item1), p => p.Item1.Contains(input.item1)) .WhereIF(!string.IsNullOrEmpty(input.item2), p => p.Item2.Contains(input.item2)) @@ -386,7 +472,7 @@ namespace NCC.Extend.SbRecords contentName = it.ContentName, deviceName = it.DeviceName, equipmentName = SqlFunc.IsNull(it.DeviceName, SqlFunc.IsNull(SqlFunc.Subqueryable().Where(k => k.Id == it.ReId).Select(k => k.Sbmc), SqlFunc.Subqueryable().Where(t => t.Id == it.ReId).Select(t => t.Sbmc))), - customerName = SqlFunc.Subqueryable().Where(k => k.Id == it.ReId).Select(k => k.Sskh), + customerName = SqlFunc.IsNull(it.CustomerName, SqlFunc.Subqueryable().Where(k => k.Id == it.ReId).Select(k => k.Sskh)), addTime = it.AddTime, addUser = SqlFunc.Subqueryable().Where(u => u.Id == it.AddUser).Select(u => u.RealName), enable = it.Enable, @@ -429,6 +515,8 @@ namespace NCC.Extend.SbRecords .WhereIF(queryAddTime != null, p => p.AddTime >= new DateTime(startAddTime.ToDate().Year, startAddTime.ToDate().Month, startAddTime.ToDate().Day, 0, 0, 0)) .WhereIF(queryAddTime != null, p => p.AddTime <= new DateTime(endAddTime.ToDate().Year, endAddTime.ToDate().Month, endAddTime.ToDate().Day, 23, 59, 59)) .WhereIF(!string.IsNullOrEmpty(input.addUser), p => p.AddUser.Equals(input.addUser)) + .WhereIF(!string.IsNullOrEmpty(input.deviceName), p => p.DeviceName != null && p.DeviceName.Contains(input.deviceName)) + .WhereIF(!string.IsNullOrEmpty(input.contentName), p => p.ContentName != null && p.ContentName.Contains(input.contentName)) .WhereIF(!string.IsNullOrEmpty(input.customerName), p => SqlFunc.Subqueryable().Where(k => k.Id == p.ReId && k.Sskh != null && k.Sskh.Contains(input.customerName)).Any()) .WhereIF(!string.IsNullOrEmpty(input.item1), p => p.Item1.Contains(input.item1)) .WhereIF(!string.IsNullOrEmpty(input.item2), p => p.Item2.Contains(input.item2)) @@ -440,7 +528,7 @@ namespace NCC.Extend.SbRecords contentName = it.ContentName, deviceName = it.DeviceName, equipmentName = SqlFunc.IsNull(it.DeviceName, SqlFunc.IsNull(SqlFunc.Subqueryable().Where(k => k.Id == it.ReId).Select(k => k.Sbmc), SqlFunc.Subqueryable().Where(t => t.Id == it.ReId).Select(t => t.Sbmc))), - customerName = SqlFunc.Subqueryable().Where(k => k.Id == it.ReId).Select(k => k.Sskh), + customerName = SqlFunc.IsNull(it.CustomerName, SqlFunc.Subqueryable().Where(k => k.Id == it.ReId).Select(k => k.Sskh)), addTime = it.AddTime, addUser = SqlFunc.Subqueryable().Where(u => u.Id == it.AddUser).Select(u => u.RealName), enable = it.Enable, diff --git a/机具(管理端)/src/views/sbRecords/index.vue b/机具(管理端)/src/views/sbRecords/index.vue index 5769c2f..d617d21 100644 --- a/机具(管理端)/src/views/sbRecords/index.vue +++ b/机具(管理端)/src/views/sbRecords/index.vue @@ -1,127 +1,182 @@