feat(odf): Add marker pole management and audit logging for v1.2.0
All checks were successful
continuous-integration/drone/push Build is passing

- Add marker pole CRUD functionality with database tables (odf_marker_poles, odf_marker_pole_images)
- Implement marker pole API endpoints and service layer with data isolation by department
- Add UniApp pages for marker pole list, detail, and creation workflows
- Add Vue management backend pages for marker pole and audit log management
- Implement audit logging system via ActionFilter to track all business entity modifications
- Extend search API to include marker poles in results alongside cables and faults
- Add OdfAuditLogsController and service for querying audit trail data
- Update optical box detail page with left-right frame color scheme (green-orange)
- Add cable type page as entry point for marker poles and fault lists
- Create database migration scripts for v1.2.0 schema and permissions
- Add DeptDataScopeHelper for department-based data access control
- Update MCP settings to disable SQL Server connection and fix formatting
- Add marker pole service integration in UniApp with COS image upload support
This commit is contained in:
zpc 2026-04-18 22:50:15 +08:00
parent 2fee350fec
commit 827d7a4367
63 changed files with 4691 additions and 46 deletions

View File

@ -1,6 +1,6 @@
{
"mcpServers": {
"api-sqlserver": {
"api-sqlserver": {
"command": "node",
"args": [
"C:/mcp/mssql-mcp-server/index.js"
@ -30,7 +30,8 @@
},
"autoApprove": [
"execute_sql"
]
],
"disabled": true
}
}
}
}

View File

@ -0,0 +1 @@
{"specId": "9a943afb-7e20-40a1-98c9-071c4599edc6", "workflowType": "requirements-first", "specType": "feature"}

View File

