feat: complete Flutter to UniApp rewrite

This commit is contained in:
zpc 2026-03-03 11:47:35 +08:00
parent 3cd14f7408
commit 3257099db1
39 changed files with 4347 additions and 0 deletions

View File

@ -0,0 +1 @@
{"specId": "8912b026-4bcf-48e3-ad23-2f417cfc5c2b", "workflowType": "requirements-first", "specType": "feature"}

View File

@ -0,0 +1,734 @@
# 技术设计文档:绥时录 — Flutter 到 UniApp 重写
## 概述
本设计文档描述将"绥时录"ODF 端口管理系统从 Flutter 重写为 UniApp (Vue 3) 的技术方案。应用管理公司 → 地区 → 机房 → 机架(ODF) → 端口的层级数据,支持端口状态查看、备注编辑、搜索、历史故障记录等功能。
UniApp 项目基于 Vue 3 Composition API使用条件编译兼容多端App、H5、小程序目标目录为 `odf-uniapp/`
### 关键设计决策
1. **Vue 3 Composition API**manifest.json 已配置 `"vueVersion": "3"`,使用 `<script setup>` 语法简化代码
2. **无 Vuex/Pinia**:应用状态简单,使用 `reactive` 对象 + 模块导出实现轻量级全局状态管理
3. **uni.request 封装**:不引入 axios直接封装 `uni.request` 实现 API 通信层
4. **图片资源**:从 Flutter 项目 `app/assets/images/` 直接复制到 `odf-uniapp/static/images/`
5. **弹窗组件**:使用 `uni.navigateTo` + 页面方式或 `<uni-popup>` 组件实现Port_Edit_Dialog 和 Add_Note_Dialog 作为子组件嵌入页面
## 架构
### 整体架构图
```mermaid
graph TB
subgraph 页面层 Pages
StartPage[启动页]
LoginPage[登录页]
CompanyList[公司列表/首页]
RegionList[地区列表]
RoomList[机房列表]
RackList[机架列表]
RackDetail[机架详情]
SearchPage[搜索页]
SettingsPage[设置页]
ChangePassword[修改密码页]
end
subgraph 组件层 Components
PortEditDialog[端口编辑弹窗]
AddNoteDialog[添加备注弹窗]
UpdateDialog[版本更新弹窗]
NavBar[自定义导航栏]
StatusDot[状态圆点]
ListCard[列表卡片]
end
subgraph 服务层 Services
API[api.js - HTTP 请求封装]
Auth[auth.js - 认证管理]
Storage[storage.js - 本地存储]
end
subgraph 状态层 Store
AppStore[store/index.js - 全局状态]
end
页面层 --> 组件层
页面层 --> 服务层
页面层 --> 状态层
组件层 --> 服务层
服务层 --> Storage
```
### 导航流程图
```mermaid
graph LR
Start[启动页] -->|Token有效| Home[公司列表]
Start -->|Token无效/401| Login[登录页]
Login -->|登录成功| Home
Home --> Region[地区列表]
Home --> Search[搜索页]
Home --> Settings[设置页]
Region --> Room[机房列表]
Room --> Rack[机架列表]
Rack --> Detail[机架详情]
Detail -->|点击端口| PortEdit[端口编辑弹窗]
PortEdit -->|添加备注| AddNote[添加备注弹窗]
Settings --> ChangePwd[修改密码]
Settings -->|退出登录| Login
Search -->|点击机房| Rack
Search -->|点击端口| Detail
```
## 组件与接口
### UniApp 项目目录结构
```
odf-uniapp/
├── pages/
│ ├── start/index.vue # 启动页
│ ├── login/index.vue # 登录页
│ ├── home/index.vue # 首页(公司列表)
│ ├── region/index.vue # 地区列表
│ ├── room/index.vue # 机房列表
│ ├── rack/index.vue # 机架列表
│ ├── rack-detail/index.vue # 机架详情
│ ├── search/index.vue # 搜索页
│ ├── settings/index.vue # 设置页
│ └── change-password/index.vue # 修改密码页
├── components/
│ ├── port-edit-dialog.vue # 端口编辑弹窗
│ ├── add-note-dialog.vue # 添加备注弹窗
│ └── update-dialog.vue # 版本更新弹窗
├── services/
│ ├── api.js # HTTP 请求封装
│ ├── auth.js # 认证相关 API
│ ├── home.js # 首页相关 API
│ ├── machine.js # 机房/机架/端口 API
│ └── search.js # 搜索 API
├── store/
│ └── index.js # 全局状态管理
├── static/
│ └── images/ # 图片资源(从 Flutter 复制)
│ ├── home_bg.png
│ ├── login_bg.png
│ ├── ic_back.png
│ ├── ic_refresh.png
│ ├── ic_search.png
│ ├── ic_set.png
│ ├── ic_exit.png
│ └── ic_update.png
├── App.vue
├── main.js
├── pages.json # 路由配置
├── manifest.json
├── uni.scss # 全局样式变量
└── index.html
```
### 路由配置 (pages.json)
```json
{
"pages": [
{
"path": "pages/start/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/login/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/home/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "", "enablePullDownRefresh": true }
},
{
"path": "pages/region/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/room/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "", "enablePullDownRefresh": true }
},
{
"path": "pages/rack/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "", "enablePullDownRefresh": true }
},
{
"path": "pages/rack-detail/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/search/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/settings/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/change-password/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "绥时录",
"navigationBarBackgroundColor": "#1A73EC",
"backgroundColor": "#F5F5F5"
}
}
```
说明:所有页面使用 `"navigationStyle": "custom"` 自定义导航栏,以匹配 Flutter 原版的 UI 风格(自定义返回按钮、标题、右侧操作按钮)。启动页为 pages 数组第一项,应用启动时自动加载。
### API 通信层设计 (services/api.js)
```javascript
// services/api.js
import store from '@/store'
const BASE_URL = 'http://49.233.115.141:11082'
const TIMEOUT = 20000
/**
* 统一请求封装
* @param {string} method - GET | POST
* @param {string} url - 接口路径
* @param {object} data - 请求参数
* @returns {Promise<{code, msg, data}>}
*/
export function request(method, url, data = {}) {
return new Promise((resolve, reject) => {
const header = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${store.token}`,
'Userid': store.userId,
'Username': store.userName
}
uni.request({
url: BASE_URL + url,
method,
data: method === 'GET' ? undefined : data,
// GET 参数通过 data 字段传递UniApp 会自动拼接为 queryString
...(method === 'GET' ? { data } : {}),
header,
timeout: TIMEOUT,
success(res) {
const { code, msg, data: resData } = res.data
resolve({ code, msg, data: resData })
},
fail(err) {
reject({ code: -1, msg: err.errMsg || '网络异常' })
}
})
})
}
export const get = (url, params) => request('GET', url, params)
export const post = (url, data) => request('POST', url, data)
```
### API 接口模块
```javascript
// services/auth.js
import { get, post } from './api'
export const appLogin = (username, password) => post('/appLogin', { username, password })
export const checkPermission = () => get('/business/OdfPorts/odf')
export const updatePassword = (oldPassword, newPassword) =>
post('/system/user/profile/updateUserPwd', { oldPassword, newPassword })
```
```javascript
// services/home.js
import { get } from './api'
export const getCompanyList = () => get('/business/OdfRooms/getcompany')
export const getDictUnitType = () => get('/system/dict/data/type/odf_ports_unit_type')
export const getDictBusinessType = () => get('/system/dict/data/type/odf_ports_business_type')
export const checkAppVersion = (version) => get('/webapi/CheckAppVersion', { version })
```
```javascript
// services/machine.js
import { get, post } from './api'
export const getRegionList = (deptId) => get('/business/OdfRooms/getregion', { deptId })
export const getRoomList = (pageNum, pageSize, deptId) =>
get('/business/OdfRooms/list', { pageNum, pageSize, deptId })
export const getRackList = (pageNum, pageSize, roomId) =>
get('/business/OdfRacks/list', { pageNum, pageSize, roomId })
export const getRackDetail = (RackId) => get('/business/OdfPorts/mlist', { RackId })
export const getPortDetail = (id) => get(`/business/OdfPorts/${id}`)
export const savePort = (data) => post('/business/OdfPorts/save', data)
```
```javascript
// services/search.js
import { get } from './api'
export const searchPorts = (key, pageNum, pageSize) =>
get('/business/OdfPorts/search2', { key, pageNum, pageSize })
```
### 全局状态管理 (store/index.js)
使用 Vue 3 `reactive` 实现轻量级状态管理,无需引入 Vuex/Pinia
```javascript
// store/index.js
import { reactive } from 'vue'
const store = reactive({
// 认证信息
token: uni.getStorageSync('token') || '',
userId: uni.getStorageSync('userId') || '',
userName: uni.getStorageSync('userName') || '',
isPermission: false,
// 字典数据
dictUnitTypes: [], // 设备型号列表
dictBusinessTypes: [], // 业务类型列表
// 设置认证信息
setAuth(token, userId, userName) {
this.token = token
this.userId = userId
this.userName = userName
uni.setStorageSync('token', token)
uni.setStorageSync('userId', userId)
uni.setStorageSync('userName', userName)
},
// 清除认证信息
clearAuth() {
this.token = ''
this.userId = ''
this.userName = ''
this.isPermission = false
uni.removeStorageSync('token')
uni.removeStorageSync('userId')
uni.removeStorageSync('userName')
}
})
export default store
```
### 组件设计
#### 1. 端口编辑弹窗 (port-edit-dialog.vue)
- Props: `visible` (Boolean), `portId` (String/Number)
- Events: `@close`, `@saved`
- 行为:打开时调用 `getPortDetail(portId)` 加载数据,根据 `store.isPermission` 控制编辑/只读模式
- 内部状态:`remarks`, `opticalAttenuation`, `opticalCableOffRemarks`, `historyFault[]`, `status`
- 子组件交互:点击"添加备注"按钮显示 AddNoteDialog回调将格式化文本写入 remarks
#### 2. 添加备注弹窗 (add-note-dialog.vue)
- Props: `visible` (Boolean)
- Events: `@close`, `@confirm(formattedText)`
- 内部状态:`businessName`, `selectedUnitType`, `selectedBusinessType`, `portNumber1/2/3`
- 格式化输出:`"{业务名称} {设备型号} {业务类型} {端口号1}/{端口号2}/{端口号3}"`
- 下拉选项来源:`store.dictUnitTypes` 和 `store.dictBusinessTypes`
#### 3. 版本更新弹窗 (update-dialog.vue)
- Props: `visible` (Boolean), `downloadUrl` (String)
- 行为:模态弹窗,不可通过返回键或点击空白关闭
- 点击"去更新"调用 `plus.runtime.openURL(downloadUrl)``window.open(downloadUrl)`
## 数据模型
基于 Flutter 源码中的 Bean 类,定义 UniApp 中使用的 JavaScript 数据结构:
### 认证相关
```javascript
// LoginResult
{
jwt: String, // JWT Token
userId: Number, // 用户 ID
userName: String // 用户名
}
```
### 公司/地区
```javascript
// Company / Region共用结构
{
deptId: Number, // 部门/地区 ID
deptName: String // 部门/地区名称
}
```
### 机房
```javascript
// Room分页列表项
{
id: Number,
deptId: Number,
deptName: String,
roomName: String,
roomAddress: String,
racksCount: Number, // ODF 机架数量
remarks: String,
createdAt: String,
updatedAt: String
}
// RoomPageResult分页响应
{
pageSize: Number,
pageIndex: Number,
totalNum: Number,
totalPage: Number,
result: Room[]
}
```
### 机架
```javascript
// Rack分页列表项
{
id: Number,
roomId: Number,
sequenceNumber: Number,
rackName: String,
frameCount: Number,
createdAt: String,
updatedAt: String
}
// RackPageResult分页响应
{
pageSize: Number,
pageIndex: Number,
totalNum: Number,
totalPage: Number,
result: Rack[]
}
```
### 机架详情(端口矩阵)
```javascript
// Frame机架详情接口返回的 Frame 分组)
{
id: Number,
name: String, // Frame 名称
odfPortsList: PortRow[]
}
// PortRow端口行每行包含多个端口
{
name: String, // 行名称
rowList: PortItem[]
}
// PortItem单个端口
{
id: Number,
name: String, // 端口名称
status: Number, // 0=已断开, 1=已连接
tips: String // 端口提示文字(显示在圆形内)
}
```
### 端口详情
```javascript
// PortDetail端口编辑弹窗使用
{
id: Number,
name: String,
roomId: Number,
roomName: String,
rackId: Number,
rackName: String,
frameId: Number,
frameName: String,
deptId: Number,
rowNumber: Number,
portNumber: Number,
status: Number, // 0=已断开, 1=已连接
remarks: String, // 备注说明
opticalAttenuation: String, // 光衰信息
opticalCableOffRemarks: String, // 光缆段信息
historyRemarks: String, // 历史障碍原因(旧字段)
historyFault: HistoryFault[], // 历史障碍记录列表
createdAt: String,
updatedAt: String,
statusLabel: String,
deptName: String,
equipmentModel: String,
businessType: String
}
// HistoryFault
{
faultTime: String, // 障碍发生时间 "yyyy-MM-dd HH:mm:ss"
faultReason: String // 障碍原因
}
```
### 搜索结果
```javascript
// SearchResult搜索接口返回
{
rooms: SearchRoom[], // 匹配的机房列表
ports: PortPageResult // 匹配的端口分页数据
}
// SearchRoom
{
roomId: Number,
roomName: String,
roomAddress: String,
remarks: String,
deptName: String
}
// SearchPort端口搜索结果项
{
id: Number,
name: String,
roomId: Number,
roomName: String,
rackId: Number,
rackName: String,
frameId: Number,
frameName: String,
rowNumber: Number,
portNumber: Number,
status: Number,
remarks: String,
opticalAttenuation: String,
opticalCableOffRemarks: String,
historyRemarks: String,
address: String
}
// PortPageResult
{
pageSize: Number,
pageIndex: Number,
totalNum: Number,
totalPage: Number,
result: SearchPort[]
}
```
### 字典数据
```javascript
// DictItem
{
dictCode: Number,
dictSort: Number,
dictLabel: String,
dictValue: String
}
```
### 版本更新
```javascript
// UpdateInfo
{
needUpdate: Boolean,
forceUpdate: Boolean,
latestVersion: String,
downloadUrl: String,
updateDescription: String
}
```
### 端口保存请求
```javascript
// PortSaveRequest提交到 POST /business/OdfPorts/save
{
Id: Number,
Status: Number, // 0 或 1
Remarks: String,
OpticalAttenuation: String,
HistoryRemarks: String,
HistoryFault: HistoryFault[],
OpticalCableOffRemarks: String
}
```
## 正确性属性 (Correctness Properties)
*属性Property是指在系统所有有效执行中都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
### Property 1: 认证数据持久化往返 (Auth Data Persistence Round-Trip)
*For any* 有效的认证数据token、userId、userName调用 `store.setAuth(token, userId, userName)` 存储后,从 `uni.getStorageSync` 读取的值应与原始输入完全一致。
**Validates: Requirements 1.1, 2.3**
### Property 2: 登录请求包含凭证
*For any* 非空的用户名和密码字符串对,调用登录接口时发送的请求体应同时包含 `username``password` 字段,且值与输入一致。
**Validates: Requirements 2.2**
### Property 3: 分页状态管理
*For any* 分页列表(机房、机架、搜索结果),当前页码为 N 且存在下一页时,触发加载更多操作后,请求的 pageNum 应为 N+1且新数据应追加到现有列表末尾而非替换列表总长度应等于之前长度加上新返回的数据条数。
**Validates: Requirements 6.5, 7.5, 11.9**
### Property 4: 端口状态颜色映射
*For any* 端口对象,当 `status === 1` 时应映射为绿色(已连接),当 `status === 0` 时应映射为红色(已断开),不存在其他映射结果。
**Validates: Requirements 8.6, 14.3**
### Property 5: 权限控制 UI 状态
*For any* 布尔值 `isPermission`,当 `isPermission` 为 true 时,端口编辑弹窗应显示编辑控件(状态切换按钮、提交按钮、添加备注按钮)且输入框可编辑;当 `isPermission` 为 false 时,所有输入框应禁用且仅显示"关闭"按钮。
**Validates: Requirements 9.11, 9.12, 9.13**
### Property 6: 历史障碍记录时间校验
*For any* 历史障碍记录列表,若列表中存在任一条目的 `faultTime` 为空字符串或仅包含空白字符,则提交操作应被阻止并返回校验失败。
**Validates: Requirements 9.15**
### Property 7: 备注格式化字符串
*For any* 有效的业务名称、设备型号、业务类型和三组端口号,格式化输出应严格匹配模式 `"{业务名称} {设备型号} {业务类型} {端口号1}/{端口号2}/{端口号3}"`,各字段之间以空格分隔,端口号之间以斜杠分隔。
**Validates: Requirements 10.9**
### Property 8: 添加备注表单校验
*For any* 业务名称和三组端口号的输入组合,当业务名称为空或任一端口号为空时,提交应被阻止;当所有必填字段均非空时,提交应被允许。
**Validates: Requirements 10.6**
### Property 9: API 响应解析
*For any* 包含 `code`、`msg`、`data` 字段的有效 JSON 响应对象API 通信层解析后返回的结果应包含这三个字段且值与原始响应一致。
**Validates: Requirements 13.4**
### Property 10: API 异常处理
*For any* 网络请求异常超时、连接失败等API 通信层应返回包含 `code: -1` 和非空错误描述 `msg` 的错误对象,不应抛出未捕获异常。
**Validates: Requirements 13.5**
### Property 11: API 请求头构造
*For any* 已认证状态下的 API 请求,请求头应包含 `Authorization`(格式为 `Bearer {token}`)、`Userid` 和 `Username` 三个字段,且值与当前全局状态中存储的认证信息一致。
**Validates: Requirements 13.2**
### Property 12: 默认分页大小
*For any* 分页列表的首次加载请求,`pageSize` 参数应为 20。
**Validates: Requirements 14.8**
### Property 13: 退出登录清除认证状态
*For any* 已登录状态token 非空),执行退出登录操作后,`store.token`、`store.userId`、`store.userName` 应为空字符串,且 `uni.getStorageSync('token')` 应返回空值。
**Validates: Requirements 12.4**
## 错误处理
### 网络层错误处理
| 错误场景 | 处理方式 |
|---------|---------|
| 网络连接失败 | 返回 `{ code: -1, msg: '网络异常' }`,页面显示 Toast 提示 |
| 请求超时20s | 同上 |
| 服务器返回 401 | 清除本地 Token跳转登录页 |
| 服务器返回 403 | 设置 `isPermission = false`,继续正常使用(只读模式) |
| 服务器返回其他非 200 | 显示服务器返回的 `msg` 作为 Toast 提示 |
### 表单校验错误处理
| 校验场景 | 提示消息 |
|---------|---------|
| 登录 - 账号/密码为空 | "请输入账号" / "请输入密码" |
| 添加备注 - 业务名称为空 | "请输入业务名称" |
| 添加备注 - 端口号为空 | "请输入端口号" |
| 端口保存 - 障碍时间未选择 | "请选择障碍发生时间!" |
| 修改密码 - 旧密码为空 | "请输入旧密码!" |
| 修改密码 - 新密码为空 | "请输入新密码!" |
### 分页加载边界处理
- 当 `pageIndex >= totalPage` 时,不再触发加载更多请求
- 下拉刷新时重置 `pageNum = 1`,清空现有列表后重新加载
- 加载中状态下不重复触发请求(防抖)
## 测试策略
### 双重测试方法
本项目采用单元测试 + 属性测试的双重测试策略:
- **单元测试**:验证具体示例、边界情况和错误条件
- **属性测试**:验证跨所有输入的通用属性
两者互补,单元测试捕获具体 bug属性测试验证通用正确性。
### 属性测试库
使用 [fast-check](https://github.com/dubzzz/fast-check) 作为 JavaScript 属性测试库,配合 Jest 或 Vitest 测试框架。
### 属性测试配置
- 每个属性测试最少运行 **100 次迭代**
- 每个属性测试必须以注释引用设计文档中的属性编号
- 标签格式:**Feature: flutter-to-uniapp-rewrite, Property {number}: {property_text}**
- 每个正确性属性由**单个**属性测试实现
### 测试范围
#### 属性测试Property-Based Tests
| 属性 | 测试文件 | 说明 |
|-----|---------|------|
| P1: 认证数据持久化往返 | `tests/store.test.js` | 生成随机 token/userId/userName验证存储往返 |
| P2: 登录请求包含凭证 | `tests/api.test.js` | 生成随机用户名密码,验证请求体 |
| P3: 分页状态管理 | `tests/pagination.test.js` | 生成随机页码和数据,验证追加逻辑 |
| P4: 端口状态颜色映射 | `tests/utils.test.js` | 生成随机 status 值0/1验证颜色映射 |
| P5: 权限控制 UI 状态 | `tests/permission.test.js` | 生成随机布尔值,验证 UI 元素可见性 |
| P6: 历史障碍记录时间校验 | `tests/validation.test.js` | 生成随机障碍记录列表,验证校验逻辑 |
| P7: 备注格式化字符串 | `tests/format.test.js` | 生成随机业务信息,验证格式化输出 |
| P8: 添加备注表单校验 | `tests/validation.test.js` | 生成随机表单输入,验证校验规则 |
| P9: API 响应解析 | `tests/api.test.js` | 生成随机 JSON 响应,验证解析结果 |
| P10: API 异常处理 | `tests/api.test.js` | 模拟各种异常,验证错误对象格式 |
| P11: API 请求头构造 | `tests/api.test.js` | 生成随机认证信息,验证请求头 |
| P12: 默认分页大小 | `tests/pagination.test.js` | 验证首次加载 pageSize=20 |
| P13: 退出登录清除认证状态 | `tests/store.test.js` | 生成随机认证状态,验证清除逻辑 |
#### 单元测试Unit Tests
| 测试范围 | 测试文件 | 说明 |
|---------|---------|------|
| 启动页路由分发 | `tests/start.test.js` | 测试 token 存在/不存在/401/403/200 各场景 |
| 登录流程 | `tests/login.test.js` | 测试登录成功、失败、错误提示 |
| 版本更新检查 | `tests/update.test.js` | 测试 needUpdate=true/false 场景 |
| 密码修改校验 | `tests/password.test.js` | 测试空密码、成功、状态码 110 场景 |
| 搜索结果分区 | `tests/search.test.js` | 测试机房和端口结果分区展示 |

View File

@ -0,0 +1,243 @@
# 需求文档:绥时录 — Flutter 到 UniApp 重写
## 简介
将现有 Flutter 版本的"绥时录"ODF光纤配线架端口管理系统完全重写为 UniApp 版本。该系统用于管理公司 → 地区 → 机房 → 机架(ODF) → 端口的层级数据,支持端口状态查看、备注编辑、搜索、历史故障记录等功能。目标项目目录为 `odf-uniapp/`
## 术语表
- **Application**: 绥时录 UniApp 移动端应用程序
- **Auth_Module**: 负责用户认证、token 管理和权限校验的模块
- **Navigation_Module**: 负责页面路由和导航的模块
- **API_Client**: 负责与后端服务器通信的 HTTP 请求模块,基础地址为 `http://49.233.115.141:11082`
- **Storage_Module**: 负责本地数据持久化的模块(基于 uni.setStorageSync / uni.getStorageSync
- **Company_List_Page**: 首页,展示公司列表
- **Region_List_Page**: 地区列表页,展示指定公司下的地区
- **Room_List_Page**: 机房列表页,展示指定地区下的机房
- **Rack_List_Page**: 机架列表页,展示指定机房下的 ODF 机架
- **Rack_Detail_Page**: 机架详情页,展示端口矩阵和端口状态
- **Search_Page**: 搜索页,支持按备注关键词搜索机房和端口
- **Settings_Page**: 设置页,提供修改密码和退出登录功能
- **Change_Password_Page**: 修改密码页
- **Login_Page**: 登录页
- **Start_Page**: 启动页,负责 token 检测和路由分发
- **Port_Edit_Dialog**: 端口编辑弹窗,用于查看和编辑端口详细信息
- **Add_Note_Dialog**: 添加备注弹窗,用于快速生成格式化备注
- **Update_Dialog**: 版本更新弹窗,提示用户下载新版本
- **ODF**: 光纤配线架Optical Distribution Frame
- **Port**: ODF 机架上的单个光纤端口
- **Frame**: ODF 机架内的分组单元,每个 Frame 包含多行端口
- **Token**: JWT 格式的用户认证令牌
- **Permission**: 用户对 ODF 端口的编辑权限isPermission 标志)
- **Dict_Data**: 系统字典数据,包括设备型号和业务类型
## 需求
### 需求 1应用启动与认证流程
**用户故事:** 作为用户,我希望应用启动时自动检测登录状态并跳转到正确页面,以便快速进入工作界面。
#### 验收标准
1. WHEN Application 启动时, THE Start_Page SHALL 从 Storage_Module 读取本地存储的 Token
2. WHILE Token 存在且非空, THE Auth_Module SHALL 调用权限检测接口 `GET /business/OdfPorts/odf` 验证 Token 有效性
3. WHEN 权限检测接口返回状态码 200, THE Auth_Module SHALL 设置 Permission 为 true 并将 Navigation_Module 导航至 Company_List_Page
4. WHEN 权限检测接口返回状态码 403, THE Auth_Module SHALL 设置 Permission 为 false 并将 Navigation_Module 导航至 Company_List_Page
5. WHEN 权限检测接口返回状态码 401, THE Navigation_Module SHALL 导航至 Login_Page
6. WHEN Token 不存在或为空, THE Navigation_Module SHALL 直接导航至 Login_Page
7. THE Start_Page SHALL 在页面中央显示应用名称"绥时录"作为启动标识
### 需求 2用户登录
**用户故事:** 作为用户,我希望通过账号密码登录系统,以便获取操作权限。
#### 验收标准
1. THE Login_Page SHALL 提供账号输入框(提示文字"请输入账号")和密码输入框(提示文字"请输入密码",密码模式遮蔽显示)
2. WHEN 用户点击"登录"按钮, THE Auth_Module SHALL 调用 `POST /appLogin` 接口发送 username 和 password 参数
3. WHEN 登录接口返回状态码 200, THE Auth_Module SHALL 从响应数据中提取 jwt、userId、userName将 Token 存储至 Storage_Module并将 userId 和 userName 保存到全局状态
4. WHEN 登录接口返回状态码 200, THE Auth_Module SHALL 调用权限检测接口确认权限后导航至 Company_List_Page
5. IF 登录接口返回非 200 状态码, THEN THE Application SHALL 以 Toast 形式显示服务器返回的错误消息
6. THE Login_Page SHALL 使用背景图片覆盖整个页面,主题色为 #1A73EC
### 需求 3首页公司列表
**用户故事:** 作为用户,我希望在首页看到所有公司列表,以便选择目标公司进入下级管理。
#### 验收标准
1. WHEN Company_List_Page 加载时, THE API_Client SHALL 调用 `GET /business/OdfRooms/getcompany` 获取公司列表数据
2. THE Company_List_Page SHALL 以卡片列表形式展示每个公司的 deptName
3. WHEN 用户点击某个公司卡片, THE Navigation_Module SHALL 携带该公司的 deptId 导航至 Region_List_Page
4. THE Company_List_Page SHALL 支持下拉刷新,刷新时重新调用公司列表接口
5. THE Company_List_Page SHALL 在顶部栏左侧显示刷新图标、中间显示"公司列表"标题、右侧显示设置图标
6. WHEN 用户点击设置图标, THE Navigation_Module SHALL 导航至 Settings_Page
7. THE Company_List_Page SHALL 在标题栏下方显示搜索入口栏(提示文字"请输入要搜索的备注内容"
8. WHEN 用户点击搜索入口栏, THE Navigation_Module SHALL 导航至 Search_Page
9. WHEN Company_List_Page 加载时, THE API_Client SHALL 同时调用设备型号字典接口 `GET /system/dict/data/type/odf_ports_unit_type` 和业务类型字典接口 `GET /system/dict/data/type/odf_ports_business_type`,并将结果存储到全局状态
10. WHEN Company_List_Page 加载时, THE Application SHALL 调用版本检查接口 `GET /webapi/CheckAppVersion` 并传入当前应用版本号
### 需求 4版本更新检查
**用户故事:** 作为用户,我希望应用能自动检测新版本并提示更新,以便使用最新功能。
#### 验收标准
1. WHEN 版本检查接口返回 needUpdate 为 true, THE Application SHALL 弹出 Update_Dialog
2. THE Update_Dialog SHALL 显示更新图标、"有新版本请更新"提示文字和"去更新"按钮
3. WHEN 用户点击"去更新"按钮, THE Application SHALL 使用系统浏览器打开 downloadUrl 指定的下载地址
4. THE Update_Dialog SHALL 为模态弹窗,用户无法通过点击空白区域或返回键关闭
### 需求 5地区列表
**用户故事:** 作为用户,我希望查看指定公司下的地区列表,以便选择目标地区。
#### 验收标准
1. WHEN Region_List_Page 加载时, THE API_Client SHALL 调用 `GET /business/OdfRooms/getregion` 并传入 deptId 参数获取地区列表
2. THE Region_List_Page SHALL 以卡片列表形式展示每个地区的 deptName
3. WHEN 用户点击某个地区卡片, THE Navigation_Module SHALL 携带该地区的 deptId 导航至 Room_List_Page
4. THE Region_List_Page SHALL 在顶部显示返回按钮和"地区列表"标题
### 需求 6机房列表
**用户故事:** 作为用户,我希望查看指定地区下的机房列表,以便选择目标机房。
#### 验收标准
1. WHEN Room_List_Page 加载时, THE API_Client SHALL 调用 `GET /business/OdfRooms/list` 并传入 pageNum=1、pageSize=20、deptId 参数获取机房列表
2. THE Room_List_Page SHALL 以卡片形式展示每个机房的 roomName机房名、roomAddress地址和 racksCountODF 数量,格式为"ODF: N台"
3. WHEN 用户点击某个机房卡片, THE Navigation_Module SHALL 携带该机房的 id 和 roomName 导航至 Rack_List_Page
4. THE Room_List_Page SHALL 支持下拉刷新,刷新时重置 pageNum 为 1 并重新加载数据
5. WHEN 用户滚动至列表底部(距底部 100px 以内), THE API_Client SHALL 自动递增 pageNum 并加载下一页数据追加到列表末尾
6. THE Room_List_Page SHALL 在顶部显示返回按钮和"机房列表"标题
### 需求 7机架列表
**用户故事:** 作为用户,我希望查看指定机房下的 ODF 机架列表,以便选择目标机架查看端口详情。
#### 验收标准
1. WHEN Rack_List_Page 加载时, THE API_Client SHALL 调用 `GET /business/OdfRacks/list` 并传入 pageNum=1、pageSize=20、roomId 参数获取机架列表
2. THE Rack_List_Page SHALL 以卡片列表形式展示每个机架的 rackName
3. WHEN 用户点击某个机架卡片, THE Navigation_Module SHALL 携带该机架的 id、rackName 和 roomName 导航至 Rack_Detail_Page
4. THE Rack_List_Page SHALL 支持下拉刷新,刷新时重置 pageNum 为 1 并重新加载数据
5. WHEN 用户滚动至列表底部(距底部 100px 以内), THE API_Client SHALL 自动递增 pageNum 并加载下一页数据追加到列表末尾
6. THE Rack_List_Page SHALL 在顶部显示返回按钮和"机房详情"标题
### 需求 8机架详情端口矩阵
**用户故事:** 作为用户,我希望查看机架内所有端口的状态矩阵,以便快速了解端口连接情况。
#### 验收标准
1. WHEN Rack_Detail_Page 加载时, THE API_Client SHALL 调用 `GET /business/OdfPorts/mlist` 并传入 RackId 参数获取机架详情数据
2. WHILE 数据加载中, THE Application SHALL 显示"loading..."加载提示
3. THE Rack_Detail_Page SHALL 在顶部显示返回按钮和"{rackName}详情"标题,标题下方显示 roomName蓝色 #1A73EC 字体加粗)
4. THE Rack_Detail_Page SHALL 在 roomName 下方显示状态图例:绿色圆点标注"已连接"、红色圆点标注"已断开"
5. THE Rack_Detail_Page SHALL 按 Frame 分组展示端口数据,每个 Frame 显示为一个白色卡片,卡片顶部显示 Frame 名称
6. THE Rack_Detail_Page SHALL 在每个 Frame 卡片内按行排列端口每个端口显示为圆形图标status=1 时为绿色已连接status=0 时为红色(已断开),圆形内显示 tips 文字,圆形下方显示端口 name
7. WHEN 端口行内容超出屏幕宽度, THE Rack_Detail_Page SHALL 支持水平滚动查看
8. WHEN 用户点击某个端口圆形图标, THE Application SHALL 弹出 Port_Edit_Dialog 并传入该端口的 id
### 需求 9端口编辑弹窗
**用户故事:** 作为用户,我希望查看和编辑端口的详细信息,以便维护端口数据。
#### 验收标准
1. WHEN Port_Edit_Dialog 打开时, THE API_Client SHALL 调用 `GET /business/OdfPorts/{id}` 获取端口详细信息
2. WHILE 数据加载中, THE Application SHALL 显示"loading..."加载提示
3. THE Port_Edit_Dialog SHALL 显示端口位置信息(格式为"位置:{frameName}{name}")和当前状态(绿色/红色圆点 + "已连接"/"已断开"文字)
4. THE Port_Edit_Dialog SHALL 提供备注说明多行输入框5行高度提示文字"请输入备注说明"
5. THE Port_Edit_Dialog SHALL 在备注输入框旁显示"添加备注"按钮,点击后弹出 Add_Note_Dialog
6. THE Port_Edit_Dialog SHALL 提供光衰信息单行输入框(提示文字"请输入光衰信息"
7. THE Port_Edit_Dialog SHALL 提供历史障碍记录区域,显示已有记录列表,每条记录包含时间选择器和故障原因输入框
8. THE Port_Edit_Dialog SHALL 提供"添加新记录"链接,点击后在历史障碍列表末尾添加一条空记录
9. THE Port_Edit_Dialog SHALL 在每条历史障碍记录右侧显示删除按钮(红色"-"),点击后移除该条记录
10. THE Port_Edit_Dialog SHALL 提供光缆段信息单行输入框(提示文字"请输入光缆段信息"
11. WHILE Permission 为 true, THE Port_Edit_Dialog SHALL 显示"改变状态"区域,包含"连接"(绿色)和"断开"(红色)两个切换按钮,以及提示文字"断开后只清空备注说明,其他内容不影响"
12. WHILE Permission 为 true, THE Port_Edit_Dialog SHALL 显示"取消"和"提交"两个操作按钮
13. WHILE Permission 为 false, THE Port_Edit_Dialog SHALL 禁用所有输入框和编辑功能,仅显示"关闭"按钮
14. WHEN 用户点击"提交"按钮, THE API_Client SHALL 调用 `POST /business/OdfPorts/save` 发送 Id、Status、Remarks、OpticalAttenuation、HistoryRemarks、HistoryFault、OpticalCableOffRemarks 参数
15. IF 历史障碍记录中存在未选择时间的条目, THEN THE Application SHALL 以 Toast 显示"请选择障碍发生时间!"并阻止提交
16. WHEN 保存接口返回成功, THE Port_Edit_Dialog SHALL 关闭弹窗并触发父页面刷新机架详情数据
### 需求 10添加备注弹窗
**用户故事:** 作为用户,我希望通过结构化表单快速生成格式化备注,以便提高备注录入效率。
#### 验收标准
1. THE Add_Note_Dialog SHALL 显示"添加备注"标题
2. THE Add_Note_Dialog SHALL 提供业务名称输入框(提示文字"请输入业务名称"
3. THE Add_Note_Dialog SHALL 提供设备型号下拉选择器,选项来源于全局存储的 Dict_Dataodf_ports_unit_type默认选中第一项
4. THE Add_Note_Dialog SHALL 提供业务类型下拉选择器,选项来源于全局存储的 Dict_Dataodf_ports_business_type默认选中第一项
5. THE Add_Note_Dialog SHALL 提供三组端口号数输入框(提示文字分别为"1号端口数"、"2号端口数"、"3号端口数"),仅允许输入数字
6. WHEN 用户点击"提交"按钮, THE Add_Note_Dialog SHALL 校验业务名称和三组端口号均不为空
7. IF 业务名称为空, THEN THE Application SHALL 以 Toast 显示"请输入业务名称"
8. IF 任一端口号为空, THEN THE Application SHALL 以 Toast 显示"请输入端口号"
9. WHEN 校验通过, THE Add_Note_Dialog SHALL 将备注内容格式化为"{业务名称} {设备型号} {业务类型} {端口号1}/{端口号2}/{端口号3}"并回填至 Port_Edit_Dialog 的备注输入框,然后关闭弹窗
10. THE Add_Note_Dialog SHALL 提供"取消"按钮,点击后关闭弹窗不做任何操作
### 需求 11搜索功能
**用户故事:** 作为用户,我希望通过备注关键词搜索端口和机房,以便快速定位目标设备。
#### 验收标准
1. THE Search_Page SHALL 在顶部显示返回按钮和"搜索"标题
2. THE Search_Page SHALL 提供搜索输入框(提示文字"请输入要搜索的备注内容")和"搜索"按钮
3. WHEN 用户点击"搜索"按钮或在键盘上按下搜索键, THE API_Client SHALL 调用 `GET /business/OdfPorts/search2` 并传入 key、pageNum=1、pageSize=20 参数
4. THE Search_Page SHALL 将搜索结果分为"机房"和"备注信息"两个区域展示
5. THE Search_Page SHALL 在"机房"区域以卡片形式展示匹配的机房列表,每个卡片显示 roomName
6. WHEN 用户点击机房卡片, THE Navigation_Module SHALL 携带 roomId 和 roomName 导航至 Rack_List_Page
7. THE Search_Page SHALL 在"备注信息"区域以卡片形式展示匹配的端口列表,每个卡片显示 roomName、address、rackNameODF名称、frameName+name点位置、remarks备注、opticalAttenuation光衰信息、historyRemarks历史故障原因及时间、opticalCableOffRemarks光缆段信息和当前状态圆点+文字)
8. WHEN 用户点击端口卡片, THE Navigation_Module SHALL 携带 rackId、rackName、roomName 和端口 id 导航至 Rack_Detail_Page并自动弹出该端口的 Port_Edit_Dialog
9. WHEN 用户滚动至搜索结果底部(距底部 100px 以内), THE API_Client SHALL 自动递增 pageNum 并加载下一页端口数据追加到列表末尾
### 需求 12设置与密码修改
**用户故事:** 作为用户,我希望能修改密码和退出登录,以便管理账户安全。
#### 验收标准
1. THE Settings_Page SHALL 在顶部显示返回按钮和"设置"标题
2. THE Settings_Page SHALL 显示"修改密码"选项卡片和"退出登录"选项卡片(红色文字)
3. WHEN 用户点击"修改密码"卡片, THE Navigation_Module SHALL 导航至 Change_Password_Page
4. WHEN 用户点击"退出登录"卡片, THE Auth_Module SHALL 清除 Storage_Module 中的 Token 并将 Navigation_Module 导航至 Login_Page清除导航栈
5. THE Change_Password_Page SHALL 在顶部显示返回按钮和"修改密码"标题
6. THE Change_Password_Page SHALL 提供旧密码输入框(提示文字"请输入旧密码")和新密码输入框(提示文字"请输入新密码"
7. WHEN 用户点击"确认修改"按钮, THE API_Client SHALL 调用 `POST /system/user/profile/updateUserPwd` 发送 oldPassword 和 newPassword 参数
8. IF 旧密码输入框为空, THEN THE Application SHALL 以 Toast 显示"请输入旧密码!"
9. IF 新密码输入框为空, THEN THE Application SHALL 以 Toast 显示"请输入新密码!"
10. WHEN 修改密码接口返回状态码 200, THE Application SHALL 以 Toast 显示"修改成功"并返回上一页
11. WHEN 修改密码接口返回状态码 110, THE Application SHALL 以 Toast 显示服务器返回的错误消息
### 需求 13API 通信层
**用户故事:** 作为开发者,我希望有统一的 API 通信层,以便所有接口请求遵循一致的规范。
#### 验收标准
1. THE API_Client SHALL 以 `http://49.233.115.141:11082` 作为所有请求的基础地址
2. THE API_Client SHALL 在每个请求的 Header 中携带 `Authorization: Bearer {token}`、`Userid` 和 `Username` 三个字段
3. THE API_Client SHALL 将请求超时时间设置为连接超时 20 秒、发送超时 10 秒、接收超时 20 秒
4. THE API_Client SHALL 统一解析响应 JSON 结构,提取 code、msg、data 三个字段
5. IF 网络请求发生异常, THEN THE API_Client SHALL 返回包含错误码 -1 和异常描述的错误对象
6. THE API_Client SHALL 支持 GET 和 POST 两种请求方法GET 请求参数通过 queryParameters 传递POST 请求参数通过 request body 传递
### 需求 14全局 UI 规范
**用户故事:** 作为用户,我希望应用具有统一的视觉风格和交互体验。
#### 验收标准
1. THE Application SHALL 使用 #1A73EC 作为主题色,应用于按钮、搜索框、链接等交互元素
2. THE Application SHALL 在所有列表页面使用统一的背景图片覆盖
3. THE Application SHALL 使用绿色圆点表示"已连接"状态,红色圆点表示"已断开"状态
4. THE Application SHALL 在所有需要等待的操作中显示"loading..."加载提示
5. THE Application SHALL 使用 Toast 消息提示用户操作结果和错误信息
6. THE Application SHALL 在所有二级页面顶部提供统一的返回按钮和居中标题栏布局
7. THE Application SHALL 在所有列表页面支持下拉刷新功能,刷新指示器颜色为 #1A73EC
8. WHILE 列表数据支持分页加载, THE Application SHALL 使用 pageSize=20 作为默认分页大小

View File

@ -0,0 +1,217 @@
# 实现计划:绥时录 — Flutter 到 UniApp 重写
## 概述
将 Flutter 版"绥时录"ODF 端口管理系统完全重写为 UniApp (Vue 3) 应用。按照基础设施 → 核心页面 → 辅助功能的顺序,逐步实现并验证每个模块。
## 任务列表
- [x] 1. 项目基础设施搭建
- [x] 1.1 配置项目结构和路由
- 复制图片资源:将 `app/assets/images/` 下所有图片复制到 `odf-uniapp/static/images/`
- 更新 `pages.json`:按设计文档配置 10 个页面路由start、login、home、region、room、rack、rack-detail、search、settings、change-password所有页面使用 `navigationStyle: custom`
- 更新 `manifest.json`:确认 `vueVersion: "3"` 配置
- 配置 `uni.scss`:定义全局样式变量(主题色 `#1A73EC`、背景色等)
- 删除默认的 `pages/index/` 目录
- 创建所有页面目录和空的 `index.vue` 占位文件
- _需求: 14.1, 14.2, 14.6_
- [x] 1.2 实现全局状态管理 (store/index.js)
- 创建 `odf-uniapp/store/index.js`
- 使用 Vue 3 `reactive` 实现全局状态对象包含token、userId、userName、isPermission、dictUnitTypes、dictBusinessTypes
- 实现 `setAuth(token, userId, userName)` 方法,同步写入 `uni.setStorageSync`
- 实现 `clearAuth()` 方法,清除状态和本地存储
- 初始化时从 `uni.getStorageSync` 恢复认证信息
- _需求: 1.1, 2.3, 12.4, 3.9_
- [ ]* 1.3 编写 store 属性测试
- **Property 1: 认证数据持久化往返**
- **Property 13: 退出登录清除认证状态**
- 创建 `odf-uniapp/tests/store.test.js`,使用 fast-check 生成随机 token/userId/userName 验证存储往返一致性
- 验证 clearAuth 后所有认证字段为空
- **验证: 需求 1.1, 2.3, 12.4**
- [x] 1.4 实现 API 通信层 (services/api.js)
- 创建 `odf-uniapp/services/api.js`
- 封装 `uni.request`,基础地址 `http://49.233.115.141:11082`,超时 20 秒
- 每个请求自动携带 `Authorization: Bearer {token}`、`Userid`、`Username` 请求头
- 统一解析响应 JSON 的 `code`、`msg`、`data` 字段
- 网络异常时返回 `{ code: -1, msg: '网络异常' }`
- 导出 `get(url, params)``post(url, data)` 快捷方法
- _需求: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_
- [ ]* 1.5 编写 API 通信层属性测试
- **Property 9: API 响应解析**
- **Property 10: API 异常处理**
- **Property 11: API 请求头构造**
- 创建 `odf-uniapp/tests/api.test.js`mock `uni.request` 验证请求头、响应解析和异常处理
- **验证: 需求 13.2, 13.4, 13.5**
- [x] 1.6 实现 API 接口模块
- 创建 `odf-uniapp/services/auth.js`appLogin、checkPermission、updatePassword
- 创建 `odf-uniapp/services/home.js`getCompanyList、getDictUnitType、getDictBusinessType、checkAppVersion
- 创建 `odf-uniapp/services/machine.js`getRegionList、getRoomList、getRackList、getRackDetail、getPortDetail、savePort
- 创建 `odf-uniapp/services/search.js`searchPorts
- 所有接口函数按设计文档中的签名实现
- _需求: 13.6_
- [x] 2. 检查点 - 基础设施验证
- 确保所有基础模块store、api、services代码无语法错误项目结构完整请用户确认是否有问题。
- [x] 3. 启动页与登录流程
- [x] 3.1 实现启动页 (pages/start/index.vue)
- 页面中央显示应用名称"绥时录"
- `onLoad` 时从 store 读取 token
- token 存在时调用 `checkPermission()` 验证有效性
- 根据返回状态码200/403/401设置 isPermission 并导航到对应页面
- token 不存在时直接导航至登录页
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
- [x] 3.2 实现登录页 (pages/login/index.vue)
- 背景图片覆盖整个页面,主题色 #1A73EC
- 账号输入框(提示"请输入账号")和密码输入框(提示"请输入密码",密码模式)
- 点击"登录"按钮调用 `appLogin` 接口
- 登录成功:提取 jwt/userId/userName调用 `store.setAuth()` 存储,检测权限后导航至首页
- 登录失败Toast 显示错误消息
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 4. 首页与版本更新
- [x] 4.1 实现首页/公司列表 (pages/home/index.vue)
- 顶部栏:左侧刷新图标、中间"公司列表"标题、右侧设置图标
- 标题栏下方搜索入口栏(提示"请输入要搜索的备注内容"),点击导航至搜索页
- `onLoad` 时调用 `getCompanyList()` 获取公司列表,以卡片形式展示 deptName
- 同时调用字典接口获取设备型号和业务类型,存入 store
- 同时调用版本检查接口
- 点击公司卡片携带 deptId 导航至地区列表
- 点击设置图标导航至设置页
- 支持下拉刷新(`onPullDownRefresh`
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
- [x] 4.2 实现版本更新弹窗 (components/update-dialog.vue)
- Props: `visible`、`downloadUrl`
- 模态弹窗,显示更新图标、"有新版本请更新"提示、"去更新"按钮
- 点击"去更新"打开下载链接
- 不可通过点击空白或返回键关闭
- _需求: 4.1, 4.2, 4.3, 4.4_
- [x] 5. 层级列表页面(地区 → 机房 → 机架)
- [x] 5.1 实现地区列表 (pages/region/index.vue)
- 顶部返回按钮 + "地区列表"标题
- `onLoad` 接收 deptId 参数,调用 `getRegionList(deptId)` 获取地区列表
- 卡片列表展示 deptName点击携带 deptId 导航至机房列表
- _需求: 5.1, 5.2, 5.3, 5.4_
- [x] 5.2 实现机房列表 (pages/room/index.vue)
- 顶部返回按钮 + "机房列表"标题
- `onLoad` 接收 deptId 参数,调用 `getRoomList(1, 20, deptId)` 获取机房列表
- 卡片展示 roomName、roomAddress、"ODF: N台"
- 点击携带 id 和 roomName 导航至机架列表
- 支持下拉刷新(重置 pageNum=1和触底加载更多距底部 100px
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_
- [x] 5.3 实现机架列表 (pages/rack/index.vue)
- 顶部返回按钮 + "机房详情"标题
- `onLoad` 接收 roomId、roomName 参数,调用 `getRackList(1, 20, roomId)` 获取机架列表
- 卡片列表展示 rackName点击携带 id、rackName、roomName 导航至机架详情
- 支持下拉刷新和触底加载更多
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
- [ ]* 5.4 编写分页逻辑属性测试
- **Property 3: 分页状态管理**
- **Property 12: 默认分页大小**
- 创建 `odf-uniapp/tests/pagination.test.js`,验证分页追加逻辑和默认 pageSize=20
- **验证: 需求 6.5, 7.5, 11.9, 14.8**
- [x] 6. 检查点 - 核心导航流程验证
- 确保启动页 → 登录 → 首页 → 地区 → 机房 → 机架的完整导航链路代码无误,请用户确认是否有问题。
- [x] 7. 机架详情与端口编辑
- [x] 7.1 实现机架详情页 (pages/rack-detail/index.vue)
- 顶部返回按钮 + "{rackName}详情"标题
- 标题下方显示 roomName蓝色 #1A73EC 加粗)
- 状态图例:绿色圆点"已连接"、红色圆点"已断开"
- `onLoad` 接收 rackId、rackName、roomName 参数,调用 `getRackDetail(rackId)` 获取数据
- 加载中显示"loading..."
- 按 Frame 分组展示端口矩阵:白色卡片 + Frame 名称 + 按行排列的端口圆形图标
- 端口圆形status=1 绿色、status=0 红色,内显 tips下显 name
- 行内容超出时支持水平滚动
- 点击端口弹出 Port_Edit_Dialog
- 支持接收搜索页传来的 portId 参数,自动弹出对应端口编辑弹窗
- _需求: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8_
- [ ]* 7.2 编写端口状态颜色映射属性测试
- **Property 4: 端口状态颜色映射**
- 创建 `odf-uniapp/tests/utils.test.js`,验证 status=1 映射绿色、status=0 映射红色
- **验证: 需求 8.6, 14.3**
- [x] 7.3 实现端口编辑弹窗 (components/port-edit-dialog.vue)
- Props: `visible`、`portId`Events: `@close`、`@saved`
- 打开时调用 `getPortDetail(portId)` 加载数据,显示"loading..."
- 显示端口位置("位置:{frameName}{name}")和状态圆点
- 备注说明多行输入框5行提示"请输入备注说明"+ "添加备注"按钮
- 光衰信息输入框(提示"请输入光衰信息"
- 历史障碍记录区域:已有记录列表(时间选择器 + 故障原因输入框)、"添加新记录"链接、删除按钮
- 光缆段信息输入框(提示"请输入光缆段信息"
- 权限控制isPermission=true 显示状态切换按钮(连接/断开)+ 取消/提交按钮isPermission=false 禁用所有输入 + 仅显示关闭按钮
- 提交时校验历史障碍时间,调用 `savePort()` 保存,成功后关闭弹窗并触发父页面刷新
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 9.9, 9.10, 9.11, 9.12, 9.13, 9.14, 9.15, 9.16_
- [ ]* 7.4 编写端口编辑相关属性测试
- **Property 5: 权限控制 UI 状态**
- **Property 6: 历史障碍记录时间校验**
- 创建 `odf-uniapp/tests/permission.test.js``odf-uniapp/tests/validation.test.js`
- **验证: 需求 9.11, 9.12, 9.13, 9.15**
- [x] 7.5 实现添加备注弹窗 (components/add-note-dialog.vue)
- Props: `visible`Events: `@close`、`@confirm(formattedText)`
- 业务名称输入框、设备型号下拉(来源 store.dictUnitTypes、业务类型下拉来源 store.dictBusinessTypes
- 三组端口号输入框(仅数字)
- 提交校验:业务名称和端口号均不为空
- 格式化输出:"{业务名称} {设备型号} {业务类型} {端口号1}/{端口号2}/{端口号3}"
- 取消按钮关闭弹窗
- _需求: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7, 10.8, 10.9, 10.10_
- [ ]* 7.6 编写添加备注相关属性测试
- **Property 7: 备注格式化字符串**
- **Property 8: 添加备注表单校验**
- 在 `odf-uniapp/tests/validation.test.js``odf-uniapp/tests/format.test.js` 中添加测试
- **验证: 需求 10.6, 10.9**
- [x] 8. 检查点 - 机架详情与端口编辑验证
- 确保机架详情页端口矩阵展示、端口编辑弹窗、添加备注弹窗的完整交互链路代码无误,请用户确认是否有问题。
- [x] 9. 搜索与设置功能
- [x] 9.1 实现搜索页 (pages/search/index.vue)
- 顶部返回按钮 + "搜索"标题
- 搜索输入框(提示"请输入要搜索的备注内容"+ "搜索"按钮
- 点击搜索或键盘搜索键调用 `searchPorts(key, 1, 20)`
- 结果分"机房"和"备注信息"两个区域展示
- 机房区域:卡片展示 roomName点击携带 roomId/roomName 导航至机架列表
- 备注信息区域:卡片展示 roomName、address、rackName、frameName+name、remarks、opticalAttenuation、historyRemarks、opticalCableOffRemarks、状态圆点
- 点击端口卡片携带 rackId/rackName/roomName/portId 导航至机架详情并自动弹出端口编辑弹窗
- 触底加载更多端口数据
- _需求: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7, 11.8, 11.9_
- [x] 9.2 实现设置页 (pages/settings/index.vue)
- 顶部返回按钮 + "设置"标题
- "修改密码"卡片,点击导航至修改密码页
- "退出登录"卡片(红色文字),点击清除 token 并导航至登录页(清除导航栈 `reLaunch`
- _需求: 12.1, 12.2, 12.3, 12.4_
- [x] 9.3 实现修改密码页 (pages/change-password/index.vue)
- 顶部返回按钮 + "修改密码"标题
- 旧密码输入框(提示"请输入旧密码")和新密码输入框(提示"请输入新密码"
- 点击"确认修改"按钮:校验非空后调用 `updatePassword(oldPassword, newPassword)`
- 状态码 200Toast "修改成功"并返回上一页
- 状态码 110Toast 显示服务器错误消息
- _需求: 12.5, 12.6, 12.7, 12.8, 12.9, 12.10, 12.11_
- [x] 10. 最终检查点 - 全功能验证
- 确保所有页面、组件、服务层代码完整且无语法错误,所有导航路径正确连通,请用户确认是否有问题。
## 备注
- 标记 `*` 的任务为可选测试任务,可跳过以加快 MVP 进度
- 每个任务引用了对应的需求编号,确保需求可追溯
- 检查点任务用于阶段性验证,确保增量开发的正确性
- 属性测试验证通用正确性属性,单元测试验证具体边界情况

17
odf-uniapp/App.vue Normal file
View File

@ -0,0 +1,17 @@
<script>
export default {
onLaunch: function() {
console.log('App Launch')
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}
</script>
<style>
/*每个页面公共css */
</style>

View File

@ -0,0 +1,287 @@
<template>
<view v-if="visible" class="add-note-overlay">
<view class="add-note-content">
<!-- 标题 -->
<text class="dialog-title">添加备注</text>
<scroll-view class="scroll-area" scroll-y>
<!-- 业务名称 -->
<view class="section">
<text class="section-label">业务名称</text>
<input
class="form-input"
v-model="businessName"
placeholder="请输入业务名称"
/>
</view>
<!-- 设备型号 -->
<view class="section">
<text class="section-label">设备型号</text>
<picker
mode="selector"
:range="store.dictUnitTypes"
range-key="dictLabel"
:value="selectedUnitType"
@change="onUnitTypeChange"
>
<view class="picker-box">
<text class="picker-text">{{ unitTypeLabel }}</text>
</view>
</picker>
</view>
<!-- 业务类型 -->
<view class="section">
<text class="section-label">业务类型</text>
<picker
mode="selector"
:range="store.dictBusinessTypes"
range-key="dictLabel"
:value="selectedBusinessType"
@change="onBusinessTypeChange"
>
<view class="picker-box">
<text class="picker-text">{{ businessTypeLabel }}</text>
</view>
</picker>
</view>
<!-- 端口号输入 -->
<view class="section">
<text class="section-label">端口号</text>
<view class="port-inputs">
<input
class="form-input port-input"
v-model="portNumber1"
type="number"
placeholder="1号端口数"
/>
<input
class="form-input port-input"
v-model="portNumber2"
type="number"
placeholder="2号端口数"
/>
<input
class="form-input port-input"
v-model="portNumber3"
type="number"
placeholder="3号端口数"
/>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="btn-row">
<view class="btn btn-cancel" @click="onCancel">
<text class="btn-text">取消</text>
</view>
<view class="btn btn-submit" @click="onSubmit">
<text class="btn-text-white">提交</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import store from '@/store'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'confirm'])
const businessName = ref('')
const selectedUnitType = ref(0)
const selectedBusinessType = ref(0)
const portNumber1 = ref('')
const portNumber2 = ref('')
const portNumber3 = ref('')
const unitTypeLabel = computed(() => {
if (store.dictUnitTypes.length > 0 && selectedUnitType.value < store.dictUnitTypes.length) {
return store.dictUnitTypes[selectedUnitType.value].dictLabel
}
return '请选择'
})
const businessTypeLabel = computed(() => {
if (store.dictBusinessTypes.length > 0 && selectedBusinessType.value < store.dictBusinessTypes.length) {
return store.dictBusinessTypes[selectedBusinessType.value].dictLabel
}
return '请选择'
})
watch(
() => props.visible,
(val) => {
if (val) {
// Reset form and default select first item
businessName.value = ''
selectedUnitType.value = 0
selectedBusinessType.value = 0
portNumber1.value = ''
portNumber2.value = ''
portNumber3.value = ''
}
}
)
function onUnitTypeChange(e) {
selectedUnitType.value = Number(e.detail.value)
}
function onBusinessTypeChange(e) {
selectedBusinessType.value = Number(e.detail.value)
}
function onCancel() {
emit('close')
}
function onSubmit() {
if (!businessName.value.trim()) {
uni.showToast({ title: '请输入业务名称', icon: 'none' })
return
}
if (!portNumber1.value || !portNumber2.value || !portNumber3.value) {
uni.showToast({ title: '请输入端口号', icon: 'none' })
return
}
const unitLabel = store.dictUnitTypes.length > 0
? store.dictUnitTypes[selectedUnitType.value].dictLabel
: ''
const bizLabel = store.dictBusinessTypes.length > 0
? store.dictBusinessTypes[selectedBusinessType.value].dictLabel
: ''
const formatted = `${businessName.value} ${unitLabel} ${bizLabel} ${portNumber1.value}/${portNumber2.value}/${portNumber3.value}`
emit('confirm', formatted)
emit('close')
}
</script>
<style scoped>
.add-note-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.add-note-content {
background-color: #fff;
border-radius: 16rpx;
width: 90%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 32rpx;
}
.dialog-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
text-align: center;
margin-bottom: 24rpx;
}
.scroll-area {
flex: 1;
max-height: 60vh;
}
.section {
margin-bottom: 24rpx;
}
.section-label {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 72rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 0 16rpx;
font-size: 26rpx;
box-sizing: border-box;
}
.picker-box {
width: 100%;
height: 72rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
display: flex;
align-items: center;
padding: 0 16rpx;
}
.picker-text {
font-size: 26rpx;
color: #333;
}
.port-inputs {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.port-input {
width: 100%;
}
.btn-row {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.btn {
flex: 1;
height: 80rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-cancel {
background-color: #f5f5f5;
border: 1rpx solid #ddd;
}
.btn-submit {
background-color: #1A73EC;
}
.btn-text {
font-size: 28rpx;
color: #666;
}
.btn-text-white {
font-size: 28rpx;
color: #fff;
}
</style>

View File

@ -0,0 +1,564 @@
<template>
<view v-if="visible" class="port-edit-overlay" @click.self="onOverlayClick">
<view class="port-edit-content">
<!-- 加载中 -->
<view v-if="loading" class="loading-box">
<text class="loading-text">loading...</text>
</view>
<scroll-view v-else class="scroll-area" scroll-y>
<!-- 端口位置和状态 -->
<view class="section">
<view class="location-row">
<text class="location-text">位置:{{ portData.frameName }}{{ portData.name }}</text>
<view class="status-badge">
<view class="status-dot" :class="portData.status === 1 ? 'dot-green' : 'dot-red'" />
<text class="status-label">{{ portData.status === 1 ? '已连接' : '已断开' }}</text>
</view>
</view>
</view>
<!-- 备注说明 -->
<view class="section">
<text class="section-title">备注说明</text>
<view class="remarks-row">
<textarea
class="remarks-input"
v-model="form.remarks"
:maxlength="-1"
placeholder="请输入备注说明"
:disabled="!store.isPermission"
:rows="5"
/>
<view v-if="store.isPermission" class="add-note-btn" @click="showAddNote = true">
<text class="add-note-text">添加备注</text>
</view>
</view>
</view>
<!-- 光衰信息 -->
<view class="section">
<text class="section-title">光衰信息</text>
<input
class="form-input"
v-model="form.opticalAttenuation"
placeholder="请输入光衰信息"
:disabled="!store.isPermission"
/>
</view>
<!-- 历史障碍记录 -->
<view class="section">
<text class="section-title">历史障碍记录</text>
<view class="fault-list">
<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)"
>
<view class="date-picker">
<text :class="item.faultTime ? 'date-text' : 'date-placeholder'">
{{ item.faultTime || '选择日期' }}
</text>
</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)">
<text class="delete-btn-text">-</text>
</view>
</view>
</view>
</view>
<view v-if="store.isPermission" class="add-record-link" @click="addFault">
<text class="add-record-text">添加新记录</text>
</view>
</view>
<!-- 光缆段信息 -->
<view class="section">
<text class="section-title">光缆段信息</text>
<input
class="form-input"
v-model="form.opticalCableOffRemarks"
placeholder="请输入光缆段信息"
:disabled="!store.isPermission"
/>
</view>
<!-- 权限控制区域 -->
<view v-if="store.isPermission" class="section">
<text class="section-title">改变状态</text>
<view class="status-toggle-row">
<view
class="toggle-btn toggle-green"
:class="{ 'toggle-active': form.status === 1 }"
@click="setStatus(1)"
>
<text class="toggle-text">连接</text>
</view>
<view
class="toggle-btn toggle-red"
:class="{ 'toggle-active': form.status === 0 }"
@click="setStatus(0)"
>
<text class="toggle-text">断开</text>
</view>
</view>
<text class="hint-text">断开后只清空备注说明,其他内容不影响</text>
</view>
<!-- 底部按钮 -->
<view class="btn-row">
<template v-if="store.isPermission">
<view class="btn btn-cancel" @click="onClose">
<text class="btn-text">取消</text>
</view>
<view class="btn btn-submit" @click="onSubmit">
<text class="btn-text-white">提交</text>
</view>
</template>
<template v-else>
<view class="btn btn-cancel btn-full" @click="onClose">
<text class="btn-text">关闭</text>
</view>
</template>
</view>
</scroll-view>
</view>
<!-- 添加备注弹窗 -->
<addNoteDialog
:visible="showAddNote"
@close="showAddNote = false"
@confirm="onNoteConfirm"
/>
</view>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { getPortDetail, savePort } from '@/services/machine'
import store from '@/store'
import addNoteDialog from '@/components/add-note-dialog.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
portId: { type: [Number, String], default: '' }
})
const emit = defineEmits(['close', 'saved'])
const loading = ref(false)
const showAddNote = ref(false)
const portData = reactive({
id: '',
name: '',
frameName: '',
status: 0,
remarks: '',
opticalAttenuation: '',
opticalCableOffRemarks: '',
historyRemarks: '',
historyFault: []
})
const form = reactive({
status: 0,
remarks: '',
opticalAttenuation: '',
opticalCableOffRemarks: '',
historyRemarks: '',
historyFault: []
})
watch(
() => props.visible,
(val) => {
if (val && props.portId) {
loadPortDetail()
}
}
)
async function loadPortDetail() {
loading.value = true
try {
const res = await getPortDetail(props.portId)
if (res.code === 200 && res.data) {
const d = res.data
Object.assign(portData, {
id: d.id,
name: d.name || '',
frameName: d.frameName || '',
status: d.status,
remarks: d.remarks || '',
opticalAttenuation: d.opticalAttenuation || '',
opticalCableOffRemarks: d.opticalCableOffRemarks || '',
historyRemarks: d.historyRemarks || '',
historyFault: d.historyFault || []
})
//
form.status = d.status
form.remarks = d.remarks || ''
form.opticalAttenuation = d.opticalAttenuation || ''
form.opticalCableOffRemarks = d.opticalCableOffRemarks || ''
form.historyRemarks = d.historyRemarks || ''
form.historyFault = (d.historyFault || []).map(item => ({
faultTime: item.faultTime || '',
faultReason: item.faultReason || ''
}))
}
} finally {
loading.value = false
}
}
function setStatus(status) {
form.status = status
if (status === 0) {
form.remarks = ''
}
}
function addFault() {
form.historyFault.push({ faultTime: '', faultReason: '' })
}
function removeFault(index) {
form.historyFault.splice(index, 1)
}
function onFaultDateChange(e, index) {
form.historyFault[index].faultTime = e.detail.value
}
function onNoteConfirm(text) {
form.remarks = form.remarks ? form.remarks + '\n' + text : text
showAddNote.value = false
}
function onClose() {
emit('close')
}
function onOverlayClick() {
//
}
async function onSubmit() {
//
for (let i = 0; i < form.historyFault.length; i++) {
if (!form.historyFault[i].faultTime || !form.historyFault[i].faultTime.trim()) {
uni.showToast({ title: '请选择障碍发生时间!', icon: 'none' })
return
}
}
try {
const res = await savePort({
Id: portData.id,
Status: form.status,
Remarks: form.remarks,
OpticalAttenuation: form.opticalAttenuation,
HistoryRemarks: form.historyRemarks,
HistoryFault: form.historyFault,
OpticalCableOffRemarks: form.opticalCableOffRemarks
})
if (res.code === 200) {
uni.showToast({ title: '保存成功', icon: 'success' })
emit('close')
emit('saved')
} else {
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '网络异常', icon: 'none' })
}
}
</script>
<style scoped>
.port-edit-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.port-edit-content {
background-color: #fff;
border-radius: 16rpx;
width: 90%;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.loading-box {
display: flex;
justify-content: center;
align-items: center;
padding: 80rpx 0;
}
.loading-text {
font-size: 28rpx;
color: #999;
}
.scroll-area {
max-height: 85vh;
padding: 32rpx;
}
.section {
margin-bottom: 28rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.location-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.location-text {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.status-badge {
display: flex;
align-items: center;
gap: 8rpx;
}
.status-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
}
.dot-green {
background-color: #4CAF50;
}
.dot-red {
background-color: #F44336;
}
.status-label {
font-size: 24rpx;
color: #666;
}
.remarks-row {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.remarks-input {
width: 100%;
height: 200rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 16rpx;
font-size: 26rpx;
box-sizing: border-box;
}
.add-note-btn {
align-self: flex-end;
padding: 10rpx 24rpx;
background-color: #1A73EC;
border-radius: 8rpx;
}
.add-note-text {
font-size: 24rpx;
color: #fff;
}
.form-input {
width: 100%;
height: 72rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 0 16rpx;
font-size: 26rpx;
box-sizing: border-box;
}
.fault-list {
margin-bottom: 12rpx;
}
.fault-item {
margin-bottom: 16rpx;
}
.fault-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.date-picker {
flex-shrink: 0;
width: 240rpx;
height: 72rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
display: flex;
align-items: center;
padding: 0 16rpx;
}
.date-text {
font-size: 24rpx;
color: #333;
}
.date-placeholder {
font-size: 24rpx;
color: #999;
}
.fault-reason-input {
flex: 1;
height: 72rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 0 16rpx;
font-size: 26rpx;
}
.delete-btn {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: #F44336;
display: flex;
align-items: center;
justify-content: center;
}
.delete-btn-text {
font-size: 36rpx;
color: #fff;
font-weight: 700;
line-height: 1;
}
.add-record-link {
padding: 8rpx 0;
}
.add-record-text {
font-size: 26rpx;
color: #1A73EC;
}
.status-toggle-row {
display: flex;
gap: 20rpx;
margin-bottom: 12rpx;
}
.toggle-btn {
flex: 1;
height: 72rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.4;
}
.toggle-green {
background-color: #4CAF50;
}
.toggle-red {
background-color: #F44336;
}
.toggle-active {
opacity: 1;
}
.toggle-text {
font-size: 28rpx;
color: #fff;
font-weight: 600;
}
.hint-text {
font-size: 22rpx;
color: #999;
}
.btn-row {
display: flex;
gap: 20rpx;
margin-top: 16rpx;
padding-bottom: 16rpx;
}
.btn {
flex: 1;
height: 80rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-cancel {
background-color: #f5f5f5;
border: 1rpx solid #ddd;
}
.btn-submit {
background-color: #1A73EC;
}
.btn-full {
flex: 1;
}
.btn-text {
font-size: 28rpx;
color: #666;
}
.btn-text-white {
font-size: 28rpx;
color: #fff;
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<view class="update-mask" v-if="visible" @click.stop>
<view class="update-dialog" @click.stop>
<image class="update-icon" src="/static/images/ic_update.png" mode="aspectFit" />
<text class="update-title">有新版本请更新</text>
<view class="update-btn" @click="handleUpdate">
<text class="update-btn-text">去更新</text>
</view>
</view>
</view>
</template>
<script setup>
const props = defineProps({
visible: { type: Boolean, default: false },
downloadUrl: { type: String, default: '' }
})
function handleUpdate() {
if (!props.downloadUrl) return
// #ifdef APP-PLUS
plus.runtime.openURL(props.downloadUrl)
// #endif
// #ifdef H5
window.open(props.downloadUrl)
// #endif
}
</script>
<style scoped>
.update-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.update-dialog {
display: flex;
flex-direction: column;
align-items: center;
width: 560rpx;
padding: 60rpx 40rpx;
background-color: #fff;
border-radius: 24rpx;
}
.update-icon {
width: 160rpx;
height: 160rpx;
margin-bottom: 40rpx;
}
.update-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 48rpx;
}
.update-btn {
width: 400rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #1A73EC;
border-radius: 40rpx;
}
.update-btn-text {
font-size: 30rpx;
color: #fff;
font-weight: 500;
}
</style>

20
odf-uniapp/index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>

22
odf-uniapp/main.js Normal file
View File

@ -0,0 +1,22 @@
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif

72
odf-uniapp/manifest.json Normal file
View File

@ -0,0 +1,72 @@
{
"name" : "odf-uniapp",
"appid" : "__UNI__45FFD83",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}

50
odf-uniapp/pages.json Normal file
View File

@ -0,0 +1,50 @@
{
"pages": [
{
"path": "pages/start/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/login/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/home/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "", "enablePullDownRefresh": true }
},
{
"path": "pages/region/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/room/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "", "enablePullDownRefresh": true }
},
{
"path": "pages/rack/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "", "enablePullDownRefresh": true }
},
{
"path": "pages/rack-detail/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/search/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/settings/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
},
{
"path": "pages/change-password/index",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "" }
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "绥时录",
"navigationBarBackgroundColor": "#1A73EC",
"backgroundColor": "#F5F5F5"
}
}

View File

@ -0,0 +1,179 @@
<template>
<view class="change-password-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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="form-area">
<view class="input-wrap">
<input
class="input-field"
v-model="oldPassword"
placeholder="请输入旧密码"
placeholder-class="placeholder"
password
/>
</view>
<view class="input-wrap">
<input
class="input-field"
v-model="newPassword"
placeholder="请输入新密码"
placeholder-class="placeholder"
password
/>
</view>
<view class="submit-btn" @click="handleSubmit">
<text class="submit-btn-text">确认修改</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { updatePassword } from '@/services/auth'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const oldPassword = ref('')
const newPassword = ref('')
function goBack() {
uni.navigateBack()
}
async function handleSubmit() {
if (!oldPassword.value) {
uni.showToast({ title: '请输入旧密码!', icon: 'none' })
return
}
if (!newPassword.value) {
uni.showToast({ title: '请输入新密码!', icon: 'none' })
return
}
const res = await updatePassword(oldPassword.value, newPassword.value)
if (res.code === 200) {
uni.showToast({ title: '修改成功', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else if (res.code === 110) {
uni.showToast({ title: res.msg, icon: 'none' })
}
}
</script>
<style scoped>
.change-password-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.form-area {
padding: 40rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.input-wrap {
width: 560rpx;
height: 90rpx;
background-color: #ECEFF3;
border: 1rpx solid rgba(0, 0, 0, 0.08);
border-radius: 20rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
box-sizing: border-box;
margin-bottom: 30rpx;
}
.input-field {
width: 100%;
height: 100%;
font-size: 28rpx;
color: #333;
}
.placeholder {
color: #999;
}
.submit-btn {
margin-top: 60rpx;
width: 560rpx;
height: 90rpx;
background-color: #1A73EC;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.submit-btn-text {
font-size: 32rpx;
color: #fff;
}
</style>

View File

@ -0,0 +1,203 @@
<template>
<view class="home-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<view class="content">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar-inner">
<image
class="nav-icon"
src="/static/images/ic_refresh.png"
mode="aspectFit"
@click="handleRefresh"
/>
<text class="nav-title">公司列表</text>
<image
class="nav-icon"
src="/static/images/ic_set.png"
mode="aspectFit"
@click="goSettings"
/>
</view>
</view>
<!-- 搜索入口栏 -->
<view class="search-bar" @click="goSearch">
<image class="search-icon" src="/static/images/ic_search.png" mode="aspectFit" />
<text class="search-placeholder">请输入要搜索的备注内容</text>
</view>
<!-- 公司列表 -->
<scroll-view class="company-list" scroll-y>
<view
class="company-card"
v-for="item in companyList"
:key="item.deptId"
@click="goRegion(item)"
>
<text class="company-name">{{ item.deptName }}</text>
</view>
</scroll-view>
</view>
<!-- 版本更新弹窗 -->
<update-dialog :visible="showUpdate" :downloadUrl="updateUrl" />
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import store from '@/store'
import { getCompanyList, getDictUnitType, getDictBusinessType, checkAppVersion } from '@/services/home'
import updateDialog from '@/components/update-dialog.vue'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const companyList = ref([])
const showUpdate = ref(false)
const updateUrl = ref('')
async function loadCompanyList() {
const res = await getCompanyList()
if (res.code === 200) {
companyList.value = res.data || []
}
}
async function loadDictData() {
const [unitRes, bizRes] = await Promise.all([
getDictUnitType(),
getDictBusinessType()
])
if (unitRes.code === 200) {
store.dictUnitTypes = unitRes.data || []
}
if (bizRes.code === 200) {
store.dictBusinessTypes = bizRes.data || []
}
}
async function loadVersionCheck() {
const res = await checkAppVersion()
if (res.code === 200 && res.data && res.data.needUpdate) {
updateUrl.value = res.data.downloadUrl || ''
showUpdate.value = true
}
}
function handleRefresh() {
loadCompanyList()
}
function goSearch() {
uni.navigateTo({ url: '/pages/search/index' })
}
function goSettings() {
uni.navigateTo({ url: '/pages/settings/index' })
}
function goRegion(item) {
uni.navigateTo({ url: '/pages/region/index?deptId=' + item.deptId })
}
onLoad(() => {
loadCompanyList()
loadDictData()
loadVersionCheck()
})
onPullDownRefresh(() => {
loadCompanyList().finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style scoped>
.home-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.search-bar {
display: flex;
align-items: center;
margin: 16rpx 24rpx;
padding: 0 24rpx;
height: 72rpx;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 36rpx;
}
.search-icon {
width: 32rpx;
height: 32rpx;
margin-right: 16rpx;
}
.search-placeholder {
font-size: 26rpx;
color: #999;
}
.company-list {
padding: 16rpx 24rpx;
height: calc(100vh - 400rpx);
}
.company-card {
display: flex;
align-items: center;
padding: 32rpx 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.company-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<view class="login-page">
<text class="app-title">绥时录</text>
<view class="input-wrap">
<input
class="input-field"
v-model="username"
placeholder="请输入账号"
placeholder-class="placeholder"
/>
</view>
<view class="input-wrap">
<input
class="input-field"
v-model="password"
placeholder="请输入密码"
placeholder-class="placeholder"
password
/>
</view>
<view class="login-btn" @click="handleLogin">
<text class="login-btn-text">登录</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import store from '@/store'
import { appLogin, checkPermission } from '@/services/auth'
const username = ref('')
const password = ref('')
async function handleLogin() {
const res = await appLogin(username.value, password.value)
if (res.code === 200) {
const { jwt, userId, userName } = res.data
store.setAuth(jwt, userId, userName)
const permRes = await checkPermission()
store.isPermission = permRes.code === 200
uni.reLaunch({ url: '/pages/home/index' })
} else {
uni.showToast({ title: res.msg, icon: 'none' })
}
}
</script>
<style scoped>
.login-page {
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
background-image: url('/static/images/login_bg.png');
background-size: cover;
background-position: center;
}
.app-title {
margin-top: 200rpx;
font-size: 40rpx;
font-weight: 600;
color: #333;
}
.input-wrap {
margin-top: 60rpx;
width: 560rpx;
height: 90rpx;
background-color: #ECEFF3;
border: 1rpx solid rgba(0, 0, 0, 0.08);
border-radius: 20rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
box-sizing: border-box;
}
.input-field {
width: 100%;
height: 100%;
font-size: 28rpx;
color: #333;
}
.placeholder {
color: #999;
}
.login-btn {
margin-top: 160rpx;
width: 560rpx;
height: 90rpx;
background-color: #1A73EC;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.login-btn-text {
font-size: 32rpx;
color: #fff;
}
</style>

View File

@ -0,0 +1,327 @@
<template>
<view class="rack-detail-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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">{{ rackName }}详情</text>
<view class="nav-icon-placeholder" />
</view>
</view>
<!-- 机房名称 -->
<view class="room-name-bar">
<text class="room-name-text">{{ roomName }}</text>
</view>
<!-- 状态图例 -->
<view class="legend-bar">
<view class="legend-item">
<view class="legend-dot legend-dot-green" />
<text class="legend-label">已连接</text>
</view>
<view class="legend-item">
<view class="legend-dot legend-dot-red" />
<text class="legend-label">已断开</text>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-box">
<text class="loading-text">loading...</text>
</view>
<!-- Frame 列表 -->
<view v-else class="frame-list">
<view class="frame-card" v-for="frame in frameList" :key="frame.id">
<text class="frame-name">{{ frame.name }}</text>
<!-- 端口行 -->
<view class="port-row" v-for="(row, rowIdx) in frame.odfPortsList" :key="rowIdx">
<text class="row-name">{{ row.name }}</text>
<scroll-view class="port-scroll" scroll-x>
<view class="port-list">
<view
class="port-item"
v-for="port in row.rowList"
:key="port.id"
@click="openPortEdit(port)"
>
<view
class="port-circle"
:class="port.status === 1 ? 'port-green' : 'port-red'"
>
<text class="port-tips">{{ port.tips }}</text>
</view>
<text class="port-name">{{ port.name }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</view>
<!-- 端口编辑弹窗 -->
<portEditDialog
:visible="showPortEdit"
:portId="currentPortId"
@close="showPortEdit = false"
@saved="onPortSaved"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getRackDetail } from '@/services/machine'
import store from '@/store'
import portEditDialog from '@/components/port-edit-dialog.vue'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const rackId = ref('')
const rackName = ref('')
const roomName = ref('')
const frameList = ref([])
const loading = ref(false)
const showPortEdit = ref(false)
const currentPortId = ref('')
let pendingPortId = ''
async function loadRackDetail() {
loading.value = true
try {
const res = await getRackDetail(rackId.value)
if (res.code === 200 && res.data) {
frameList.value = res.data
}
} finally {
loading.value = false
//
if (pendingPortId) {
currentPortId.value = pendingPortId
showPortEdit.value = true
pendingPortId = ''
}
}
}
function goBack() {
uni.navigateBack()
}
function openPortEdit(port) {
currentPortId.value = port.id
showPortEdit.value = true
}
function onPortSaved() {
showPortEdit.value = false
loadRackDetail()
}
onLoad((options) => {
if (options.rackId) {
rackId.value = options.rackId
}
if (options.rackName) {
rackName.value = decodeURIComponent(options.rackName)
}
if (options.roomName) {
roomName.value = decodeURIComponent(options.roomName)
}
if (options.portId) {
pendingPortId = options.portId
}
loadRackDetail()
})
</script>
<style scoped>
.rack-detail-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.room-name-bar {
padding: 8rpx 24rpx 0;
}
.room-name-text {
font-size: 30rpx;
font-weight: 700;
color: #1A73EC;
}
.legend-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
gap: 32rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.legend-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
}
.legend-dot-green {
background-color: #4CAF50;
}
.legend-dot-red {
background-color: #F44336;
}
.legend-label {
font-size: 24rpx;
color: #666;
}
.loading-box {
display: flex;
justify-content: center;
padding: 60rpx 0;
}
.loading-text {
font-size: 28rpx;
color: #999;
}
.frame-list {
padding: 0 24rpx 24rpx;
}
.frame-card {
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.frame-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.port-row {
margin-bottom: 20rpx;
}
.row-name {
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
}
.port-scroll {
width: 100%;
white-space: nowrap;
}
.port-list {
display: flex;
flex-direction: row;
gap: 16rpx;
}
.port-item {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.port-circle {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.port-green {
background-color: #4CAF50;
}
.port-red {
background-color: #F44336;
}
.port-tips {
font-size: 20rpx;
color: #fff;
text-align: center;
}
.port-name {
font-size: 20rpx;
color: #333;
margin-top: 6rpx;
text-align: center;
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<view class="rack-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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="rack-list">
<view
class="rack-card"
v-for="item in rackList"
:key="item.id"
@click="goDetail(item)"
>
<text class="rack-name">{{ item.rackName }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getRackList } from '@/services/machine'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const rackList = ref([])
const roomIdRef = ref('')
const roomName = ref('')
const pageNum = ref(1)
const pageSize = 20
const totalPage = ref(0)
const loading = ref(false)
async function loadRackList(isLoadMore = false) {
if (loading.value) return
loading.value = true
try {
const res = await getRackList(pageNum.value, pageSize, roomIdRef.value)
if (res.code === 200 && res.data) {
totalPage.value = res.data.totalPage || 0
if (isLoadMore) {
rackList.value = [...rackList.value, ...(res.data.result || [])]
} else {
rackList.value = res.data.result || []
}
}
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack()
}
function goDetail(item) {
uni.navigateTo({
url: '/pages/rack-detail/index?rackId=' + item.id + '&rackName=' + encodeURIComponent(item.rackName) + '&roomName=' + encodeURIComponent(roomName.value)
})
}
onLoad((options) => {
if (options.roomId) {
roomIdRef.value = options.roomId
}
if (options.roomName) {
roomName.value = decodeURIComponent(options.roomName)
}
loadRackList()
})
onPullDownRefresh(() => {
pageNum.value = 1
loadRackList().finally(() => {
uni.stopPullDownRefresh()
})
})
onReachBottom(() => {
if (pageNum.value >= totalPage.value) return
pageNum.value++
loadRackList(true)
})
</script>
<style scoped>
.rack-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.rack-list {
padding: 16rpx 24rpx;
}
.rack-card {
display: flex;
align-items: center;
padding: 32rpx 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.rack-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<view class="region-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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>
<!-- 地区列表 -->
<scroll-view class="region-list" scroll-y>
<view
class="region-card"
v-for="item in regionList"
:key="item.deptId"
@click="goRoom(item)"
>
<text class="region-name">{{ item.deptName }}</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getRegionList } from '@/services/machine'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const regionList = ref([])
async function loadRegionList(deptId) {
const res = await getRegionList(deptId)
if (res.code === 200) {
regionList.value = res.data || []
}
}
function goBack() {
uni.navigateBack()
}
function goRoom(item) {
uni.navigateTo({ url: '/pages/room/index?deptId=' + item.deptId })
}
onLoad((options) => {
if (options.deptId) {
loadRegionList(options.deptId)
}
})
</script>
<style scoped>
.region-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.region-list {
padding: 16rpx 24rpx;
height: calc(100vh - 300rpx);
}
.region-card {
display: flex;
align-items: center;
padding: 32rpx 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.region-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
</style>

View File

@ -0,0 +1,179 @@
<template>
<view class="room-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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="room-list">
<view
class="room-card"
v-for="item in roomList"
:key="item.id"
@click="goRack(item)"
>
<text class="room-name">{{ item.roomName }}</text>
<text class="room-address">{{ item.roomAddress }}</text>
<text class="room-odf">ODF: {{ item.racksCount }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getRoomList } from '@/services/machine'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const roomList = ref([])
const deptIdRef = ref('')
const pageNum = ref(1)
const pageSize = 20
const totalPage = ref(0)
const loading = ref(false)
async function loadRoomList(isLoadMore = false) {
if (loading.value) return
loading.value = true
try {
const res = await getRoomList(pageNum.value, pageSize, deptIdRef.value)
if (res.code === 200 && res.data) {
totalPage.value = res.data.totalPage || 0
if (isLoadMore) {
roomList.value = [...roomList.value, ...(res.data.result || [])]
} else {
roomList.value = res.data.result || []
}
}
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack()
}
function goRack(item) {
uni.navigateTo({
url: '/pages/rack/index?roomId=' + item.id + '&roomName=' + encodeURIComponent(item.roomName)
})
}
onLoad((options) => {
if (options.deptId) {
deptIdRef.value = options.deptId
loadRoomList()
}
})
onPullDownRefresh(() => {
pageNum.value = 1
loadRoomList().finally(() => {
uni.stopPullDownRefresh()
})
})
onReachBottom(() => {
if (pageNum.value >= totalPage.value) return
pageNum.value++
loadRoomList(true)
})
</script>
<style scoped>
.room-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.room-list {
padding: 16rpx 24rpx;
}
.room-card {
display: flex;
flex-direction: column;
padding: 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.room-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
margin-bottom: 12rpx;
}
.room-address {
font-size: 26rpx;
color: #666;
margin-bottom: 8rpx;
}
.room-odf {
font-size: 24rpx;
color: #999;
}
</style>

View File

@ -0,0 +1,360 @@
<template>
<view class="search-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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="search-bar">
<input
class="search-input"
v-model="keyword"
placeholder="请输入要搜索的备注内容"
confirm-type="search"
@confirm="doSearch"
/>
<view class="search-btn" @click="doSearch">
<text class="search-btn-text">搜索</text>
</view>
</view>
<!-- 搜索结果 -->
<view class="result-area" v-if="searched">
<!-- 机房区域 -->
<view class="section" v-if="rooms.length > 0">
<text class="section-title">机房</text>
<view
class="room-card"
v-for="item in rooms"
:key="item.roomId"
@click="goRack(item)"
>
<text class="room-card-name">{{ item.roomName }}</text>
</view>
</view>
<!-- 备注信息区域 -->
<view class="section" v-if="ports.length > 0">
<text class="section-title">备注信息</text>
<view
class="port-card"
v-for="item in ports"
:key="item.id"
@click="goPortDetail(item)"
>
<view class="port-card-row">
<text class="port-label">机房</text>
<text class="port-value">{{ item.roomName }}</text>
</view>
<view class="port-card-row" v-if="item.address">
<text class="port-label">地址</text>
<text class="port-value">{{ item.address }}</text>
</view>
<view class="port-card-row">
<text class="port-label">ODF名称</text>
<text class="port-value">{{ item.rackName }}</text>
</view>
<view class="port-card-row">
<text class="port-label">点位置</text>
<text class="port-value">{{ item.frameName }}{{ item.name }}</text>
</view>
<view class="port-card-row" v-if="item.remarks">
<text class="port-label">备注</text>
<text class="port-value">{{ item.remarks }}</text>
</view>
<view class="port-card-row" v-if="item.opticalAttenuation">
<text class="port-label">光衰信息</text>
<text class="port-value">{{ item.opticalAttenuation }}</text>
</view>
<view class="port-card-row" v-if="item.historyRemarks">
<text class="port-label">历史故障</text>
<text class="port-value">{{ item.historyRemarks }}</text>
</view>
<view class="port-card-row" v-if="item.opticalCableOffRemarks">
<text class="port-label">光缆段信息</text>
<text class="port-value">{{ item.opticalCableOffRemarks }}</text>
</view>
<view class="port-card-row">
<text class="port-label">状态</text>
<view class="status-wrap">
<view
class="status-dot"
:class="item.status === 1 ? 'status-green' : 'status-red'"
/>
<text class="status-text">{{ item.status === 1 ? '已连接' : '已断开' }}</text>
</view>
</view>
</view>
</view>
<!-- 无结果 -->
<view class="no-result" v-if="rooms.length === 0 && ports.length === 0">
<text class="no-result-text">暂无搜索结果</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import { searchPorts } from '@/services/search'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
const keyword = ref('')
const rooms = ref([])
const ports = ref([])
const searched = ref(false)
const pageNum = ref(1)
const pageSize = 20
const totalPage = ref(0)
const loading = ref(false)
async function doSearch() {
const key = keyword.value.trim()
if (!key) return
pageNum.value = 1
loading.value = true
try {
const res = await searchPorts(key, 1, pageSize)
if (res.code === 200 && res.data) {
rooms.value = res.data.rooms || []
const portsData = res.data.ports || {}
ports.value = portsData.result || []
totalPage.value = portsData.totalPage || 0
}
} finally {
loading.value = false
searched.value = true
}
}
async function loadMore() {
if (loading.value) return
if (pageNum.value >= totalPage.value) return
loading.value = true
pageNum.value++
try {
const res = await searchPorts(keyword.value.trim(), pageNum.value, pageSize)
if (res.code === 200 && res.data) {
const portsData = res.data.ports || {}
ports.value = [...ports.value, ...(portsData.result || [])]
totalPage.value = portsData.totalPage || 0
}
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack()
}
function goRack(item) {
uni.navigateTo({
url: '/pages/rack/index?roomId=' + item.roomId + '&roomName=' + encodeURIComponent(item.roomName)
})
}
function goPortDetail(item) {
uni.navigateTo({
url: '/pages/rack-detail/index?rackId=' + item.rackId + '&rackName=' + encodeURIComponent(item.rackName) + '&roomName=' + encodeURIComponent(item.roomName) + '&portId=' + item.id
})
}
onReachBottom(() => {
loadMore()
})
</script>
<style scoped>
.search-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
}
.search-input {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
background-color: #fff;
border-radius: 12rpx;
font-size: 28rpx;
}
.search-btn {
margin-left: 16rpx;
padding: 0 28rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #1A73EC;
border-radius: 12rpx;
}
.search-btn-text {
color: #fff;
font-size: 28rpx;
}
.result-area {
padding: 16rpx 24rpx;
}
.section {
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.room-card {
display: flex;
align-items: center;
padding: 32rpx 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.room-card-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.port-card {
padding: 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.port-card-row {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
}
.port-card-row:last-child {
margin-bottom: 0;
}
.port-label {
font-size: 26rpx;
color: #999;
flex-shrink: 0;
}
.port-value {
font-size: 26rpx;
color: #333;
flex: 1;
}
.status-wrap {
display: flex;
align-items: center;
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-right: 8rpx;
}
.status-green {
background-color: #4CAF50;
}
.status-red {
background-color: #F44336;
}
.status-text {
font-size: 26rpx;
color: #333;
}
.no-result {
display: flex;
justify-content: center;
padding: 80rpx 0;
}
.no-result-text {
font-size: 28rpx;
color: #999;
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<view class="settings-page">
<image class="bg-image" src="/static/images/home_bg.png" mode="aspectFill" />
<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="settings-list">
<view class="settings-card" @click="goChangePassword">
<text class="settings-label">修改密码</text>
</view>
<view class="settings-card" @click="handleLogout">
<text class="settings-label logout-text">退出登录</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import store from '@/store'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
function goBack() {
uni.navigateBack()
}
function goChangePassword() {
uni.navigateTo({ url: '/pages/change-password/index' })
}
function handleLogout() {
store.clearAuth()
uni.reLaunch({ url: '/pages/login/index' })
}
</script>
<style scoped>
.settings-page {
position: relative;
min-height: 100vh;
background-color: #F5F5F5;
}
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 500rpx;
z-index: 0;
}
.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;
}
.settings-list {
padding: 16rpx 24rpx;
}
.settings-card {
display: flex;
align-items: center;
padding: 32rpx 24rpx;
margin-bottom: 20rpx;
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.settings-label {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.logout-text {
color: #E53935;
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<view class="start-page">
<text class="app-name">绥时录</text>
</view>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app'
import store from '@/store'
import { checkPermission } from '@/services/auth'
onLoad(() => {
if (store.token) {
checkPermission().then((res) => {
if (res.code === 200) {
store.isPermission = true
uni.reLaunch({ url: '/pages/home/index' })
} else if (res.code === 403) {
store.isPermission = false
uni.reLaunch({ url: '/pages/home/index' })
} else if (res.code === 401) {
uni.reLaunch({ url: '/pages/login/index' })
}
}).catch(() => {
uni.reLaunch({ url: '/pages/login/index' })
})
} else {
uni.reLaunch({ url: '/pages/login/index' })
}
})
</script>
<style scoped>
.start-page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background-color: #ffffff;
}
.app-name {
font-size: 48rpx;
font-weight: bold;
color: #1A73EC;
}
</style>

View File

@ -0,0 +1,42 @@
// services/api.js
import store from '@/store'
const BASE_URL = 'http://49.233.115.141:11082'
const TIMEOUT = 20000
/**
* 统一请求封装
* @param {string} method - GET | POST
* @param {string} url - 接口路径
* @param {object} data - 请求参数
* @returns {Promise<{code, msg, data}>}
*/
export function request(method, url, data = {}) {
return new Promise((resolve, reject) => {
const header = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${store.token}`,
'Userid': store.userId,
'Username': store.userName
}
uni.request({
url: BASE_URL + url,
method,
data: method === 'GET' ? undefined : data,
// GET 参数通过 data 字段传递UniApp 会自动拼接为 queryString
...(method === 'GET' ? { data } : {}),
header,
timeout: TIMEOUT,
success(res) {
const { code, msg, data: resData } = res.data
resolve({ code, msg, data: resData })
},
fail(err) {
reject({ code: -1, msg: err.errMsg || '网络异常' })
}
})
})
}
export const get = (url, params) => request('GET', url, params)
export const post = (url, data) => request('POST', url, data)

View File

@ -0,0 +1,6 @@
import { get, post } from './api'
export const appLogin = (username, password) => post('/appLogin', { username, password })
export const checkPermission = () => get('/business/OdfPorts/odf')
export const updatePassword = (oldPassword, newPassword) =>
post('/system/user/profile/updateUserPwd', { oldPassword, newPassword })

View File

@ -0,0 +1,6 @@
import { get } from './api'
export const getCompanyList = () => get('/business/OdfRooms/getcompany')
export const getDictUnitType = () => get('/system/dict/data/type/odf_ports_unit_type')
export const getDictBusinessType = () => get('/system/dict/data/type/odf_ports_business_type')
export const checkAppVersion = (version) => get('/webapi/CheckAppVersion', { version })

View File

@ -0,0 +1,10 @@
import { get, post } from './api'
export const getRegionList = (deptId) => get('/business/OdfRooms/getregion', { deptId })
export const getRoomList = (pageNum, pageSize, deptId) =>
get('/business/OdfRooms/list', { pageNum, pageSize, deptId })
export const getRackList = (pageNum, pageSize, roomId) =>
get('/business/OdfRacks/list', { pageNum, pageSize, roomId })
export const getRackDetail = (RackId) => get('/business/OdfPorts/mlist', { RackId })
export const getPortDetail = (id) => get(`/business/OdfPorts/${id}`)
export const savePort = (data) => post('/business/OdfPorts/save', data)

View File

@ -0,0 +1,4 @@
import { get } from './api'
export const searchPorts = (key, pageNum, pageSize) =>
get('/business/OdfPorts/search2', { key, pageNum, pageSize })

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

BIN
odf-uniapp/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

37
odf-uniapp/store/index.js Normal file
View File

@ -0,0 +1,37 @@
// store/index.js
import { reactive } from 'vue'
const store = reactive({
// 认证信息
token: uni.getStorageSync('token') || '',
userId: uni.getStorageSync('userId') || '',
userName: uni.getStorageSync('userName') || '',
isPermission: false,
// 字典数据
dictUnitTypes: [], // 设备型号列表
dictBusinessTypes: [], // 业务类型列表
// 设置认证信息
setAuth(token, userId, userName) {
this.token = token
this.userId = userId
this.userName = userName
uni.setStorageSync('token', token)
uni.setStorageSync('userId', userId)
uni.setStorageSync('userName', userName)
},
// 清除认证信息
clearAuth() {
this.token = ''
this.userId = ''
this.userName = ''
this.isPermission = false
uni.removeStorageSync('token')
uni.removeStorageSync('userId')
uni.removeStorageSync('userName')
}
})
export default store

View File

@ -0,0 +1,13 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});

88
odf-uniapp/uni.scss Normal file
View File

@ -0,0 +1,88 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量同时无需 import 这个文件
*/
/* ========== 绥时录自定义变量 ========== */
$theme-color: #1A73EC;
$bg-color-page: #F5F5F5;
$bg-color-card: #FFFFFF;
$text-color-primary: #333333;
$text-color-secondary: #666666;
$text-color-placeholder: #999999;
$color-connected: #4CAF50;
$color-disconnected: #F44336;
$border-radius-card: 12rpx;
$spacing-page: 24rpx;
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #1A73EC;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;