@ -0,0 +1,564 @@
# 技术设计文档 — ODF v1.2.0 标石杆号牌及管理后台优化
## 概述
本设计文档覆盖 ODF v1.2.0 版本的全部功能模块基于现有技术栈UniApp 前端 + .NET 后端 + Vue 管理后台 + SQL Server 数据库)进行扩展。主要包含:
1. **标石/杆号牌 CRUD**:新增数据库表 `odf_marker_poles`、`odf_marker_pole_images`,后端 Controller/Service/ModelUniApp 前端 4 个新页面(光缆类型页、列表页、详情页、新增页),管理后台 Vue 页面
2. **搜索扩展**:在现有干线搜索 API 和搜索结果页中增加标石/杆号牌类型
3. **光交箱 UI 优化**:修改光交箱详情页左右框体配色(左绿右橙)
4. **审计日志(修改统计)**:新增 `odf_audit_logs` 表,通过 .NET ActionFilter 自动记录增删改操作
5. **组织架构权限**:基于 `DeptId` 的数据隔离、机房权限修复、分公司管理员角色
## 架构
### 整体架构
```mermaid
graph TB
subgraph 前端
A[odf-uniapp<br/>UniApp/Vue3] -->|HTTP REST| C
B[ZR.Vue<br/>管理后台] -->|HTTP REST| C
end
subgraph 后端
C[ZR.Admin.WebApi<br/>.NET Controllers] --> D[ZR.Service<br/>业务逻辑层]
D --> E[SqlSugar ORM]
C --> F[ActionFilter<br/>审计日志拦截]
F --> E
end
subgraph 存储
E --> G[(SQL Server)]
A -->|直传| H[腾讯云 COS<br/>图片存储]
end
```
### 数据流
- **标石/杆号牌新增流程**:拍照 → 水印叠加 → COS 直传 → 获取 COS URL → 提交表单(含 URL→ 后端入库
- **审计日志流程**Controller Action 执行 → AuditLogFilter 拦截 → 记录操作前后数据 → 写入 `odf_audit_logs`
- **数据隔离流程**:请求到达 → 从 JWT Token 提取 DeptId → Service 层查询 `sys_dept` 获取本级及下级部门 ID 列表 → 拼接 WHERE 条件过滤
## 组件与接口
### 后端组件
#### 1. 标石/杆号牌模块
| 组件 | 路径 | 职责 |
|------|------|------|
| `OdfMarkerPolesController` | `Controllers/Business/OdfMarkerPolesController.cs` | REST API 入口 |
| `OdfMarkerPolesService` | `Service/Business/OdfMarkerPolesService.cs` | 业务逻辑 |
| `IOdfMarkerPolesService` | `Service/Business/IBusinessService/IOdfMarkerPolesService.cs` | 服务接口 |
| `OdfMarkerPoles` | `Model/Business/OdfMarkerPoles.cs` | 实体模型 |
| `OdfMarkerPoleImages` | `Model/Business/OdfMarkerPoleImages.cs` | 图片实体 |
| `OdfMarkerPolesDto` | `Model/Business/Dto/OdfMarkerPolesDto.cs` | 查询/新增/编辑 DTO |
#### 2. 审计日志模块
| 组件 | 路径 | 职责 |
|------|------|------|
| `OdfAuditLogsController` | `Controllers/Business/OdfAuditLogsController.cs` | 审计日志查询 API |
| `OdfAuditLogsService` | `Service/Business/OdfAuditLogsService.cs` | 审计日志业务逻辑 |
| `OdfAuditLogs` | `Model/Business/OdfAuditLogs.cs` | 审计日志实体 |
| `OdfAuditLogFilter` | `Filters/OdfAuditLogFilter.cs` | ActionFilter自动拦截记录 |
#### 3. 数据隔离组件
| 组件 | 路径 | 职责 |
|------|------|------|
| `DeptDataScopeHelper` | `Service/Business/DeptDataScopeHelper.cs` | 根据 DeptId 查询本级及下级部门 ID 列表 |
### API 接口设计
#### 标石/杆号牌 API
```
GET /business/OdfMarkerPoles/list?cableId={id}&pageNum=1&pageSize=10
权限: odfmarkerpoles:list
返回: PagedInfo<OdfMarkerPoleListDto>
GET /business/OdfMarkerPoles/{id}
权限: odfmarkerpoles:query
返回: OdfMarkerPoleDetailDto含图片列表
POST /business/OdfMarkerPoles/add
权限: odfmarkerpoles:add
Body: OdfMarkerPoleAddDto
返回: int (新记录ID)
PUT /business/OdfMarkerPoles/edit
权限: odfmarkerpoles:edit
Body: OdfMarkerPoleEditDto
返回: int (影响行数)
DELETE /business/OdfMarkerPoles/delete/{ids}
权限: odfmarkerpoles:delete
返回: int (删除行数)
```
#### 搜索 API 扩展
```
GET /business/OdfCables/search?deptId={id}&keyword={kw}
现有接口,返回结构新增 markerPoles 字段:
{ cables: [...], faults: [...], markerPoles: [...] }
```
#### 审计日志 API
```
GET /business/OdfAuditLogs/list?pageNum=1&pageSize=10&beginTime=&endTime=&operationType=
权限: odfauditlogs:list
返回: PagedInfo<OdfAuditLogDto>
```
### 前端组件UniApp
| 页面 | 路由 | 职责 |
|------|------|------|
| 光缆类型页 | `pages/cable-type/index` | 展示【标石、杆号牌】和【故障列表】入口 |
| 标石杆号牌列表页 | `pages/marker-pole-list/index` | 展示某光缆下标石/杆号牌列表 |
| 标石杆号牌详情页 | `pages/marker-pole-detail/index` | 展示详情,支持导航 |
| 新增标石杆号牌页 | `pages/marker-pole-add/index` | 拍照+水印+表单提交 |
服务层新增 `services/markerPole.js`,封装标石/杆号牌相关 API 调用。
### 管理后台组件ZR.Vue
| 组件 | 路径 | 职责 |
|------|------|------|
| `OdfMarkerPoles.vue` | `views/business/OdfMarkerPoles.vue` | 标石/杆号牌 CRUD 管理页 |
| `OdfAuditLogs.vue` | `views/business/OdfAuditLogs.vue` | 修改统计查看页 |
| `odfmarkerpoles.js` | `api/business/odfmarkerpoles.js` | 标石/杆号牌 API 封装 |
| `odfauditlogs.js` | `api/business/odfauditlogs.js` | 审计日志 API 封装 |
## 数据模型
### 数据库表设计
#### 1. `odf_marker_poles` — 标石/杆号牌表
```sql
CREATE TABLE odf_marker_poles (
Id INT IDENTITY(1,1) PRIMARY KEY, -- 主键
CableId INT NOT NULL, -- 关联光缆ID (odf_cables.Id)
Name NVARCHAR(200) NOT NULL, -- 标石/杆号牌名称
RecordTime DATETIME NOT NULL, -- 记录时间(拍照时间)
Personnel NVARCHAR(100) NULL, -- 责任人
Latitude DECIMAL(10,7) DEFAULT 0, -- 纬度
Longitude DECIMAL(10,7) DEFAULT 0, -- 经度
ActualMileage NVARCHAR(100) NULL, -- 实际里程
DeptId BIGINT NOT NULL, -- 所属公司/部门ID
DeptName NVARCHAR(200) NULL, -- 部门名称(冗余)
UserId BIGINT NULL, -- 提交人用户ID
CreatedAt DATETIME DEFAULT GETDATE(), -- 创建时间
UpdatedAt DATETIME DEFAULT GETDATE() -- 更新时间
);
CREATE INDEX IX_odf_marker_poles_CableId ON odf_marker_poles(CableId);
CREATE INDEX IX_odf_marker_poles_DeptId ON odf_marker_poles(DeptId);
```
#### 2. `odf_marker_pole_images` — 标石/杆号牌图片表
```sql
CREATE TABLE odf_marker_pole_images (
Id INT IDENTITY(1,1) PRIMARY KEY,
MarkerPoleId INT NOT NULL, -- 关联标石ID (odf_marker_poles.Id)
ImageUrl NVARCHAR(500) NOT NULL, -- COS 图片 URL
CreatedAt DATETIME DEFAULT GETDATE()
);
CREATE INDEX IX_odf_marker_pole_images_MarkerPoleId ON odf_marker_pole_images(MarkerPoleId);
```
#### 3. `odf_audit_logs` — 审计日志表
```sql
CREATE TABLE odf_audit_logs (
Id INT IDENTITY(1,1) PRIMARY KEY,
TableName NVARCHAR(100) NOT NULL, -- 操作的表名
RecordId INT NULL, -- 操作的记录ID
OperationType NVARCHAR(20) NOT NULL, -- 操作类型: INSERT/UPDATE/DELETE
OperatorId BIGINT NOT NULL, -- 操作人用户ID
OperatorName NVARCHAR(100) NULL, -- 操作人用户名
SourceClient NVARCHAR(20) NOT NULL, -- 操作来源: App/Admin
OldData NVARCHAR(MAX) NULL, -- 修改前数据 (JSON)
NewData NVARCHAR(MAX) NULL, -- 修改后数据 (JSON)
OperationTime DATETIME DEFAULT GETDATE(), -- 操作时间
DeptId BIGINT NULL, -- 操作人所属部门
Remark NVARCHAR(500) NULL -- 备注
);
CREATE INDEX IX_odf_audit_logs_OperationTime ON odf_audit_logs(OperationTime);
CREATE INDEX IX_odf_audit_logs_TableName ON odf_audit_logs(TableName);
CREATE INDEX IX_odf_audit_logs_OperationType ON odf_audit_logs(OperationType);
```
### 实体模型
#### OdfMarkerPoles.cs
```csharp
[SugarTable("odf_marker_poles")]
public class OdfMarkerPoles
{
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
public int CableId { get; set; }
public string Name { get; set; }
public DateTime RecordTime { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public long DeptId { get; set; }
public string? DeptName { get; set; }
public long? UserId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
```
#### OdfMarkerPoleImages.cs
```csharp
[SugarTable("odf_marker_pole_images")]
public class OdfMarkerPoleImages
{
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
public int MarkerPoleId { get; set; }
public string ImageUrl { get; set; }
public DateTime? CreatedAt { get; set; }
}
```
#### OdfAuditLogs.cs
```csharp
[SugarTable("odf_audit_logs")]
public class OdfAuditLogs
{
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
public string TableName { get; set; }
public int? RecordId { get; set; }
public string OperationType { get; set; }
public long OperatorId { get; set; }
public string? OperatorName { get; set; }
public string SourceClient { get; set; }
[SugarColumn(ColumnDataType = "nvarchar(max)")]
public string? OldData { get; set; }
[SugarColumn(ColumnDataType = "nvarchar(max)")]
public string? NewData { get; set; }
public DateTime? OperationTime { get; set; }
public long? DeptId { get; set; }
public string? Remark { get; set; }
}
```
### DTO 设计
#### OdfMarkerPolesDto.cs
```csharp
// 查询 DTO
public class OdfMarkerPolesQueryDto : PagerInfo
{
public int? CableId { get; set; }
public string? Keyword { get; set; }
}
// 新增 DTO
public class OdfMarkerPoleAddDto
{
public int CableId { get; set; }
public string Name { get; set; }
public string RecordTime { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public long? UserId { get; set; }
public string[] ImageUrls { get; set; }
}
// 编辑 DTO
public class OdfMarkerPoleEditDto
{
public int Id { get; set; }
public string Name { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public int CableId { get; set; }
}
// 审计日志查询 DTO
public class OdfAuditLogsQueryDto : PagerInfo
{
public DateTime? BeginTime { get; set; }
public DateTime? EndTime { get; set; }
public string? OperationType { get; set; }
public string? TableName { get; set; }
}
```
### 页面路由设计UniApp pages.json 新增)
```json
{
"path": "pages/cable-type/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/marker-pole-list/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/marker-pole-detail/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/marker-pole-add/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
}
```
### 页面导航流程
```mermaid
graph LR
A[光缆列表页<br/>cable/index] -->|点击光缆| B[光缆类型页<br/>cable-type/index]
B -->|标石杆号牌| C[标石列表页<br/>marker-pole-list/index]
B -->|故障列表| D[故障列表页<br/>fault-list/index]
C -->|点击记录| E[标石详情页<br/>marker-pole-detail/index]
C -->|新增| F[新增标石页<br/>marker-pole-add/index]
E -->|导航按钮| G[外部导航APP/地图网页]
```
### 权限与数据隔离方案
#### 数据隔离实现
`DeptDataScopeHelper` 工具类提供统一的部门范围查询:
```csharp
public static class DeptDataScopeHelper
{
/// <summary>
/// 获取当前用户可见的部门ID列表本级 + 所有下级)
/// </summary>
public static List<long> GetVisibleDeptIds(ISqlSugarClient db, long deptId)
{
var allDepts = db.Queryable<SysDept>().ToList();
var result = new List<long> { deptId };
CollectChildDeptIds(allDepts, deptId, result);
return result;
}
private static void CollectChildDeptIds(List<SysDept> all, long parentId, List<long> result)
{
var children = all.Where(d => d.ParentId == parentId).ToList();
foreach (var child in children)
{
result.Add(child.DeptId);
CollectChildDeptIds(all, child.DeptId, result);
}
}
}
```
在 Service 层查询时统一应用:
```csharp
var deptIds = DeptDataScopeHelper.GetVisibleDeptIds(Context, currentDeptId);
predicate = predicate.And(it => deptIds.Contains(it.DeptId));
```
需要应用数据隔离的模块:
- 标石/杆号牌列表查询
- 干线故障列表查询(现有,需补充 DeptId 过滤)
- 光缆列表查询
#### 机房权限修复
现有机房模块的权限控制需与干线模块对齐:
- 在 `OdfPortsController` 的编辑接口上增加 `odfports:edit` 权限校验
- 前端 UniApp 在端口编辑弹窗中根据用户权限(从 store 中读取)控制编辑按钮的显示/隐藏
- 权限值通过登录接口返回,存储在 `store.permissions`
#### 分公司管理员角色
- 在 `sys_role` 表中新增「分公司管理员」角色记录
- 角色权限包含:查看本公司及下属公司数据、管理本公司及下级公司人员账号
- 后端在创建用户时校验:分公司管理员只能创建 DeptId 在其可见范围内的账号
- 管理后台登录时校验:分公司账号必须拥有「分公司管理员」角色才允许登录
### 光交箱 UI 配色方案
修改 `odf-uniapp/pages/optical-box-detail/index.vue` 中的样式:
| 区域 | 当前颜色 | 新颜色 |
|------|---------|--------|
| 左侧框体(配线端子)背景 | `#FFC0CB` (粉色) | `#C8E6C9` (浅绿) |
| 左侧行背景 | `#FFB6C1` | `#A5D6A7` |
| 右侧框体(局线端子)背景 | `#E0F7FA` (浅蓝) | `#FFE0B2` (浅橙) |
| 右侧行背景 | `#B3E5FC` | `#FFCC80` |
## 正确性属性
*正确性属性是在系统所有有效执行中都应成立的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
### 属性 1标石记录列表渲染包含必要字段
*对于任意*标石/杆号牌记录,当在列表页(包括标石列表页和搜索结果页)渲染时,渲染输出应包含名称、时间、责任人、所属光缆名称。
**验证需求: 2.1, 5.3**
### 属性 2标石详情页展示完整信息
*对于任意*标石/杆号牌记录,当在详情页渲染时,渲染输出应包含照片、名称、时间、责任人、经纬度、所属公司、实际里程。
**验证需求: 3.1**
### 属性 3标石 CRUD round-trip
*对于任意*有效的标石/杆号牌数据,创建后通过 API 查询应返回与提交数据一致的记录;编辑后查询应返回修改后的值。
**验证需求: 4.8, 6.4**
### 属性 4标石删除后不可查
*对于任意*已存在的标石/杆号牌记录,执行删除操作后,通过 API 查询该记录应返回空或不存在。
**验证需求: 6.5**
### 属性 5新增标石自动填充所属公司
*对于任意*用户,新增标石/杆号牌时,后端应自动将该记录的 DeptId 和 DeptName 设置为当前用户的所属公司信息。
**验证需求: 4.4**
### 属性 6搜索结果包含匹配的标石记录
*对于任意*关键词和标石/杆号牌数据集,当标石名称包含该关键词时,搜索 API 返回的 markerPoles 列表应包含该标石记录。
**验证需求: 5.1**
### 属性 7管理后台标石列表展示完整字段
*对于任意*标石/杆号牌记录,管理后台列表 API 返回的数据应包含名称、时间、责任人、经纬度、所属公司、所属光缆、实际里程。
**验证需求: 6.2**
### 属性 8业务操作产生完整审计日志
*对于任意*对 ODF/干线/标石相关数据的增删改操作审计日志表中应存在对应记录且该记录包含操作人、操作时间、操作类型、操作来源端当操作类型为修改时应同时包含修改前数据OldData和修改后数据NewData
**验证需求: 8.2, 8.3, 8.4**
### 属性 9审计日志筛选正确性
*对于任意*时间范围和操作类型筛选条件,审计日志查询 API 返回的所有记录应满足:操作时间在指定范围内,且操作类型匹配筛选条件。
**验证需求: 8.5, 8.6**
### 属性 10数据隔离 — 查询结果仅包含可见部门数据
*对于任意*用户和任意数据查询(光缆、故障、标石),返回的所有记录的 DeptId 应属于该用户可见的部门 ID 集合(本级 + 所有下级部门)。
**验证需求: 9.1, 9.2, 9.3, 9.4**
### 属性 11机房操作权限与用户角色匹配
*对于任意*用户和机房操作请求,当用户权限为「仅查看」时,修改操作应被拒绝;当用户权限为「修改和查看」时,查看和修改操作应被允许。
**验证需求: 10.1, 10.2**
### 属性 12分公司管理员创建账号范围限制
*对于任意*分公司管理员创建用户账号操作,新账号的 DeptId 必须在该管理员可见的部门范围内(本公司及下级公司),否则操作应被拒绝。
**验证需求: 11.3, 11.5**
### 属性 13非分公司管理员禁止登录管理后台
*对于任意*分公司级别的账号,如果该账号不具有「分公司管理员」角色,则登录管理后台的请求应被拒绝。
**验证需求: 11.4**
## 错误处理
### 前端错误处理UniApp
| 场景 | 处理方式 |
|------|---------|
| 未拍照即提交 | `uni.showToast` 提示「请至少拍摄一张照片」,阻止提交 |
| 名称为空即提交 | `uni.showToast` 提示「请输入名称」,阻止提交 |
| 网络请求失败 | `uni.showToast` 提示错误信息,保留用户已填写数据 |
| GPS 定位失败 | 提示用户检查定位权限,不阻止表单提交(经纬度可为 0 |
| COS 图片上传失败 | 提示上传失败,允许重试 |
| 导航经纬度为空 | 隐藏导航按钮,不展示导航入口 |
### 后端错误处理(.NET
| 场景 | HTTP 状态码 | 处理方式 |
|------|------------|---------|
| CableId 不存在 | 400 | 抛出 `CustomException("光缆不存在")` |
| 标石记录不存在 | 400 | 抛出 `CustomException("标石记录不存在")` |
| 无图片 URL | 400 | 抛出 `CustomException("请至少上传一张图片")` |
| 无权限访问 | 403 | 框架 `ActionPermissionFilter` 自动拦截 |
| 数据隔离越权 | 403 | Service 层校验 DeptId 范围,不在范围内返回空数据 |
| 分公司管理员越权创建账号 | 400 | 抛出 `CustomException("无权为该公司创建账号")` |
| 审计日志写入失败 | - | 记录错误日志,不影响主业务流程(异步/try-catch |
## 测试策略
### 双重测试方法
本项目采用单元测试 + 属性测试的双重测试策略:
- **单元测试**:验证具体示例、边界条件和错误处理
- **属性测试**:验证跨所有输入的通用属性
### 属性测试配置
- **测试库**:后端使用 [FsCheck](https://fscheck.github.io/FsCheck/) (.NET 属性测试库);前端使用 [fast-check](https://github.com/dubzzz/fast-check) (JavaScript 属性测试库)
- **每个属性测试最少运行 100 次迭代**
- **每个属性测试必须通过注释引用设计文档中的属性编号**
- **标签格式**`Feature: odf-v120-marker-pole, Property {number}: {property_text}`
- **每个正确性属性由单个属性测试实现**
### 单元测试覆盖
| 测试类别 | 覆盖内容 |
|---------|---------|
| API 示例测试 | 标石 CRUD 接口的正常流程验证 |
| 边界条件 | 空列表状态、经纬度为零、未拍照提交、上传失败 |
| 权限测试 | 仅查看用户无法修改、非管理员无法登录后台 |
| 数据隔离 | 跨公司数据不可见 |
| 搜索测试 | 关键词匹配、无结果场景 |
| UI 样式 | 光交箱左绿右橙配色验证 |
### 属性测试覆盖
| 属性编号 | 测试内容 | 测试库 |
|---------|---------|--------|
| 属性 1 | 标石列表渲染字段完整性 | fast-check |
| 属性 2 | 标石详情渲染字段完整性 | fast-check |
| 属性 3 | 标石 CRUD round-trip | FsCheck |
| 属性 4 | 标石删除后不可查 | FsCheck |
| 属性 5 | 新增标石自动填充公司 | FsCheck |
| 属性 6 | 搜索结果包含匹配标石 | FsCheck |
| 属性 7 | 管理后台列表字段完整性 | FsCheck |
| 属性 8 | 审计日志完整性 | FsCheck |
| 属性 9 | 审计日志筛选正确性 | FsCheck |
| 属性 10 | 数据隔离正确性 | FsCheck |
| 属性 11 | 机房权限匹配 | FsCheck |
| 属性 12 | 创建账号范围限制 | FsCheck |
| 属性 13 | 非管理员登录拒绝 | FsCheck |

View File

@ -0,0 +1,174 @@
# 需求文档 — ODF v1.2.0 标石杆号牌及管理后台优化
## 简介
ODF光缆资源管理v1.2.0 版本在现有 odf-uniappUniApp/Vue前端和 .NET 后端基础上进行功能扩展。本次更新包含三大模块:新增标石/杆号牌完整 CRUD 功能含拍照水印、GPS 定位、导航)、光交箱机房详情页 UI 优化、管理后台修改统计与组织架构权限优化。H5 与 APP 同步更新。
## 需求来源
#[[file:docs/1.2.0/1.2.0.md]]
## 术语表
- **App**odf-uniapp 前端应用,基于 UniApp + Vue 构建,运行于安卓和 H5 平台
- **管理后台**:基于 ZR.VueVue.js构建的 Web 管理系统,用于数据配置和管理
- **后端**:基于 .NET 的 ZR.Admin.WebApi 服务端,提供 RESTful API
- **光缆列表页**:展示某公司下所有光缆的页面(现有 pages/cable/index
- **光缆类型页**:点击光缆后进入的中间页面,展示【标石、杆号牌】和【故障列表】两个入口按钮
- **标石杆号牌列表页**:展示某光缆下所有标石/杆号牌记录的页面
- **标石杆号牌详情页**:展示单条标石/杆号牌完整信息的页面
- **新增标石杆号牌页**:用于创建新标石/杆号牌记录的表单页面
- **水印照片**:拍摄后在照片上叠加坐标、时间、责任人、所属光缆等信息文字的照片
- **导航点**:标石/杆号牌的 GPS 经纬度坐标,用于地图导航
- **实际里程**:标石/杆号牌在光缆上的实际里程数值
- **光交箱详情页**:光交箱类型机架的详情页面(现有 pages/optical-box-detail/index
- **修改统计**:管理后台记录所有增删改操作的审计功能
- **组织架构权限**:基于公司层级的数据隔离和访问控制机制
- **分公司管理员**:分公司级别的管理角色,可管理本公司及下属公司人员账号
## 需求
### 需求 1光缆类型页入口
**用户故事:** 作为用户,我希望点击光缆后看到功能分类入口,以便分别进入标石杆号牌管理或故障管理。
#### 验收标准
1. WHEN 用户在光缆列表页点击任意光缆THE App SHALL 跳转至光缆类型页
2. THE 光缆类型页 SHALL 展示【标石、杆号牌】和【故障列表】两个入口按钮
3. WHEN 用户点击【标石、杆号牌】按钮THE App SHALL 跳转至该光缆的标石杆号牌列表页
4. WHEN 用户点击【故障列表】按钮THE App SHALL 跳转至该光缆的故障列表页(现有页面)
5. THE 光缆类型页 SHALL 在导航栏显示当前光缆名称
### 需求 2标石杆号牌列表页
**用户故事:** 作为用户,我希望查看某光缆下的所有标石/杆号牌记录,以便了解标石分布情况并新增记录。
#### 验收标准
1. THE 标石杆号牌列表页 SHALL 展示标石/杆号牌记录列表,每条记录显示名称、时间、责任人、导航点、实际里程
2. WHEN 用户点击任意标石/杆号牌记录THE App SHALL 跳转至该记录的标石杆号牌详情页
3. THE 标石杆号牌列表页 SHALL 提供【新增】按钮
4. WHEN 用户点击【新增】按钮THE App SHALL 跳转至新增标石杆号牌页
5. WHILE 该光缆下无标石/杆号牌记录THE 标石杆号牌列表页 SHALL 展示空状态提示文字
### 需求 3标石杆号牌详情页
**用户故事:** 作为用户,我希望查看标石/杆号牌的完整信息并导航至该位置,以便进行现场巡检。
#### 验收标准
1. THE 标石杆号牌详情页 SHALL 展示以下信息:照片、名称、时间、责任人、导航点(经纬度)、所属公司、实际里程
2. WHEN 用户点击照片THE App SHALL 以全屏模式放大展示该照片
3. THE 标石杆号牌详情页 SHALL 提供【导航】按钮
4. WHEN 用户点击【导航】按钮THE App SHALL 根据导航点经纬度拉起导航应用
5. IF 导航点经纬度为空或为零THEN THE 标石杆号牌详情页 SHALL 隐藏【导航】按钮
### 需求 4新增标石杆号牌页
**用户故事:** 作为用户,我希望在现场拍照记录标石/杆号牌信息并提交,以便及时上报标石数据。
#### 验收标准
1. THE 新增标石杆号牌页 SHALL 提供拍照功能,仅支持相机拍摄(不支持从相册选择)
2. WHEN 用户拍摄照片THE App SHALL 在照片上叠加水印信息,水印内容包含:当前坐标、拍摄时间、责任人、所属光缆名称
3. WHEN 用户拍摄照片完成THE App SHALL 自动获取拍摄时间并填充至时间字段
4. THE 新增标石杆号牌页 SHALL 自动填充所属公司(取当前用户所属公司)
5. THE 新增标石杆号牌页 SHALL 提供以下手动输入字段:名称、责任人、实际里程
6. THE 新增标石杆号牌页 SHALL 提供【获取经纬度】按钮
7. WHEN 用户点击【获取经纬度】按钮THE App SHALL 调用设备定位功能获取当前经纬度,并在页面上显示经纬度数值
8. WHEN 用户点击【提交】按钮THE App SHALL 将所有数据(照片、名称、时间、责任人、经纬度、所属公司、所属光缆、实际里程)上传至后端
9. WHEN 数据上传成功THE App SHALL 弹出系统提示「提交成功」并自动返回标石杆号牌列表页
10. IF 数据上传失败THEN THE App SHALL 弹出错误提示信息,保留用户已填写的数据
11. IF 用户未拍摄照片即点击提交THEN THE App SHALL 提示用户至少拍摄一张照片
### 需求 5搜索结果页支持标石杆号牌类型
**用户故事:** 作为用户,我希望搜索结果中能展示标石/杆号牌类型,以便在搜索时快速定位标石信息。
#### 验收标准
1. WHEN 用户在光缆列表页搜索关键词THE App SHALL 在搜索范围中包含标石/杆号牌记录
2. THE 搜索结果页 SHALL 新增「标石、杆号牌」分类区域,展示匹配的标石/杆号牌记录
3. WHEN 搜索结果中包含匹配的标石/杆号牌记录THE 搜索结果页 SHALL 在「标石、杆号牌」分类下展示名称、时间、责任人、所属光缆
4. WHEN 用户点击搜索结果中的标石/杆号牌项THE App SHALL 跳转至该记录的标石杆号牌详情页
### 需求 6管理后台标石杆号牌增删改
**用户故事:** 作为管理员,我希望在管理后台对标石/杆号牌数据进行增删改操作,以便维护标石数据的准确性。
#### 验收标准
1. THE 管理后台 SHALL 提供标石/杆号牌管理页面,展示标石/杆号牌数据列表
2. THE 管理后台标石杆号牌列表 SHALL 展示以下字段:名称、时间、责任人、经纬度、所属公司、所属光缆、实际里程
3. THE 管理后台 SHALL 提供新增标石/杆号牌功能,支持填写名称、责任人、经纬度、所属光缆、实际里程
4. THE 管理后台 SHALL 提供编辑标石/杆号牌功能,支持修改已有记录的所有字段
5. THE 管理后台 SHALL 提供删除标石/杆号牌功能,支持删除单条记录
6. WHEN 管理员执行删除操作THE 管理后台 SHALL 弹出确认对话框,确认后执行删除
### 需求 7光交箱机房详情页 UI 优化
**用户故事:** 作为用户,我希望光交箱详情页的左右两侧用不同颜色区分,以便快速识别光交箱端子和 ODF 端子。
#### 验收标准
1. THE 光交箱详情页 SHALL 将左侧框体背景色设置为绿色
2. THE 光交箱详情页 SHALL 将右侧框体背景色设置为橙色
3. THE 光交箱详情页 SHALL 仅对光交箱类型机架应用此配色方案ODF 类型机架保持现有样式不变
### 需求 8管理后台修改统计功能
**用户故事:** 作为管理员,我希望查看所有数据修改记录,以便追溯数据变更历史和责任人。
#### 验收标准
1. THE 管理后台 SHALL 提供修改统计页面,展示所有增删改操作记录
2. WHEN 前端 App 或管理后台对 ODF、干线相关内容执行新增、修改、删除操作THE 后端 SHALL 记录该操作的审计日志
3. THE 修改统计记录 SHALL 包含以下信息修改人、修改时间、具体修改内容、操作来源端App 端或管理后台端)
4. WHEN 操作类型为修改THE 修改统计记录 SHALL 保存修改前和修改后的数据,支持对比查看
5. THE 管理后台修改统计页面 SHALL 支持按时间范围筛选查看记录
6. THE 管理后台修改统计页面 SHALL 支持按操作类型(新增、修改、删除)筛选查看记录
### 需求 9组织架构权限 — 公司数据隔离
**用户故事:** 作为管理员,我希望每个账号只能看到所属公司的数据,以便实现数据安全隔离。
#### 验收标准
1. THE 后端 SHALL 根据当前登录账号的所属公司过滤返回数据,每个账号仅能查看所属公司的信息
2. WHILE 当前账号为上级公司账号THE 后端 SHALL 允许该账号查看本公司及所有下级公司的信息
3. THE 后端 SHALL 对干线故障数据执行公司隔离,管理后台仅展示当前账号有权限查看的公司故障数据
4. THE 后端 SHALL 对标石/杆号牌数据执行公司隔离,遵循与干线故障相同的公司层级权限规则
### 需求 10组织架构权限 — 机房权限修复
**用户故事:** 作为管理员,我希望机房内容的查看/修改权限与干线权限保持一致,以便权限控制准确生效。
#### 验收标准
1. WHILE 当前账号权限为「仅查看」THE App SHALL 禁止该账号对机房内容执行任何修改操作
2. WHILE 当前账号权限为「修改和查看」THE App SHALL 允许该账号对机房内容执行查看和修改操作
3. THE App 机房权限控制 SHALL 与干线版块的「仅查看」「修改和查看」权限逻辑保持一致
### 需求 11组织架构权限 — 分公司管理员角色
**用户故事:** 作为分公司管理员,我希望管理本公司及下属公司的人员账号,以便实现分级管理。
#### 验收标准
1. THE 管理后台 SHALL 新增「分公司管理员」角色
2. THE 分公司管理员 SHALL 能查看本公司及下属公司的所有信息
3. THE 分公司管理员 SHALL 能新增所属分公司和下级公司的人员账号
4. WHILE 分公司账号角色不是「分公司管理员」THE 管理后台 SHALL 禁止该账号登录管理后台
5. THE 后端 SHALL 在分公司管理员创建账号时,限制新账号的所属公司范围为本公司及下级公司
### 需求 12平台兼容性
**用户故事:** 作为用户,我希望在安卓和 H5 平台上使用相同的标石杆号牌功能,以便在不同设备上获得一致的体验。
#### 验收标准
1. THE App SHALL 在安卓平台和 H5 平台同步提供标石杆号牌的所有功能
2. WHEN App 在新增标石杆号牌页使用拍照功能THE App SHALL 在安卓平台调用原生相机 API在 H5 平台调用浏览器媒体 API
3. WHEN App 在新增标石杆号牌页使用定位功能THE App SHALL 在安卓平台调用原生定位 API在 H5 平台调用浏览器 Geolocation API
4. WHEN App 在标石杆号牌详情页调用导航功能THE App SHALL 在安卓平台弹出已安装的导航 APP 列表,在 H5 平台打开地图网页进行导航

View File

@ -0,0 +1,219 @@
# 实现计划ODF v1.2.0 标石杆号牌及管理后台优化
## 概述
基于需求文档和设计文档,按照数据库 → 后端模型 → 后端服务/接口 → 前端 UniApp → 管理后台 Vue 的顺序实现。涵盖标石/杆号牌 CRUD、搜索扩展、光交箱 UI 优化、审计日志、组织架构权限五大模块。
## 任务
- [x] 1. 数据库迁移脚本
- [x] 1.1 创建标石/杆号牌相关表
- 创建 `sql/v1.2.0/` 目录
- 编写 `01_create_odf_marker_poles.sql`:创建 `odf_marker_poles` 表及索引CableId, DeptId
- 编写 `02_create_odf_marker_pole_images.sql`:创建 `odf_marker_pole_images` 表及索引MarkerPoleId
- _需求: 2.1, 4.8, 6.1_
- [x] 1.2 创建审计日志表
- 编写 `03_create_odf_audit_logs.sql`:创建 `odf_audit_logs` 表及索引OperationTime, TableName, OperationType
- _需求: 8.1, 8.2_
- [x] 1.3 创建菜单和权限数据脚本
- 编写 `04_insert_marker_pole_menus_permissions.sql`:插入标石/杆号牌管理菜单、审计日志菜单及对应按钮权限odfmarkerpoles:list/query/add/edit/delete, odfauditlogs:list
- 编写 `05_insert_branch_admin_role.sql`:插入「分公司管理员」角色记录到 `sys_role`,并配置角色权限
- _需求: 6.1, 8.1, 11.1_
- [x] 2. 后端模型层ZR.Model
- [x] 2.1 创建标石/杆号牌实体模型
- 创建 `server/ZR.Model/Business/OdfMarkerPoles.cs`:定义 `OdfMarkerPoles` 实体,映射 `odf_marker_poles`
- 创建 `server/ZR.Model/Business/OdfMarkerPoleImages.cs`:定义 `OdfMarkerPoleImages` 实体,映射 `odf_marker_pole_images`
- _需求: 2.1, 3.1, 4.8_
- [x] 2.2 创建标石/杆号牌 DTO
- 创建 `server/ZR.Model/Business/Dto/OdfMarkerPolesDto.cs`:定义 `OdfMarkerPolesQueryDto`、`OdfMarkerPoleAddDto`、`OdfMarkerPoleEditDto`、`OdfMarkerPoleListDto`、`OdfMarkerPoleDetailDto`
- _需求: 2.1, 3.1, 4.8, 6.2, 6.3, 6.4_
- [x] 2.3 创建审计日志实体和 DTO
- 创建 `server/ZR.Model/Business/OdfAuditLogs.cs`:定义 `OdfAuditLogs` 实体,映射 `odf_audit_logs`
- 创建 `server/ZR.Model/Business/Dto/OdfAuditLogsDto.cs`:定义 `OdfAuditLogsQueryDto`
- _需求: 8.2, 8.3_
- [x] 3. 后端服务层与 API — 标石/杆号牌模块
- [x] 3.1 创建数据隔离工具类
- 创建 `server/ZR.Service/Business/DeptDataScopeHelper.cs`:实现 `GetVisibleDeptIds` 方法,根据 DeptId 递归查询本级及所有下级部门 ID 列表
- _需求: 9.1, 9.2_
- [x] 3.2 创建标石/杆号牌服务接口和实现
- 创建 `server/ZR.Service/Business/IBusinessService/IOdfMarkerPolesService.cs`:定义 GetList、GetDetail、Add、Update、Delete 接口方法
- 创建 `server/ZR.Service/Business/OdfMarkerPolesService.cs`:实现标石/杆号牌 CRUD 逻辑,查询时应用 DeptDataScopeHelper 进行数据隔离,新增时自动填充 DeptId/DeptName
- _需求: 2.1, 3.1, 4.4, 4.8, 6.3, 6.4, 6.5, 9.4_
- [x] 3.3 创建标石/杆号牌图片服务
- 创建 `server/ZR.Service/Business/IBusinessService/IOdfMarkerPoleImagesService.cs`
- 创建 `server/ZR.Service/Business/OdfMarkerPoleImagesService.cs`:实现图片批量插入、按 MarkerPoleId 查询、按 MarkerPoleId 删除
- _需求: 3.1, 4.1, 4.8_
- [x] 3.4 创建标石/杆号牌 Controller
- 创建 `server/ZR.Admin.WebApi/Controllers/Business/OdfMarkerPolesController.cs`:实现 list/query/add/edit/delete 五个接口,配置 `ActionPermissionFilter` 权限校验
- 新增时校验 CableId 存在性和 ImageUrls 非空
- _需求: 2.1, 3.1, 4.8, 6.3, 6.4, 6.5_
- [ ]* 3.5 编写标石/杆号牌 CRUD 属性测试
- **属性 3: 标石 CRUD round-trip**
- **属性 4: 标石删除后不可查**
- **属性 5: 新增标石自动填充所属公司**
- **验证需求: 4.4, 4.8, 6.4, 6.5**
- [x] 4. 后端服务层与 API — 搜索扩展
- [x] 4.1 扩展搜索接口支持标石/杆号牌
- 修改 `server/ZR.Service/Business/OdfCablesService.cs``Search` 方法:在搜索结果中新增 `markerPoles` 字段,按关键词匹配标石名称
- 修改 `server/ZR.Admin.WebApi/Controllers/Business/OdfCablesController.cs` 对应接口返回结构
- _需求: 5.1, 5.2_
- [ ]* 4.2 编写搜索属性测试
- **属性 6: 搜索结果包含匹配的标石记录**
- **验证需求: 5.1**
- [x] 5. 后端服务层与 API — 审计日志模块
- [x] 5.1 创建审计日志服务
- 创建 `server/ZR.Service/Business/IBusinessService/IOdfAuditLogsService.cs`
- 创建 `server/ZR.Service/Business/OdfAuditLogsService.cs`:实现分页查询(支持时间范围、操作类型筛选)和写入方法
- _需求: 8.1, 8.5, 8.6_
- [x] 5.2 创建审计日志 ActionFilter
- 创建 `server/ZR.Admin.WebApi/Filters/OdfAuditLogFilter.cs`:实现 `IAsyncActionFilter`,拦截标石/ODF/干线相关 Controller 的增删改操作
- 在 `OnActionExecutionAsync` 中记录操作前数据OldData在操作后记录新数据NewData
- 通过 `SourceClient` 区分 App 端和管理后台端(从请求 Header 或路由判断)
- 审计日志写入失败时 try-catch 不影响主业务
- _需求: 8.2, 8.3, 8.4_
- [x] 5.3 创建审计日志 Controller
- 创建 `server/ZR.Admin.WebApi/Controllers/Business/OdfAuditLogsController.cs`:实现 list 查询接口,配置 `odfauditlogs:list` 权限
- _需求: 8.1, 8.5_
- [x] 5.4 在现有 Controller 上应用审计日志 Filter
- 在 `OdfMarkerPolesController`、`OdfCablesController`、`OdfCableFaultsController`、`OdfPortsController`、`OdfRacksController` 的增删改 Action 上添加 `[ServiceFilter(typeof(OdfAuditLogFilter))]`
- _需求: 8.2_
- [ ]* 5.5 编写审计日志属性测试
- **属性 8: 业务操作产生完整审计日志**
- **属性 9: 审计日志筛选正确性**
- **验证需求: 8.2, 8.3, 8.4, 8.5, 8.6**
- [x] 6. 后端 — 数据隔离与权限修复
- [x] 6.1 应用数据隔离到现有模块
- 修改 `server/ZR.Service/Business/OdfCableFaultsService.cs`:在查询方法中应用 `DeptDataScopeHelper` 过滤 DeptId
- 修改 `server/ZR.Service/Business/OdfCablesService.cs`:在查询方法中应用 `DeptDataScopeHelper` 过滤 DeptId
- _需求: 9.1, 9.2, 9.3_
- [x] 6.2 修复机房权限控制
- 修改 `server/ZR.Admin.WebApi/Controllers/Business/OdfPortsController.cs`:在编辑接口上增加 `odfports:edit` 权限校验
- _需求: 10.1, 10.2, 10.3_
- [x] 6.3 实现分公司管理员登录限制
- 修改后端登录逻辑:分公司级别账号登录管理后台时,校验是否拥有「分公司管理员」角色,无该角色则拒绝登录
- _需求: 11.4_
- [x] 6.4 实现分公司管理员创建账号范围限制
- 修改后端用户创建逻辑:分公司管理员创建用户时,校验新账号 DeptId 是否在管理员可见部门范围内
- _需求: 11.3, 11.5_
- [ ]* 6.5 编写数据隔离与权限属性测试
- **属性 10: 数据隔离 — 查询结果仅包含可见部门数据**
- **属性 11: 机房操作权限与用户角色匹配**
- **属性 12: 分公司管理员创建账号范围限制**
- **属性 13: 非分公司管理员禁止登录管理后台**
- **验证需求: 9.1, 9.2, 9.3, 9.4, 10.1, 10.2, 11.3, 11.4, 11.5**
- [x] 7. 检查点 — 后端完成验证
- 确保所有后端代码编译通过,所有测试通过,如有问题请向用户确认。
- [x] 8. UniApp 前端 — 标石/杆号牌服务层
- [x] 8.1 创建标石/杆号牌 API 服务
- 创建 `odf-uniapp/services/markerPole.js`:封装 getMarkerPoleList、getMarkerPoleDetail、addMarkerPole、searchMarkerPoles 等 API 调用方法
- _需求: 2.1, 3.1, 4.8, 5.1_
- [x] 9. UniApp 前端 — 光缆类型页
- [x] 9.1 创建光缆类型页
- 创建 `odf-uniapp/pages/cable-type/index.vue`:展示【标石、杆号牌】和【故障列表】两个入口按钮
- 导航栏显示当前光缆名称
- 点击按钮分别跳转至标石列表页和故障列表页,传递 cableId 和 cableName 参数
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5_
- [x] 9.2 修改光缆列表页跳转逻辑
- 修改 `odf-uniapp/pages/cable/index.vue`:点击光缆时跳转至光缆类型页(而非直接跳转故障列表页)
- _需求: 1.1_
- [x] 9.3 注册新页面路由
- 修改 `odf-uniapp/pages.json`:添加 cable-type、marker-pole-list、marker-pole-detail、marker-pole-add 四个页面路由配置
- _需求: 1.1, 2.1, 3.1, 4.1_
- [x] 10. UniApp 前端 — 标石/杆号牌列表页
- [x] 10.1 创建标石/杆号牌列表页
- 创建 `odf-uniapp/pages/marker-pole-list/index.vue`:展示标石/杆号牌记录列表,每条显示名称、时间、责任人、导航点、实际里程
- 点击记录跳转详情页,点击【新增】按钮跳转新增页
- 无数据时展示空状态提示
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5_
- [ ]* 10.2 编写标石列表渲染属性测试
- **属性 1: 标石记录列表渲染包含必要字段**
- **验证需求: 2.1, 5.3**
- [x] 11. UniApp 前端 — 标石/杆号牌详情页
- [x] 11.1 创建标石/杆号牌详情页
- 创建 `odf-uniapp/pages/marker-pole-detail/index.vue`:展示照片、名称、时间、责任人、导航点(经纬度)、所属公司、实际里程
- 照片支持点击全屏预览uni.previewImage
- 提供【导航】按钮,经纬度为空或为零时隐藏
- 点击导航按钮:安卓端弹出导航 APP 列表H5 端打开地图网页
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 12.4_
- [ ]* 11.2 编写标石详情渲染属性测试
- **属性 2: 标石详情页展示完整信息**
- **验证需求: 3.1**
- [x] 12. UniApp 前端 — 新增标石/杆号牌页
- [x] 12.1 创建新增标石/杆号牌页
- 创建 `odf-uniapp/pages/marker-pole-add/index.vue`
- 拍照功能:调用 uni.chooseImagesourceType: ['camera']),拍摄后叠加水印(坐标、时间、责任人、所属光缆)
- 自动填充拍摄时间和所属公司
- 手动输入字段:名称、责任人、实际里程
- 【获取经纬度】按钮:调用 uni.getLocation 获取 GPS 坐标
- 提交前校验:名称非空、至少一张照片
- 照片上传至腾讯云 COS获取 URL 后提交表单
- 提交成功后提示并返回列表页
- _需求: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 4.10, 4.11, 12.1, 12.2, 12.3_
- [x] 13. UniApp 前端 — 搜索扩展
- [x] 13.1 扩展搜索结果页支持标石/杆号牌
- 修改 `odf-uniapp/pages/trunk-search/index.vue`(或对应搜索结果页):新增「标石、杆号牌」分类区域
- 展示匹配的标石记录(名称、时间、责任人、所属光缆)
- 点击标石记录跳转至详情页
- _需求: 5.1, 5.2, 5.3, 5.4_
- [x] 13.2 修改搜索服务调用
- 修改 `odf-uniapp/services/search.js`:适配搜索 API 返回的 markerPoles 新字段
- _需求: 5.1_
- [x] 14. UniApp 前端 — 光交箱 UI 优化与机房权限修复
- [x] 14.1 修改光交箱详情页配色
- 修改 `odf-uniapp/pages/optical-box-detail/index.vue`:左侧框体背景改为浅绿(#C8E6C9 / #A5D6A7),右侧框体背景改为浅橙(#FFE0B2 / #FFCC80
- 仅光交箱类型应用新配色ODF 类型保持不变
- _需求: 7.1, 7.2, 7.3_
- [x] 14.2 修复机房端口编辑权限控制
- 修改 `odf-uniapp/components/port-edit-dialog.vue`(或对应端口编辑组件):根据 store 中用户权限控制编辑按钮显示/隐藏
- 「仅查看」权限用户隐藏编辑入口,「修改和查看」权限用户显示编辑入口
- _需求: 10.1, 10.2, 10.3_
- [x] 15. 检查点 — UniApp 前端完成验证
- 确保所有 UniApp 页面编译通过,页面导航流程正确,如有问题请向用户确认。
- [x] 16. 管理后台 Vue — 标石/杆号牌管理页
- [x] 16.1 创建标石/杆号牌 API 封装
- 创建 `server/ZR.Vue/src/api/business/odfmarkerpoles.js`:封装 list、get、add、update、del 接口调用
- _需求: 6.1_
- [x] 16.2 创建标石/杆号牌管理页面
- 创建 `server/ZR.Vue/src/views/business/OdfMarkerPoles.vue`:实现数据列表(名称、时间、责任人、经纬度、所属公司、所属光缆、实际里程)、新增对话框、编辑对话框、删除确认
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_
- [ ]* 16.3 编写管理后台标石列表属性测试
- **属性 7: 管理后台标石列表展示完整字段**
- **验证需求: 6.2**
- [x] 17. 管理后台 Vue — 审计日志页
- [x] 17.1 创建审计日志 API 封装
- 创建 `server/ZR.Vue/src/api/business/odfauditlogs.js`:封装 list 查询接口
- _需求: 8.1_
- [x] 17.2 创建审计日志管理页面
- 创建 `server/ZR.Vue/src/views/business/OdfAuditLogs.vue`:实现审计日志列表(操作人、时间、操作类型、操作来源、表名)、时间范围筛选、操作类型筛选、修改前后数据对比展示
- _需求: 8.1, 8.3, 8.4, 8.5, 8.6_
- [x] 18. 管理后台 Vue — 分公司管理员登录限制
- [x] 18.1 管理后台登录页增加分公司管理员校验
- 修改管理后台登录流程:登录成功后检查返回的角色信息,分公司账号若无「分公司管理员」角色则提示无权登录并阻止进入
- _需求: 11.4_
- [x] 19. 最终检查点 — 全部完成验证
- 确保所有后端编译通过、前端编译通过、管理后台编译通过,所有测试通过,如有问题请向用户确认。
## 备注
- 标记 `*` 的子任务为可选测试任务,可跳过以加快 MVP 进度
- 每个任务引用了具体的需求编号,确保需求全覆盖
- 检查点任务用于阶段性验证,确保增量开发的正确性
- 属性测试验证设计文档中定义的正确性属性

94
docs/1.2.0/1.2.0.md Normal file
View File

@ -0,0 +1,94 @@
# odf光缆资源管理v1.2.0需求文档
# 需求大纲
1. H5、APP同步更新。
2. 新增标石、杆号牌相关功能。
3. 优化后台相关功能。
# 标石、杆号牌入口
![](attachments/895d5b87-4235-4a0d-a6a4-a0ce8cc82d18.png)
1. 干线,光缆列表,点击任意光缆,跳转进"光缆类型页"。
2. 展示【标石、杆号牌】【故障列表】入口按钮。
3. 点击【标石、杆号牌】按钮,跳转至"标石、杆号牌列表页"
4. 点击【故障列表】按钮,跳转至原故障列表页
# 标石、杆号牌列表页
![](attachments/290c3dda-14b9-4a8c-8233-0994b3488cfc.png)
1. 展示名称、时间、责任人、导航点、实际里程。
2. 点击任一区域,跳转至对应"标石、杆号牌详情页"。
3. 点击【新增】按钮,跳转至"新增标石、杆号牌页"。
# 标石、杆号牌详情页
![](attachments/f13df494-322c-4c04-9ee9-693fa4cba2cf.png)
1. 展示照片、名称、时间、责任人、导航点、所属公司、实际里程。
2. 点击【导航】按钮,根据导航点经纬度,拉起导航。
# 新增标石、杆号牌页
![](attachments/9c560e3d-6da7-4811-85ec-ffb19b639663.png)
1. 点击【拍照】,呼出相机。
2. 照片增加水印信息:坐标、时间、责任人、所属光缆。
3. 输入"标石、杆号牌"名称、责任人、实际里程。
4. 时间,拍照上传后自动填写。
5. 所属公司,自动填写。
6. 点击【获取经纬度】按钮,获取当前经纬度。
7. 点击【提交】按钮,提交信息,自动返回上一级页面。
8. 管理后台支持增删改。
# 搜索结果页
![](attachments/55220979-83e5-4ff3-949a-6cbf5e9053f5.png)
1. 搜索结果页,可展示"标石、杆号牌"搜索结果类型。
# 光交箱机房详情页
1. 修改光交箱详情页,左右两框的颜色。
2. 左边为绿色,右边为橙色,参考如下图。
![](attachments/efa7641d-c66b-4702-b1b7-5f5d1cb78f4b.jpg)
# 管理后台优化
## 修改统计
1. 后台新增修改统计功能。
2. 前端或管理后台新增、修改、删除ODF、干线内容时可在管理后台查看记录。
1. 可查看修改前后的对比数据。
2. 记录包含修改人、时间、具体修改内容、从哪个端修改。
3. 可根据时间查看。
## 组织架构权限
1. 每个账号,只能看到自己所属公司的信息。
2. 上级公司账号,可查看本公司、所属下级公司的信息。
1. 干线故障,现在后台能看见所有的信息,没有进行分公司隔离,需进行公司隔离。
3. 前端查看"机房"内容时,该账号的权限为"仅查看""修改和查看"时,权限不正确,应同步修改干线时的"仅查看""修改和查看"账号权限。
1. 现在"仅查看"权限,能对"机房"内容修改,应只能查看。
4. 分公司新增"分公司管理员"权限,能查看本公司及下属公司信息、能新增所属分公司、下级公司人员账号。
1. 分公司中,只有分公司管理员账号允许登录管理后台。

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -24,9 +24,9 @@
<view class="remarks-row">
<view class="textarea-wrap">
<textarea class="remarks-input" v-model="form.remarks" :maxlength="-1" placeholder="请输入备注说明"
:disabled="!store.isPermission" auto-height />
:disabled="!canEdit" auto-height />
</view>
<view v-if="store.isPermission" class="add-note-btn" @click="showAddNote = true">
<view v-if="canEdit" class="add-note-btn" @click="showAddNote = true">
<text class="add-note-text">添加备注</text>
</view>
</view>
@ -36,7 +36,7 @@
<view class="section">
<text class="section-title">光衰信息</text>
<input class="form-input" v-model="form.opticalAttenuation" placeholder="请输入光衰信息"
:disabled="!store.isPermission" />
:disabled="!canEdit" />
</view>
<!-- 历史障碍记录 -->
@ -46,7 +46,7 @@
<view class="fault-item" v-for="(item, index) in form.historyFault" :key="index">
<view class="fault-row">
<picker mode="date" :value="item.faultTime ? item.faultTime.substring(0, 10) : ''"
:disabled="!store.isPermission" @change="onFaultDateChange($event, index)">
:disabled="!canEdit" @change="onFaultDateChange($event, index)">
<view class="date-picker">
<text :class="item.faultTime ? 'date-text' : 'date-placeholder'">
{{ item.faultTime || '选择日期' }}
@ -54,14 +54,14 @@
</view>
</picker>
<input class="fault-reason-input" v-model="item.faultReason" placeholder="故障原因"
:disabled="!store.isPermission" />
<view v-if="store.isPermission" class="delete-btn" @click="removeFault(index)">
:disabled="!canEdit" />
<view v-if="canEdit" class="delete-btn" @click="removeFault(index)">
<text class="delete-btn-text">-</text>
</view>
</view>
</view>
</view>
<view v-if="store.isPermission" class="add-record-link" @click="addFault">
<view v-if="canEdit" class="add-record-link" @click="addFault">
<text class="add-record-text">添加新记录</text>
</view>
</view>
@ -70,11 +70,11 @@
<view class="section">
<text class="section-title">光缆段信息</text>
<input class="form-input" v-model="form.opticalCableOffRemarks" placeholder="请输入光缆段信息"
:disabled="!store.isPermission" />
:disabled="!canEdit" />
</view>
<!-- 权限控制区域 -->
<view v-if="store.isPermission" class="section">
<view v-if="canEdit" class="section">
<text class="section-title">改变状态</text>
<view class="status-toggle-row">
<view class="toggle-btn toggle-green" :class="{ 'toggle-active': form.status === 1 }"
@ -92,7 +92,7 @@
<!-- 底部按钮固定在弹窗底部 -->
<view v-if="!loading" class="btn-row">
<template v-if="store.isPermission">
<template v-if="canEdit">
<view class="btn btn-cancel" @click="onClose">
<text class="btn-text">取消</text>
</view>
@ -117,6 +117,7 @@
import {
ref,
reactive,
computed,
watch
} from 'vue'
import {
@ -126,6 +127,8 @@
import store from '@/store'
import addNoteDialog from '@/components/add-note-dialog.vue'
const canEdit = computed(() => store.hasPermi('odfports:edit'))
const props = defineProps({
visible: {
type: Boolean,

View File

@ -2,8 +2,8 @@
"name" : "绥时录",
"appid" : "__UNI__45FFD83",
"description" : "",
"versionName" : "1.0.4",
"versionCode" : 104,
"versionName" : "1.0.5",
"versionCode" : 105,
"transformPx" : false,
/* 5+App */
"app-plus" : {

View File

@ -141,6 +141,34 @@
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/cable-type/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/marker-pole-list/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/marker-pole-detail/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/marker-pole-add/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
}
],
"globalStyle": {

View File

@ -0,0 +1,141 @@
<template>
<view class="cable-type-page">
<view class="content">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar-inner">
<image
class="nav-icon"
src="/static/images/ic_back.png"
mode="aspectFit"
@click="goBack"
/>
<text class="nav-title">{{ cableName }}</text>
<view class="nav-icon-placeholder" />
</view>
</view>
<!-- 小标题 -->
<text class="section-title">请选择功能</text>
<!-- 入口按钮 -->
<view class="entry-list">
<view class="entry-card" @click="goMarkerPoleList">
<text class="entry-name">标石杆号牌</text>
</view>
<view class="entry-card" @click="goFaultList">
<text class="entry-name">故障列表</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const cableId = ref('')
const cableName = ref('')
function goBack() {
uni.navigateBack()
}
function goMarkerPoleList() {
uni.navigateTo({
url: '/pages/marker-pole-list/index?cableId=' + cableId.value + '&cableName=' + encodeURIComponent(cableName.value)
})
}
function goFaultList() {
uni.navigateTo({
url: '/pages/fault-list/index?cableId=' + cableId.value + '&cableName=' + encodeURIComponent(cableName.value)
})
}
onLoad((options) => {
if (options.cableId) {
cableId.value = options.cableId
}
if (options.cableName) {
cableName.value = decodeURIComponent(options.cableName)
}
})
</script>
<style scoped>
.cable-type-page {
position: relative;
min-height: 100vh;
background-color: transparent;
}
.content {
position: relative;
z-index: 1;
}
.nav-bar {
width: 100%;
}
.nav-bar-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
.nav-icon {
width: 44rpx;
height: 44rpx;
}
.nav-icon-placeholder {
width: 44rpx;
height: 44rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
padding: 16rpx 24rpx 8rpx;
display: block;
}
.entry-list {
padding: 16rpx 24rpx;
}
.entry-card {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
padding: 40rpx 24rpx;
background-color: #fff;
border-radius: 12rpx;
border: 1rpx solid #E8E8E8;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.entry-name {
font-size: 32rpx;
color: #333;
font-weight: 500;
}
</style>

View File

@ -84,7 +84,7 @@ function handleSearch() {
function goFaultList(item) {
uni.navigateTo({
url: '/pages/fault-list/index?cableId=' + item.id + '&cableName=' + encodeURIComponent(item.cableName)
url: '/pages/cable-type/index?cableId=' + item.id + '&cableName=' + encodeURIComponent(item.cableName)
})
}

View File

@ -0,0 +1,561 @@
<template>
<view class="marker-pole-add-page">
<view class="content">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar-inner">
<image
class="nav-icon"
src="/static/images/ic_back.png"
mode="aspectFit"
@click="goBack"
/>
<text class="nav-title">新增标石/杆号牌</text>
<view class="nav-icon-placeholder" />
</view>
</view>
<!-- 拍照区域 -->
<view class="photo-area">
<scroll-view class="photo-scroll" scroll-x>
<view class="photo-list">
<view class="photo-add-btn" @click="takePhoto">
<text class="plus-icon">+</text>
<text class="add-text">点击拍摄</text>
</view>
<image
class="photo-thumb"
v-for="(photo, index) in photoList"
:key="index"
:src="photo"
mode="aspectFill"
/>
</view>
</scroll-view>
</view>
<!-- 表单区域 -->
<view class="form-area">
<view class="form-group">
<text class="form-label">名称</text>
<input
class="form-input"
v-model="form.name"
placeholder="请输入名称"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-group">
<text class="form-label">拍摄时间</text>
<view class="form-display">
<text class="display-text">{{ form.recordTime || '拍摄第一张照片后自动填充' }}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">责任人</text>
<input
class="form-input"
v-model="form.personnel"
placeholder="请输入"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-group">
<text class="form-label">实际里程</text>
<input
class="form-input"
v-model="form.actualMileage"
placeholder="请输入"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-group">
<text class="form-label">所属光缆</text>
<view class="form-display">
<text class="display-text">{{ form.cableName }}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">所属公司</text>
<view class="form-display">
<text class="display-text">{{ form.companyName || '提交时自动填充' }}</text>
</view>
</view>
<view class="form-group">
<text class="form-label">地点</text>
<view class="location-btn" @click="getLocation">
<text class="location-btn-text">获取经纬度</text>
</view>
<text class="location-text">当前经度{{ form.longitude }} 当前纬度{{ form.latitude }}</text>
</view>
</view>
</view>
<!-- 底部固定提交按钮 -->
<view class="bottom-bar">
<view class="submit-btn" :class="{ 'submit-btn-disabled': submitting }" @click.stop="handleSubmit">
<text class="submit-btn-text">{{ submitting ? '提交中...' : '提交' }}</text>
</view>
</view>
<!-- 离屏 canvas 用于水印绘制 -->
<canvas
canvas-id="watermarkCanvas"
:style="{
position: 'fixed',
left: '0px',
top: '0px',
width: canvasW + 'px',
height: canvasH + 'px',
opacity: 0,
pointerEvents: 'none',
zIndex: -1
}"
/>
</view>
</template>
<script setup>
import { ref, reactive, getCurrentInstance, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import store from '@/store'
import { addMarkerPole } from '@/services/markerPole'
import { getPresignUrls, uploadToCos } from '@/services/cos'
import { addWatermark } from '@/utils/watermark'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const photoList = ref([])
const cableId = ref('')
const submitting = ref(false)
const canvasW = ref(100)
const canvasH = ref(100)
const instance = getCurrentInstance()
const form = reactive({
name: '',
recordTime: '',
personnel: '',
actualMileage: '',
cableName: '',
companyName: store.userName || '',
latitude: 0,
longitude: 0
})
function goBack() {
uni.navigateBack()
}
function formatNow() {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
const h = String(now.getHours()).padStart(2, '0')
const min = String(now.getMinutes()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}`
}
function takePhoto() {
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success(res) {
const tempPath = res.tempFilePaths[0]
photoList.value.push(tempPath)
//
if (photoList.value.length === 1) {
form.recordTime = formatNow()
}
}
})
}
function getLocation() {
// #ifdef H5
uni.showLoading({ title: '定位中...', mask: true })
if (window.AMap) {
AMap.plugin('AMap.Geolocation', () => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true,
timeout: 10000
})
geolocation.getCurrentPosition((status, result) => {
uni.hideLoading()
if (status === 'complete' && result.position) {
form.longitude = result.position.lng
form.latitude = result.position.lat
uni.showToast({ title: '获取成功', icon: 'success' })
} else {
uni.showToast({ title: '获取位置失败,请检查浏览器定位权限', icon: 'none' })
}
})
})
} else {
uni.hideLoading()
uni.showToast({ title: '地图SDK加载失败', icon: 'none' })
}
// #endif
// #ifndef H5
doGetLocation()
// #endif
}
// #ifndef H5
function doGetLocation() {
uni.showLoading({ title: '定位中...', mask: true })
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 10000,
success(res) {
form.latitude = res.latitude
form.longitude = res.longitude
uni.hideLoading()
uni.showToast({ title: '获取成功', icon: 'success' })
},
fail(err) {
fallbackPlusLocation(err)
}
})
}
function fallbackPlusLocation(originalErr) {
// #ifdef APP-PLUS
if (typeof plus !== 'undefined' && plus.geolocation) {
plus.geolocation.getCurrentPosition(
(pos) => {
form.latitude = pos.coords.latitude
form.longitude = pos.coords.longitude
uni.hideLoading()
uni.showToast({ title: '获取成功', icon: 'success' })
},
(plusErr) => {
uni.hideLoading()
handleLocationError(originalErr)
},
{ provider: 'system', coordsType: 'gcj02', timeout: 15000 }
)
} else {
uni.hideLoading()
handleLocationError(originalErr)
}
// #endif
// #ifndef APP-PLUS
uni.hideLoading()
handleLocationError(originalErr)
// #endif
}
function handleLocationError(err) {
const errMsg = (err.errMsg || '').toLowerCase()
if (errMsg.includes('deny') || errMsg.includes('auth') || errMsg.includes('permission')) {
uni.showModal({
title: '定位权限未开启',
content: '请在系统设置中允许本应用使用定位服务',
confirmText: '去设置',
success(modalRes) {
if (modalRes.confirm) {
uni.openSetting && uni.openSetting()
}
}
})
} else {
uni.showToast({ title: '获取位置失败请检查GPS是否开启', icon: 'none', duration: 3000 })
}
}
// #endif
async function handleSubmit() {
if (!form.name.trim()) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
if (photoList.value.length === 0) {
uni.showToast({ title: '请至少拍摄一张照片', icon: 'none' })
return
}
if (!cableId.value) {
uni.showToast({ title: '所属光缆信息缺失,无法提交', icon: 'none' })
return
}
if (submitting.value) return
submitting.value = true
uni.showLoading({ title: '处理图片中...', mask: true })
try {
// 1.
const watermarkLines = [
`经度:${form.longitude} 纬度:${form.latitude}`,
`${form.recordTime} ${form.personnel}`,
`所属光缆:${form.cableName}`
]
const watermarkedPhotos = []
for (let idx = 0; idx < photoList.value.length; idx++) {
const photo = photoList.value[idx]
try {
const result = await addWatermark(photo, watermarkLines, {
canvasId: 'watermarkCanvas',
proxy: instance.proxy,
setSize(w, h) {
canvasW.value = w
canvasH.value = h
}
})
watermarkedPhotos.push(result)
await nextTick()
} catch (err) {
console.warn(`[marker-pole-add] 第${idx + 1}张水印失败, 使用原图`, err)
watermarkedPhotos.push(photo)
}
}
// 2. COS URL
uni.showLoading({ title: '准备上传...', mask: true })
const presignList = await getPresignUrls(watermarkedPhotos.length, '.jpg')
// 3. COS
const imageUrls = []
for (let i = 0; i < watermarkedPhotos.length; i++) {
uni.showLoading({ title: `上传图片 ${i + 1}/${watermarkedPhotos.length}`, mask: true })
await uploadToCos(presignList[i].presignUrl, watermarkedPhotos[i])
imageUrls.push(presignList[i].accessUrl)
}
// 4.
uni.showLoading({ title: '提交中...', mask: true })
const res = await addMarkerPole({
cableId: Number(cableId.value),
name: form.name,
recordTime: form.recordTime,
personnel: form.personnel,
latitude: Number(form.latitude),
longitude: Number(form.longitude),
actualMileage: form.actualMileage,
imageUrls
})
if (res.code === 200) {
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => {
// #ifdef H5
uni.redirectTo({
url: '/pages/marker-pole-list/index?cableId=' + cableId.value + '&cableName=' + encodeURIComponent(form.cableName)
})
// #endif
// #ifndef H5
uni.navigateBack()
// #endif
}, 1500)
} else {
uni.showToast({ title: res.msg || '提交失败', icon: 'none' })
}
} catch (err) {
uni.showToast({ title: err.message || '网络异常,请重试', icon: 'none' })
} finally {
uni.hideLoading()
submitting.value = false
}
}
onLoad((options) => {
if (options.cableId) {
cableId.value = options.cableId
}
if (options.cableName) {
form.cableName = decodeURIComponent(options.cableName)
}
})
</script>
<style scoped>
.marker-pole-add-page {
position: relative;
min-height: 100vh;
background-color: transparent;
padding-bottom: 120rpx;
}
.content {
position: relative;
z-index: 1;
}
.nav-bar {
width: 100%;
}
.nav-bar-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
.nav-icon {
width: 44rpx;
height: 44rpx;
}
.nav-icon-placeholder {
width: 44rpx;
height: 44rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.photo-area {
padding: 24rpx;
}
.photo-scroll {
white-space: nowrap;
}
.photo-list {
display: inline-flex;
align-items: center;
}
.photo-add-btn {
width: 200rpx;
height: 200rpx;
background: #fff;
border: 2rpx dashed #CCCCCC;
border-radius: 12rpx;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.plus-icon {
font-size: 48rpx;
color: #999;
}
.add-text {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.photo-thumb {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin-left: 16rpx;
flex-shrink: 0;
}
.form-area {
padding: 0 24rpx;
}
.form-group {
margin-bottom: 32rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
font-weight: 500;
display: block;
}
.form-input {
height: 80rpx;
padding: 0 24rpx;
background: #fff;
border-radius: 12rpx;
border: 1rpx solid #E8E8E8;
font-size: 28rpx;
color: #333;
}
.form-display {
height: 80rpx;
padding: 0 24rpx;
background: #F5F5F5;
border-radius: 12rpx;
border: 1rpx solid #E8E8E8;
display: flex;
align-items: center;
}
.display-text {
font-size: 28rpx;
color: #333;
}
.input-placeholder {
color: #999;
}
.location-btn {
background: #1A73EC;
border-radius: 12rpx;
padding: 16rpx 0;
text-align: center;
width: 100%;
}
.location-btn-text {
color: #fff;
font-size: 28rpx;
}
.location-text {
font-size: 26rpx;
color: #999;
margin-top: 12rpx;
display: block;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 24rpx;
background: #fff;
box-sizing: border-box;
z-index: 9999;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: #1A73EC;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.submit-btn-text {
color: #fff;
font-size: 32rpx;
pointer-events: none;
}
.submit-btn-disabled {
background: #93bdf5;
}
</style>

View File

@ -0,0 +1,316 @@
<template>
<view class="marker-pole-detail-page">
<view class="content">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar-inner">
<image
class="nav-icon"
src="/static/images/ic_back.png"
mode="aspectFit"
@click="goBack"
/>
<text class="nav-title">标石/杆号牌详情</text>
<view class="nav-icon-placeholder" />
</view>
</view>
<!-- 图片区域 -->
<view class="image-area" v-if="imageList.length > 0">
<scroll-view class="image-scroll" scroll-x>
<view class="image-grid">
<view
class="image-wrapper"
v-for="(img, index) in imageList"
:key="img"
@click="previewImage(index)"
>
<view class="image-placeholder" v-if="imageStatus[index] !== 'loaded'">
<text class="placeholder-text">{{ imageStatus[index] === 'error' ? '加载失败' : '加载中...' }}</text>
</view>
<image
class="image-item"
:class="{ 'image-hidden': imageStatus[index] !== 'loaded' }"
:src="img"
mode="aspectFill"
@load="onImageLoad(index)"
@error="onImageError(index)"
/>
</view>
</view>
</scroll-view>
</view>
<!-- 信息展示区域 -->
<view class="info-area">
<view class="info-row">
<text class="info-label">名称</text>
<text class="info-value">{{ detail.name }}</text>
</view>
<view class="info-row">
<text class="info-label">时间</text>
<text class="info-value">{{ detail.recordTime }}</text>
</view>
<view class="info-row">
<text class="info-label">责任人</text>
<text class="info-value">{{ detail.personnel || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">导航点</text>
<text class="info-value">{{ formatCoord(detail.longitude, detail.latitude) }}</text>
</view>
<view class="info-row">
<text class="info-label">所属公司</text>
<text class="info-value">{{ detail.deptName || '-' }}</text>
</view>
<view class="info-row last-row">
<text class="info-label">实际里程</text>
<text class="info-value">{{ detail.actualMileage || '-' }}</text>
</view>
</view>
</view>
<!-- 底部导航按钮 -->
<view class="bottom-bar" v-if="hasLocation">
<view class="navigate-btn" @click="handleNavigate">
<text class="navigate-btn-text">导航</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getMarkerPoleDetail } from '@/services/markerPole'
import { BASE_URL } from '@/services/api'
import { openNavigation } from '@/utils/navigation'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const recordId = ref('')
const imageList = ref([])
const imageStatus = reactive({})
const detail = reactive({
name: '',
recordTime: '',
personnel: '',
latitude: 0,
longitude: 0,
deptName: '',
actualMileage: ''
})
const hasLocation = computed(() => {
return detail.latitude && detail.longitude &&
Number(detail.latitude) !== 0 && Number(detail.longitude) !== 0
})
function formatCoord(lng, lat) {
if ((!lng || Number(lng) === 0) && (!lat || Number(lat) === 0)) return '-'
return `${lng}, ${lat}`
}
async function loadDetail() {
try {
const res = await getMarkerPoleDetail(recordId.value)
if (res.code === 200 && res.data) {
const d = res.data
detail.name = d.name || ''
detail.recordTime = d.recordTime || ''
detail.personnel = d.personnel || ''
detail.latitude = d.latitude || 0
detail.longitude = d.longitude || 0
detail.deptName = d.deptName || ''
detail.actualMileage = d.actualMileage || ''
imageList.value = (d.images || []).map((img, i) => {
const url = img.url || img.imageUrl || ''
imageStatus[i] = 'loading'
return url.startsWith('http') ? url : BASE_URL + url
})
}
} catch (err) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
function onImageLoad(index) {
imageStatus[index] = 'loaded'
}
function onImageError(index) {
imageStatus[index] = 'error'
}
function goBack() {
uni.navigateBack()
}
function previewImage(index) {
uni.previewImage({
urls: imageList.value,
current: imageList.value[index]
})
}
function handleNavigate() {
openNavigation(detail.latitude, detail.longitude, detail.name || '标石/杆号牌')
}
onLoad((options) => {
if (options.id) {
recordId.value = options.id
}
loadDetail()
})
</script>
<style scoped>
.marker-pole-detail-page {
position: relative;
min-height: 100vh;
background-color: transparent;
padding-bottom: 120rpx;
}
.content {
position: relative;
z-index: 1;
}
.nav-bar {
width: 100%;
}
.nav-bar-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
.nav-icon {
width: 44rpx;
height: 44rpx;
}
.nav-icon-placeholder {
width: 44rpx;
height: 44rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.image-area {
padding: 24rpx;
}
.image-scroll {
white-space: nowrap;
}
.image-grid {
display: inline-flex;
gap: 16rpx;
}
.image-wrapper {
position: relative;
width: 280rpx;
height: 280rpx;
flex-shrink: 0;
border-radius: 8rpx;
overflow: hidden;
}
.image-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #E8E8E8;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.placeholder-text {
font-size: 24rpx;
color: #999;
}
.image-item {
width: 280rpx;
height: 280rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.image-hidden {
opacity: 0;
}
.info-area {
background-color: #fff;
margin: 0 24rpx;
padding: 24rpx;
border-radius: 12rpx;
}
.info-row {
display: flex;
align-items: flex-start;
margin-bottom: 16rpx;
}
.info-row.last-row {
margin-bottom: 0;
}
.info-label {
font-size: 26rpx;
color: #999;
flex-shrink: 0;
width: 180rpx;
}
.info-value {
font-size: 26rpx;
color: #333;
flex: 1;
word-break: break-all;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 24rpx;
background: #fff;
box-sizing: border-box;
}
.navigate-btn {
width: 100%;
height: 88rpx;
background: #1A73EC;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.navigate-btn-text {
color: #fff;
font-size: 32rpx;
}
</style>

View File

@ -0,0 +1,265 @@
<template>
<view class="marker-pole-list-page">
<view class="content">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar-inner">
<image
class="nav-icon"
src="/static/images/ic_back.png"
mode="aspectFit"
@click="goBack"
/>
<text class="nav-title">标石/杆号牌</text>
<view class="nav-icon-placeholder" />
</view>
</view>
<!-- 小标题 -->
<text class="section-title">标石/杆号牌列表</text>
<!-- 列表 -->
<view class="marker-pole-list" v-if="list.length > 0">
<view
class="marker-pole-card"
v-for="item in list"
:key="item.id"
@click="goDetail(item)"
>
<view class="card-row">
<text class="card-label">名称</text>
<text class="card-value">{{ item.name }}</text>
</view>
<view class="card-row">
<text class="card-label">时间</text>
<text class="card-value">{{ item.recordTime }}</text>
</view>
<view class="card-row">
<text class="card-label">责任人</text>
<text class="card-value">{{ item.personnel || '-' }}</text>
</view>
<view class="card-row">
<text class="card-label">导航点</text>
<text class="card-value">{{ formatCoord(item.longitude, item.latitude) }}</text>
</view>
<view class="card-row last-row">
<text class="card-label">实际里程</text>
<text class="card-value">{{ item.actualMileage || '-' }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="!loading && list.length === 0">
<text class="empty-text">暂无标石/杆号牌记录</text>
</view>
</view>
<!-- 底部固定按钮 -->
<view class="bottom-bar">
<view class="add-btn" @click="goAdd">
<text class="add-btn-text">新增</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow, onReachBottom } from '@dcloudio/uni-app'
import { getMarkerPoleList } from '@/services/markerPole'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const list = ref([])
const cableId = ref('')
const cableName = ref('')
const pageNum = ref(1)
const pageSize = ref(20)
const totalPage = ref(1)
const loading = ref(false)
function formatCoord(lng, lat) {
if ((!lng || lng === 0) && (!lat || lat === 0)) return '-'
return `${lng}, ${lat}`
}
async function loadList(isLoadMore = false) {
if (loading.value) return
loading.value = true
try {
const res = await getMarkerPoleList(cableId.value, pageNum.value, pageSize.value)
if (res.code === 200) {
const data = res.data || {}
const items = data.result || []
if (isLoadMore) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
totalPage.value = data.totalPage || 1
}
} catch (err) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack()
}
function goDetail(item) {
uni.navigateTo({ url: '/pages/marker-pole-detail/index?id=' + item.id })
}
function goAdd() {
uni.navigateTo({
url: '/pages/marker-pole-add/index?cableId=' + cableId.value + '&cableName=' + encodeURIComponent(cableName.value)
})
}
onLoad((options) => {
if (options.cableId) {
cableId.value = options.cableId
}
if (options.cableName) {
cableName.value = decodeURIComponent(options.cableName)
}
})
onShow(() => {
pageNum.value = 1
loadList()
})
onReachBottom(() => {
if (pageNum.value < totalPage.value) {
pageNum.value++
loadList(true)
}
})
</script>
<style scoped>
.marker-pole-list-page {
position: relative;
min-height: 100vh;
background-color: transparent;
padding-bottom: 120rpx;
}
.content {
position: relative;
z-index: 1;
}
.nav-bar {
width: 100%;
}
.nav-bar-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
.nav-icon {
width: 44rpx;
height: 44rpx;
}
.nav-icon-placeholder {
width: 44rpx;
height: 44rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
padding: 16rpx 24rpx 8rpx;
display: block;
}
.marker-pole-list {
padding: 0 0 24rpx;
}
.marker-pole-card {
background-color: #fff;
border-radius: 12rpx;
border: 1rpx solid #E8E8E8;
padding: 24rpx;
margin: 0 24rpx 20rpx;
}
.card-row {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
}
.card-row.last-row {
margin-bottom: 0;
}
.card-label {
font-size: 26rpx;
color: #999;
flex-shrink: 0;
}
.card-value {
font-size: 26rpx;
color: #333;
flex: 1;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 24rpx;
background: #fff;
box-sizing: border-box;
z-index: 99;
}
.add-btn {
width: 100%;
height: 88rpx;
background: #1A73EC;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.add-btn-text {
color: #fff;
font-size: 32rpx;
}
</style>

View File

@ -202,10 +202,10 @@ onLoad((options) => {
.port-tips { font-size: 20rpx; color: #fff; text-align: center; }
.port-name { font-size: 20rpx; color: #333; margin-top: 6rpx; text-align: center; }
.optical-box-wrapper { display: inline-flex; flex-direction: row; min-width: 100%; }
.optical-left-col { background-color: #FFC0CB; padding: 16rpx; border-radius: 8rpx 0 0 8rpx; }
.optical-left-col .optical-port-row { background-color: #FFB6C1; border-radius: 8rpx; padding: 12rpx; margin-bottom: 12rpx; }
.optical-right-col { background-color: #E0F7FA; padding: 16rpx; border-radius: 0 8rpx 8rpx 0; }
.optical-right-col .optical-port-row { background-color: #B3E5FC; border-radius: 8rpx; padding: 12rpx; margin-bottom: 12rpx; }
.optical-left-col { background-color: #C8E6C9; padding: 16rpx; border-radius: 8rpx 0 0 8rpx; }
.optical-left-col .optical-port-row { background-color: #A5D6A7; border-radius: 8rpx; padding: 12rpx; margin-bottom: 12rpx; }
.optical-right-col { background-color: #FFE0B2; padding: 16rpx; border-radius: 0 8rpx 8rpx 0; }
.optical-right-col .optical-port-row { background-color: #FFCC80; border-radius: 8rpx; padding: 12rpx; margin-bottom: 12rpx; }
.optical-divider { width: 4rpx; background-color: #999; flex-shrink: 0; }
.optical-col-header { padding: 0 0 12rpx; text-align: center; }
.optical-col-title { font-size: 26rpx; font-weight: 600; color: #333; }

View File

@ -58,8 +58,36 @@
</view>
</view>
<!-- 标石杆号牌分类 -->
<view class="section" v-if="markerPoles.length > 0">
<text class="section-title">标石杆号牌</text>
<view
class="fault-card"
v-for="item in markerPoles"
:key="item.id"
@click="goMarkerPoleDetail(item)"
>
<view class="fault-row">
<text class="fault-label">名称</text>
<text class="fault-value">{{ item.name }}</text>
</view>
<view class="fault-row">
<text class="fault-label">时间</text>
<text class="fault-value">{{ item.recordTime }}</text>
</view>
<view class="fault-row">
<text class="fault-label">责任人</text>
<text class="fault-value">{{ item.personnel }}</text>
</view>
<view class="fault-row last-row">
<text class="fault-label">所属光缆</text>
<text class="fault-value">{{ item.cableName }}</text>
</view>
</view>
</view>
<!-- 无结果 -->
<view class="no-result" v-if="cables.length === 0 && faults.length === 0">
<view class="no-result" v-if="cables.length === 0 && faults.length === 0 && markerPoles.length === 0">
<text class="no-result-text">暂无搜索结果</text>
</view>
</view>
@ -71,10 +99,12 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { searchCablesAndFaults } from '@/services/trunk'
import { extractMarkerPoles } from '@/services/search'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const cables = ref([])
const faults = ref([])
const markerPoles = ref([])
const loading = ref(true)
async function doSearch(deptId, keyword) {
@ -84,6 +114,7 @@ async function doSearch(deptId, keyword) {
if (res.code === 200 && res.data) {
cables.value = res.data.cables || []
faults.value = res.data.faults || []
markerPoles.value = extractMarkerPoles(res.data)
}
} catch (err) {
uni.showToast({ title: '搜索失败', icon: 'none' })
@ -106,6 +137,10 @@ function goFaultDetail(item) {
uni.navigateTo({ url: '/pages/fault-detail/index?faultId=' + item.id })
}
function goMarkerPoleDetail(item) {
uni.navigateTo({ url: '/pages/marker-pole-detail/index?id=' + item.id })
}
onLoad((options) => {
const deptId = options.deptId || ''
const keyword = decodeURIComponent(options.keyword || '')

View File

@ -2,9 +2,9 @@
import store from '@/store'
// export const BASE_URL = 'http://49.233.115.141:11082'
// export const BASE_URL = 'https://ssl.api.suigongxj.top'
export const BASE_URL = 'https://ssl.api.suigongxj.top'
// const BASE_URL = 'http://115.190.188.216:2861'
export const BASE_URL = 'https://api.wux.shhmkjgs.cn'
// export const BASE_URL = 'https://api.wux.shhmkjgs.cn'
export const APP_VERSION = '1.0.5'
// export const BASE_URL = 'http://127.0.0.1:8888'
const TIMEOUT = 20000

View File

@ -0,0 +1,27 @@
import { get, post } from './api'
/**
* 获取标石/杆号牌列表
* @param {number} cableId - 光缆ID
* @param {number} pageNum - 页码
* @param {number} pageSize - 每页条数
* @returns {Promise}
*/
export const getMarkerPoleList = (cableId, pageNum, pageSize) =>
get('/business/OdfMarkerPoles/list', { cableId, pageNum, pageSize })
/**
* 获取标石/杆号牌详情
* @param {number} id - 标石记录ID
* @returns {Promise}
*/
export const getMarkerPoleDetail = (id) =>
get(`/business/OdfMarkerPoles/${id}`)
/**
* 新增标石/杆号牌JSON 提交图片已上传至 COS
* @param {object} data - 标石信息 imageUrls 数组
* @returns {Promise}
*/
export const addMarkerPole = (data) =>
post('/business/OdfMarkerPoles/add', data, { timeout: 120000 })

View File

@ -5,3 +5,12 @@ export const searchPorts = (key, pageNum, pageSize, roomId) => {
if (roomId) params.roomId = roomId
return get('/business/OdfPorts/search2', params)
}
/**
* 从搜索 API 响应中提取 markerPoles 字段
* @param {object} data - 搜索 API 返回的 data 对象
* @returns {Array} 标石/杆号牌记录列表
*/
export const extractMarkerPoles = (data) => {
return data?.markerPoles || []
}

View File

@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Mvc;
using ZR.Model.Business.Dto;
using ZR.Service.Business.IBusinessService;
namespace ZR.Admin.WebApi.Controllers.Business
{
/// <summary>
/// 审计日志管理
/// </summary>
[Route("business/OdfAuditLogs")]
public class OdfAuditLogsController : BaseController
{
/// <summary>
/// 审计日志接口
/// </summary>
private readonly IOdfAuditLogsService _OdfAuditLogsService;
public OdfAuditLogsController(IOdfAuditLogsService OdfAuditLogsService)
{
_OdfAuditLogsService = OdfAuditLogsService;
}
/// <summary>
/// 审计日志列表分页查询
/// </summary>
/// <param name="parm"></param>
/// <returns></returns>
[HttpGet("list")]
[ActionPermissionFilter(Permission = "odfauditlogs:list")]
public IActionResult GetList([FromQuery] OdfAuditLogsQueryDto parm)
{
var response = _OdfAuditLogsService.GetList(parm);
return SUCCESS(response);
}
}
}

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using ZR.Admin.WebApi.Filters;
using ZR.Model.Business.Dto;
using ZR.Service.Business.IBusinessService;
@ -31,7 +32,8 @@ namespace ZR.Admin.WebApi.Controllers.Business
[ActionPermissionFilter(Permission = "odfcablefaults:list")]
public IActionResult GetList([FromQuery] OdfCableFaultsQueryDto parm)
{
var response = _OdfCableFaultsService.GetList(parm);
var deptId = HttpContext.GetDeptId();
var response = _OdfCableFaultsService.GetList(parm, deptId);
return SUCCESS(response);
}
@ -55,6 +57,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("add")]
[ActionPermissionFilter(Permission = "odfcablefaults:add")]
[Log(Title = "干线故障", BusinessType = BusinessType.INSERT)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> Add([FromBody] OdfCableFaultAddDto dto)
{
dto.UserId = HttpContext.GetUId();
@ -68,6 +71,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("incrementFaultCount/{id}")]
[ActionPermissionFilter(Permission = "odfcablefaults:edit")]
[Log(Title = "干线故障", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult IncrementFaultCount(int id)
{
var response = _OdfCableFaultsService.IncrementFaultCount(id);
@ -80,6 +84,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("updateMileageCorrection/{id}")]
[ActionPermissionFilter(Permission = "odfcablefaults:edit")]
[Log(Title = "干线故障", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult UpdateMileageCorrection(int id, [FromBody] MileageCorrectionDto dto)
{
var response = _OdfCableFaultsService.UpdateMileageCorrection(id, dto.MileageCorrection);
@ -93,6 +98,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("delete/{ids}")]
[ActionPermissionFilter(Permission = "odfcablefaults:delete")]
[Log(Title = "干线故障", BusinessType = BusinessType.DELETE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Delete(string ids)
{
var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries)
@ -122,7 +128,8 @@ namespace ZR.Admin.WebApi.Controllers.Business
[ActionPermissionFilter(Permission = "odfcablefaults:export")]
public IActionResult Export([FromQuery] OdfCableFaultsQueryDto parm)
{
var list = _OdfCableFaultsService.ExportList(parm);
var deptId = HttpContext.GetDeptId();
var list = _OdfCableFaultsService.ExportList(parm, deptId);
if (list == null || list.Count <= 0)
{
return ToResponse(ResultCode.FAIL, "没有要导出的数据");

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using ZR.Admin.WebApi.Filters;
using ZR.Model.Business;
using ZR.Model.Business.Dto;
using ZR.Service.Business.IBusinessService;
@ -31,12 +32,13 @@ namespace ZR.Admin.WebApi.Controllers.Business
[ActionPermissionFilter(Permission = "odfcables:list")]
public IActionResult GetList([FromQuery] OdfCablesQueryDto parm)
{
var response = _OdfCablesService.GetList(parm);
var deptId = HttpContext.GetDeptId();
var response = _OdfCablesService.GetList(parm, deptId);
return SUCCESS(response);
}
/// <summary>
/// 搜索光缆和故障(限定公司范围)
/// 搜索光缆、故障和标石/杆号牌(限定公司范围)
/// </summary>
/// <returns></returns>
[HttpGet("search")]
@ -54,6 +56,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost]
[ActionPermissionFilter(Permission = "odfcables:add")]
[Log(Title = "光缆管理", BusinessType = BusinessType.INSERT)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Add([FromBody] OdfCables parm)
{
parm.CreatedAt = DateTime.Now;
@ -69,6 +72,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPut]
[ActionPermissionFilter(Permission = "odfcables:edit")]
[Log(Title = "光缆管理", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Update([FromBody] OdfCables parm)
{
parm.UpdatedAt = DateTime.Now;
@ -96,6 +100,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("delete/{id}")]
[ActionPermissionFilter(Permission = "odfcables:delete")]
[Log(Title = "光缆管理", BusinessType = BusinessType.DELETE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Delete(int id)
{
var response = _OdfCablesService.Delete(id);
@ -111,7 +116,8 @@ namespace ZR.Admin.WebApi.Controllers.Business
[ActionPermissionFilter(Permission = "odfcables:export")]
public IActionResult Export([FromQuery] OdfCablesQueryDto parm)
{
var list = _OdfCablesService.ExportList(parm);
var deptId = HttpContext.GetDeptId();
var list = _OdfCablesService.ExportList(parm, deptId);
if (list == null || list.Result == null || list.Result.Count <= 0)
{
return ToResponse(ResultCode.FAIL, "没有要导出的数据");

View File

@ -0,0 +1,110 @@
using Microsoft.AspNetCore.Mvc;
using ZR.Admin.WebApi.Filters;
using ZR.Model.Business.Dto;
using ZR.Service.Business.IBusinessService;
//创建时间2025-01-01
namespace ZR.Admin.WebApi.Controllers.Business
{
/// <summary>
/// 标石/杆号牌管理
/// </summary>
[Route("business/OdfMarkerPoles")]
public class OdfMarkerPolesController : BaseController
{
/// <summary>
/// 标石/杆号牌接口
/// </summary>
private readonly IOdfMarkerPolesService _OdfMarkerPolesService;
public OdfMarkerPolesController(IOdfMarkerPolesService OdfMarkerPolesService)
{
_OdfMarkerPolesService = OdfMarkerPolesService;
}
/// <summary>
/// 标石/杆号牌列表分页查询
/// </summary>
/// <param name="parm"></param>
/// <returns></returns>
[HttpGet("list")]
[ActionPermissionFilter(Permission = "odfmarkerpoles:list")]
public IActionResult GetList([FromQuery] OdfMarkerPolesQueryDto parm)
{
var deptId = HttpContext.GetDeptId();
var response = _OdfMarkerPolesService.GetList(parm, deptId);
return SUCCESS(response);
}
/// <summary>
/// 标石/杆号牌详情(含图片)
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}")]
[ActionPermissionFilter(Permission = "odfmarkerpoles:query")]
public IActionResult GetDetail(int id)
{
var response = _OdfMarkerPolesService.GetDetail(id);
return SUCCESS(response);
}
/// <summary>
/// 新增标石/杆号牌图片已上传至COS提交COS URL
/// </summary>
/// <returns></returns>
[HttpPost("add")]
[ActionPermissionFilter(Permission = "odfmarkerpoles:add")]
[Log(Title = "标石/杆号牌", BusinessType = BusinessType.INSERT)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Add([FromBody] OdfMarkerPoleAddDto dto)
{
dto.UserId = HttpContext.GetUId();
var deptId = HttpContext.GetDeptId();
var response = _OdfMarkerPolesService.Add(dto, deptId);
return SUCCESS(response);
}
/// <summary>
/// 编辑标石/杆号牌
/// </summary>
/// <returns></returns>
[HttpPut("edit")]
[ActionPermissionFilter(Permission = "odfmarkerpoles:edit")]
[Log(Title = "标石/杆号牌", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Update([FromBody] OdfMarkerPoleEditDto dto)
{
var response = _OdfMarkerPolesService.Update(dto);
return ToResponse(response);
}
/// <summary>
/// 删除标石/杆号牌(支持单个/批量)
/// </summary>
/// <returns></returns>
[HttpDelete("delete/{ids}")]
[ActionPermissionFilter(Permission = "odfmarkerpoles:delete")]
[Log(Title = "标石/杆号牌", BusinessType = BusinessType.DELETE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult Delete(string ids)
{
var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var v) ? v : 0)
.Where(v => v > 0)
.ToList();
if (idList.Count == 0)
{
return ToResponse(ResultCode.FAIL, "请选择要删除的数据");
}
int total = 0;
foreach (var id in idList)
{
total += _OdfMarkerPolesService.Delete(id);
}
return ToResponse(total);
}
}
}

View File

@ -14,6 +14,7 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using ZR.Admin.WebApi.Filters;
using ZR.Model.Business;
using ZR.Model.Business.Dto;
using ZR.Model.System.Dto;
@ -361,6 +362,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpGet("status/{Id}/{status}")]
[ActionPermissionFilter(Permission = "odfports:edit")]
[Log(Title = "更新端口状态", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult GetOdfPortsStatus(int Id, int status)
{
var response = _OdfPortsService.GetInfo(Id);
@ -388,6 +390,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost]
[ActionPermissionFilter(Permission = "odfports:add")]
[Log(Title = "端口", BusinessType = BusinessType.INSERT)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> AddOdfPorts([FromBody] OdfPortsDto parm)
{
var modal = parm.Adapt<OdfPorts>().ToCreate(HttpContext);
@ -419,6 +422,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPut]
[ActionPermissionFilter(Permission = "odfports:edit")]
[Log(Title = "端口", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> UpdateOdfPorts([FromBody] OdfPortsDto parm)
{
var modal = parm.Adapt<OdfPorts>().ToUpdate(HttpContext);
@ -454,6 +458,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("save")]
[ActionPermissionFilter(Permission = "odfports:edit")]
[Log(Title = "APP修改端口", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> SaveMOdfPorts([FromBody] OdfPortsMMDto parm)
{
var port = _OdfPortsService.GetById(parm.Id);
@ -562,6 +567,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpGet("empty/{Id}")]
[ActionPermissionFilter(Permission = "odfports:edit")]
[Log(Title = "清空数据", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult GetOdfPortsEmpty(int Id)
{
var response = _OdfPortsService.GetInfo(Id);
@ -582,6 +588,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("delete/{ids}")]
[ActionPermissionFilter(Permission = "odfports:delete")]
[Log(Title = "端口", BusinessType = BusinessType.DELETE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult DeleteOdfPorts([FromRoute] string ids)
{
var idArr = Tools.SplitAndConvert<int>(ids);
@ -1016,6 +1023,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("deleteAll")]
[ActionPermissionFilter(Permission = "odfports:delete")]
[Log(Title = "删除端口数据", BusinessType = BusinessType.DELETE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> DeleteAll([FromBody] OdfRoomsTreeDto treeDto)
{
if (treeDto.Level == 2)

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using ZR.Admin.WebApi.Filters;
using ZR.Model.Business.Dto;
using ZR.Model.Business;
using ZR.Service.Business.IBusinessService;
@ -75,6 +76,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost]
[ActionPermissionFilter(Permission = "odfracks:add")]
[Log(Title = "机架列表", BusinessType = BusinessType.INSERT)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public IActionResult AddOdfRacks([FromBody] OdfRacksDto parm)
{
var modal = parm.Adapt<OdfRacks>().ToCreate(HttpContext);
@ -100,6 +102,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("expert")]
[ActionPermissionFilter(Permission = "odfracks:add")]
[Log(Title = "机架列表", BusinessType = BusinessType.INSERT)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> AddOdfRacksExpert([FromBody] OdfRacksExpertDto parm)
{
var modal = parm.Adapt<OdfRacks>().ToCreate(HttpContext);
@ -278,6 +281,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPut]
[ActionPermissionFilter(Permission = "odfracks:edit")]
[Log(Title = "机架列表", BusinessType = BusinessType.UPDATE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> UpdateOdfRacks([FromBody] OdfRacksDto parm)
{
var modal = parm.Adapt<OdfRacks>().ToUpdate(HttpContext);
@ -311,6 +315,7 @@ namespace ZR.Admin.WebApi.Controllers.Business
[HttpPost("delete/{ids}")]
[ActionPermissionFilter(Permission = "odfracks:delete")]
[Log(Title = "机架列表", BusinessType = BusinessType.DELETE)]
[ServiceFilter(typeof(OdfAuditLogFilter))]
public async Task<IActionResult> DeleteOdfRacks([FromRoute] string ids)
{
var idArr = Tools.SplitAndConvert<int>(ids);

View File

@ -23,6 +23,7 @@ namespace ZR.Admin.WebApi.Controllers.System
private readonly ISysConfigService sysConfigService;
private readonly ISysRoleService roleService;
private readonly ISmsCodeLogService smsCodeLogService;
private readonly ISysDeptService deptService;
public SysLoginController(
ISysMenuService sysMenuService,
@ -32,6 +33,7 @@ namespace ZR.Admin.WebApi.Controllers.System
ISysConfigService configService,
ISysRoleService sysRoleService,
ISmsCodeLogService smsCodeLogService,
ISysDeptService deptService,
ICaptcha captcha)
{
SecurityCodeHelper = captcha;
@ -41,6 +43,7 @@ namespace ZR.Admin.WebApi.Controllers.System
this.permissionService = permissionService;
this.sysConfigService = configService;
this.smsCodeLogService = smsCodeLogService;
this.deptService = deptService;
roleService = sysRoleService;
}
@ -68,6 +71,23 @@ namespace ZR.Admin.WebApi.Controllers.System
var user = sysLoginService.Login(loginBody, new SysLogininfor() { LoginLocation = location });
List<SysRole> roles = roleService.SelectUserRoleListByUserId(user.UserId);
// 分公司账号登录管理后台限制:非超级管理员的分公司账号必须拥有「分公司管理员」角色
if (!user.IsAdmin && !roles.Any(r => r.RoleKey == GlobalConstant.AdminRole))
{
var dept = deptService.GetFirst(d => d.DeptId == user.DeptId);
if (dept != null)
{
// 判断是否为分公司级别Ancestors 中包含两级以上(如 "0,100" 表示分公司)
var ancestors = dept.Ancestors?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
bool isBranchCompany = ancestors.Length >= 2;
if (isBranchCompany && !roles.Any(r => r.RoleKey == "branch_admin"))
{
throw new CustomException(ResultCode.LOGIN_ERROR, "该账号无权登录管理后台,请联系管理员分配「分公司管理员」角色", false);
}
}
}
//权限集合 eg *:*:*,system:user:list
List<string> permissions = permissionService.GetMenuPermission(new SysUserDto() { UserId = user.UserId });

View File

@ -7,6 +7,7 @@ using SqlSugar;
using ZR.Model;
using ZR.Model.System;
using ZR.Model.System.Dto;
using ZR.Service.Business;
using static SKIT.FlurlHttpClient.Wechat.Api.Models.CgibinUserInfoBatchGetRequest.Types;
@ -23,17 +24,20 @@ namespace ZR.Admin.WebApi.Controllers.System
private readonly ISysRoleService RoleService;
private readonly ISysPostService PostService;
private readonly ISysUserPostService UserPostService;
private readonly ISqlSugarClient _db;
public SysUserController(
ISysUserService userService,
ISysRoleService roleService,
ISysPostService postService,
ISysUserPostService userPostService)
ISysUserPostService userPostService,
ISqlSugarClient db)
{
UserService = userService;
RoleService = roleService;
PostService = postService;
UserPostService = userPostService;
_db = db;
}
/// <summary>
@ -91,6 +95,18 @@ namespace ZR.Admin.WebApi.Controllers.System
var user = parm.Adapt<SysUser>().ToCreate(HttpContext);
user.DeptId = parm.DeptId;
if (user == null) { return ToResponse(ApiResult.Error(101, "请求参数错误")); }
// 分公司管理员创建账号范围限制:非超级管理员需校验新账号 DeptId 在可见部门范围内
if (!HttpContext.IsAdmin())
{
var currentDeptId = HttpContext.GetDeptId();
var visibleDeptIds = DeptDataScopeHelper.GetVisibleDeptIds(_db, currentDeptId);
if (!visibleDeptIds.Contains(user.DeptId))
{
throw new CustomException("无权为该公司创建账号");
}
}
if (UserConstants.NOT_UNIQUE.Equals(UserService.CheckUserNameUnique(user.UserName)))
{
return ToResponse(ApiResult.Error($"新增用户 '{user.UserName}'失败,登录账号已存在"));

View File

@ -0,0 +1,336 @@
using Infrastructure;
using Infrastructure.Attribute;
using Infrastructure.Enums;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using SqlSugar;
using System.Text.Json;
using ZR.Model.Business;
using ZR.Service.Business.IBusinessService;
namespace ZR.Admin.WebApi.Filters
{
/// <summary>
/// ODF 审计日志 ActionFilter
/// 拦截标石/ODF/干线相关 Controller 的增删改操作,自动记录审计日志。
/// 使用方式:在 Action 上添加 [ServiceFilter(typeof(OdfAuditLogFilter))]
/// </summary>
public class OdfAuditLogFilter : IAsyncActionFilter
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IOdfAuditLogsService _auditLogsService;
/// <summary>
/// 控制器名称到数据库表名的映射
/// </summary>
private static readonly Dictionary<string, string> ControllerTableMap = new(StringComparer.OrdinalIgnoreCase)
{
{ "OdfMarkerPoles", "odf_marker_poles" },
{ "OdfCables", "odf_cables" },
{ "OdfCableFaults", "odf_cable_faults" },
{ "OdfPorts", "odf_ports" },
{ "OdfRacks", "odf_racks" },
{ "OdfFrames", "odf_frames" },
{ "OdfRooms", "odf_rooms" },
};
public OdfAuditLogFilter(IOdfAuditLogsService auditLogsService)
{
_auditLogsService = auditLogsService;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
string oldData = null;
string operationType = null;
string tableName = null;
int? recordId = null;
try
{
// 从 [Log] 特性获取 BusinessType
var businessType = GetBusinessType(context);
operationType = businessType switch
{
BusinessType.INSERT => "INSERT",
BusinessType.UPDATE => "UPDATE",
BusinessType.DELETE => "DELETE",
_ => null
};
// 仅拦截增删改操作
if (operationType == null)
{
await next();
return;
}
// 获取表名
tableName = GetTableName(context);
// 获取记录 ID从路由参数或请求体
recordId = GetRecordId(context);
// 操作前:对于 UPDATE/DELETE尝试读取现有数据作为 OldData
if ((operationType == "UPDATE" || operationType == "DELETE") && recordId.HasValue && recordId.Value > 0)
{
oldData = await GetExistingDataJson(tableName, recordId.Value);
}
}
catch (Exception ex)
{
logger.Error(ex, "审计日志:操作前记录数据失败");
}
// 执行实际的 Action
var resultContext = await next();
// 操作后:记录审计日志
try
{
// 如果 Action 执行出错,不记录审计日志
if (resultContext.Exception != null && !resultContext.ExceptionHandled)
{
return;
}
if (operationType == null)
{
return;
}
// 获取新数据
string newData = null;
if (operationType == "INSERT" || operationType == "UPDATE")
{
newData = GetNewDataFromActionArguments(context);
}
// 对于 INSERT尝试从返回结果获取新记录 ID
if (operationType == "INSERT" && (!recordId.HasValue || recordId.Value <= 0))
{
recordId = GetRecordIdFromResult(resultContext);
}
// 获取用户信息
var operatorId = context.HttpContext.GetUId();
var operatorName = context.HttpContext.GetName();
var deptId = context.HttpContext.GetDeptId();
// 判断操作来源
var sourceClient = GetSourceClient(context.HttpContext);
var auditLog = new OdfAuditLogs
{
TableName = tableName ?? "unknown",
RecordId = recordId,
OperationType = operationType,
OperatorId = operatorId,
OperatorName = operatorName,
SourceClient = sourceClient,
OldData = oldData,
NewData = newData,
OperationTime = DateTime.Now,
DeptId = deptId > 0 ? deptId : null,
};
_auditLogsService.AddLog(auditLog);
}
catch (Exception ex)
{
// 审计日志写入失败不影响主业务
logger.Error(ex, "审计日志:写入审计日志失败");
}
}
/// <summary>
/// 从 [Log] 特性获取 BusinessType
/// </summary>
private static BusinessType? GetBusinessType(ActionExecutingContext context)
{
if (context.ActionDescriptor is not ControllerActionDescriptor descriptor)
return null;
var logAttr = descriptor.MethodInfo
.GetCustomAttributes(inherit: true)
.OfType<LogAttribute>()
.FirstOrDefault();
return logAttr?.BusinessType;
}
/// <summary>
/// 从控制器名称映射到数据库表名
/// </summary>
private static string GetTableName(ActionExecutingContext context)
{
if (context.ActionDescriptor is ControllerActionDescriptor descriptor)
{
var controllerName = descriptor.ControllerName;
if (ControllerTableMap.TryGetValue(controllerName, out var table))
{
return table;
}
// 回退:使用控制器名称
return controllerName;
}
return "unknown";
}
/// <summary>
/// 从路由参数或请求体中提取记录 ID
/// </summary>
private static int? GetRecordId(ActionExecutingContext context)
{
// 尝试从路由参数获取 id
if (context.ActionArguments.TryGetValue("id", out var idObj))
{
if (idObj is int intId) return intId;
if (int.TryParse(idObj?.ToString(), out var parsed)) return parsed;
}
// 尝试从路由参数获取 ids批量删除场景取第一个
if (context.ActionArguments.TryGetValue("ids", out var idsObj) && idsObj is string idsStr)
{
var first = idsStr.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (int.TryParse(first?.Trim(), out var firstId)) return firstId;
}
// 尝试从请求体 DTO 中获取 Id 属性
foreach (var arg in context.ActionArguments.Values)
{
if (arg == null) continue;
var idProp = arg.GetType().GetProperty("Id");
if (idProp != null && idProp.PropertyType == typeof(int))
{
var val = (int)(idProp.GetValue(arg) ?? 0);
if (val > 0) return val;
}
}
return null;
}
/// <summary>
/// 从 Action 参数中获取新数据(请求体 DTO 序列化为 JSON
/// </summary>
private static string GetNewDataFromActionArguments(ActionExecutingContext context)
{
try
{
// 查找请求体中的 DTO 对象(排除简单类型)
foreach (var arg in context.ActionArguments)
{
if (arg.Value == null) continue;
var type = arg.Value.GetType();
if (type.IsClass && type != typeof(string) && !type.IsPrimitive)
{
return JsonSerializer.Serialize(arg.Value, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
}
}
catch (Exception ex)
{
logger.Warn(ex, "审计日志:序列化新数据失败");
}
return null;
}
/// <summary>
/// 从 Action 返回结果中尝试获取新记录 ID
/// </summary>
private static int? GetRecordIdFromResult(ActionExecutedContext context)
{
try
{
if (context.Result is ObjectResult objResult && objResult.Value != null)
{
// 尝试从返回对象中获取 data 或 result 属性
var resultType = objResult.Value.GetType();
var dataProp = resultType.GetProperty("Data") ?? resultType.GetProperty("data");
if (dataProp != null)
{
var dataVal = dataProp.GetValue(objResult.Value);
if (dataVal is int intVal && intVal > 0) return intVal;
if (int.TryParse(dataVal?.ToString(), out var parsed) && parsed > 0) return parsed;
}
}
}
catch
{
// 忽略
}
return null;
}
/// <summary>
/// 判断操作来源App 端或管理后台端
/// 优先从自定义 Header "X-Source-Client" 判断,
/// 其次从 User-Agent 判断(包含 uni-app 或移动端标识则为 App
/// </summary>
private static string GetSourceClient(HttpContext httpContext)
{
// 优先检查自定义 Header
if (httpContext.Request.Headers.TryGetValue("X-Source-Client", out var sourceHeader))
{
var val = sourceHeader.ToString().Trim();
if (!string.IsNullOrEmpty(val))
{
return val.Equals("App", StringComparison.OrdinalIgnoreCase) ? "App" : "Admin";
}
}
// 从 User-Agent 判断
var userAgent = httpContext.Request.Headers["User-Agent"].ToString();
if (!string.IsNullOrEmpty(userAgent))
{
if (userAgent.Contains("uni-app", StringComparison.OrdinalIgnoreCase)
|| userAgent.Contains("Android", StringComparison.OrdinalIgnoreCase)
|| userAgent.Contains("iPhone", StringComparison.OrdinalIgnoreCase)
|| userAgent.Contains("iPad", StringComparison.OrdinalIgnoreCase))
{
return "App";
}
}
// 默认为管理后台
return "Admin";
}
/// <summary>
/// 查询现有记录数据并序列化为 JSON用于 OldData
/// 通过 SqlSugar 直接查询对应表
/// </summary>
private async Task<string> GetExistingDataJson(string tableName, int recordId)
{
try
{
var db = App.GetService<ISqlSugarClient>();
if (db == null) return null;
var data = await db.Queryable<dynamic>()
.AS(tableName)
.Where("Id = @id", new { id = recordId })
.FirstAsync();
if (data == null) return null;
return JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch (Exception ex)
{
logger.Warn(ex, $"审计日志:查询表 {tableName} 记录 {recordId} 的旧数据失败");
return null;
}
}
}
}

View File

@ -62,6 +62,8 @@ builder.Services.AddJwt();
builder.Services.AddSingleton(new AppSettings(builder.Configuration));
//app服务注册
builder.Services.AddAppService();
// 注册 ODF 审计日志 FilterServiceFilter 需要 DI 注入)
builder.Services.AddScoped<ZR.Admin.WebApi.Filters.OdfAuditLogFilter>();
var wxMp = builder.Configuration.GetSection("WxMp");
var mp_appid = wxMp.GetSection("AppID").Value ?? "";//默认值
var mp_appSecret = wxMp.GetSection("AppSecret").Value ?? "";//默认值

View File

@ -0,0 +1,28 @@
namespace ZR.Model.Business.Dto
{
/// <summary>
/// 审计日志查询对象
/// </summary>
public class OdfAuditLogsQueryDto : PagerInfo
{
/// <summary>
/// 查询时间范围 - 开始
/// </summary>
public DateTime? BeginTime { get; set; }
/// <summary>
/// 查询时间范围 - 结束
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// 操作类型筛选: INSERT/UPDATE/DELETE
/// </summary>
public string? OperationType { get; set; }
/// <summary>
/// 表名筛选
/// </summary>
public string? TableName { get; set; }
}
}

View File

@ -0,0 +1,121 @@
namespace ZR.Model.Business.Dto
{
/// <summary>
/// 标石/杆号牌查询对象
/// </summary>
public class OdfMarkerPolesQueryDto : PagerInfo
{
public int? CableId { get; set; }
public string? Keyword { get; set; }
}
/// <summary>
/// 标石/杆号牌新增对象
/// </summary>
public class OdfMarkerPoleAddDto
{
public int CableId { get; set; }
public string Name { get; set; }
public string RecordTime { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public long? UserId { get; set; }
/// <summary>
/// COS 图片访问 URL 列表(前端直传 COS 后传入)
/// </summary>
public string[] ImageUrls { get; set; }
}
/// <summary>
/// 标石/杆号牌编辑对象
/// </summary>
public class OdfMarkerPoleEditDto
{
public int Id { get; set; }
public string Name { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public int CableId { get; set; }
}
/// <summary>
/// 标石/杆号牌列表返回对象
/// </summary>
public class OdfMarkerPoleListDto
{
public int Id { get; set; }
public int CableId { get; set; }
public string Name { get; set; }
public DateTime RecordTime { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public string? DeptName { get; set; }
}
/// <summary>
/// 标石/杆号牌详情返回对象
/// </summary>
public class OdfMarkerPoleDetailDto
{
public int Id { get; set; }
public int CableId { get; set; }
public string Name { get; set; }
public DateTime RecordTime { get; set; }
public string? Personnel { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string? ActualMileage { get; set; }
public long DeptId { get; set; }
public string? DeptName { get; set; }
public long? UserId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// 图片 URL 列表
/// </summary>
public List<string> ImageUrls { get; set; } = new List<string>();
}
}

View File

@ -0,0 +1,72 @@
namespace ZR.Model.Business
{
/// <summary>
/// 审计日志
/// </summary>
[SugarTable("odf_audit_logs")]
public class OdfAuditLogs
{
/// <summary>
/// Id
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>
/// 操作的表名
/// </summary>
public string TableName { get; set; }
/// <summary>
/// 操作的记录ID
/// </summary>
public int? RecordId { get; set; }
/// <summary>
/// 操作类型: INSERT/UPDATE/DELETE
/// </summary>
public string OperationType { get; set; }
/// <summary>
/// 操作人用户ID
/// </summary>
public long OperatorId { get; set; }
/// <summary>
/// 操作人用户名
/// </summary>
public string? OperatorName { get; set; }
/// <summary>
/// 操作来源: App/Admin
/// </summary>
public string SourceClient { get; set; }
/// <summary>
/// 修改前数据 (JSON)
/// </summary>
[SugarColumn(ColumnDataType = "nvarchar(max)")]
public string? OldData { get; set; }
/// <summary>
/// 修改后数据 (JSON)
/// </summary>
[SugarColumn(ColumnDataType = "nvarchar(max)")]
public string? NewData { get; set; }
/// <summary>
/// 操作时间
/// </summary>
public DateTime? OperationTime { get; set; }
/// <summary>
/// 操作人所属部门
/// </summary>
public long? DeptId { get; set; }
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
}
}

View File

@ -0,0 +1,30 @@
namespace ZR.Model.Business
{
/// <summary>
/// 标石/杆号牌图片
/// </summary>
[SugarTable("odf_marker_pole_images")]
public class OdfMarkerPoleImages
{
/// <summary>
/// Id
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>
/// 关联标石ID
/// </summary>
public int MarkerPoleId { get; set; }
/// <summary>
/// 图片访问URL
/// </summary>
public string ImageUrl { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime? CreatedAt { get; set; }
}
}

View File

@ -0,0 +1,75 @@
namespace ZR.Model.Business
{
/// <summary>
/// 标石/杆号牌
/// </summary>
[SugarTable("odf_marker_poles")]
public class OdfMarkerPoles
{
/// <summary>
/// Id
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>
/// 关联光缆ID
/// </summary>
public int CableId { get; set; }
/// <summary>
/// 标石/杆号牌名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 记录时间(拍照时间)
/// </summary>
public DateTime RecordTime { get; set; }
/// <summary>
/// 责任人
/// </summary>
public string? Personnel { get; set; }
/// <summary>
/// 纬度
/// </summary>
public decimal Latitude { get; set; }
/// <summary>
/// 经度
/// </summary>
public decimal Longitude { get; set; }
/// <summary>
/// 实际里程
/// </summary>
public string? ActualMileage { get; set; }
/// <summary>
/// 所属公司/部门ID
/// </summary>
public long DeptId { get; set; }
/// <summary>
/// 部门名称(冗余)
/// </summary>
public string? DeptName { get; set; }
/// <summary>
/// 提交人用户ID
/// </summary>
public long? UserId { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime? CreatedAt { get; set; }
/// <summary>
/// 更新时间
/// </summary>
public DateTime? UpdatedAt { get; set; }
}
}

View File

@ -0,0 +1,39 @@
using SqlSugar;
using ZR.Model.System;
namespace ZR.Service.Business
{
/// <summary>
/// 部门数据隔离工具类
/// 根据 DeptId 递归查询本级及所有下级部门 ID 列表
/// </summary>
public static class DeptDataScopeHelper
{
/// <summary>
/// 获取当前用户可见的部门ID列表本级 + 所有下级)
/// </summary>
/// <param name="db">SqlSugar 客户端实例</param>
/// <param name="deptId">当前用户所属部门ID</param>
/// <returns>可见部门ID列表</returns>
public static List<long> GetVisibleDeptIds(ISqlSugarClient db, long deptId)
{
var allDepts = db.Queryable<SysDept>().ToList();
var result = new List<long> { deptId };
CollectChildDeptIds(allDepts, deptId, result);
return result;
}
/// <summary>
/// 递归收集所有下级部门ID
/// </summary>
private static void CollectChildDeptIds(List<SysDept> all, long parentId, List<long> result)
{
var children = all.Where(d => d.ParentId == parentId).ToList();
foreach (var child in children)
{
result.Add(child.DeptId);
CollectChildDeptIds(all, child.DeptId, result);
}
}
}
}

View File

@ -0,0 +1,22 @@
using ZR.Model;
using ZR.Model.Business;
using ZR.Model.Business.Dto;
namespace ZR.Service.Business.IBusinessService
{
/// <summary>
/// 审计日志service接口
/// </summary>
public interface IOdfAuditLogsService : IBaseService<OdfAuditLogs>
{
/// <summary>
/// 分页查询审计日志列表,支持时间范围、操作类型、表名筛选
/// </summary>
PagedInfo<OdfAuditLogs> GetList(OdfAuditLogsQueryDto parm);
/// <summary>
/// 写入审计日志记录
/// </summary>
int AddLog(OdfAuditLogs log);
}
}

View File

@ -10,9 +10,9 @@ namespace ZR.Service.Business.IBusinessService
public interface IOdfCableFaultsService : IBaseService<OdfCableFaults>
{
/// <summary>
/// 按 CableId 分页查询故障列表,联查光缆名称,按 FaultTime DESC 排序
/// 按 CableId 分页查询故障列表,联查光缆名称,按 FaultTime DESC 排序,应用部门数据隔离
/// </summary>
PagedInfo<dynamic> GetList(OdfCableFaultsQueryDto parm);
PagedInfo<dynamic> GetList(OdfCableFaultsQueryDto parm, long deptId);
/// <summary>
/// 查询故障详情,联查光缆名称和图片列表
@ -32,7 +32,7 @@ namespace ZR.Service.Business.IBusinessService
/// <summary>
/// 导出故障列表
/// </summary>
List<OdfCableFaultExportDto> ExportList(OdfCableFaultsQueryDto parm);
List<OdfCableFaultExportDto> ExportList(OdfCableFaultsQueryDto parm, long deptId);
/// <summary>
/// 批量导入故障

View File

@ -10,11 +10,12 @@ namespace ZR.Service.Business.IBusinessService
public interface IOdfCablesService : IBaseService<OdfCables>
{
/// <summary>
/// 按 DeptId 过滤光缆列表(支持分页和 CableName 模糊查询)
/// 按 DeptId 过滤光缆列表(支持分页和 CableName 模糊查询),应用部门数据隔离
/// </summary>
/// <param name="parm"></param>
/// <param name="deptId">当前用户所属部门ID用于数据隔离</param>
/// <returns></returns>
PagedInfo<OdfCables> GetList(OdfCablesQueryDto parm);
PagedInfo<OdfCables> GetList(OdfCablesQueryDto parm, long deptId);
/// <summary>
/// 在指定公司范围内搜索光缆和故障
@ -56,7 +57,8 @@ namespace ZR.Service.Business.IBusinessService
/// 导出光缆列表
/// </summary>
/// <param name="parm"></param>
/// <param name="deptId">当前用户所属部门ID用于数据隔离</param>
/// <returns></returns>
PagedInfo<OdfCables> ExportList(OdfCablesQueryDto parm);
PagedInfo<OdfCables> ExportList(OdfCablesQueryDto parm, long deptId);
}
}

View File

@ -0,0 +1,32 @@
using ZR.Model.Business;
namespace ZR.Service.Business.IBusinessService
{
/// <summary>
/// 标石/杆号牌图片service接口
/// </summary>
public interface IOdfMarkerPoleImagesService : IBaseService<OdfMarkerPoleImages>
{
/// <summary>
/// 按标石 ID 查询图片列表
/// </summary>
/// <param name="markerPoleId"></param>
/// <returns></returns>
List<OdfMarkerPoleImages> GetByMarkerPoleId(int markerPoleId);
/// <summary>
/// 批量插入图片记录
/// </summary>
/// <param name="markerPoleId"></param>
/// <param name="imageUrls"></param>
/// <returns></returns>
int BatchInsert(int markerPoleId, string[] imageUrls);
/// <summary>
/// 按标石 ID 删除所有图片记录
/// </summary>
/// <param name="markerPoleId"></param>
/// <returns></returns>
int DeleteByMarkerPoleId(int markerPoleId);
}
}

View File

@ -0,0 +1,37 @@
using ZR.Model;
using ZR.Model.Business;
using ZR.Model.Business.Dto;
namespace ZR.Service.Business.IBusinessService
{
/// <summary>
/// 标石/杆号牌service接口
/// </summary>
public interface IOdfMarkerPolesService : IBaseService<OdfMarkerPoles>
{
/// <summary>
/// 分页查询标石/杆号牌列表,支持按 CableId 过滤,应用部门数据隔离
/// </summary>
PagedInfo<OdfMarkerPoleListDto> GetList(OdfMarkerPolesQueryDto parm, long deptId);
/// <summary>
/// 查询标石/杆号牌详情,含图片 URL 列表
/// </summary>
OdfMarkerPoleDetailDto GetDetail(int id);
/// <summary>
/// 新增标石/杆号牌(含图片记录),自动填充 DeptId/DeptName
/// </summary>
int Add(OdfMarkerPoleAddDto dto, long deptId);
/// <summary>
/// 编辑标石/杆号牌
/// </summary>
int Update(OdfMarkerPoleEditDto dto);
/// <summary>
/// 删除标石/杆号牌及关联图片
/// </summary>
int Delete(int id);
}
}

View File

@ -0,0 +1,52 @@
using Infrastructure.Attribute;
using ZR.Model;
using ZR.Model.Business;
using ZR.Model.Business.Dto;
using ZR.Repository;
using ZR.Service.Business.IBusinessService;
namespace ZR.Service.Business
{
/// <summary>
/// 审计日志Service业务层处理
/// </summary>
[AppService(ServiceType = typeof(IOdfAuditLogsService), ServiceLifetime = LifeTime.Transient)]
public class OdfAuditLogsService : BaseService<OdfAuditLogs>, IOdfAuditLogsService
{
/// <summary>
/// 分页查询审计日志列表,支持时间范围、操作类型、表名筛选
/// </summary>
public PagedInfo<OdfAuditLogs> GetList(OdfAuditLogsQueryDto parm)
{
var predicate = Expressionable.Create<OdfAuditLogs>();
predicate = predicate.AndIF(parm.BeginTime != null, it => it.OperationTime >= parm.BeginTime);
predicate = predicate.AndIF(parm.EndTime != null, it => it.OperationTime <= parm.EndTime);
predicate = predicate.AndIF(!string.IsNullOrEmpty(parm.OperationType), it => it.OperationType == parm.OperationType);
predicate = predicate.AndIF(!string.IsNullOrEmpty(parm.TableName), it => it.TableName == parm.TableName);
var total = 0;
var list = Queryable()
.Where(predicate.ToExpression())
.OrderByDescending(it => it.OperationTime)
.ToPageList(parm.PageNum, parm.PageSize, ref total);
return new PagedInfo<OdfAuditLogs>
{
Result = list,
TotalNum = total,
PageIndex = parm.PageNum,
PageSize = parm.PageSize
};
}
/// <summary>
/// 写入审计日志记录
/// </summary>
public int AddLog(OdfAuditLogs log)
{
log.OperationTime ??= DateTime.Now;
return Insert(log);
}
}
}

View File

@ -16,16 +16,20 @@ namespace ZR.Service.Business
public class OdfCableFaultsService : BaseService<OdfCableFaults>, IOdfCableFaultsService
{
/// <summary>
/// 按 CableId 分页查询故障列表,联查光缆名称,按 FaultTime DESC 排序
/// 按 CableId 分页查询故障列表,联查光缆名称,按 FaultTime DESC 排序,应用部门数据隔离
/// </summary>
public PagedInfo<dynamic> GetList(OdfCableFaultsQueryDto parm)
public PagedInfo<dynamic> GetList(OdfCableFaultsQueryDto parm, long deptId)
{
var predicate = QueryExp(parm);
// 部门数据隔离:通过关联光缆的 DeptId 过滤
var visibleDeptIds = DeptDataScopeHelper.GetVisibleDeptIds(Context, deptId);
var total = 0;
var list = Queryable()
.Where(predicate.ToExpression())
.LeftJoin<OdfCables>((f, c) => f.CableId == c.Id)
.Where((f, c) => visibleDeptIds.Contains(c.DeptId))
.Select((f, c) => new
{
f.Id,
@ -234,13 +238,17 @@ namespace ZR.Service.Business
/// <summary>
/// 导出故障列表
/// </summary>
public List<OdfCableFaultExportDto> ExportList(OdfCableFaultsQueryDto parm)
public List<OdfCableFaultExportDto> ExportList(OdfCableFaultsQueryDto parm, long deptId)
{
var predicate = QueryExp(parm);
// 部门数据隔离:通过关联光缆的 DeptId 过滤
var visibleDeptIds = DeptDataScopeHelper.GetVisibleDeptIds(Context, deptId);
return Queryable()
.Where(predicate.ToExpression())
.LeftJoin<OdfCables>((f, c) => f.CableId == c.Id)
.Where((f, c) => visibleDeptIds.Contains(c.DeptId))
.Select((f, c) => new OdfCableFaultExportDto
{
Id = f.Id,

View File

@ -14,15 +14,19 @@ namespace ZR.Service.Business
public class OdfCablesService : BaseService<OdfCables>, IOdfCablesService
{
/// <summary>
/// 按 DeptId 过滤光缆列表(支持分页和 CableName 模糊查询)
/// 按 DeptId 过滤光缆列表(支持分页和 CableName 模糊查询),应用部门数据隔离
/// </summary>
/// <param name="parm"></param>
/// <param name="deptId">当前用户所属部门ID用于数据隔离</param>
/// <returns></returns>
public PagedInfo<OdfCables> GetList(OdfCablesQueryDto parm)
public PagedInfo<OdfCables> GetList(OdfCablesQueryDto parm, long deptId)
{
var predicate = Expressionable.Create<OdfCables>();
predicate = predicate.AndIF(parm.DeptId != null, it => it.DeptId == parm.DeptId);
// 部门数据隔离:查询本级及所有下级部门的数据
var visibleDeptIds = DeptDataScopeHelper.GetVisibleDeptIds(Context, deptId);
predicate = predicate.And(it => visibleDeptIds.Contains(it.DeptId));
predicate = predicate.AndIF(!string.IsNullOrEmpty(parm.CableName), it => it.CableName.Contains(parm.CableName));
var response = Queryable()
@ -34,7 +38,7 @@ namespace ZR.Service.Business
}
/// <summary>
/// 在指定公司范围内搜索光缆和故障
/// 在指定公司范围内搜索光缆、故障和标石/杆号牌
/// </summary>
/// <param name="deptId"></param>
/// <param name="keyword"></param>
@ -67,7 +71,24 @@ namespace ZR.Service.Business
})
.ToList();
return new { cables, faults };
// 搜索标石/杆号牌:联查 odf_cables 按 DeptId 过滤,按 Name LIKE keyword
var markerPoles = Context.Queryable<OdfMarkerPoles>()
.LeftJoin<OdfCables>((m, c) => m.CableId == c.Id)
.Where((m, c) => c.DeptId == deptId)
.WhereIF(!string.IsNullOrEmpty(keyword), (m, c) => m.Name.Contains(keyword))
.OrderByDescending((m, c) => m.RecordTime)
.Select((m, c) => new
{
m.Id,
m.Name,
m.RecordTime,
m.Personnel,
m.CableId,
CableName = c.CableName
})
.ToList();
return new { cables, faults, markerPoles };
}
/// <summary>
@ -117,12 +138,13 @@ namespace ZR.Service.Business
/// 导出光缆列表
/// </summary>
/// <param name="parm"></param>
/// <param name="deptId">当前用户所属部门ID用于数据隔离</param>
/// <returns></returns>
public PagedInfo<OdfCables> ExportList(OdfCablesQueryDto parm)
public PagedInfo<OdfCables> ExportList(OdfCablesQueryDto parm, long deptId)
{
parm.PageNum = 1;
parm.PageSize = 100000;
return GetList(parm);
return GetList(parm, deptId);
}
}
}

View File

@ -0,0 +1,64 @@
using Infrastructure.Attribute;
using ZR.Model.Business;
using ZR.Repository;
using ZR.Service.Business.IBusinessService;
namespace ZR.Service.Business
{
/// <summary>
/// 标石/杆号牌图片Service业务层处理
/// </summary>
[AppService(ServiceType = typeof(IOdfMarkerPoleImagesService), ServiceLifetime = LifeTime.Transient)]
public class OdfMarkerPoleImagesService : BaseService<OdfMarkerPoleImages>, IOdfMarkerPoleImagesService
{
/// <summary>
/// 按标石 ID 查询图片列表
/// </summary>
/// <param name="markerPoleId"></param>
/// <returns></returns>
public List<OdfMarkerPoleImages> GetByMarkerPoleId(int markerPoleId)
{
return Queryable()
.Where(x => x.MarkerPoleId == markerPoleId)
.OrderBy(x => x.Id)
.ToList();
}
/// <summary>
/// 批量插入图片记录
/// </summary>
/// <param name="markerPoleId"></param>
/// <param name="imageUrls"></param>
/// <returns></returns>
public int BatchInsert(int markerPoleId, string[] imageUrls)
{
if (imageUrls == null || imageUrls.Length == 0)
{
return 0;
}
var list = imageUrls
.Where(url => !string.IsNullOrWhiteSpace(url))
.Select(url => new OdfMarkerPoleImages
{
MarkerPoleId = markerPoleId,
ImageUrl = url,
CreatedAt = DateTime.Now
}).ToList();
return Insert(list);
}
/// <summary>
/// 按标石 ID 删除所有图片记录
/// </summary>
/// <param name="markerPoleId"></param>
/// <returns></returns>
public int DeleteByMarkerPoleId(int markerPoleId)
{
return Deleteable()
.Where(x => x.MarkerPoleId == markerPoleId)
.ExecuteCommand();
}
}
}

View File

@ -0,0 +1,192 @@
using Infrastructure;
using Infrastructure.Attribute;
using ZR.Model;
using ZR.Model.Business;
using ZR.Model.Business.Dto;
using ZR.Model.System;
using ZR.Repository;
using ZR.Service.Business.IBusinessService;
namespace ZR.Service.Business
{
/// <summary>
/// 标石/杆号牌Service业务层处理
/// </summary>
[AppService(ServiceType = typeof(IOdfMarkerPolesService), ServiceLifetime = LifeTime.Transient)]
public class OdfMarkerPolesService : BaseService<OdfMarkerPoles>, IOdfMarkerPolesService
{
/// <summary>
/// 分页查询标石/杆号牌列表,应用部门数据隔离
/// </summary>
public PagedInfo<OdfMarkerPoleListDto> GetList(OdfMarkerPolesQueryDto parm, long deptId)
{
var predicate = Expressionable.Create<OdfMarkerPoles>();
// 部门数据隔离
var visibleDeptIds = DeptDataScopeHelper.GetVisibleDeptIds(Context, deptId);
predicate = predicate.And(it => visibleDeptIds.Contains(it.DeptId));
// 按 CableId 过滤
predicate = predicate.AndIF(parm.CableId != null, it => it.CableId == parm.CableId);
var total = 0;
var list = Queryable()
.Where(predicate.ToExpression())
.OrderByDescending(it => it.RecordTime)
.ToPageList(parm.PageNum, parm.PageSize, ref total);
var result = list.Select(it => new OdfMarkerPoleListDto
{
Id = it.Id,
CableId = it.CableId,
Name = it.Name,
RecordTime = it.RecordTime,
Personnel = it.Personnel,
Latitude = it.Latitude,
Longitude = it.Longitude,
ActualMileage = it.ActualMileage,
DeptName = it.DeptName
}).ToList();
return new PagedInfo<OdfMarkerPoleListDto>
{
Result = result,
TotalNum = total,
PageIndex = parm.PageNum,
PageSize = parm.PageSize
};
}
/// <summary>
/// 查询标石/杆号牌详情,含图片 URL 列表
/// </summary>
public OdfMarkerPoleDetailDto GetDetail(int id)
{
var entity = GetFirst(it => it.Id == id);
if (entity == null)
{
throw new CustomException("标石记录不存在");
}
var images = Context.Queryable<OdfMarkerPoleImages>()
.Where(img => img.MarkerPoleId == id)
.OrderBy(img => img.Id)
.Select(img => img.ImageUrl)
.ToList();
return new OdfMarkerPoleDetailDto
{
Id = entity.Id,
CableId = entity.CableId,
Name = entity.Name,
RecordTime = entity.RecordTime,
Personnel = entity.Personnel,
Latitude = entity.Latitude,
Longitude = entity.Longitude,
ActualMileage = entity.ActualMileage,
DeptId = entity.DeptId,
DeptName = entity.DeptName,
UserId = entity.UserId,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
ImageUrls = images
};
}
/// <summary>
/// 新增标石/杆号牌,自动填充 DeptId/DeptName
/// </summary>
public int Add(OdfMarkerPoleAddDto dto, long deptId)
{
// 校验 CableId 存在
var cable = Context.Queryable<OdfCables>()
.Where(c => c.Id == dto.CableId)
.First();
if (cable == null)
{
throw new CustomException("光缆不存在");
}
// 校验至少 1 张图片
if (dto.ImageUrls == null || dto.ImageUrls.Length == 0)
{
throw new CustomException("请至少上传一张图片");
}
// 查询部门名称
var dept = Context.Queryable<SysDept>()
.Where(d => d.DeptId == deptId)
.First();
var model = new OdfMarkerPoles
{
CableId = dto.CableId,
Name = dto.Name,
RecordTime = DateTime.Parse(dto.RecordTime),
Personnel = dto.Personnel,
Latitude = dto.Latitude,
Longitude = dto.Longitude,
ActualMileage = dto.ActualMileage,
DeptId = deptId,
DeptName = dept?.DeptName,
UserId = dto.UserId,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
var entity = Insertable(model).ExecuteReturnEntity();
// 插入图片记录
foreach (var imageUrl in dto.ImageUrls)
{
if (string.IsNullOrWhiteSpace(imageUrl)) continue;
var imageRecord = new OdfMarkerPoleImages
{
MarkerPoleId = entity.Id,
ImageUrl = imageUrl,
CreatedAt = DateTime.Now
};
Context.Insertable(imageRecord).ExecuteCommand();
}
return entity.Id;
}
/// <summary>
/// 编辑标石/杆号牌
/// </summary>
public int Update(OdfMarkerPoleEditDto dto)
{
var entity = GetFirst(it => it.Id == dto.Id);
if (entity == null)
{
throw new CustomException("标石记录不存在");
}
entity.Name = dto.Name;
entity.Personnel = dto.Personnel;
entity.Latitude = dto.Latitude;
entity.Longitude = dto.Longitude;
entity.ActualMileage = dto.ActualMileage;
entity.CableId = dto.CableId;
entity.UpdatedAt = DateTime.Now;
return base.Update(entity, true);
}
/// <summary>
/// 删除标石/杆号牌及关联图片
/// </summary>
public int Delete(int id)
{
// 删除关联图片记录
Context.Deleteable<OdfMarkerPoleImages>()
.Where(img => img.MarkerPoleId == id)
.ExecuteCommand();
// 删除标石记录
return base.Delete(id);
}
}
}

View File

@ -0,0 +1,13 @@
import request from '@/utils/request'
/**
* 审计日志列表分页查询
* @param {查询条件} query
*/
export function listOdfAuditLogs(query) {
return request({
url: 'business/OdfAuditLogs/list',
method: 'get',
params: query,
})
}

View File

@ -0,0 +1,59 @@
import request from '@/utils/request'
/**
* 标石/杆号牌列表分页查询
* @param {查询条件} query
*/
export function listOdfMarkerPoles(query) {
return request({
url: 'business/OdfMarkerPoles/list',
method: 'get',
params: query,
})
}
/**
* 获取标石/杆号牌详情
* @param {Id}
*/
export function getOdfMarkerPoles(id) {
return request({
url: 'business/OdfMarkerPoles/' + id,
method: 'get'
})
}
/**
* 新增标石/杆号牌
* @param data
*/
export function addOdfMarkerPoles(data) {
return request({
url: 'business/OdfMarkerPoles/add',
method: 'post',
data: data,
})
}
/**
* 修改标石/杆号牌
* @param data
*/
export function updateOdfMarkerPoles(data) {
return request({
url: 'business/OdfMarkerPoles/edit',
method: 'put',
data: data,
})
}
/**
* 删除标石/杆号牌
* @param {主键} pid
*/
export function delOdfMarkerPoles(pid) {
return request({
url: 'business/OdfMarkerPoles/delete/' + pid,
method: 'delete'
})
}

View File

@ -74,7 +74,7 @@ service.interceptors.response.use(
})
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code == 0 || code == 1 || code == 110 || code == 101 || code == 403 || code == 500 || code == 429) {
} else if (code == 0 || code == 1 || code == 110 || code == 101 || code == 105 || code == 403 || code == 500 || code == 429) {
ElMessage({
message: msg,
type: 'error'

View File

@ -0,0 +1,216 @@
<!--
* @Descripttion: (审计日志/odf_audit_logs)
* @Author: (admin)
* @Date: (2025-08-05)
-->
<template>
<div>
<el-form :model="queryParams" label-position="right" inline ref="queryRef" v-show="showSearch" @submit.prevent>
<el-form-item label="操作类型" prop="operationType">
<el-select v-model="queryParams.operationType" placeholder="全部" clearable style="width: 150px;">
<el-option label="新增" value="INSERT" />
<el-option label="修改" value="UPDATE" />
<el-option label="删除" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="时间范围" prop="dateRange">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="handleQuery">{{ $t('btn.search') }}</el-button>
<el-button icon="refresh" @click="resetQuery">{{ $t('btn.reset') }}</el-button>
</el-form-item>
</el-form>
<!-- 工具区域 -->
<el-row :gutter="15" class="mb10">
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
</el-row>
<el-table
:data="dataList"
v-loading="loading"
ref="table"
border
header-cell-class-name="el-table-header-cell"
highlight-current-row
@sort-change="sortChange">
<el-table-column prop="id" label="Id" align="center" width="80" v-if="columns.showColumn('id')" />
<el-table-column prop="operatorName" label="操作人" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('operatorName')" />
<el-table-column prop="operationTime" label="操作时间" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('operationTime')" />
<el-table-column prop="operationType" label="操作类型" align="center" width="100" v-if="columns.showColumn('operationType')">
<template #default="scope">
<el-tag v-if="scope.row.operationType === 'INSERT'" type="success">新增</el-tag>
<el-tag v-else-if="scope.row.operationType === 'UPDATE'" type="warning">修改</el-tag>
<el-tag v-else-if="scope.row.operationType === 'DELETE'" type="danger">删除</el-tag>
<span v-else>{{ scope.row.operationType }}</span>
</template>
</el-table-column>
<el-table-column prop="sourceClient" label="操作来源" align="center" width="100" v-if="columns.showColumn('sourceClient')">
<template #default="scope">
<el-tag v-if="scope.row.sourceClient === 'App'" type="">App</el-tag>
<el-tag v-else-if="scope.row.sourceClient === 'Admin'" type="info">管理后台</el-tag>
<span v-else>{{ scope.row.sourceClient }}</span>
</template>
</el-table-column>
<el-table-column prop="tableName" label="表名" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('tableName')" />
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button
type="primary"
size="small"
icon="view"
title="查看详情"
@click="handleViewDetail(scope.row)"></el-button>
</template>
</el-table-column>
</el-table>
<pagination :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<!-- 数据对比对话框 -->
<el-dialog title="修改前后数据对比" v-model="detailVisible" width="800px" destroy-on-close>
<el-descriptions :column="2" border class="mb10">
<el-descriptions-item label="操作人">{{ detailRow.operatorName }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ detailRow.operationTime }}</el-descriptions-item>
<el-descriptions-item label="操作类型">{{ detailRow.operationType }}</el-descriptions-item>
<el-descriptions-item label="操作来源">{{ detailRow.sourceClient }}</el-descriptions-item>
<el-descriptions-item label="表名">{{ detailRow.tableName }}</el-descriptions-item>
<el-descriptions-item label="记录ID">{{ detailRow.recordId }}</el-descriptions-item>
</el-descriptions>
<el-row :gutter="20">
<el-col :span="12">
<div class="data-compare-title">修改前数据 (OldData)</div>
<el-input
type="textarea"
:rows="15"
:model-value="formatJson(detailRow.oldData)"
readonly />
</el-col>
<el-col :span="12">
<div class="data-compare-title">修改后数据 (NewData)</div>
<el-input
type="textarea"
:rows="15"
:model-value="formatJson(detailRow.newData)"
readonly />
</el-col>
</el-row>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="odfauditlogs">
import { listOdfAuditLogs } from '@/api/business/odfauditlogs.js'
const { proxy } = getCurrentInstance()
const loading = ref(false)
const showSearch = ref(true)
const detailVisible = ref(false)
const detailRow = ref({})
const dateRange = ref([])
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
sort: undefined,
sortType: undefined,
operationType: undefined,
beginTime: undefined,
endTime: undefined
})
const columns = ref([
{ visible: true, align: 'center', type: '', prop: 'id', label: 'Id' },
{ visible: true, align: 'center', type: '', prop: 'operatorName', label: '操作人', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'operationTime', label: '操作时间', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'operationType', label: '操作类型' },
{ visible: true, align: 'center', type: '', prop: 'sourceClient', label: '操作来源' },
{ visible: true, align: 'center', type: '', prop: 'tableName', label: '表名', showOverflowTooltip: true }
])
const total = ref(0)
const dataList = ref([])
const queryRef = ref()
function getList() {
loading.value = true
//
if (dateRange.value && dateRange.value.length === 2) {
queryParams.beginTime = dateRange.value[0]
queryParams.endTime = dateRange.value[1]
} else {
queryParams.beginTime = undefined
queryParams.endTime = undefined
}
listOdfAuditLogs(queryParams).then((res) => {
const { code, data } = res
if (code == 200) {
dataList.value = data.result
total.value = data.totalNum
loading.value = false
}
})
}
//
function handleQuery() {
queryParams.pageNum = 1
getList()
}
//
function resetQuery() {
dateRange.value = []
proxy.resetForm('queryRef')
handleQuery()
}
//
function sortChange(column) {
var sort = undefined
var sortType = undefined
if (column.prop != null && column.order != null) {
sort = column.prop
sortType = column.order
}
queryParams.sort = sort
queryParams.sortType = sortType
handleQuery()
}
//
function handleViewDetail(row) {
detailRow.value = row
detailVisible.value = true
}
// JSON
function formatJson(jsonStr) {
if (!jsonStr) return '(无数据)'
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
handleQuery()
</script>
<style scoped>
.data-compare-title {
font-weight: bold;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
</style>

View File

@ -0,0 +1,378 @@
<!--
* @Descripttion: (标石/杆号牌管理/odf_marker_poles)
* @Author: (admin)
* @Date: (2025-08-05)
-->
<template>
<div>
<el-form :model="queryParams" label-position="right" inline ref="queryRef" v-show="showSearch" @submit.prevent>
<el-form-item label="名称" prop="keyword">
<el-input v-model="queryParams.keyword" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="所属光缆" prop="cableId">
<el-select
v-model="queryParams.cableId"
filterable
remote
reserve-keyword
clearable
placeholder="请输入光缆名称搜索"
:remote-method="remoteCableSearch"
:loading="cableSearchLoading">
<el-option v-for="item in cableOptions" :key="item.id" :label="item.cableName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="handleQuery">{{ $t('btn.search') }}</el-button>
<el-button icon="refresh" @click="resetQuery">{{ $t('btn.reset') }}</el-button>
</el-form-item>
</el-form>
<!-- 工具区域 -->
<el-row :gutter="15" class="mb10">
<el-col :span="1.5">
<el-button type="primary" v-hasPermi="['odfmarkerpoles:add']" plain icon="plus" @click="handleAdd">
{{ $t('btn.add') }}
</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" :disabled="single" v-hasPermi="['odfmarkerpoles:edit']" plain icon="edit" @click="handleUpdate">
{{ $t('btn.edit') }}
</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" :disabled="multiple" v-hasPermi="['odfmarkerpoles:delete']" plain icon="delete" @click="handleDelete">
{{ $t('btn.delete') }}
</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
</el-row>
<el-table
:data="dataList"
v-loading="loading"
ref="table"
border
header-cell-class-name="el-table-header-cell"
highlight-current-row
@sort-change="sortChange"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="id" label="Id" align="center" v-if="columns.showColumn('id')" />
<el-table-column prop="name" label="名称" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('name')" />
<el-table-column prop="recordTime" label="时间" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('recordTime')" />
<el-table-column prop="personnel" label="责任人" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('personnel')" />
<el-table-column label="经纬度" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('longitude')">
<template #default="scope">
<span v-if="scope.row.longitude || scope.row.latitude">{{ scope.row.longitude }}, {{ scope.row.latitude }}</span>
</template>
</el-table-column>
<el-table-column prop="deptName" label="所属公司" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('deptName')" />
<el-table-column prop="cableName" label="所属光缆" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('cableName')" />
<el-table-column prop="actualMileage" label="实际里程" align="center" :show-overflow-tooltip="true" v-if="columns.showColumn('actualMileage')" />
<el-table-column label="操作" width="160">
<template #default="scope">
<el-button
type="success"
size="small"
icon="edit"
title="编辑"
v-hasPermi="['odfmarkerpoles:edit']"
@click="handleUpdate(scope.row)"></el-button>
<el-button
type="danger"
size="small"
icon="delete"
title="删除"
v-hasPermi="['odfmarkerpoles:delete']"
@click="handleDelete(scope.row)"></el-button>
</template>
</el-table-column>
</el-table>
<pagination :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<!-- 新增/编辑对话框 -->
<el-dialog :title="formTitle" v-model="formVisible" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="责任人" prop="personnel">
<el-input v-model="form.personnel" placeholder="请输入责任人" />
</el-form-item>
<el-form-item label="经度" prop="longitude">
<el-input-number v-model="form.longitude" :precision="7" :step="0.0000001" controls-position="right" placeholder="经度" style="width: 100%;" />
</el-form-item>
<el-form-item label="纬度" prop="latitude">
<el-input-number v-model="form.latitude" :precision="7" :step="0.0000001" controls-position="right" placeholder="纬度" style="width: 100%;" />
</el-form-item>
<el-form-item label="所属光缆" prop="cableId">
<el-select
v-model="form.cableId"
filterable
remote
reserve-keyword
clearable
placeholder="请输入光缆名称搜索"
:remote-method="remoteFormCableSearch"
:loading="formCableSearchLoading"
style="width: 100%;">
<el-option v-for="item in formCableOptions" :key="item.id" :label="item.cableName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="实际里程" prop="actualMileage">
<el-input v-model="form.actualMileage" placeholder="请输入实际里程" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">{{ $t('common.ok') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="odfmarkerpoles">
import { listOdfMarkerPoles, getOdfMarkerPoles, addOdfMarkerPoles, updateOdfMarkerPoles, delOdfMarkerPoles } from '@/api/business/odfmarkerpoles.js'
import { listOdfCables } from '@/api/business/odfcables.js'
const { proxy } = getCurrentInstance()
const ids = ref([])
const loading = ref(false)
const showSearch = ref(true)
const formVisible = ref(false)
const formTitle = ref('')
const submitting = ref(false)
const cableOptions = ref([])
const cableSearchLoading = ref(false)
const formCableOptions = ref([])
const formCableSearchLoading = ref(false)
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
sort: undefined,
sortType: undefined,
keyword: undefined,
cableId: undefined
})
const columns = ref([
{ visible: true, align: 'center', type: '', prop: 'id', label: 'Id' },
{ visible: true, align: 'center', type: '', prop: 'name', label: '名称', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'recordTime', label: '时间', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'personnel', label: '责任人', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'longitude', label: '经纬度', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'deptName', label: '所属公司', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'cableName', label: '所属光缆', showOverflowTooltip: true },
{ visible: true, align: 'center', type: '', prop: 'actualMileage', label: '实际里程', showOverflowTooltip: true }
])
const total = ref(0)
const dataList = ref([])
const queryRef = ref()
const formRef = ref()
const data = reactive({
form: {
id: undefined,
name: undefined,
personnel: undefined,
longitude: 0,
latitude: 0,
cableId: undefined,
actualMileage: undefined
},
rules: {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
cableId: [{ required: true, message: '请选择所属光缆', trigger: 'change' }]
}
})
const { form, rules } = toRefs(data)
const state = reactive({
single: true,
multiple: true
})
const { single, multiple } = toRefs(state)
//
function remoteCableSearch(query) {
if (query) {
cableSearchLoading.value = true
listOdfCables({ cableName: query, pageNum: 1, pageSize: 50 }).then((res) => {
if (res.code == 200) {
cableOptions.value = res.data.result || []
}
cableSearchLoading.value = false
}).catch(() => {
cableSearchLoading.value = false
})
} else {
cableOptions.value = []
}
}
//
function remoteFormCableSearch(query) {
if (query) {
formCableSearchLoading.value = true
listOdfCables({ cableName: query, pageNum: 1, pageSize: 50 }).then((res) => {
if (res.code == 200) {
formCableOptions.value = res.data.result || []
}
formCableSearchLoading.value = false
}).catch(() => {
formCableSearchLoading.value = false
})
} else {
formCableOptions.value = []
}
}
function getList() {
loading.value = true
listOdfMarkerPoles(queryParams).then((res) => {
const { code, data } = res
if (code == 200) {
dataList.value = data.result
total.value = data.totalNum
loading.value = false
}
})
}
//
function handleQuery() {
queryParams.pageNum = 1
getList()
}
//
function resetQuery() {
proxy.resetForm('queryRef')
handleQuery()
}
//
function handleSelectionChange(selection) {
ids.value = selection.map((item) => item.id)
state.single = selection.length != 1
state.multiple = !selection.length
}
//
function sortChange(column) {
var sort = undefined
var sortType = undefined
if (column.prop != null && column.order != null) {
sort = column.prop
sortType = column.order
}
queryParams.sort = sort
queryParams.sortType = sortType
handleQuery()
}
//
function resetForm() {
data.form = {
id: undefined,
name: undefined,
personnel: undefined,
longitude: 0,
latitude: 0,
cableId: undefined,
actualMileage: undefined
}
formCableOptions.value = []
}
//
function handleAdd() {
resetForm()
formTitle.value = '新增标石/杆号牌'
formVisible.value = true
}
//
function handleUpdate(row) {
resetForm()
const id = row?.id || ids.value[0]
getOdfMarkerPoles(id).then((res) => {
if (res.code == 200) {
const d = res.data
data.form = {
id: d.id,
name: d.name,
personnel: d.personnel,
longitude: d.longitude,
latitude: d.latitude,
cableId: d.cableId,
actualMileage: d.actualMileage
}
//
if (d.cableId && d.cableName) {
formCableOptions.value = [{ id: d.cableId, cableName: d.cableName }]
}
formTitle.value = '编辑标石/杆号牌'
formVisible.value = true
}
})
}
//
function submitForm() {
proxy.$refs['formRef'].validate((valid) => {
if (valid) {
submitting.value = true
if (form.value.id) {
updateOdfMarkerPoles(form.value).then((res) => {
if (res.code == 200) {
proxy.$modal.msgSuccess('修改成功')
formVisible.value = false
getList()
}
submitting.value = false
}).catch(() => {
submitting.value = false
})
} else {
addOdfMarkerPoles(form.value).then((res) => {
if (res.code == 200) {
proxy.$modal.msgSuccess('新增成功')
formVisible.value = false
getList()
}
submitting.value = false
}).catch(() => {
submitting.value = false
})
}
}
})
}
//
function handleDelete(row) {
const Ids = row.id || ids.value
proxy
.$confirm('是否确认删除参数编号为"' + Ids + '"的数据项?', '警告', {
confirmButtonText: proxy.$t('common.ok'),
cancelButtonText: proxy.$t('common.cancel'),
type: 'warning'
})
.then(function () {
return delOdfMarkerPoles(Ids)
})
.then(() => {
getList()
proxy.$modal.msgSuccess('删除成功')
})
}
handleQuery()
</script>

View File

@ -0,0 +1,15 @@
-- =============================================
-- 创建故障频次时间记录表
-- 版本: v1.0.2.1
-- =============================================
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'odf_cable_fault_times')
BEGIN
CREATE TABLE odf_cable_fault_times (
Id INT IDENTITY(1,1) PRIMARY KEY,
FaultId INT NOT NULL, -- 关联故障 odf_cable_faults.Id
FaultTime DATETIME NOT NULL, -- 故障发生时间
CreatedAt DATETIME NOT NULL DEFAULT GETDATE() -- 创建时间
);
END
GO

View File

@ -0,0 +1,26 @@
-- =============================================
-- ODF v1.2.0 - 创建标石/杆号牌表 odf_marker_poles
-- 需求: 2.1, 4.8, 6.1
-- =============================================
CREATE TABLE odf_marker_poles (
Id INT IDENTITY(1,1) PRIMARY KEY, -- 主键
CableId INT NOT NULL, -- 关联光缆ID (odf_cables.Id)
Name NVARCHAR(200) NOT NULL, -- 标石/杆号牌名称
RecordTime DATETIME NOT NULL, -- 记录时间(拍照时间)
Personnel NVARCHAR(100) NULL, -- 责任人
Latitude DECIMAL(10,7) DEFAULT 0, -- 纬度
Longitude DECIMAL(10,7) DEFAULT 0, -- 经度
ActualMileage NVARCHAR(100) NULL, -- 实际里程
DeptId BIGINT NOT NULL, -- 所属公司/部门ID
DeptName NVARCHAR(200) NULL, -- 部门名称(冗余)
UserId BIGINT NULL, -- 提交人用户ID
CreatedAt DATETIME DEFAULT GETDATE(), -- 创建时间
UpdatedAt DATETIME DEFAULT GETDATE() -- 更新时间
);
-- 索引按光缆ID查询标石列表
CREATE INDEX IX_odf_marker_poles_CableId ON odf_marker_poles(CableId);
-- 索引按部门ID进行数据隔离查询
CREATE INDEX IX_odf_marker_poles_DeptId ON odf_marker_poles(DeptId);

View File

@ -0,0 +1,14 @@
-- =============================================
-- ODF v1.2.0 - 创建标石/杆号牌图片表 odf_marker_pole_images
-- 需求: 2.1, 4.8, 6.1
-- =============================================
CREATE TABLE odf_marker_pole_images (
Id INT IDENTITY(1,1) PRIMARY KEY,
MarkerPoleId INT NOT NULL, -- 关联标石ID (odf_marker_poles.Id)
ImageUrl NVARCHAR(500) NOT NULL, -- COS 图片 URL
CreatedAt DATETIME DEFAULT GETDATE()
);
-- 索引按标石ID查询图片
CREATE INDEX IX_odf_marker_pole_images_MarkerPoleId ON odf_marker_pole_images(MarkerPoleId);

View File

@ -0,0 +1,28 @@
-- =============================================
-- ODF v1.2.0 - 创建审计日志表 odf_audit_logs
-- 需求: 8.1, 8.2
-- =============================================
CREATE TABLE odf_audit_logs (
Id INT IDENTITY(1,1) PRIMARY KEY,
TableName NVARCHAR(100) NOT NULL, -- 操作的表名
RecordId INT NULL, -- 操作的记录ID
OperationType NVARCHAR(20) NOT NULL, -- 操作类型: INSERT/UPDATE/DELETE
OperatorId BIGINT NOT NULL, -- 操作人用户ID
OperatorName NVARCHAR(100) NULL, -- 操作人用户名
SourceClient NVARCHAR(20) NOT NULL, -- 操作来源: App/Admin
OldData NVARCHAR(MAX) NULL, -- 修改前数据 (JSON)
NewData NVARCHAR(MAX) NULL, -- 修改后数据 (JSON)
OperationTime DATETIME DEFAULT GETDATE(), -- 操作时间
DeptId BIGINT NULL, -- 操作人所属部门
Remark NVARCHAR(500) NULL -- 备注
);
-- 索引:按操作时间范围查询
CREATE INDEX IX_odf_audit_logs_OperationTime ON odf_audit_logs(OperationTime);
-- 索引:按表名筛选
CREATE INDEX IX_odf_audit_logs_TableName ON odf_audit_logs(TableName);
-- 索引:按操作类型筛选
CREATE INDEX IX_odf_audit_logs_OperationType ON odf_audit_logs(OperationType);

View File

@ -0,0 +1,62 @@
-- =============================================
-- ODF v1.2.0 - 标石/杆号牌管理 & 审计日志 菜单权限
-- 需求: 6.1, 8.1
-- MenuId 从 11230 开始,避免与 v1.0.2.1 (11224) 冲突
-- =============================================
SET IDENTITY_INSERT sys_menu ON;
-- =============================================
-- 1. 标石/杆号牌管理(一级菜单)
-- =============================================
INSERT INTO sys_menu (MenuId, MenuName, ParentId, OrderNum, Path, Component, IsCache, IsFrame, MenuType, Visible, Status, Perms, Icon, Create_by, Create_time)
VALUES (11230, N'标石杆号牌管理', 0, 9, 'OdfMarkerPoles', 'business/OdfMarkerPoles', 0, 0, 'C', '0', '0', 'odfmarkerpoles:list', 'icon1', 'admin', GETDATE());
-- 标石/杆号牌管理 - 按钮权限
INSERT INTO sys_menu (MenuId, MenuName, ParentId, OrderNum, MenuType, Perms, Status, IsFrame, IsCache, Visible, Create_by, Create_time)
VALUES (11231, N'查询', 11230, 1, 'F', 'odfmarkerpoles:query', 0, 1, 0, '0', 'admin', GETDATE());
INSERT INTO sys_menu (MenuId, MenuName, ParentId, OrderNum, MenuType, Perms, Status, IsFrame, IsCache, Visible, Create_by, Create_time)
VALUES (11232, N'新增', 11230, 2, 'F', 'odfmarkerpoles:add', 0, 1, 0, '0', 'admin', GETDATE());
INSERT INTO sys_menu (MenuId, MenuName, ParentId, OrderNum, MenuType, Perms, Status, IsFrame, IsCache, Visible, Create_by, Create_time)
VALUES (11233, N'修改', 11230, 3, 'F', 'odfmarkerpoles:edit', 0, 1, 0, '0', 'admin', GETDATE());
INSERT INTO sys_menu (MenuId, MenuName, ParentId, OrderNum, MenuType, Perms, Status, IsFrame, IsCache, Visible, Create_by, Create_time)
VALUES (11234, N'删除', 11230, 4, 'F', 'odfmarkerpoles:delete', 0, 1, 0, '0', 'admin', GETDATE());
-- =============================================
-- 2. 审计日志/修改统计(一级菜单)
-- =============================================
INSERT INTO sys_menu (MenuId, MenuName, ParentId, OrderNum, Path, Component, IsCache, IsFrame, MenuType, Visible, Status, Perms, Icon, Create_by, Create_time)
VALUES (11240, N'修改统计', 0, 10, 'OdfAuditLogs', 'business/OdfAuditLogs', 0, 0, 'C', '0', '0', 'odfauditlogs:list', 'icon1', 'admin', GETDATE());
SET IDENTITY_INSERT sys_menu OFF;
-- =============================================
-- 角色菜单权限分配
-- Role 2 (common): 标石全部权限 + 审计日志查看
-- Role 3 (editor): 标石查询 + 审计日志查看
-- Role 4 (lock): 标石查询 + 审计日志查看
-- =============================================
-- Role 2 (common) — 标石/杆号牌管理:全部权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (2, 11230);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (2, 11231);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (2, 11232);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (2, 11233);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (2, 11234);
-- Role 2 (common) — 审计日志:查看权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (2, 11240);
-- Role 3 (editor) — 标石/杆号牌管理:菜单 + 查询
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (3, 11230);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (3, 11231);
-- Role 3 (editor) — 审计日志:查看权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (3, 11240);
-- Role 4 (lock) — 标石/杆号牌管理:菜单 + 查询
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (4, 11230);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (4, 11231);
-- Role 4 (lock) — 审计日志:查看权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (4, 11240);

View File

@ -0,0 +1,54 @@
-- =============================================
-- ODF v1.2.0 - 新增「分公司管理员」角色
-- 需求: 11.1
-- 角色权限:查看本公司及下属公司数据,管理本公司及下级公司人员账号
-- dataScope = 3 (本部门数据权限),后端通过 DeptDataScopeHelper 扩展为本级+下级
-- =============================================
SET IDENTITY_INSERT sys_role ON;
INSERT INTO sys_role (roleId, roleName, roleKey, roleSort, dataScope, menu_check_strictly, dept_check_strictly, status, delFlag, create_by, create_time, remark)
VALUES (5, N'分公司管理员', 'branch_admin', 5, 3, 1, 1, 0, 0, 'admin', GETDATE(), N'分公司级别管理角色,可管理本公司及下属公司人员账号');
SET IDENTITY_INSERT sys_role OFF;
-- =============================================
-- 分公司管理员菜单权限分配
-- 拥有所有 ODF 业务菜单的全部权限 + 审计日志查看
-- =============================================
-- 光缆管理:全部权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11190);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11191);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11192);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11193);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11194);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11195);
-- 干线故障管理:全部权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11200);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11201);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11202);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11203);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11223);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11224);
-- 签到记录管理:全部权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11210);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11211);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11212);
-- 用户模块权限:全部权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11220);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11221);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11222);
-- 标石/杆号牌管理:全部权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11230);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11231);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11232);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11233);
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11234);
-- 审计日志:查看权限
INSERT INTO sys_role_menu (Role_id, Menu_id) VALUES (5, 11240);