feat: 超级管理员前端完整开发 + UI问题修复

新增功能:
- 超级管理员前端完整页面开发(账号管理/权限管理/审计日志/系统管理)
- Mock适配器(支持独立运行无需后端)
- 共享组件库(QueryPanel/DataTable/Pagination/ActionBar/Breadcrumb/StatusTag等)
- AdminLayout布局(侧边栏/头部/标签页)
- v-hasPermission自定义指令注册
- 路由守卫与权限控制

UI问题修复:
- 注册v-hasPermission自定义指令(修复控制台警告)
- DataTable显式导入DocumentDelete图标
- QueryPanel添加el-select/input最小宽度约束(修复下拉框文字截断)
- 10个列表页主列从width改为min-width(修复宽屏右侧空白)
- Pagination组件支持对象传参+独立传参双模式(修复分页不可见)
- index.html viewport改为桌面端width=1920(去掉Pad适配)
- el-link underline从boolean改为never(消除75条废弃API警告)

文档更新:
- docs/07-前端界面开发规范.md 新增9.6浏览器渲染验证+9.7预防规则
- 功能清单/接口规范/技术要求等文档同步更新
master
jiang 3 weeks ago
parent da8b02b492
commit d88b91f3c9

@ -3,6 +3,7 @@
> 模块编码contract > 模块编码contract
> 端侧Web专属仅医院账号 > 端侧Web专属仅医院账号
> 关联文档01-模块划分 §4.1 / 02-功能清单-医院 §1 / 03-业务流转逻辑-医院 §1 / 05-接口规范 §9.2 > 关联文档01-模块划分 §4.1 / 02-功能清单-医院 §1 / 03-业务流转逻辑-医院 §1 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -85,6 +86,59 @@
| 列表查询 | /api/v1/contracts | GET | 分页查询 | | 列表查询 | /api/v1/contracts | GET | 分页查询 |
| 导出 | /api/v1/contracts/export | GET | — | | 导出 | /api/v1/contracts/export | GET | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用合同列表API默认分页20条/页)→ 渲染列表到期日≤30天的行标记橙色
2. **查询/筛选交互流程**:填写筛选条件 → 点击"查询"按钮 → 调用API携带筛选参数 → 刷新列表;点击"重置"清空所有条件并重新加载
3. **表单填写与提交流程**:点击"新增合同" → 跳转合同录入页;点击"导出Excel" → 调用导出API → 下载文件
4. **弹窗/抽屉交互流程**:无弹窗交互,操作均跳转页面
5. **行内操作流程**:点击"查看"跳转合同详情页;点击"审批"(状态=审批中时显示)跳转合同详情页审批区域
6. **异常与错误处理**API请求失败显示el-message错误提示列表为空显示el-empty导出失败提示"导出失败,请重试"
7. **联动/级联交互**状态筛选与列表数据联动筛选后分页重置为第1页
8. **权限控制交互表现**:无新增权限时隐藏"新增合同"按钮;无导出权限时隐藏"导出Excel"按钮;无审批权限时行操作不显示"审批"
### 前端硬性约束
- **H1 防重复提交(强制)**: "查询"/"导出Excel"按钮请求期间置 loading+disabled 状态;行操作(查看/审批)点击后禁用该行所有操作按钮直至请求完成;翻页/切换每页条数时 abort 未完成的列表请求
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s导出(GET大文件场景)超时 60s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: 本页面无危险操作需二次确认
- **H5 权限隔离(建议)**: 当后端返回空列表且 code=200 时展示"暂无数据";当返回 code=403 时展示"无权限访问该模块",引导联系管理员
- **H6 批量限制(建议)**: 导出Excel限制单次≤500条超出时提示"数据量过大,请缩小筛选范围后导出"
- **H8 反馈机制(建议)**: 查询成功静默刷新(无 success 提示);查询/导出失败显示 error 提示duration=0网络异常时显示重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 合同名称输入 | el-input | placeholder="请输入合同名称"clearablemaxlength=100 |
| 合同类型选择 | el-select | placeholder="请选择合同类型"clearableoptions: 保洁/维修/安保/综合/其他 |
| 状态选择 | el-select | placeholder="请选择状态"clearablemultiple=false |
| 物业公司选择 | el-select | placeholder="请选择物业公司"clearableremote-method动态加载 |
| 签约日期范围 | el-date-picker | type="daterange"start-placeholder="开始日期"end-placeholder="结束日期" |
| 查询按钮 | el-button | type="primary"icon="Search" |
| 重置按钮 | el-button | type="default"icon="Refresh" |
| 新增合同按钮 | el-button | type="primary"icon="Plus" |
| 导出Excel按钮 | el-button | type="success"icon="Download" |
| 数据列表 | el-table | stripeborderrow-key="id"@row-click查看详情 |
| 合同类型列 | el-tag | :type根据合同类型配色 |
| 状态列 | el-tag | :type根据状态配色审批中=warning履约中=success即将到期=danger |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next":page-sizes="[10,20,50]" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 合同名称 | maxlength=100 | 合同名称不能超过100个字符 |
| 签约日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区单行排列,列表全字段展示,操作栏水平排列 |
| 1024-1279pxPad横屏 | 查询条件区双行排列,隐藏"序号""操作"列,操作收入"更多"下拉 |
| 768-1023pxPad竖屏 | 查询条件区纵向堆叠,仅显示合同名称/类型/状态筛选,列表显示核心字段(合同名称/物业公司/状态/到期日),操作列固定右侧 |
--- ---
## 页面2合同录入页 ## 页面2合同录入页
@ -137,6 +191,68 @@
| 新增 | /api/v1/contracts | POST | — | | 新增 | /api/v1/contracts | POST | — |
| 提交审批 | /api/v1/contracts/{id}/submit | POST | — | | 提交审批 | /api/v1/contracts/{id}/submit | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 渲染空表单 → 加载下拉选项数据(物业公司、院区、合同类型)
2. **查询/筛选交互流程**:不适用
3. **表单填写与提交流程**:逐项填写表单 → 上传合同附件(支持拖拽上传,上传后显示文件列表含预览/删除)→ 付款方式选"分期"时展开付款节点子表单(动态增删行)→ 点击"保存草稿"保存为草稿状态 / 点击"提交审批"触发校验后提交
4. **弹窗/抽屉交互流程**合同附件上传使用el-upload点击已上传文件可弹窗预览PDF使用iframe预览图片使用el-image-viewer预览
5. **行内操作流程**:付款节点子表单支持行内添加/删除节点
6. **异常与错误处理**:必填项未填时表单校验不通过,高亮未填字段并滚动到第一个错误字段;文件上传失败提示"上传失败"提交审批失败显示el-message错误提示
7. **联动/级联交互**:付款方式选择"分期"→展开付款节点子表单;付款方式选择"一次性"→隐藏付款节点;服务期限(止)自动校验需晚于(起)
8. **权限控制交互表现**:无创建权限时跳转回列表页
### 前端硬性约束
- **H1 防重复提交(强制)**: "保存草稿"/"提交审批"按钮请求期间置 loading+disabled 状态;提交后禁用所有表单交互直至请求完成
- **H2 超时控制(强制)**: 表单提交(POST)超时 30s文件上传超时 60s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "提交审批"操作前弹出 confirm("确定要提交该合同审批?提交后将进入审批流程,不可自行修改。", type='warning')
- **H4 脏数据检测(强制)**: 页面加载时 deep clone 初始表单数据为 _originData监听表单变化维护 isDirty 标志;用户未保存尝试离开(路由切换/关闭标签)时通过 beforeRouteLeave 拦截并弹窗确认
- **H7 文件上传(建议)**: 合同附件上传限制单个文件≤10MB、总数≤9个超出时提示"单文件不超过10MB附件总数不超过9个";支持 .pdf/.doc/.docx/.jpg/.png
- **H8 反馈机制(建议)**: 保存草稿成功显示 success 提示duration=2s+ 静默刷新页面状态;提交审批成功显示 success 提示duration=2s+ 延迟跳转;失败显示 error 提示duration=0网络异常时显示重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 合同名称输入 | el-input | placeholder="请输入合同名称"maxlength=100show-word-limit |
| 合同类型选择 | el-select | placeholder="请选择合同类型"options: 字典管理-合同类型 |
| 关联物业公司 | el-select | placeholder="请选择物业公司"filterableremote:remote-method搜索 |
| 关联院区 | el-select | placeholder="请选择院区"multiplecollapse-tagscollapse-tags-tooltip |
| 合同金额 | el-input-number | :min=0.01:precision=2controls-position="right" |
| 服务期限 | el-date-picker | type="daterange"start-placeholder="起始日期"end-placeholder="结束日期" |
| 付款方式 | el-select | placeholder="请选择付款方式"@change联动付款节点显示/隐藏 |
| 合同描述 | el-input | type="textarea":rows=4maxlength=500show-word-limit |
| 合同附件 | el-upload | action="/api/v1/files/upload":limit=10accept=".pdf,.doc,.docx,.jpg,.png":file-list.sync:on-preview预览:on-remove删除 |
| 付款节点表格 | el-table + el-button | 动态增删行,每行:节点名称/金额/日期/条件 |
| 保存草稿按钮 | el-button | type="default"@click保存草稿 |
| 提交审批按钮 | el-button | type="primary"@click提交审批:loading提交中 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 合同名称 | requiredmaxlength=100 | 请输入合同名称 / 合同名称不能超过100个字符 |
| 合同类型 | required | 请选择合同类型 |
| 关联物业公司 | required | 请选择关联物业公司 |
| 关联院区 | required | 请选择关联院区 |
| 合同金额 | required>0 | 请输入合同金额 / 合同金额必须大于0 |
| 服务期限(起) | required | 请选择服务起始日期 |
| 服务期限(止) | required晚于起始日期 | 请选择服务结束日期 / 结束日期必须晚于起始日期 |
| 付款方式 | required | 请选择付款方式 |
| 合同附件 | required≤10个文件 | 请上传合同附件 / 附件数量不能超过10个 |
| 节点名称 | required分期时 | 请输入节点名称 |
| 节点金额 | required分期时>0 | 请输入节点金额 / 节点金额必须大于0 |
| 预计付款日期 | required分期时 | 请选择预计付款日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单双列布局,付款节点子表单全宽展示 |
| 1024-1279pxPad横屏 | 表单单列布局,付款节点子表单全宽展示 |
| 768-1023pxPad竖屏 | 表单单列布局合同附件上传区域宽度100%,按钮组纵向堆叠 |
--- ---
## 页面3合同详情页 ## 页面3合同详情页
@ -164,6 +280,61 @@
| 申请变更 | contract:change:create | 底部 | 状态=履约中 | — | | 申请变更 | contract:change:create | 底部 | 状态=履约中 | — |
| 终止合同 | contract:list:update | 底部 | 状态=履约中 | 二次确认 | | 终止合同 | contract:list:update | 底部 | 状态=履约中 | 二次确认 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 根据合同ID调用详情API → 渲染基本信息及各标签页数据,默认展示"基本信息"标签页
2. **查询/筛选交互流程**:不适用(详情页无查询)
3. **表单填写与提交流程**:审批通过/驳回 → 点击按钮 → 弹出审批确认弹窗(通过直接确认,驳回需填写驳回原因)→ 确认后调用API申请变更 → 跳转变更申请页;终止合同 → 弹出二次确认弹窗
4. **弹窗/抽屉交互流程**审批驳回弹窗el-dialog含驳回原因多行文本输入终止合同确认弹窗el-message-box确认
5. **行内操作流程**:标签页切换展示不同内容区域;附件标签页点击文件可预览/下载
6. **异常与错误处理**合同ID无效跳转404审批/终止操作失败显示el-message错误提示
7. **联动/级联交互**:合同状态=审批中时显示审批按钮;状态=履约中时显示申请变更和终止按钮
8. **权限控制交互表现**:无审批权限隐藏审批按钮;无变更权限隐藏申请变更按钮;无终止权限隐藏终止合同按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "审批通过"/"审批驳回"/"申请变更"/"终止合同"按钮请求期间置 loading+disabled 状态;操作期间禁用同组其他按钮
- **H2 超时控制(强制)**: 详情加载(GET)超时 15s操作提交(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "终止合同"前弹出 confirm(`确定要终止合同${contractNo}?终止后该合同将无法恢复,相关付款节点将作废。`, type='error')"审批驳回"前弹出 confirm + 原因输入框(必填)
- **H8 反馈机制(建议)**: 操作成功显示 success 提示duration=2s+ 静默刷新详情数据;失败显示 error 提示duration=0网络异常时显示重试按钮
- **弹窗表单(审批驳回)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内确认按钮 loading+disabled
- **H2 超时控制(强制)**: 审批提交(POST)超时 30s
- **H3 操作确认(强制)**: 已由外层 H3 覆盖(驳回需填写原因)
- **H4 脏数据检测(强制)**: 弹窗打开时记录初始状态,有修改未提交尝试关闭弹窗时拦截确认
- **H8 反馈机制(建议)**: 成功后关闭弹窗 + 刷新列表 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 标签页 | el-tabs | type="border-card":tab-click切换标签页 |
| 基本信息 | el-descriptions | :column=2borderlabel-class-name="desc-label" |
| 付款节点列表 | el-table | :data=付款节点数据stripeshow-summary |
| 变更记录列表 | el-timeline | 展示变更历史时间线 |
| 关联项目 | el-link | :underline=falsetype="primary",点击跳转招标管理 |
| 附件列表 | el-upload | :file-list.syncdisabled:on-preview预览下载 |
| 审批通过按钮 | el-button | type="success"icon="Check" |
| 审批驳回按钮 | el-button | type="danger"icon="Close" |
| 申请变更按钮 | el-button | type="warning"icon="Edit" |
| 终止合同按钮 | el-button | type="danger"icon="SwitchButton" |
| 审批驳回弹窗 | el-dialog | title="审批驳回"width="500px"含el-input textarea |
| 终止确认弹窗 | el-message-box | type="warning"confirm-button-text="确认终止" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 驳回原因 | required审批驳回时 | 请输入驳回原因 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 标签页内容区双列描述列表,底部按钮水平居右排列 |
| 1024-1279pxPad横屏 | 标签页内容区单列描述列表,底部按钮水平居右排列 |
| 768-1023pxPad竖屏 | 标签页内容区单列描述列表,底部按钮纵向堆叠,附件列表自适应宽度 |
--- ---
## 页面4付款管理页 ## 页面4付款管理页
@ -196,6 +367,58 @@
|----------|----------|----------|----------|----------| |----------|----------|----------|----------|----------|
| 付款节点到期提醒 | 医院账号 | Web提醒 | 付款节点即将到期 | 03-医院 §1.2 | | 付款节点到期提醒 | 医院账号 | Web提醒 | 付款节点即将到期 | 03-医院 §1.2 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用付款列表API → 渲染列表,逾期状态行标红
2. **查询/筛选交互流程**:不适用(由合同筛选进入,可按合同名称/状态筛选)
3. **表单填写与提交流程**:点击"确认付款" → 弹出付款确认弹窗(填写实际付款日期、付款金额、付款备注)→ 确认后调用API → 刷新列表
4. **弹窗/抽屉交互流程**付款确认弹窗el-dialog含实际付款日期、付款金额、备注字段
5. **行内操作流程**:点击"确认付款"弹出付款确认弹窗
6. **异常与错误处理**付款确认失败显示el-message错误提示逾期状态高亮显示
7. **联动/级联交互**:付款状态与行操作按钮联动(仅待付款状态显示确认付款按钮)
8. **权限控制交互表现**:无付款确认权限时隐藏"确认付款"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "确认付款"行操作按钮点击后置 loading+disabled 状态;弹窗内确认按钮同样 loading+disabled操作期间禁用该行其他操作
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s付款确认(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "确认付款"前弹出 confirm("确定要确认该付款节点已付款?确认后状态将变更为\"已付款\"。", type='warning')
- **H5 权限隔离(建议)**: 当返回空列表展示"暂无付款记录"code=403 时展示"无权限访问"
- **H8 反馈机制(建议)**: 付款成功显示 successduration=2s+ 静默刷新列表;失败显示 errorduration=0网络异常显示重试按钮
- **弹窗表单(付款确认)额外约束**:
- **H1 防重复提交(强制)**: 弹窗确认按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST)超时 30s
- **H4 脏数据检测(强制)**: 弹窗内表单修改后尝试关闭时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 付款列表 | el-table | stripeborder:default-sort="{prop: 'expectedDate', order: 'ascending'}" |
| 状态列 | el-tag | :type根据状态配色待付款=warning已付款=success逾期=danger |
| 确认付款按钮 | el-button | type="primary"size="small"link样式 |
| 付款确认弹窗 | el-dialog | title="确认付款"width="500px" |
| 实际付款日期 | el-date-picker | type="date"placeholder="请选择实际付款日期" |
| 付款金额 | el-input-number | :min=0.01:precision=2 |
| 付款备注 | el-input | type="textarea":rows=3 |
| 确认按钮 | el-button | type="primary":loading提交中 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 实际付款日期 | required | 请选择实际付款日期 |
| 付款金额 | required>0 | 请输入付款金额 / 付款金额必须大于0 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示,操作列右对齐 |
| 1024-1279pxPad横屏 | 隐藏"节点名称"列,操作列固定右侧 |
| 768-1023pxPad竖屏 | 仅显示合同名称/节点金额/状态/操作核心字段,付款确认弹窗全屏宽度 |
--- ---
## 页面5变更管理页 ## 页面5变更管理页
@ -222,6 +445,59 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 审批 | contract:change:approve | 行操作 | 审批状态=待审批 | 通过/驳回 | | 审批 | contract:change:approve | 行操作 | 审批状态=待审批 | 通过/驳回 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用变更列表API → 渲染列表,默认按申请时间倒序
2. **查询/筛选交互流程**:不适用(由合同详情进入,可按合同名称/审批状态筛选)
3. **表单填写与提交流程**:不适用(变更申请由合同详情页发起)
4. **弹窗/抽屉交互流程**:点击"审批" → 弹出审批弹窗(选择通过/驳回,驳回需填写原因)→ 确认后调用API → 刷新列表;点击"查看" → 跳转变更详情页
5. **行内操作流程**:审批状态=待审批时显示"审批"按钮;始终显示"查看"按钮
6. **异常与错误处理**审批操作失败显示el-message错误提示列表为空显示el-empty
7. **联动/级联交互**:审批状态与操作按钮联动(待审批显示审批,其余仅查看)
8. **权限控制交互表现**:无审批权限时行操作仅显示"查看"
### 前端硬性约束
- **H1 防重复提交(强制)**: "审批"行操作按钮点击后置 loading+disabled 状态;弹窗内确认按钮同样 loading+disabled
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s变更审批(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: 审批驳回前弹出 confirm + 驳回原因输入框(必填);审批通过弹出 confirm("确定要通过该变更申请?通过后变更将立即生效。", type='warning')
- **H5 权限隔离(建议)**: 返回空列表展示"暂无变更记录"code=403 时展示"无权限访问"
- **H8 反馈机制(建议)**: 操作成功 successduration=2s+ 静默刷新列表;失败 errorduration=0网络异常显示重试按钮
- **弹窗表单(变更审批)额外约束**:
- **H1 防重复提交(强制)**: 弹窗确认按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST)超时 30s
- **H3 操作确认(强制)**: 已由外层 H3 覆盖
- **H4 脏数据检测(强制)**: 弹窗内审批结果/原因修改后尝试关闭时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 变更列表 | el-table | stripeborder:default-sort="{prop: 'applyTime', order: 'descending'}" |
| 变更类型列 | el-tag | :type根据类型配色金额变更=warning期限变更=info条款变更=primary |
| 审批状态列 | el-tag | :type根据状态配色待审批=warning已通过=success已驳回=danger |
| 审批按钮 | el-button | type="primary"size="small"link样式 |
| 查看按钮 | el-button | type="default"size="small"link样式 |
| 审批弹窗 | el-dialog | title="变更审批"width="500px" |
| 审批结果 | el-radio-group | 通过/驳回单选 |
| 驳回原因 | el-input | type="textarea":rows=3v-if="审批结果=驳回" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审批结果 | required | 请选择审批结果 |
| 驳回原因 | required审批结果=驳回时) | 请输入驳回原因 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示,操作列右对齐 |
| 1024-1279pxPad横屏 | 隐藏"变更原因"列(点击查看详情),操作列固定右侧 |
| 768-1023pxPad竖屏 | 仅显示合同名称/变更类型/审批状态/操作核心字段,审批弹窗全屏宽度 |
--- ---
## 页面6到期预警页 ## 页面6到期预警页
@ -248,6 +524,50 @@
| 合同到期前15天 | 医院账号 | Web提醒 | 合同即将到期 | — | | 合同到期前15天 | 医院账号 | Web提醒 | 合同即将到期 | — |
| 合同到期前7天 | 医院账号 | Web提醒 | 合同即将到期 | — | | 合同到期前7天 | 医院账号 | Web提醒 | 合同即将到期 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用到期预警列表API → 渲染列表剩余天数≤7天行标红≤30天行标橙色
2. **查询/筛选交互流程**:不适用(仅展示即将到期合同,可按物业公司/剩余天数筛选)
3. **表单填写与提交流程**:不适用
4. **弹窗/抽屉交互流程**:点击"续签" → 弹出续签确认弹窗(确认续签后跳转合同录入页,自动填充原合同信息);点击"终止" → 弹出终止确认弹窗el-message-box二次确认
5. **行内操作流程**:点击"续签"或"终止"执行对应操作
6. **异常与错误处理**:续签/终止操作失败显示el-message错误提示列表为空显示el-empty提示"暂无即将到期的合同"
7. **联动/级联交互**:合同状态与操作按钮联动(仅履约中状态显示续签/终止按钮)
8. **权限控制交互表现**:无续签权限隐藏"续签"按钮;无终止权限隐藏"终止"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "续签"/"终止"行操作按钮点击后置 loading+disabled 状态;操作期间禁用该行其他操作
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s操作提交(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "终止合同"前弹出 confirm(`确定要终止合同${contractNo}?终止后该合同将作废且无法恢复。`, type='error')"续签"前弹出 confirm("确定要续签该合同?将跳转至合同录入页并自动填充原合同信息。", type='warning')
- **H5 权限隔离(建议)**: 返回空列表展示"暂无即将到期的合同"code=403 时展示"无权限访问"
- **H8 反馈机制(建议)**: 操作成功 successduration=2s+ 静默刷新列表;失败 errorduration=0网络异常显示重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 预警列表 | el-table | stripeborder:row-class-name根据剩余天数设置行样式 |
| 剩余天数列 | el-tag | :type根据天数配色≤7天=danger≤15天=warning≤30天=info |
| 续签按钮 | el-button | type="primary"size="small"link样式icon="RefreshRight" |
| 终止按钮 | el-button | type="danger"size="small"link样式icon="SwitchButton" |
| 续签确认弹窗 | el-message-box | title="确认续签"type="info"confirm-button-text="确认续签" |
| 终止确认弹窗 | el-message-box | title="确认终止"type="warning"confirm-button-text="确认终止" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示,操作列右对齐 |
| 1024-1279pxPad横屏 | 隐藏"物业公司"列,操作列固定右侧 |
| 768-1023pxPad竖屏 | 仅显示合同名称/到期日期/剩余天数/操作核心字段,操作按钮改为图标按钮 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码bidding > 模块编码bidding
> 端侧Web专属仅医院账号 > 端侧Web专属仅医院账号
> 关联文档01-模块划分 §4.2 / 02-功能清单-医院 §2 / 03-业务流转逻辑-医院 §2 / 05-接口规范 §9.2 > 关联文档01-模块划分 §4.2 / 02-功能清单-医院 §2 / 03-业务流转逻辑-医院 §2 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -70,6 +71,69 @@
| 新增 | /api/v1/bidding-plans | POST | — | | 新增 | /api/v1/bidding-plans | POST | — |
| 编辑 | /api/v1/bidding-plans/{id} | PUT | — | | 编辑 | /api/v1/bidding-plans/{id} | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用招标计划列表API → 渲染列表
2. **查询/筛选交互流程**:填写筛选条件 → 点击"查询"按钮 → 调用API携带筛选参数 → 刷新列表;点击"重置"清空条件重新加载
3. **表单填写与提交流程**:点击"新增招标计划" → 弹出新增弹窗(或跳转新增页)→ 填写表单 → 提交保存;点击"编辑"(状态=计划中)→ 弹出编辑弹窗 → 修改后保存
4. **弹窗/抽屉交互流程**:新增/编辑弹窗el-dialog含招标计划表单字段
5. **行内操作流程**:点击"查看"跳转计划详情页;点击"编辑"弹出编辑弹窗
6. **异常与错误处理**API请求失败显示el-message错误提示投标截止日期早于当前时间提交时提示错误
7. **联动/级联交互**状态筛选与列表数据联动筛选后分页重置为第1页
8. **权限控制交互表现**:无创建权限隐藏"新增招标计划"按钮;状态非"计划中"时隐藏"编辑"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "查询"/"新增招标计划"按钮请求期间置 loading+disabled 状态;行操作(编辑/查看)点击后禁用该行操作直至请求完成;翻页时 abort 未完成请求
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s表单提交(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: 本页面无危险操作需二次确认
- **H5 权限隔离(建议)**: 返回空列表展示"暂无招标计划"code=403 时展示"无权限访问"
- **H6 批量限制(建议)**: 本页面无批量导出操作
- **H8 反馈机制(建议)**: 操作成功 successduration=2s+ 静默刷新;失败 errorduration=0网络异常显示重试按钮
- **弹窗表单(新增/编辑招标计划)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内提交按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST/PUT)超时 30s
- **H4 脏数据检测(强制)**: 弹窗内表单修改后尝试关闭时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 计划名称输入 | el-input | placeholder="请输入计划名称"clearablemaxlength=100 |
| 状态选择 | el-select | placeholder="请选择状态"clearable |
| 招标方式选择 | el-select | placeholder="请选择招标方式"clearableoptions: 公开招标/邀请招标/竞争性谈判 |
| 查询按钮 | el-button | type="primary"icon="Search" |
| 重置按钮 | el-button | type="default"icon="Refresh" |
| 新增招标计划按钮 | el-button | type="primary"icon="Plus" |
| 计划列表 | el-table | stripeborderrow-key="id" |
| 状态列 | el-tag | :type根据状态配色计划中=info投标中=warning已完成=success |
| 编辑按钮 | el-button | type="primary"size="small"link样式 |
| 查看按钮 | el-button | type="default"size="small"link样式 |
| 新增/编辑弹窗 | el-dialog | title="新增招标计划/编辑招标计划"width="600px" |
| 关联项目选择 | el-select | placeholder="请选择关联项目"filterable |
| 预算金额 | el-input-number | :min=0.01:precision=2controls-position="right" |
| 投标截止日期 | el-date-picker | type="datetime"placeholder="请选择投标截止日期" |
| 计划描述 | el-input | type="textarea":rows=4maxlength=500show-word-limit |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 计划名称 | requiredmaxlength=100 | 请输入计划名称 / 计划名称不能超过100个字符 |
| 关联项目 | required | 请选择关联项目 |
| 招标方式 | required | 请选择招标方式 |
| 预算金额 | required>0 | 请输入预算金额 / 预算金额必须大于0 |
| 投标截止日期 | required晚于当前时间 | 请选择投标截止日期 / 截止日期必须晚于当前时间 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区单行排列,列表全字段展示 |
| 1024-1279pxPad横屏 | 查询条件区双行排列,隐藏"创建时间"列编辑弹窗宽度90% |
| 768-1023pxPad竖屏 | 查询条件区纵向堆叠,仅显示计划名称/状态筛选,列表显示核心字段(计划名称/招标方式/状态/操作),弹窗全屏宽度 |
--- ---
## 页面2标段管理页 ## 页面2标段管理页
@ -100,6 +164,61 @@
| 资质要求 | 多行文本 | 否 | — | 自填 | — | | 资质要求 | 多行文本 | 否 | — | 自填 | — |
| 评标标准 | 多行文本 | 否 | — | 自填 | — | | 评标标准 | 多行文本 | 否 | — | 自填 | — |
### 交互流程要求
1. **页面加载流程**:进入页面(从招标计划详情进入)→ 调用标段列表API → 渲染列表
2. **查询/筛选交互流程**:不适用(按所属计划自动过滤)
3. **表单填写与提交流程**:点击"新增标段"(操作栏)→ 弹出新增弹窗 → 填写标段信息 → 保存;点击"编辑" → 弹出编辑弹窗 → 修改后保存
4. **弹窗/抽屉交互流程**:新增/编辑标段弹窗el-dialog含标段名称、范围、预算金额、资质要求、评标标准字段
5. **行内操作流程**:点击"编辑"弹出编辑弹窗;点击"查看"跳转标段详情
6. **异常与错误处理**保存失败显示el-message错误提示预算金额为0时阻止提交
7. **联动/级联交互**:所属计划与标段数据联动,切换计划自动加载对应标段
8. **权限控制交互表现**:无编辑权限时行操作仅显示"查看"
### 前端硬性约束
- **H1 防重复提交(强制)**: "新增标段"/行操作(编辑/查看)按钮请求期间置 loading+disabled 状态;操作期间禁用该行其他操作
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s标段保存(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: 本页面无危险操作需二次确认
- **H5 权限隔离(建议)**: 返回空列表展示"暂无标段数据"code=403 时展示"无权限访问"
- **H8 反馈机制(建议)**: 操作成功 successduration=2s+ 静默刷新列表;失败 errorduration=0网络异常显示重试按钮
- **弹窗表单(新增/编辑标段)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内提交按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST)超时 30s
- **H4 脏数据检测(强制)**: 弹窗内表单修改后尝试关闭时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 标段列表 | el-table | stripeborderrow-key="id" |
| 状态列 | el-tag | :type根据状态配色 |
| 编辑按钮 | el-button | type="primary"size="small"link样式 |
| 查看按钮 | el-button | type="default"size="small"link样式 |
| 新增/编辑弹窗 | el-dialog | title="新增标段/编辑标段"width="600px" |
| 标段名称输入 | el-input | placeholder="请输入标段名称"maxlength=50 |
| 标段范围 | el-input | type="textarea":rows=3maxlength=500show-word-limit |
| 预算金额 | el-input-number | :min=0.01:precision=2controls-position="right" |
| 资质要求 | el-input | type="textarea":rows=3 |
| 评标标准 | el-input | type="textarea":rows=3 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 标段名称 | requiredmaxlength=50 | 请输入标段名称 / 标段名称不能超过50个字符 |
| 标段范围 | requiredmaxlength=500 | 请输入标段范围 / 标段范围不能超过500个字符 |
| 预算金额 | required>0 | 请输入预算金额 / 预算金额必须大于0 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示,操作列右对齐 |
| 1024-1279pxPad横屏 | 隐藏"标段范围"列编辑弹窗宽度90% |
| 768-1023pxPad竖屏 | 仅显示标段名称/预算金额/状态/操作核心字段,弹窗全屏宽度 |
--- ---
## 页面3供应商管理页 ## 页面3供应商管理页
@ -149,6 +268,74 @@
| 资质审核 | /api/v1/suppliers/{id}/audit | POST | — | | 资质审核 | /api/v1/suppliers/{id}/audit | POST | — |
| 拉黑 | /api/v1/suppliers/{id}/blacklist | PUT | — | | 拉黑 | /api/v1/suppliers/{id}/blacklist | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用供应商列表API → 渲染列表,黑名单供应商行灰色标记
2. **查询/筛选交互流程**:支持按供应商名称、资质状态筛选,查询后刷新列表
3. **表单填写与提交流程**:点击"新增供应商" → 弹出新增弹窗 → 填写供应商信息 → 保存;点击"编辑" → 弹出编辑弹窗 → 修改后保存
4. **弹窗/抽屉交互流程**:新增/编辑供应商弹窗el-dialog资质审核弹窗选择通过/驳回驳回需填写原因拉黑确认弹窗el-message-box
5. **行内操作流程**:点击"资质审核"弹出审核弹窗;点击"拉黑"弹出确认弹窗,确认后状态变更;点击"移出黑名单"恢复正常状态
6. **异常与错误处理**:统一社会信用代码重复时提示"该供应商已存在";拉黑后该供应商不可投标,操作时二次确认提醒
7. **联动/级联交互**:资质状态与操作按钮联动(待审核显示审核按钮,黑名单显示移出黑名单按钮)
8. **权限控制交互表现**:无审核权限隐藏"资质审核"按钮;无拉黑权限隐藏"拉黑/移出黑名单"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "新增供应商"/行操作(编辑/资质审核/拉黑)按钮请求期间置 loading+disabled 状态;操作期间禁用该行其他操作
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s提交/审核(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "拉黑"前弹出 confirm(`确定要将供应商"${supplierName}"加入黑名单?加入后该供应商将无法参与任何投标。`, type='error')"移出黑名单"前弹出 confirm("确定要将该供应商移出黑名单?", type='warning')"资质审核-驳回"前弹出 confirm + 驳回原因输入框(必填)
- **H5 权限隔离(建议)**: 返回空列表展示"暂无供应商数据"code=403 时展示"无权限访问"
- **H7 文件上传(建议)**: 资质文件上传限制单个文件≤10MB、总数≤9个超出时提示"单文件不超过10MB附件总数不超过9个"
- **H8 反馈机制(建议)**: 操作成功 successduration=2s+ 静默刷新列表;失败 errorduration=0网络异常显示重试按钮
- **弹窗表单(新增/编辑供应商)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内提交按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST)超时 30s
- **H4 脏数据检测(强制)**: 弹窗内表单修改后尝试关闭时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
- **弹窗表单(资质审核)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内确认按钮 loading+disabled
- **H2 超时控制(强制)**: 审核(POST)超时 30s
- **H3 操作确认(强制)**: 已由外层 H3 覆盖(驳回需原因)
- **H4 脏数据检测(强制)**: 审核结果/驳回原因修改后尝试关闭时拦截
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 供应商列表 | el-table | stripeborder:row-class-name黑名单行灰色 |
| 资质状态列 | el-tag | :type根据状态配色已审核=success待审核=warning黑名单=danger |
| 新增供应商按钮 | el-button | type="primary"icon="Plus" |
| 编辑按钮 | el-button | type="primary"size="small"link样式 |
| 资质审核按钮 | el-button | type="warning"size="small"link样式 |
| 拉黑按钮 | el-button | type="danger"size="small"link样式 |
| 新增/编辑弹窗 | el-dialog | title="新增供应商/编辑供应商"width="600px" |
| 统一社会信用代码 | el-input | placeholder="请输入统一社会信用代码"maxlength=18 |
| 联系电话 | el-input | placeholder="请输入联系电话" |
| 资质文件上传 | el-upload | action="/api/v1/files/upload":limit=5:file-list.sync |
| 资质审核弹窗 | el-dialog | title="资质审核"width="500px" |
| 审核结果 | el-radio-group | 通过/驳回单选 |
| 审核驳回原因 | el-input | type="textarea":rows=3v-if="审核结果=驳回" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 供应商名称 | requiredmaxlength=100 | 请输入供应商名称 / 供应商名称不能超过100个字符 |
| 统一社会信用代码 | requiredpattern=/^[0-9A-Z]{18}$/ | 请输入统一社会信用代码 / 统一社会信用代码格式不正确18位字母数字 |
| 联系人 | required | 请输入联系人 |
| 联系电话 | requiredpattern=/^1[3-9]\d{9}$/ | 请输入联系电话 / 联系电话格式不正确 |
| 审核结果 | required审核时 | 请选择审核结果 |
| 审核驳回原因 | required审核驳回时 | 请输入驳回原因 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示,操作列右对齐 |
| 1024-1279pxPad横屏 | 隐藏"合作次数"列,操作收入"更多"下拉 |
| 768-1023pxPad竖屏 | 仅显示供应商名称/资质状态/操作核心字段,弹窗全屏宽度 |
--- ---
## 页面4招标发布页 ## 页面4招标发布页
@ -172,6 +359,54 @@
| 招标文件 | 文件上传 | 是 | — | 上传 | ≤10个文件 | | 招标文件 | 文件上传 | 是 | — | 上传 | ≤10个文件 |
| 邀请供应商 | 下拉多选 | 条件 | — | 供应商列表(已审核) | 邀请招标时必填 | | 邀请供应商 | 下拉多选 | 条件 | — | 供应商列表(已审核) | 邀请招标时必填 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 加载可选招标计划(状态=待发布)→ 渲染发布表单
2. **查询/筛选交互流程**:不适用
3. **表单填写与提交流程**:选择招标计划 → 自动加载关联标段信息 → 确认标段 → 上传招标文件 → 填写招标公告 → (邀请招标时)选择邀请供应商 → 点击"发布" → 二次确认后调用发布API → 发布成功跳转招标计划页
4. **弹窗/抽屉交互流程**发布确认弹窗el-message-box提示"确认发布招标?发布后不可撤回"招标文件上传使用el-upload支持预览已上传文件
5. **行内操作流程**:不适用(发布页为表单交互)
6. **异常与错误处理**:招标文件未上传时阻止发布;邀请招标未选择供应商时提示"请选择邀请供应商"发布失败显示el-message错误提示
7. **联动/级联交互**:招标方式为"邀请招标"时显示邀请供应商选择(必填);招标方式为"公开招标"时隐藏;选择招标计划后自动加载标段信息
8. **权限控制交互表现**:无发布权限时隐藏"发布"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "发布"按钮请求期间置 loading+disabled 状态;提交后禁用所有表单交互直至请求完成
- **H2 超时控制(强制)**: 表单提交(POST)超时 30s文件上传超时 60s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "发布"前弹出 confirm("确定要发布该招标?发布后不可撤回,供应商将可查看并投标。", type='warning')
- **H4 脏数据检测(强制)**: 页面加载时 deep clone 初始表单数据为 _originData监听表单变化维护 isDirty 标志;用户未保存尝试离开时通过 beforeRouteLeave 拦截并弹窗确认
- **H7 文件上传(建议)**: 招标文件上传限制单个文件≤10MB、总数≤9个超出时提示"单文件不超过10MB文件总数不超过9个";支持 .pdf/.doc/.docx
- **H8 反馈机制(建议)**: 发布成功显示 successduration=2s+ 延迟跳转招标计划页;失败显示 errorduration=0网络异常时显示重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 招标计划选择 | el-select | placeholder="请选择招标计划"filterable@change加载标段信息 |
| 标段确认区 | el-table | :data=关联标段stripeborderdisabled不可编辑 |
| 招标公告 | el-input | type="textarea":rows=6placeholder="请输入招标公告" |
| 招标文件上传 | el-upload | action="/api/v1/files/upload":limit=10accept=".pdf,.doc,.docx"drag拖拽上传:on-preview预览 |
| 邀请供应商 | el-select | placeholder="请选择供应商"multiplefilterablecollapse-tagsv-if="招标方式=邀请招标" |
| 发布按钮 | el-button | type="primary"icon="Promotion":loading提交中 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 招标计划 | required | 请选择招标计划 |
| 招标公告 | required | 请输入招标公告 |
| 招标文件 | required≤10个 | 请上传招标文件 / 招标文件不能超过10个 |
| 邀请供应商 | required邀请招标时 | 请选择邀请供应商 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单双列布局,标段确认区全宽展示 |
| 1024-1279pxPad横屏 | 表单单列布局,标段确认区全宽展示 |
| 768-1023pxPad竖屏 | 表单单列布局文件上传区域宽度100%,邀请供应商选择全宽展示 |
--- ---
## 页面5投标管理页 ## 页面5投标管理页
@ -191,6 +426,48 @@
| 5 | 投标文件 | 100px | 否 | 下载查看 | | 5 | 投标文件 | 100px | 否 | 下载查看 |
| 6 | 状态 | 80px | 否 | 已投标/已开标 | | 6 | 状态 | 80px | 否 | 已投标/已开标 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用投标列表API → 渲染列表,默认按投标时间倒序
2. **查询/筛选交互流程**:支持按标段名称、供应商、状态筛选
3. **表单填写与提交流程**:不适用(医院端仅查看投标数据,投标由供应商提交)
4. **弹窗/抽屉交互流程**:点击投标文件"下载查看" → 新窗口打开/下载投标文件
5. **行内操作流程**:点击投标文件列链接下载查看
6. **异常与错误处理**:投标文件下载失败提示"文件下载失败,请重试"列表为空显示el-empty
7. **联动/级联交互**:标段选择后自动过滤该标段下的投标数据
8. **权限控制交互表现**:医院账号仅查看,无操作按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: 投标文件下载按钮点击后置 loading+disabled 状态直至下载完成
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s文件下载超时 60s超过阈值提示"请求超时,请稍后重试"
- **H8 反馈机制(建议)**: 文件下载失败显示 errorduration=0网络异常时显示重试按钮列表加载成功静默刷新
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 投标列表 | el-table | stripeborder:default-sort="{prop: 'bidTime', order: 'descending'}" |
| 状态列 | el-tag | :type根据状态配色已投标=info已开标=success |
| 投标文件 | el-link | type="primary":underline=falseicon="Download"@click下载 |
| 标段筛选 | el-select | placeholder="请选择标段"clearablefilterable |
| 供应商筛选 | el-input | placeholder="请输入供应商名称"clearable |
| 状态筛选 | el-select | placeholder="请选择状态"clearable |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示 |
| 1024-1279pxPad横屏 | 隐藏"投标文件"列(改为行操作下载按钮) |
| 768-1023pxPad竖屏 | 仅显示标段名称/供应商/投标金额/状态核心字段 |
--- ---
## 页面6评标管理页 ## 页面6评标管理页
@ -227,6 +504,69 @@
| 设置评分标准 | bidding:award:create | 操作栏 | 始终 | — | | 设置评分标准 | bidding:award:create | 操作栏 | 始终 | — |
| 提交评标结果 | bidding:award:update | 操作栏 | 始终 | — | | 提交评标结果 | bidding:award:update | 操作栏 | 始终 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用评标数据API → 渲染评标委员会信息、评分标准、评分录入表格
2. **查询/筛选交互流程**:不适用
3. **表单填写与提交流程**:流程步骤:组建评标委员会 → 设置评分标准 → 录入各供应商评分 → 提交评标结果。组建委员会:点击按钮 → 弹窗选择评标人员 → 保存;设置评分标准:点击按钮 → 弹窗设置各维度权重 → 保存(权重之和必须=100%);录入评分:在评分表格中逐行填写各供应商的商务分/技术分/价格分 → 系统自动计算总分和排名 → 点击"提交评标结果"
4. **弹窗/抽屉交互流程**组建评标委员会弹窗el-dialog含评标人员多选设置评分标准弹窗el-dialog含各维度权重滑块/输入)
5. **行内操作流程**:评分录入表格中行内输入评分数据,总分和排名自动计算
6. **异常与错误处理**评分权重之和不等于100%时阻止保存;单项评分超出范围提示错误;提交评标结果时校验所有供应商评分是否填写完整
7. **联动/级联交互**:评分标准设置后,评分录入表头动态显示对应权重;各维度评分输入后自动计算总分和排名
8. **权限控制交互表现**:无评标权限时仅查看评分结果,不可编辑
### 前端硬性约束
- **H1 防重复提交(强制)**: "组建评标委员会"/"设置评分标准"/"提交评标结果"按钮请求期间置 loading+disabled 状态;评分录入期间锁定已填写行
- **H2 超时控制(强制)**: 数据加载(GET)超时 15s提交(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "提交评标结果"前弹出 confirm("确定要提交评标结果?提交后将进入定标审批流程,不可修改评分。", type='warning')
- **H4 脏数据检测(强制)**: 页面加载时 deep clone 初始评分数据为 _originData监听评分变化维护 isDirty 标志;用户有未保存评分尝试离开时通过 beforeRouteLeave 拦截确认
- **H8 反馈机制(建议)**: 操作成功显示 successduration=2s+ 静默刷新页面状态;失败显示 errorduration=0网络异常显示重试按钮
- **弹窗表单(组建委员会/设置标准)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内确认按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST)超时 30s
- **H4 脏数据检测(强制)**: 弹窗内数据修改后尝试关闭时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 评标步骤指示 | el-steps | :active=当前步骤,:space=200finish-status="success" |
| 评标委员会区 | el-card | shadow="hover",含评标人员列表 |
| 组建评标委员会按钮 | el-button | type="primary"icon="User"size="small" |
| 评标人员展示 | el-tag | v-for遍历评标人员closable |
| 评分标准区 | el-card | shadow="hover",含各维度权重展示 |
| 设置评分标准按钮 | el-button | type="primary"icon="Setting"size="small" |
| 评分录入表格 | el-table | :data=供应商评分数据stripebordershow-summary |
| 商务分/技术分/价格分 | el-input-number | :min=0:max=100:precision=1controls-position="right"size="small" |
| 总分列 | 自动计算 | :formatter根据权重加权计算 |
| 排名列 | 自动计算 | 根据总分降序排列 |
| 提交评标结果按钮 | el-button | type="primary"icon="Check":loading提交中 |
| 组建评标委员会弹窗 | el-dialog | title="组建评标委员会"width="600px" |
| 评标人员选择 | el-select | multiplefilterableplaceholder="请选择评标人员" |
| 设置评分标准弹窗 | el-dialog | title="设置评分标准"width="500px" |
| 权重输入 | el-slider | :min=0:max=100show-input:marks="{0:'0%', 50:'50%', 100:'100%'}" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 评标人员 | required≥3人 | 请选择评标人员 / 评标委员会至少3人 |
| 商务评分权重 | required0-100 | 请设置商务评分权重 |
| 技术评分权重 | required0-100 | 请设置技术评分权重 |
| 价格评分权重 | required0-100 | 请设置价格评分权重 |
| 权重总和 | =100% | 评分权重之和必须等于100% |
| 商务分/技术分/价格分 | required0-100 | 请输入评分 / 评分范围为0-100 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 评标委员会与评分标准左右并排,评分录入表格全宽 |
| 1024-1279pxPad横屏 | 评标委员会与评分标准上下排列,评分录入表格全宽,评分输入列宽缩小 |
| 768-1023pxPad竖屏 | 全部区域纵向堆叠,评分录入表格横向滚动,弹窗全屏宽度 |
--- ---
## 页面7定标审批页 ## 页面7定标审批页
@ -241,6 +581,58 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 定标审批 | bidding:award:approve | 行操作 | 状态=待定标 | 通过/驳回 | | 定标审批 | bidding:award:approve | 行操作 | 状态=待定标 | 通过/驳回 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用待定标项目列表API → 渲染列表
2. **查询/筛选交互流程**:支持按标段名称、状态筛选
3. **表单填写与提交流程**:点击"定标审批" → 弹出审批弹窗(查看评标结果摘要,选择通过/驳回)→ 驳回需填写原因 → 确认后调用API → 刷新列表
4. **弹窗/抽屉交互流程**定标审批弹窗el-dialog含评标结果摘要展示、通过/驳回选择、驳回原因输入)
5. **行内操作流程**:仅状态=待定标时显示"定标审批"按钮
6. **异常与错误处理**审批操作失败显示el-message错误提示
7. **联动/级联交互**:审批结果与列表状态联动(通过→公示中,驳回→评标中)
8. **权限控制交互表现**:无审批权限隐藏"定标审批"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "定标审批"行操作按钮点击后置 loading+disabled 状态;弹窗内确认按钮同样 loading+disabled
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s定标审批(POST)超时 30s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "定标审批-通过"前弹出 confirm("确定要通过该定标审批?通过后将进入公示阶段。", type='warning')"定标审批-驳回"前弹出 confirm + 驳回原因输入框(必填),含影响说明:"驳回后需重新评标"
- **H5 权限隔离(建议)**: 返回空列表展示"暂无待定标项目"code=403 时展示"无权限访问"
- **H8 反馈机制(建议)**: 操作成功 successduration=2s+ 静默刷新列表;失败 errorduration=0网络异常显示重试按钮
- **弹窗表单(定标审批)额外约束**:
- **H1 防重复提交(强制)**: 弹窗内确认按钮 loading+disabled
- **H2 超时控制(强制)**: 提交(POST)超时 30s
- **H3 操作确认(强制)**: 已由外层 H3 覆盖
- **H4 脏数据检测(强制)**: 审批结果/驳回原因修改后尝试关闭弹窗时拦截确认
- **H8 反馈机制(建议)**: 成功关闭弹窗 + 刷新 + success(2s);失败 error(0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 定标列表 | el-table | stripeborder |
| 状态列 | el-tag | :type根据状态配色待定标=warning公示中=success评标中=info |
| 定标审批按钮 | el-button | type="primary"size="small"link样式 |
| 定标审批弹窗 | el-dialog | title="定标审批"width="600px" |
| 评标结果摘要 | el-descriptions | :column=1border展示中标候选人、评分等 |
| 审批结果 | el-radio-group | 通过/驳回单选 |
| 驳回原因 | el-input | type="textarea":rows=3v-if="审批结果=驳回" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审批结果 | required | 请选择审批结果 |
| 驳回原因 | required审批驳回时 | 请输入驳回原因 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示审批弹窗600px宽度 |
| 1024-1279pxPad横屏 | 列表隐藏次要字段审批弹窗80%宽度 |
| 768-1023pxPad竖屏 | 列表仅显示核心字段,审批弹窗全屏宽度 |
--- ---
## 页面8中标公示页 ## 页面8中标公示页
@ -267,6 +659,48 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 生成合同 | contract:list:create | 行操作 | 状态=已生效 | 跳转合同管理创建合同 | | 生成合同 | contract:list:create | 行操作 | 状态=已生效 | 跳转合同管理创建合同 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用中标公示列表API → 渲染列表
2. **查询/筛选交互流程**:支持按标段名称、状态筛选
3. **表单填写与提交流程**:点击"生成合同"(状态=已生效)→ 二次确认后跳转合同录入页,自动填充标段信息和中标供应商
4. **弹窗/抽屉交互流程**生成合同确认弹窗el-message-box提示"确认生成合同?将跳转合同管理创建合同"
5. **行内操作流程**:仅状态=已生效时显示"生成合同"按钮
6. **异常与错误处理**生成合同跳转失败显示el-message错误提示列表为空显示el-empty
7. **联动/级联交互**:公示状态与操作按钮联动(仅已生效显示生成合同按钮)
8. **权限控制交互表现**:无合同创建权限隐藏"生成合同"按钮
### 前端硬性约束
- **H1 防重复提交(强制)**: "生成合同"行操作按钮点击后置 loading+disabled 状态;操作期间禁用该行其他操作
- **H2 超时控制(强制)**: 列表查询(GET)超时 15s超过阈值提示"请求超时,请稍后重试"
- **H3 操作确认(强制)**: "生成合同"前弹出 confirm("确认生成合同?将跳转至合同管理并自动填充中标信息。", type='info')
- **H5 权限隔离(建议)**: 返回空列表展示"暂无中标公示记录"code=403 时展示"无权限访问"
- **H8 反馈机制(建议)**: 跳转成功无需提示(已跳转);跳转失败显示 errorduration=0网络异常显示重试按钮列表加载静默刷新
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 公示列表 | el-table | stripeborder |
| 状态列 | el-tag | :type根据状态配色公示中=warning已生效=success |
| 生成合同按钮 | el-button | type="primary"size="small"link样式icon="Document" |
| 生成合同确认弹窗 | el-message-box | title="确认生成合同"type="info"confirm-button-text="确认生成" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示,操作列右对齐 |
| 1024-1279pxPad横屏 | 隐藏"公示开始日/公示结束日"列,操作列固定右侧 |
| 768-1023pxPad竖屏 | 仅显示标段名称/中标供应商/状态/操作核心字段 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码service-supervision > 模块编码service-supervision
> 端侧Web专属仅医院账号 > 端侧Web专属仅医院账号
> 关联文档01-模块划分 §4.3 / 02-功能清单-医院 §3 / 03-业务流转逻辑-医院 §3 / 05-接口规范 §9.2 > 关联文档01-模块划分 §4.3 / 02-功能清单-医院 §3 / 03-业务流转逻辑-医院 §3 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -99,6 +100,67 @@
| 列表查询 | /api/v1/repair-orders | GET | 只读,自动过滤关联物业公司 | | 列表查询 | /api/v1/repair-orders | GET | 只读,自动过滤关联物业公司 |
| 详情查询 | /api/v1/repair-orders/{id} | GET | 只读 | | 详情查询 | /api/v1/repair-orders/{id} | GET | 只读 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用报修工单列表API自动过滤关联物业公司→ 渲染统计卡片 + 列表
2. **查询/筛选交互流程**:填写筛选条件 → 点击"查询"按钮 → 调用API携带筛选参数 → 刷新列表和统计卡片;点击"重置"清空条件重新加载
3. **表单填写与提交流程**:不适用(只读页面,无任何操作按钮)
4. **弹窗/抽屉交互流程**:不适用(查看详情跳转只读详情页)
5. **行内操作流程**:点击工单号或"查看详情" → 跳转只读工单详情页(标签页:基本信息/流转记录/照片附件,底部无操作按钮)
6. **异常与错误处理**API请求失败显示el-message错误提示列表为空显示el-empty无权限时提示"您无权查看此数据"
7. **联动/级联交互**:筛选条件变化后统计卡片数据同步刷新;所属区域级联选择(楼栋→楼层→房间)
8. **权限控制交互表现**:医院账号仅查看,所有操作按钮隐藏;仅显示关联物业公司的数据
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 列表查询/翻页期间显示loading状态并禁用查询/分页控件切换分页时abort上一页未完成请求"查看详情"操作防重复点击 |
| **H2** | 请求超时 | **强制** | GET列表请求设置15s超时GET详情请求设置15s超时 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读查看页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 医院账号仅可见关联物业公司数据范围;无`repair:list:view`权限时隐藏列表及查询区域;权限不足时展示空状态或无权提示而非报错 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 成功提示自动2s后消失(silent刷新);错误提示需手动关闭(duration=0);网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单号输入 | el-input | placeholder="请输入工单号"clearable |
| 状态选择 | el-select | placeholder="请选择状态"clearablemultiple |
| 报修类型选择 | el-select | placeholder="请选择报修类型"clearable |
| 紧急程度选择 | el-select | placeholder="请选择紧急程度"clearable |
| 提交日期范围 | el-date-picker | type="daterange"start-placeholder="开始日期"end-placeholder="结束日期" |
| 所属班组选择 | el-select | placeholder="请选择班组"clearable |
| 所属区域 | el-cascader | placeholder="请选择区域":props="{checkStrictly: true}",级联:楼栋/楼层/房间 |
| 查询按钮 | el-button | type="primary"icon="Search" |
| 重置按钮 | el-button | type="default"icon="Refresh" |
| 统计卡片 | el-card | v-for遍历4个指标shadow="hover",含数字和标签 |
| 工单列表 | el-table | stripeborder:default-sort="{prop: 'submitTime', order: 'descending'}" |
| 紧急程度列 | el-tag | :type根据程度配色紧急=danger较急=warning一般=info |
| 状态列 | el-tag | :type根据状态配色 |
| 工单号列 | el-link | type="primary":underline=false@click跳转详情 |
| 查看详情按钮 | el-button | type="default"size="small"link样式icon="View" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next":page-sizes="[10,20,50]" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验,仅查询条件) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区双行排列统计卡片横排4列列表全字段展示 |
| 1024-1279pxPad横屏 | 查询条件区三行排列统计卡片2×2网格列表隐藏"报修人""维修人员"列 |
| 768-1023pxPad竖屏 | 查询条件区纵向堆叠(仅保留工单号/状态/报修类型统计卡片2×2网格列表仅显示工单号/类型/状态/提交时间/操作 |
--- ---
## 页面2巡检数据查看页 ## 页面2巡检数据查看页
@ -126,6 +188,56 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 查看详情 | inspection:task:view(只读) | 行操作 | 始终 | 只读 | | 查看详情 | inspection:task:view(只读) | 行操作 | 始终 | 只读 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用巡检数据列表API自动过滤关联物业公司→ 渲染列表
2. **查询/筛选交互流程**:支持按巡检人员、计划名称、打卡方式、状态筛选
3. **表单填写与提交流程**:不适用(只读页面)
4. **弹窗/抽屉交互流程**:点击"查看详情" → 跳转只读巡检详情页
5. **行内操作流程**:点击"查看详情"跳转只读详情页;异常数>0时该行标记橙色
6. **异常与错误处理**API请求失败显示el-message错误提示列表为空显示el-empty
7. **联动/级联交互**:状态筛选与列表数据联动
8. **权限控制交互表现**:医院账号仅查看,无操作按钮
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 列表查询/筛选/翻页期间显示loading状态并禁用相关控件切换分页时abort上一页未完成请求"查看详情"操作防重复点击 |
| **H2** | 请求超时 | **强制** | GET列表请求设置15s超时GET详情请求设置15s超时 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读查看页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 仅展示本医院关联物业公司的巡检数据;无`inspection:task:view`权限时隐藏整个列表区域 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 成功提示自动2s后消失(silent刷新);错误提示需手动关闭(duration=0);网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 巡检数据列表 | el-table | stripeborder:row-class-name异常行高亮 |
| 打卡方式列 | el-tag | :type根据方式配色蓝牙=success手动=warning补录=info |
| 状态列 | el-tag | :type根据状态配色正常=success异常=danger |
| 异常数列 | el-tag | v-if="异常数>0" type="danger",否则显示"0" |
| 查看详情按钮 | el-button | type="default"size="small"link样式icon="View" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验,仅查询条件) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示 |
| 1024-1279pxPad横屏 | 隐藏"打卡方式"列 |
| 768-1023pxPad竖屏 | 仅显示巡检人员/计划名称/状态/异常数/操作核心字段 |
--- ---
## 页面3保洁数据查看页 ## 页面3保洁数据查看页
@ -153,6 +265,55 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 查看详情 | cleaning:task:view(只读) | 行操作 | 始终 | 只读 | | 查看详情 | cleaning:task:view(只读) | 行操作 | 始终 | 只读 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用保洁数据列表API自动过滤关联物业公司→ 渲染列表
2. **查询/筛选交互流程**:支持按保洁人员、保洁区域、任务日期、完成状态筛选
3. **表单填写与提交流程**:不适用(只读页面)
4. **弹窗/抽屉交互流程**:点击"查看详情" → 跳转只读保洁详情页
5. **行内操作流程**:点击"查看详情"跳转只读详情页;超时/未执行状态行标记橙色;抽查不合格行标记红色
6. **异常与错误处理**API请求失败显示el-message错误提示列表为空显示el-empty
7. **联动/级联交互**:完成状态筛选与列表数据联动
8. **权限控制交互表现**:医院账号仅查看,无操作按钮
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 列表查询/筛选/翻页期间显示loading状态并禁用相关控件切换分页时abort上一页未完成请求"查看详情"操作防重复点击 |
| **H2** | 请求超时 | **强制** | GET列表请求设置15s超时GET详情请求设置15s超时 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读查看页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 仅展示本医院关联物业公司的保洁数据;无`cleaning:task:view`权限时隐藏整个列表区域 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 成功提示自动2s后消失(silent刷新);错误提示需手动关闭(duration=0);网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 保洁数据列表 | el-table | stripeborder:row-class-name超时/不合格行高亮 |
| 完成状态列 | el-tag | :type根据状态配色已完成=success超时=warning未执行=danger |
| 抽查结果列 | el-tag | :type根据结果配色合格=success不合格=danger未抽查=info |
| 查看详情按钮 | el-button | type="default"size="small"link样式icon="View" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验,仅查询条件) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表全字段展示 |
| 1024-1279pxPad横屏 | 隐藏"打卡方式"列 |
| 768-1023pxPad竖屏 | 仅显示保洁人员/保洁区域/完成状态/抽查结果/操作核心字段 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码evaluation > 模块编码evaluation
> 端侧Web专属仅医院账号 > 端侧Web专属仅医院账号
> 关联文档01-模块划分 §4.4 / 02-功能清单-医院 §4 / 03-业务流转逻辑-医院 §4 / 05-接口规范 §9.2 > 关联文档01-模块划分 §4.4 / 02-功能清单-医院 §4 / 03-业务流转逻辑-医院 §4 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -85,6 +86,49 @@
|----------|---------|------|------| |----------|---------|------|------|
| 发起评价 | /api/v1/evaluations | POST | — | | 发起评价 | /api/v1/evaluations | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 渲染空评价表单 → 加载下拉选项(物业公司列表、服务类型)
2. **查询/筛选交互流程**:不适用
3. **表单填写与提交流程**:选择评价类型 → 选择物业公司 → 选择关联服务(综合评价时跳过)→ 评分1-5星→ 填写留言(可选)→ 上传图片可选≤5张→ 点击"提交评价" → 校验通过后调用API → 提交成功提示"评价提交成功",低评分自动触发通知
4. **弹窗/抽屉交互流程**:不适用(表单在页面内直接展示)
5. **行内操作流程**:选择评价类型后联动显示对应的服务选择项;评分选择后显示对应文字描述
6. **异常与错误处理**必填项未填时表单校验不通过提交失败显示el-message错误提示图片上传失败提示"图片上传失败"
7. **联动/级联交互**:评价类型选择→关联物业公司选择→关联服务选择(级联);评价类型为"综合评价"时隐藏关联服务选择;物业公司选择后动态加载该物业的服务数据
8. **权限控制交互表现**:仅医院账号可发起评价;无评价权限时隐藏"提交评价"按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 评价类型 | el-radio-group | v-for遍历4种类型@change联动关联服务显示 |
| 关联物业公司 | el-select | placeholder="请选择物业公司"filterable@change加载服务数据 |
| 关联服务 | el-select | placeholder="请选择关联服务"filterablev-if="非综合评价" |
| 评分 | el-rate | :max=5show-text:texts="['非常不满意','不满意','一般','满意','非常满意']"allow-half=false |
| 留言 | el-input | type="textarea":rows=4maxlength=500show-word-limitplaceholder="请输入评价内容" |
| 图片上传 | el-upload | action="/api/v1/files/upload":limit=5accept=".jpg,.jpeg,.png"list-type="picture-card":on-preview预览 |
| 取消按钮 | el-button | type="default"@click返回上一页 |
| 提交评价按钮 | el-button | type="primary":loading提交中 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 评价类型 | required | 请选择评价类型 |
| 关联物业公司 | required | 请选择关联物业公司 |
| 关联服务 | required非综合评价时 | 请选择关联服务 |
| 评分 | required | 请选择评分 |
| 留言 | maxlength=500 | 评价内容不能超过500个字符 |
| 图片 | ≤5张 | 图片数量不能超过5张 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单居中展示宽度800px评分与文字描述水平排列 |
| 1024-1279pxPad横屏 | 表单宽度90%,评分与文字描述水平排列 |
| 768-1023pxPad竖屏 | 表单宽度100%,评分与文字描述纵向堆叠,图片上传区全宽 |
--- ---
## 页面2评价汇总查看页 ## 页面2评价汇总查看页
@ -123,6 +167,42 @@
|----------|---------|------|------| |----------|---------|------|------|
| 汇总数据 | /api/v1/evaluations/summary | GET | 本医院发起的评价汇总 | | 汇总数据 | /api/v1/evaluations/summary | GET | 本医院发起的评价汇总 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用评价汇总API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新汇总数据和图表
3. **表单填写与提交流程**:不适用(只读页面)
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**图表支持交互折线图hover显示具体数据柱状图点击可查看物业公司详情
6. **异常与错误处理**API请求失败显示el-message错误提示无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围选择后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:仅医院账号可查看本医院发起的评价汇总
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 统计卡片 | el-card | shadow="hover"v-for遍历3个指标含数字和标签 |
| 评分趋势图 | echarts | 折线图,:xAxis=时间,:yAxis=平均分tooltipdataZoom |
| 各物业公司评分对比图 | echarts | 柱状图,:xAxis=物业公司,:yAxis=平均分tooltip |
| 星级分布图 | echarts | 饼图,:data=各星级占比tooltiplegend |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片横排3列折线图与柱状图左右并排饼图全宽居中 |
| 1024-1279pxPad横屏 | 统计卡片横排3列折线图与柱状图上下排列饼图全宽 |
| 768-1023pxPad竖屏 | 统计卡片纵向堆叠所有图表纵向堆叠ECharts图表宽度100%自适应高度300px |
--- ---
## 页面3评价列表查看页 ## 页面3评价列表查看页
@ -173,6 +253,66 @@
| 列表查询 | /api/v1/evaluations | GET | 本医院发起的评价 | | 列表查询 | /api/v1/evaluations | GET | 本医院发起的评价 |
| 详情 | /api/v1/evaluations/{id} | GET | 含物业回复 | | 详情 | /api/v1/evaluations/{id} | GET | 含物业回复 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用评价列表API → 渲染列表,默认按评价时间倒序
2. **查询/筛选交互流程**:选择评价类型/评分/物业公司/日期范围 → 点击"查询"按钮 → 刷新列表
3. **表单填写与提交流程**:不适用(只读页面)
4. **弹窗/抽屉交互流程**:点击"查看详情" → 弹出评价详情弹窗(含评价内容、图片、物业回复)
5. **行内操作流程**:点击"查看详情"弹出详情弹窗;评分列显示星级;回复状态列标签化
6. **异常与错误处理**API请求失败显示el-message错误提示列表为空显示el-empty
7. **联动/级联交互**筛选条件变化后分页重置为第1页
8. **权限控制交互表现**:医院账号仅查看本医院发起的评价
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 列表查询/翻页期间显示loading状态并禁用查询/分页控件切换分页时abort上一页未完成请求"查看详情"弹窗防重复弹出 |
| **H2** | 请求超时 | **强制** | GET列表请求设置15s超时GET详情请求设置15s超时 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读列表查看页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 仅展示本医院发起的评价数据;无`evaluation:list:view`权限时隐藏列表区域;不同角色可见的数据范围通过后端过滤体现 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读列表页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 成功提示自动2s后消失(silent刷新);错误提示需手动关闭(duration=0);详情弹窗内图片预览成功/失败分别处理反馈 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 评价类型选择 | el-select | placeholder="请选择评价类型"clearable |
| 评分选择 | el-select | placeholder="请选择评分"clearableoptions: 1-5分 |
| 物业公司选择 | el-select | placeholder="请选择物业公司"clearablefilterable |
| 日期范围 | el-date-picker | type="daterange"start-placeholder="开始日期"end-placeholder="结束日期" |
| 查询按钮 | el-button | type="primary"icon="Search" |
| 重置按钮 | el-button | type="default"icon="Refresh" |
| 评价列表 | el-table | stripeborder:default-sort="{prop: 'evaluationTime', order: 'descending'}" |
| 评分列 | el-rate | :max=5disabledshow-score |
| 回复状态列 | el-tag | :type根据状态配色已回复=success未回复=warning |
| 查看详情按钮 | el-button | type="default"size="small"link样式icon="View" |
| 评价详情弹窗 | el-dialog | title="评价详情"width="600px" |
| 评价内容展示 | el-descriptions | :column=1border |
| 评价图片 | el-image | v-for遍历:preview-src-list预览 |
| 物业回复展示 | el-card | shadow="never",含回复内容和回复时间 |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next":page-sizes="[10,20,50]" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| (无表单校验,仅查询条件) | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区单行排列列表全字段展示详情弹窗600px |
| 1024-1279pxPad横屏 | 查询条件区双行排列,隐藏"评价内容""物业回复"列查看详情详情弹窗80%宽度 |
| 768-1023pxPad竖屏 | 查询条件区纵向堆叠(仅保留评价类型/评分/物业公司),列表显示核心字段(评价类型/物业公司/评分/回复状态/操作),详情弹窗全屏宽度 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码statistics > 模块编码statistics
> 端侧Web专属仅医院账号 > 端侧Web专属仅医院账号
> 关联文档01-模块划分 §4.5 / 02-功能清单-医院 §5 / 03-业务流转逻辑-医院 §5 / 05-接口规范 §9.2 > 关联文档01-模块划分 §4.5 / 02-功能清单-医院 §5 / 03-业务流转逻辑-医院 §5 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -39,12 +40,66 @@
| 查看 | statistics:repair:view | 仅查看,不可导出 | | 查看 | statistics:repair:view | 仅查看,不可导出 |
| 导出 | ❌ 不支持 | 业务数据报表仅查看来源03-医院 §5 | | 导出 | ❌ 不支持 | 业务数据报表仅查看来源03-医院 §5 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用报修统计API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新统计数据和图表;支持按物业公司筛选
3. **表单填写与提交流程**:不适用(只读页面,不支持导出)
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**图表支持交互饼图hover显示各状态数量柱状图hover显示各班组工单量折线图hover显示趋势数据
6. **异常与错误处理**API请求失败显示el-message错误提示无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围/物业公司筛选后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:仅查看权限,无导出按钮;无查看权限时隐藏整个页面内容
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 切换时间范围/物业公司筛选加载数据期间显示loading状态(skeleton)并禁用筛选控件;防止重复请求 |
| **H2** | 请求超时 | **强制** | GET统计请求设置**30s超时**统计报表类接口数据量大使用30s档位 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读统计页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 无`statistics:repair:view`权限时隐藏整个页面或展示无权提示;物业公司筛选下拉仅展示有权限的数据范围 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读统计页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页不支持导出且无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 数据加载成功后silent刷新图表和卡片API失败时在对应区域展示错误状态而非全局toast网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 物业公司筛选 | el-select | placeholder="请选择物业公司"clearablefilterable |
| 统计卡片区 | el-row + el-col | :gutter=20v-for遍历6个指标每个el-card shadow="hover" |
| 各状态分布图 | echarts | 饼图,:data=各状态数量tooltiplegendcolor预设配色 |
| 各班组工单量图 | echarts | 柱状图,:xAxis=班组,:yAxis=工单数tooltip |
| 完成率/紧急占比 | echarts | 仪表盘图/环形图tooltip |
| 平均处理时长 | echarts | 折线图(时间趋势),:xAxis=时间,:yAxis=小时 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片3×2网格图表区2列布局饼图+柱状图并排),下方折线图全宽 |
| 1024-1279pxPad横屏 | 统计卡片3×2网格图表区上下排列ECharts图表宽度100% |
| 768-1023pxPad竖屏 | 统计卡片2×3网格所有图表纵向堆叠ECharts图表宽度100%高度250px支持手势缩放 |
--- ---
## 页面2巡检统计页只读 ## 页面2巡检统计页只读
**页面编号**HO-ST-02-P01 **页面编号**HO-ST-02-P01
**端侧归属**Web专属 **端侧归属**Web专属
**页面路径**/statistics/inspection
### 统计指标 ### 统计指标
@ -56,12 +111,49 @@
| 蓝牙打卡率 | — | | 蓝牙打卡率 | — |
| 补录率 | — | | 补录率 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用巡检统计API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新统计数据和图表
3. **表单填写与提交流程**:不适用(只读页面,不支持导出)
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**图表支持交互饼图hover显示完成/异常分布柱状图hover显示各区域巡检量
6. **异常与错误处理**API请求失败显示el-message错误提示无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围筛选后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:仅查看权限,无导出按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 统计卡片区 | el-row + el-col | :gutter=20v-for遍历5个指标 |
| 完成率/异常率图 | echarts | 饼图/环形图,:data=完成/异常分布 |
| 蓝牙打卡率/补录率图 | echarts | 柱状图,:xAxis=时间段,:yAxis=百分比 |
| 巡检趋势图 | echarts | 折线图,:xAxis=时间,:yAxis=任务数 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片横排5列图表区2列布局 |
| 1024-1279pxPad横屏 | 统计卡片3+2排列图表区上下排列 |
| 768-1023pxPad竖屏 | 统计卡片2×3网格所有图表纵向堆叠ECharts图表宽度100%高度250px支持手势缩放 |
--- ---
## 页面3保洁统计页只读 ## 页面3保洁统计页只读
**页面编号**HO-ST-03-P01 **页面编号**HO-ST-03-P01
**端侧归属**Web专属 **端侧归属**Web专属
**页面路径**/statistics/cleaning
### 统计指标 ### 统计指标
@ -72,12 +164,49 @@
| 超时率 | — | | 超时率 | — |
| 抽查合格率 | — | | 抽查合格率 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用保洁统计API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新统计数据和图表
3. **表单填写与提交流程**:不适用(只读页面,不支持导出)
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**图表支持交互柱状图hover显示各区域保洁完成量折线图hover显示超时率趋势
6. **异常与错误处理**API请求失败显示el-message错误提示无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围筛选后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:仅查看权限,无导出按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 统计卡片区 | el-row + el-col | :gutter=20v-for遍历4个指标 |
| 完成率/超时率图 | echarts | 饼图/环形图,:data=完成/超时分布 |
| 抽查合格率图 | echarts | 仪表盘图,:min=0:max=100 |
| 各区域保洁量图 | echarts | 柱状图,:xAxis=区域,:yAxis=任务数 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片横排4列图表区2列布局 |
| 1024-1279pxPad横屏 | 统计卡片2×2排列图表区上下排列 |
| 768-1023pxPad竖屏 | 统计卡片2×2网格所有图表纵向堆叠ECharts图表宽度100%高度250px支持手势缩放 |
--- ---
## 页面4评价统计页只读 ## 页面4评价统计页只读
**页面编号**HO-ST-04-P01 **页面编号**HO-ST-04-P01
**端侧归属**Web专属 **端侧归属**Web专属
**页面路径**/statistics/evaluation
### 统计指标 ### 统计指标
@ -89,6 +218,59 @@
| 低评分占比 | — | | 低评分占比 | — |
| 物业回复率 | — | | 物业回复率 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用评价统计API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新统计数据和图表;支持按物业公司筛选
3. **表单填写与提交流程**:不适用(只读页面,不支持导出)
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**图表支持交互柱状图hover显示各物业公司评分饼图hover显示星级分布折线图hover显示评分趋势
6. **异常与错误处理**API请求失败显示el-message错误提示无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围/物业公司筛选后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:仅查看权限,无导出按钮
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 切换时间范围/物业公司筛选加载数据期间显示loading状态(skeleton)并禁用筛选控件;防止重复请求 |
| **H2** | 请求超时 | **强制** | GET统计请求设置**30s超时**统计报表类接口数据量大使用30s档位 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读统计页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 无`statistics:evaluation:view`权限时隐藏整个页面或展示无权提示;物业公司筛选仅展示有权限范围 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读统计页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页不支持导出且无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 数据加载成功后silent刷新图表和卡片API失败时在对应区域展示错误状态网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 物业公司筛选 | el-select | placeholder="请选择物业公司"clearablefilterable |
| 统计卡片区 | el-row + el-col | :gutter=20v-for遍历5个指标 |
| 各物业公司评分对比图 | echarts | 柱状图,:xAxis=物业公司,:yAxis=评分color预设配色 |
| 星级分布图 | echarts | 饼图,:data=各星级占比tooltiplegend |
| 评分趋势图 | echarts | 折线图,:xAxis=时间,:yAxis=平均分dataZoom |
| 低评分占比图 | echarts | 环形图,:data=低评分/正常占比 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片横排5列柱状图与饼图左右并排折线图全宽 |
| 1024-1279pxPad横屏 | 统计卡片3+2排列所有图表上下排列 |
| 768-1023pxPad竖屏 | 统计卡片2×3网格所有图表纵向堆叠ECharts图表宽度100%高度250px支持手势缩放 |
--- ---
## 页面5合同统计页可导出 ## 页面5合同统计页可导出
@ -115,12 +297,68 @@
| 查看 | statistics:contract:view | — | | 查看 | statistics:contract:view | — |
| 导出 | statistics:contract:export | 合同统计可导出来源03-医院 §5 | | 导出 | statistics:contract:export | 合同统计可导出来源03-医院 §5 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用合同统计API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新统计数据和图表;支持按物业公司筛选
3. **表单填写与提交流程**:不适用(只读查看,但支持导出)
4. **弹窗/抽屉交互流程**:点击"导出" → 选择导出格式Excel/PDF→ 调用导出API → 下载文件
5. **行内操作流程**:图表支持交互;点击"导出"按钮下载报表
6. **异常与错误处理**API请求失败显示el-message错误提示导出失败提示"导出失败,请重试";无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围/物业公司筛选后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:有导出权限显示"导出"按钮;无导出权限隐藏
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 切换时间范围/物业公司筛选加载数据期间显示loading状态(skeleton)并禁用筛选控件;**导出期间显示loading并禁用"导出"按钮**,防止重复点击重复下载 |
| **H2** | 请求超时 | **强制** | GET统计请求设置**30s超时**(统计报表类接口数据量大);**POST导出请求设置60s超时**(导出文件可能较大) |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读统计+导出页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 无`statistics:contract:view`权限时隐藏整个页面或展示无权提示;有`statistics:contract:export`权限才显示"导出"按钮及格式选择下拉 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读统计页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页为下载导出场景,无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 数据加载成功后silent刷新图表和卡片**导出成功后显示success提示(2s)并触发浏览器下载**导出失败显示error提示(duration=0)含重试选项;网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 物业公司筛选 | el-select | placeholder="请选择物业公司"clearablefilterable |
| 统计卡片区 | el-row + el-col | :gutter=20v-for遍历6个指标 |
| 各物业合同分布图 | echarts | 柱状图,:xAxis=物业公司,:yAxis=合同数/金额tooltip |
| 合同金额趋势图 | echarts | 折线图,:xAxis=时间,:yAxis=金额dataZoom |
| 付款完成率图 | echarts | 环形图/仪表盘图 |
| 到期预警列表 | el-table | stripeborder:data=即将到期合同 |
| 导出按钮 | el-button | type="success"icon="Download"@click导出 |
| 导出格式选择 | el-dropdown | 触发导出,:options="[{label: 'Excel'}, {label: 'PDF'}]" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片3×2网格柱状图与折线图左右并排到期预警列表全宽 |
| 1024-1279pxPad横屏 | 统计卡片3×2网格图表上下排列到期预警列表全宽 |
| 768-1023pxPad竖屏 | 统计卡片2×3网格所有图表纵向堆叠到期预警列表隐藏次要列ECharts图表宽度100%高度250px支持手势缩放 |
--- ---
## 页面6招标统计页可导出 ## 页面6招标统计页可导出
**页面编号**HO-ST-06-P01 **页面编号**HO-ST-06-P01
**端侧归属**Web专属 **端侧归属**Web专属
**页面路径**/statistics/bidding
### 统计指标 ### 统计指标
@ -139,6 +377,59 @@
| 查看 | statistics:bidding:view | — | | 查看 | statistics:bidding:view | — |
| 导出 | statistics:bidding:export | 招标统计可导出 | | 导出 | statistics:bidding:export | 招标统计可导出 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载本月数据 → 调用招标统计API → 渲染统计卡片 + 图表
2. **查询/筛选交互流程**:选择时间范围(本月/本季度/本年/自定义)→ 刷新统计数据和图表
3. **表单填写与提交流程**:不适用(只读查看,但支持导出)
4. **弹窗/抽屉交互流程**:点击"导出" → 选择导出格式Excel/PDF→ 调用导出API → 下载文件
5. **行内操作流程**图表支持交互成本对比图hover显示预算vs中标金额详情
6. **异常与错误处理**API请求失败显示el-message错误提示导出失败提示"导出失败,请重试";无数据时图表显示"暂无数据"
7. **联动/级联交互**:时间范围筛选后所有统计卡片和图表同步刷新
8. **权限控制交互表现**:有导出权限显示"导出"按钮;无导出权限隐藏
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 切换时间范围加载数据期间显示loading状态(skeleton)并禁用时间选择控件;**导出期间显示loading并禁用"导出"按钮**,防止重复点击重复下载 |
| **H2** | 请求超时 | **强制** | GET统计请求设置**30s超时**(统计报表类接口数据量大);**POST导出请求设置60s超时**(导出文件可能较大) |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读统计+导出页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 无`statistics:bidding:view`权限时隐藏整个页面或展示无权提示;有`statistics:bidding:export`权限才显示"导出"按钮及格式选择下拉 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读统计页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页为下载导出场景,无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 数据加载成功后silent刷新图表和卡片**导出成功后显示success提示(2s)并触发浏览器下载**导出失败显示error提示(duration=0)含重试选项;网络异常时展示重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 本月/本季度/本年/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 统计卡片区 | el-row + el-col | :gutter=20v-for遍历5个指标 |
| 成本对比图 | echarts | 柱状图(分组),:xAxis=标段,两组数据:预算/中标金额tooltip |
| 各标段中标情况图 | echarts | 饼图/柱状图,:data=各标段中标状态 |
| 招标周期趋势图 | echarts | 折线图,:xAxis=项目,:yAxis=天数tooltip |
| 导出按钮 | el-button | type="success"icon="Download"@click导出 |
| 导出格式选择 | el-dropdown | 触发导出,:options="[{label: 'Excel'}, {label: 'PDF'}]" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片横排5列成本对比图与中标情况图左右并排周期趋势图全宽 |
| 1024-1279pxPad横屏 | 统计卡片3+2排列所有图表上下排列 |
| 768-1023pxPad竖屏 | 统计卡片2×3网格所有图表纵向堆叠ECharts图表宽度100%高度250px支持手势缩放 |
--- ---
## 页面7综合看板页 ## 页面7综合看板页
@ -171,6 +462,57 @@
- 服务数据:物业公司业务数据(只读) - 服务数据:物业公司业务数据(只读)
- 合同/招标数据:本系统数据 - 合同/招标数据:本系统数据
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认加载"今天"数据 → 并行调用各模块统计API → 渲染统计卡片
2. **查询/筛选交互流程**:选择时间范围(今天/本周/本月/自定义)→ 刷新所有看板数据
3. **表单填写与提交流程**:不适用(只读看板)
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**:点击各统计卡片可跳转对应统计详情页(如点击"报修完成率"跳转报修统计页)
6. **异常与错误处理**部分模块API失败时仅该模块显示"数据加载失败",其他模块正常展示;全部失败显示"看板数据加载失败"
7. **联动/级联交互**:时间范围切换后所有模块数据同步刷新
8. **权限控制交互表现**:无查看权限的模块对应卡片灰显或隐藏
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | 切换时间范围刷新数据期间显示loading状态(skeleton)并禁用时间选择控件;各模块并行加载时防止重复请求;卡片点击跳转防抖处理 |
| **H2** | 请求超时 | **强制** | 各模块GET统计请求均设置**30s超时**综合看板涉及多模块并行调用使用30s档位 |
| **H3** | 操作确认 | 强制(不适用) | 本页为只读综合看板页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | 强制(不适用) | 本页无编辑表单,不涉及脏数据检测 |
| **H5** | 权限隔离表现 | 建议 | 各统计卡片根据用户权限动态显隐或灰显;无某模块权限时该区域展示"暂无数据访问权";整体无看板权限时隐藏页面内容 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为只读看板页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 各模块独立加载反馈成功则silent渲染**部分模块失败仅在该卡片区域展示错误状态,不影响其他正常模块**;全部失败时展示全局错误提示+重试按钮 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间选择 | el-radio-group | 今天/本周/本月/自定义,@change刷新数据 |
| 自定义日期 | el-date-picker | type="daterange"v-if="自定义" |
| 服务数据卡片组 | el-card | shadow="hover":gutter=204个指标报修完成率/巡检完成率/保洁完成率/评价均分),@click跳转详情 |
| 合同/招标数据卡片组 | el-card | shadow="hover":gutter=204个指标活跃合同/付款完成率/招标项目/即将到期合同),@click跳转详情 |
| 数值展示 | el-statistic | :precision=1均分/ 0其他prefix/suffix图标 |
| 加载状态 | el-skeleton | :loading=数据加载中animated |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围 | 结束日期≥开始日期 | 结束日期不能早于开始日期 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 两组卡片各横排4列整体2行布局 |
| 1024-1279pxPad横屏 | 两组卡片各2×2网格整体2行布局 |
| 768-1023pxPad竖屏 | 所有卡片纵向堆叠8行1列支持手势下拉刷新卡片宽度100%,高度自适应 |
--- ---
## 页面8自定义报表页 ## 页面8自定义报表页
@ -195,6 +537,63 @@
| 生成报表 | statistics:custom:view | — | | 生成报表 | statistics:custom:view | — |
| 导出 | statistics:custom:export | 合同/招标相关数据可导出,业务数据仅查看 | | 导出 | statistics:custom:export | 合同/招标相关数据可导出,业务数据仅查看 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 渲染报表配置表单(数据源/维度/指标/时间范围选择)
2. **查询/筛选交互流程**:选择数据源 → 联动加载可选维度和指标 → 选择维度和指标 → 选择时间范围
3. **表单填写与提交流程**:配置报表参数 → 点击"生成报表" → 调用自定义报表API → 渲染图表/表格 → (合同/招标数据)点击"导出"下载报表
4. **弹窗/抽屉交互流程**:不适用
5. **行内操作流程**报表结果支持图表交互hover查看数据点表格数据支持排序
6. **异常与错误处理**:未选择数据源时"生成报表"按钮禁用生成失败显示el-message错误提示业务数据导出时提示"业务数据仅查看,不支持导出"
7. **联动/级联交互**:数据源选择→联动可选维度和指标;选择"报修/巡检/保洁/评价"数据源时隐藏导出按钮;选择"合同/招标"数据源时显示导出按钮
8. **权限控制交互表现**:有导出权限且选择合同/招标数据源时显示"导出"按钮;业务数据源始终不显示导出
### 前端硬性约束
> 以下为本页面必须遵守的前端交互与体验硬性约束H=Hard Constraint
| 编号 | 约束项 | 级别 | 本页要求 |
|------|--------|------|----------|
| **H1** | 防重复提交 | **强制** | "生成报表"期间显示loading状态并禁用"生成报表"按钮;切换数据源/维度/指标联动加载期间禁用相关控件;**导出期间显示loading并禁用"导出"按钮** |
| **H2** | 请求超时 | **强制** | GET加载维度/指标选项15s超时GET/POST生成报表请求**30s超时****POST导出请求设置60s超时** |
| **H3** | 操作确认 | 强制(不适用) | 本页为报表配置+查看页面,无删除/停用等危险操作,不涉及 |
| **H4** | 脏数据保护 | **强制** | 用户配置了报表参数但未点"生成报表"即离开页面时若已有已生成的报表结果则标记isDirty=true触发beforeunload离开拦截确认 |
| **H5** | 权限隔离表现 | 建议 | 无`statistics:custom:view`权限时隐藏整个配置表单区;有`statistics:custom:export`权限且选择合同/招标数据源才显示导出按钮;业务数据源始终不显示导出入口 |
| **H6** | 批量操作限制 | 建议(不适用) | 本页为单次报表生成页面,无批量操作,不涉及 |
| **H7** | 文件上传约束 | 建议(不适用) | 本页为报表生成+导出下载场景,无文件上传功能,不涉及 |
| **H8** | 反馈规范 | 建议 | 生成报表成功后silent渲染图表/表格;**业务数据源尝试导出时显示warning提示"业务数据仅查看,不支持导出"(duration=0)**;合同/招标导出成功显示success(2s)并触发下载失败显示error(duration=0)+重试 |
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 数据源选择 | el-select | placeholder="请选择数据源"@change联动维度/指标options: 报修/巡检/保洁/评价/合同/招标 |
| 维度选择 | el-checkbox-group | v-for遍历可选维度@change联动指标 |
| 指标选择 | el-checkbox-group | v-for遍历可选指标 |
| 时间范围 | el-date-picker | type="daterange"start-placeholder="开始日期"end-placeholder="结束日期" |
| 生成报表按钮 | el-button | type="primary"icon="DataAnalysis":disabled="未选择数据源" |
| 导出按钮 | el-button | type="success"icon="Download"v-if="数据源=合同/招标" |
| 报表图表区 | echarts | 根据配置动态渲染,:resize自适应容器 |
| 报表表格区 | el-table | :data=报表数据stripebordersortable |
| 空状态 | el-empty | description="请选择数据源和指标后生成报表" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 数据源 | required | 请选择数据源 |
| 维度 | required至少选1项 | 请至少选择一个维度 |
| 指标 | required至少选1项 | 请至少选择一个指标 |
| 时间范围 | required | 请选择时间范围 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 配置区左侧报表结果区右侧宽度比3:7 |
| 1024-1279pxPad横屏 | 配置区上方,报表结果区下方,全宽展示 |
| 768-1023pxPad竖屏 | 配置区上方折叠为el-collapse报表结果区下方全宽ECharts图表宽度100%高度250px表格横向滚动 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码common > 模块编码common
> 端侧:小程序 > 端侧:小程序
> 关联文档01-模块划分.md §5.2、02-功能清单-小程序端.md §1、03-业务流转逻辑-小程序端.md §1&10、05-接口规范.md §2、06-项目技术要求.md §4.1 > 关联文档01-模块划分.md §5.2、02-功能清单-小程序端.md §1、03-业务流转逻辑-小程序端.md §1&10、05-接口规范.md §2、06-项目技术要求.md §4.1
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -33,6 +34,44 @@
| 微信一键登录 | — | 触发wx.login()获取code后端换openid | | 微信一键登录 | — | 触发wx.login()获取code后端换openid |
| 获取手机号 | — | 微信获取手机号组件,绑定手机号 | | 获取手机号 | — | 微信获取手机号组件,绑定手机号 |
#### 交互流程要求
1. **页面加载流程**页面加载时检查本地token是否有效→有效则直接跳转工作台→无效则显示登录页展示应用Logo和系统名称显示骨架屏占位
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:点击「微信一键登录」→ 触发wx.login()获取code → 后端换取openid → 若未绑定手机号则显示获取手机号按钮 → 点击获取手机号调用微信获取手机号组件 → 绑定成功后跳转工作台
4. **弹窗/弹层交互流程**:首次使用弹出用户协议与隐私政策确认弹窗→用户点击同意后继续登录流程;匹配失败时弹出提示"未找到关联信息,请联系管理员"
5. **行内操作流程**:点击协议链接→跳转协议详情页
6. **异常与错误处理**:微信授权失败显示"授权失败,请重试"网络异常显示重试按钮openid匹配失败提示联系管理员离线状态下提示"网络不可用,请检查网络连接"
7. **联动/级联交互**:登录成功后自动加载权限集,根据权限渲染工作台功能入口
8. **权限控制交互表现**登录前无法访问其他页面所有页面跳转均需验证token有效性
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 应用Logo | `image` | mode="aspectFit"宽度120px |
| 微信一键登录按钮 | `button` | open-type="getPhoneNumber"type="primary"size="default" |
| 获取手机号按钮 | `button` | open-type="getPhoneNumber"type="default" |
| 协议链接 | `navigator` | url="/pages/agreement/index" |
| 版本号 | `text` | font-size="12px"color="#999" |
| 确认弹窗 | `uni-popup` | type="dialog":mask-click="false" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 微信授权 | wx.login()必须返回有效code | "微信授权失败,请重试" |
| 手机号绑定 | 必须获取微信手机号组件返回的有效手机号 | "获取手机号失败,请重试" |
| 用户协议 | 首次登录必须同意协议 | "请先同意用户协议与隐私政策" |
| 人员匹配 | openid必须关联系统人员信息 | "未找到关联信息,请联系管理员" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px按钮宽度max-width=320px居中
- **横竖屏适配策略**竖屏为默认布局横屏时Logo缩小按钮宽度自适应
- **手势交互规范**登录按钮可点击区域≥44px协议链接可点击区域≥44px
- **安全区域**底部按钮适配底部安全区padding-bottom=env(safe-area-inset-bottom)
### 页面2工作台首页 ### 页面2工作台首页
- **页面路径**`/pages/workbench/index` - **页面路径**`/pages/workbench/index`
@ -57,6 +96,48 @@
|------|------|------| |------|------|------|
| 各功能入口 | 按角色 | 根据权限集动态显示/隐藏 | | 各功能入口 | 按角色 | 根据权限集动态显示/隐藏 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取用户信息及权限集→根据权限动态渲染功能入口网格→显示骨架屏占位→数据返回后渲染用户信息栏和功能入口
2. **查询/筛选交互流程**:无手动筛选,功能入口根据角色权限自动过滤显示
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:点击消息入口→跳转消息通知列表页;功能入口红点提示为系统推送自动标记
5. **行内操作流程**点击功能入口→跳转对应功能页面点击消息入口→跳转消息列表底部Tab切换工作台/消息/我的
6. **异常与错误处理**:权限加载失败显示重试按钮;网络异常显示错误提示;离线时显示缓存的功能入口但提示"数据可能不是最新"
7. **联动/级联交互**:功能入口数量与权限集联动,角色变更后重新渲染入口网格
8. **权限控制交互表现**:无权限的功能入口不显示;不同角色看到不同的功能入口集合
9. **H1 防重复请求(强制)**功能入口点击后做轻量防抖300ms避免重复跳转权限集加载请求pending期间不重复发下拉刷新时abort前一次请求后重发
10. **H2 超时配置(强制)**获取用户信息及权限集GET请求超时15s>3s显示wx.showLoading("加载中...");超时后提示"加载超时,请下拉刷新"+重试按钮
11. **H8 反馈(建议)**数据加载成功无额外toast静默渲染加载失败wx.showToast({icon:'none', title:'数据加载失败'}); 禺线模式wx.showToast({icon:'none', title:'数据可能不是最新'})
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 用户信息栏 | `view` + `image` + `text` | flex布局头像40px圆形 |
| 功能入口网格 | `uni-grid` | :column="2":showBorder="false" |
| 功能入口项 | `uni-grid-item` | 点击跳转对应页面 |
| 红点提示 | `uni-badge` | :content="count"type="error" |
| 消息入口 | `uni-icons` | type="notification"size="24" |
| 底部Tab栏 | `uni-tabbar` | 3个Tab工作台/消息/我的 |
| 今日指标卡片 | `uni-card` | mode="center",简洁数字展示 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 用户信息 | 必须返回有效的用户信息 | "获取用户信息失败,请下拉刷新" |
| 权限集 | 必须返回有效的权限数据 | "权限加载失败,请重新登录" |
| 功能入口 | 至少有一个可访问的功能入口 | "暂无可用功能,请联系管理员" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px功能入口网格宽度自适应
- **横竖屏适配策略**竖屏2列网格横屏改为3~4列网格用户信息栏水平展开
- **手势交互规范**功能入口可点击区域≥44px底部Tab可点击区域≥48px支持下拉刷新
- **安全区域**底部Tab栏适配底部安全区padding-bottom=env(safe-area-inset-bottom)
### 页面3个人信息页 ### 页面3个人信息页
- **页面路径**`/pages/profile/index` - **页面路径**`/pages/profile/index`
@ -80,6 +161,50 @@
| 修改手机号 | — | 弹出微信获取手机号组件 | | 修改手机号 | — | 弹出微信获取手机号组件 |
| 退出登录 | — | 清除本地token跳转登录页 | | 退出登录 | — | 清除本地token跳转登录页 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当前用户个人信息→渲染头像、姓名、手机号、所属公司/班组/角色
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:点击修改手机号→弹出微信获取手机号组件→获取新手机号→调用接口更新→更新成功提示"手机号修改成功"
4. **弹窗/弹层交互流程**:点击头像→弹出大图预览;退出登录→弹出确认弹窗"确定要退出登录吗?"
5. **行内操作流程**点击修改手机号→调用微信手机号组件点击退出登录→确认后清除token跳转登录页
6. **异常与错误处理**:信息加载失败显示重试;手机号修改失败提示错误信息;退出登录清除本地缓存失败则强制跳转
7. **联动/级联交互**:手机号修改成功后同步更新页面显示和本地缓存
8. **权限控制交互表现**:所有登录用户均可访问;部分信息只读不可编辑
9. **H1 防重复请求(强制)**修改手机号按钮点击后loading态+disabled退出登录按钮点击后立即disabled防止重复触发手机号更新请求pending期间禁止重复调用微信组件
10. **H2 超时配置(强制)**手机号更新接口POST请求超时30s>3s显示wx.showLoading("正在更新...");超时后提示"请求超时,请重试"+重试按钮
11. **H3 操作确认(强制)**退出登录必须使用uni-popup(type="dialog")弹出确认弹窗"确定要退出登录吗?退出后需重新登录"含后果说明用户确认后才执行清除token和跳转
12. **H4 脏数据检测(强制)**:修改手机号操作前对当前用户信息做深拷贝快照(JSON.parse(JSON.stringify(userInfo)))页面卸载onUnload时检测isDirty若有未保存的修改弹出提示
13. **H7 文件上传(建议)**:本页面不涉及文件上传
14. **H8 反馈(建议)**手机号修改成功wx.showToast({icon:'success', title:'手机号修改成功'})修改失败wx.showToast({icon:'none', title:'修改失败原因'}); 退出登录成功无需toast静默跳转
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 头像 | `image` | mode="aspectFill"80px圆形@click预览大图 |
| 信息列表 | `uni-list` + `uni-list-item` | :showArrow="false",右侧为值 |
| 修改手机号 | `button` | open-type="getPhoneNumber"type="default" |
| 退出登录 | `button` | type="warn"size="default" |
| 确认弹窗 | `uni-popup` | type="dialog",确认退出/取消 |
| 大图预览 | `uni-popup` | type="center",图片全屏预览 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 手机号 | 必须为11位有效手机号 | "手机号格式不正确" |
| 头像 | 点击预览需有效URL | — |
| 退出确认 | 必须用户二次确认 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px信息列表宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏信息列表水平分组展示
- **手势交互规范**头像可点击区域≥44px修改手机号按钮≥44px退出登录按钮≥44px
- **安全区域**:底部退出按钮适配底部安全区
### 页面4消息通知列表 ### 页面4消息通知列表
- **页面路径**`/pages/message/list` - **页面路径**`/pages/message/list`
@ -110,6 +235,50 @@
| 全部标记已读 | — | 将所有未读消息标为已读 | | 全部标记已读 | — | 将所有未读消息标为已读 |
| 点击消息 | — | 跳转到对应业务详情页 | | 点击消息 | — | 跳转到对应业务详情页 |
#### 交互流程要求
1. **页面加载流程**页面加载时请求消息列表→显示骨架屏→数据返回后渲染分类Tab和消息列表默认显示"全部"分类
2. **查询/筛选交互流程**点击分类Tab切换消息类别→重新加载对应分类的消息列表支持滚动加载更多
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**:点击消息→标记该消息已读→跳转对应业务详情页;点击「全部标记已读」→批量标记所有未读消息
6. **异常与错误处理**:消息加载失败显示重试;网络异常显示错误提示;离线时显示缓存消息并提示"离线模式"
7. **联动/级联交互**Tab切换与列表数据联动标记已读后红点数量实时更新
8. **权限控制交互表现**:所有登录用户均可访问;消息内容根据权限控制跳转详情页的可见性
9. **H1 防重复请求(强制)**「全部标记已读」按钮点击后loading态+disabledTab切换请求pending时不重复发上拉加载更多时分页请求使用requestTask新请求时abort前一次下拉刷新时abort进行中的列表请求
10. **H2 超时配置(强制)**消息列表GET请求超时15s标记已读POST请求超时30s列表加载>3s保持骨架屏超时后提示"请求超时,请下拉刷新"+重试按钮
11. **H3 操作确认(强制)**:本页面无删除等危险操作,「全部标记已读」为低风险操作可不加确认
12. **H6 批量限制(建议)**:「全部标记已读」为批量操作,操作前检查未读数量,>50条时提示"未读消息较多,确认全部标记?"再执行
13. **H8 反馈(建议)**标记已读成功wx.showToast({icon:'success', title:'已全部标记已读'})消息加载失败wx.showToast({icon:'none', title:'消息加载失败'}); 无未读消息时提示"暂无未读消息"
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 分类Tab | `uni-segmented-control` | :current="currentTab":values="['全部','工单','审批','系统']" |
| 消息列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 未读红点 | `uni-badge` | :content="1"type="error"size="small" |
| 消息类型图标 | `uni-icons` | type="notification/chat/settings"size="22" |
| 全部标记已读 | `button` | type="default"size="mini" |
| 下拉刷新 | `uni-refresher` | @onRefresh回调threshold=45px |
| 上拉加载 | `uni-load-more` | :status="loadingStatus" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 消息列表 | 加载失败时允许重试 | "消息加载失败,请下拉刷新重试" |
| 分类Tab | 切换时重新请求数据 | — |
| 全部标记已读 | 须有未读消息才可操作 | "暂无未读消息" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px消息列表宽度自适应
- **横竖屏适配策略**:竖屏垂直列表;横屏可双列展示消息卡片
- **手势交互规范**消息项可点击区域≥44pxTab切换区域≥44px支持下拉刷新
- **安全区域**:底部标记已读按钮适配底部安全区
### 页面5数据补录申请页 ### 页面5数据补录申请页
- **页面路径**`/pages/supplement/apply` - **页面路径**`/pages/supplement/apply`
@ -131,6 +300,54 @@
|------|------|------| |------|------|------|
| 提交申请 | — | 提交补录申请,等待主管审核 | | 提交申请 | — | 提交补录申请,等待主管审核 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取补录模块字典→渲染模块选择器
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择补录模块→渲染对应的动态表单→选择补录原因→填写补录说明→上传证明材料→点击提交申请→确认弹窗→提交成功提示"补录申请已提交,等待主管审核"
4. **弹窗/弹层交互流程**:提交前弹出确认弹窗"确认提交补录申请?";上传照片时可选择拍照或从相册选择
5. **行内操作流程**:选择补录模块→动态渲染对应表单字段;点击上传区域→调起相机/相册
6. **异常与错误处理**:网络异常提交失败提示"提交失败,请重试";图片上传失败提示重传;离线时数据暂存本地,联网后自动提交
7. **联动/级联交互**:补录模块选择与动态表单联动,不同模块显示不同字段
8. **权限控制交互表现**:所有登录用户均可提交补录申请;提交后状态为待审核,不可重复提交同一条
9. **H1 防重复请求(强制)**:提交申请按钮点击后:loading="true"且disabled表单提交请求pending期间禁用提交按钮和返回操作使用requestTask管理提交请求
10. **H2 超时配置(强制)**补录模块字典GET请求超时15s提交补录POST请求超时30s图片上传超时60s>3s wx.showLoading("正在提交..."); 超时后提示"请求超时,请重试"+重试按钮
11. **H3 操作确认(强制)**提交前必须弹出uni-popup确认弹窗"确认提交补录申请?提交后将等待主管审核",含后果说明
12. **H4 脏数据检测(强制)**页面进入时对表单初始值做深拷贝快照离开页面onUnload/onHide时检测表单是否有填写内容(isDirty),若有已填写内容但未提交则弹出提示"有未提交的内容,确定离开吗?"
13. **H6 批量限制(建议)**证明材料最多3张已在校验规则限制超出时提示"证明材料最多上传3张"
14. **H7 文件上传(建议)**证明材料使用wx.chooseMedia选择图片限制最多3张单张≤10MB格式白名单jpg/jpeg/png上传时显示进度回调(uni-file-picker内置);上传失败提示具体错误原因
15. **H8 反馈(建议)**提交成功wx.showToast({icon:'success', title:'补录申请已提交,等待主管审核'}); 提交失败wx.showToast({icon:'none', title:'提交失败原因'}可显示多文字); 网络异常wx.getNetworkType检测后提示+重试按钮
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 补录模块选择 | `uni-data-select` | v-model="module":localdata="moduleList" |
| 补录原因选择 | `uni-data-select` | v-model="reason":localdata="reasonList" |
| 补录说明 | `uni-easyinput` | type="textarea"maxlength="200":showWordLimit="true" |
| 证明材料上传 | `uni-file-picker` | limit="3"file-mediatype="image":auto-upload="true" |
| 动态表单 | `uni-forms` | :modelValue="formData":rules="rules" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog",确认提交/取消 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 补录模块 | 必选 | "请选择补录模块" |
| 补录原因 | 必选 | "请选择补录原因" |
| 补录说明 | 必填最多200字 | "请填写补录说明" / "补录说明不能超过200字" |
| 证明材料 | 最多3张 | "证明材料最多上传3张" |
| 动态表单 | 根据模块动态校验 | 根据具体字段提示 |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏表单水平分组展示
- **手势交互规范**选择器可点击区域≥44px提交按钮≥44px上传区域≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面6通讯录页 ### 页面6通讯录页
- **页面路径**`/pages/contacts/index` - **页面路径**`/pages/contacts/index`
@ -160,6 +377,48 @@
|------|------|------| |------|------|------|
| 拨打电话 | — | 点击手机号直接拨打 | | 拨打电话 | — | 点击手机号直接拨打 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取联系人数据→按班组分组→渲染分组列表
2. **查询/筛选交互流程**:输入搜索关键词→实时过滤联系人(按姓名/手机号匹配)→无匹配结果显示"未找到联系人"
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**点击手机号图标→调用wx.makePhoneCall拨打电话滚动列表浏览不同班组的联系人
6. **异常与错误处理**:联系人加载失败显示重试;拨号失败提示"拨号失败";离线时显示缓存数据
7. **联动/级联交互**:搜索关键词与列表过滤联动,输入即过滤
8. **权限控制交互表现**:所有登录用户均可访问;只能查看本公司/班组人员
9. **H1 防重复请求(强制)**:搜索输入做防抖处理(300ms)避免频繁过滤联系人列表加载请求pending期间不重复发拨打电话按钮点击后短暂disabled防止连击
10. **H2 超时配置(强制)**联系人列表GET请求超时15s>3s显示加载骨架屏超时后提示"加载超时,请重试"+重试按钮
11. **H6 批量限制(建议)**:本页面无批量操作
12. **H8 反馈(建议)**联系人加载成功静默渲染加载失败wx.showToast({icon:'none', title:'联系人加载失败'}); 拨号失败wx.showToast({icon:'none', title:'拨号失败,请重试'})
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 搜索框 | `uni-search-bar` | placeholder="搜索姓名/手机号"@confirm="search" |
| 班组分组 | `uni-section` | :title="groupName"type="line" |
| 联系人列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=false |
| 头像 | `image` | mode="aspectFill"40px圆形 |
| 手机号图标 | `uni-icons` | type="phone"size="22"color="#007AFF" |
| 拨打电话 | `button` | @click="makePhoneCall"open-type="contact" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 搜索关键词 | 最少2个字符才触发搜索 | "请输入至少2个字符" |
| 手机号 | 脱敏显示中间4位 | — |
| 联系人列表 | 加载失败允许重试 | "加载失败,请重试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px联系人列表宽度自适应
- **横竖屏适配策略**:竖屏垂直列表;横屏可双列展示联系人卡片
- **手势交互规范**联系人项可点击区域≥44px拨打电话图标≥44px搜索框≥44px
- **安全区域**:底部适配底部安全区
### 页面7版本更新提示弹窗 ### 页面7版本更新提示弹窗
- **页面路径**:弹窗组件(非独立页面) - **页面路径**:弹窗组件(非独立页面)
@ -174,6 +433,47 @@
- 中部:更新说明 - 中部:更新说明
- 底部:操作按钮 - 底部:操作按钮
#### 交互流程要求
1. **页面加载流程**:应用启动时调用后台接口获取最新版本号+最低兼容版本→对比当前版本→小版本更新弹窗提示→大版本更新强制弹窗
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:小版本更新→弹窗显示更新内容+更新/取消按钮,用户可关闭;大版本更新→弹窗仅显示更新按钮,不可关闭
5. **行内操作流程**:点击更新→跳转微信小程序更新页面;点击取消→关闭弹窗继续使用(小版本)
6. **异常与错误处理**版本检查接口失败静默处理不影响正常使用SDK依赖校验失败提示"应用需要更新"
7. **联动/级联交互**:版本号与更新内容联动展示
8. **权限控制交互表现**:所有用户均可见更新提示
9. **H1 防重复请求(强制)**更新按钮点击后loading态+disabled防止重复下载版本检查请求pending期间不重复发送使用requestTask管理版本检查接口
10. **H2 超时配置(强制)**版本检查GET接口超时15s>3s显示wx.showLoading("检查更新中..."); 接口超时或失败静默处理不影响正常使用
11. **H3 操作确认(强制)**:小版本更新弹窗需明确展示更新内容+更新/取消选项;大版本更新弹窗仅显示更新按钮不可关闭,强制用户更新
12. **H8 反馈(建议)**版本检查成功且有新版本正常展示弹窗已是最新版本无需提示SDK校验失败wx.showToast({icon:'none', title:'应用需要更新'})
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 弹窗容器 | `uni-popup` | type="center":mask-click="false"(大版本)/ :mask-click="true"(小版本) |
| 版本号 | `text` | font-size="18px"font-weight="bold" |
| 更新说明 | `scroll-view` | scroll-y=truemax-height="300rpx" |
| 更新按钮 | `button` | type="primary"@click="updateApp" |
| 取消按钮 | `button` | type="default"@click="closePopup"(仅小版本显示) |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 版本号 | 必须为有效版本格式x.y.z | — |
| 更新内容 | 不能为空 | — |
| 最低兼容版本 | 必须为有效版本格式 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px弹窗宽度max-width=300px居中
- **横竖屏适配策略**:竖屏居中弹窗;横屏弹窗宽度适当增大
- **手势交互规范**更新按钮≥44px取消按钮≥44px更新说明区域可滚动
- **安全区域**:弹窗内容不受安全区影响
## 需求追溯 ## 需求追溯
| 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 | | 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 |

@ -3,6 +3,7 @@
> 模块编码repair > 模块编码repair
> 端侧:小程序 > 端侧:小程序
> 关联文档01-模块划分.md §3.1、02-功能清单-小程序端.md §2、03-业务流转逻辑-小程序端.md §2、05-接口规范.md §9.2、06-项目技术要求.md §4.4 > 关联文档01-模块划分.md §3.1、02-功能清单-小程序端.md §2、03-业务流转逻辑-小程序端.md §2、05-接口规范.md §9.2、06-项目技术要求.md §4.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -32,6 +33,41 @@
| 扫描二维码 | — | 调用wx.scanCode扫描项目二维码 | | 扫描二维码 | — | 调用wx.scanCode扫描项目二维码 |
| 确认绑定 | — | 绑定身份到对应项目 | | 确认绑定 | — | 绑定身份到对应项目 |
#### 交互流程要求
1. **页面加载流程**页面加载时自动调起wx.scanCode扫码→扫码成功后显示项目信息→等待用户确认绑定
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**点击扫描二维码→调用wx.scanCode→获取项目编码→查询项目信息→展示项目名称、地址→点击确认绑定→绑定成功跳转工作台
4. **弹窗/弹层交互流程**:扫码失败弹出提示"二维码无效,请重新扫描";绑定成功弹出成功提示
5. **行内操作流程**:点击扫码区域→调起扫码;点击确认绑定→提交绑定请求
6. **异常与错误处理**:扫码超时提示"扫码超时,请重试";二维码无效提示重扫;网络异常显示重试;相机权限被拒提示"请开启相机权限"
7. **联动/级联交互**:扫码结果与项目信息联动展示
8. **权限控制交互表现**:所有用户均可扫码注册;已绑定用户不显示此页面
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 扫码区域 | `button` | @click="scanCode"type="primary"text="扫描二维码" |
| 项目信息展示 | `uni-card` | mode="basic",展示项目名称、地址 |
| 确认绑定 | `button` | type="primary":loading="binding" |
| 提示弹窗 | `uni-popup` | type="dialog",确认/取消 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 二维码 | 必须为系统有效项目二维码 | "二维码无效,请重新扫描" |
| 项目信息 | 扫码后必须返回有效项目数据 | "未找到项目信息" |
| 绑定状态 | 未绑定才可操作 | "您已绑定项目" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px扫码区域居中
- **横竖屏适配策略**:竖屏垂直布局;横屏扫码区域与信息并排展示
- **手势交互规范**扫码按钮≥44px确认绑定按钮≥44px
- **安全区域**:底部按钮适配底部安全区
### 页面2一键报修页 ### 页面2一键报修页
- **页面路径**`/pages/repair/create` - **页面路径**`/pages/repair/create`
@ -53,6 +89,53 @@
| 从相册选择 | — | 选择相册照片,自动添加水印 | | 从相册选择 | — | 选择相册照片,自动添加水印 |
| 提交报修 | — | 生成工单,等待分配 | | 提交报修 | — | 生成工单,等待分配 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取报修类型字典→渲染类型选择器;获取当前位置信息预填位置
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择报修类型→输入报修描述→拍照/选择照片(自动添加时间+位置水印)→选择/确认报修位置→确认联系方式→点击提交报修→确认弹窗→提交成功跳转工单列表
4. **弹窗/弹层交互流程**:点击拍照/相册弹出选择弹窗(拍照/从相册选择);提交前弹出确认弹窗
5. **行内操作流程**:点击照片上传区域→调起相机/相册→选择照片→自动添加水印→显示缩略图;点击位置→打开地图选择位置
6. **异常与错误处理**:定位失败提示手动选择位置;图片上传失败提示重传;提交失败显示重试;离线时数据暂存本地草稿
7. **联动/级联交互**:报修类型与描述提示联动;位置自动获取与手动选择联动
8. **权限控制交互表现**:所有登录用户均可报修;相机/定位权限被拒时引导开启
9. **H1 防重复请求(强制)**:提交报修按钮点击后:loading="submitting"且disabled表单提交请求pending期间禁用提交和返回操作使用requestTask管理提交请求生命周期
10. **H2 超时配置(强制)**报修类型字典GET请求超时15s提交报修POST请求超时30s图片上传超时60s定位接口超时10s>3s wx.showLoading("正在提交..."); 超时后提示"请求超时,请重试"+重试按钮
11. **H3 操作确认(强制)**提交前必须弹出uni-popup确认弹窗"确认提交报修单?提交后将生成工单等待分配",含后果说明
12. **H4 脏数据检测(强制)**进入页面对空表单做深拷贝快照填写过程中任何字段变更标记isDirty=true页面卸载/onBackPress时检测isDirty若有已填写内容描述/照片等弹出uni-popup确认"有未提交的报修内容,确定离开吗?"
13. **H7 文件上传(建议)**照片上传使用wx.chooseMedia({count:9, mediaType:['image']})选择单张≤10MB格式白名单jpg/jpeg/png自动添加水印(时间+位置)后再上传;上传过程显示进度回调(uni-file-picker);上传失败提示具体原因支持重传
14. **H8 反馈(建议)**报修提交成功wx.showToast({icon:'success', title:'报修成功'}); 提交失败wx.showToast({icon:'none', title:'提交失败原因'})可显示多文字定位失败wx.showToast({icon:'none', title:'定位失败,请手动选择位置'}); 离线模式提示数据已存草稿
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 报修类型选择 | `uni-data-select` | v-model="type":localdata="typeList" |
| 报修描述 | `uni-easyinput` | type="textarea"maxlength="500":showWordLimit="true" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image":auto-upload="true" |
| 报修位置 | `uni-list-item` | :showArrow="true"@click="chooseLocation" |
| 联系方式 | `uni-easyinput` | type="number"maxlength="11" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
| 位置选择 | `map` + `button` | 调用wx.chooseLocation |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 报修类型 | 必选 | "请选择报修类型" |
| 报修描述 | 必填最多500字 | "请填写报修描述" / "描述不能超过500字" |
| 照片 | 最多9张必须含水印 | "照片最多上传9张" |
| 联系方式 | 必填11位手机号 | "请输入联系方式" / "手机号格式不正确" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**竖屏垂直布局横屏照片上传区3~4列网格
- **手势交互规范**照片上传区域≥44px提交按钮≥44px位置选择≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面3我的工单列表 ### 页面3我的工单列表
- **页面路径**`/pages/repair/my-orders` - **页面路径**`/pages/repair/my-orders`
@ -88,6 +171,51 @@
| 查看详情 | — | 跳转工单详情页 | | 查看详情 | — | 跳转工单详情页 |
| 催单 | repair:list:view | 处理中工单可催单 | | 催单 | repair:list:view | 处理中工单可催单 |
#### 交互流程要求
1. **页面加载流程**页面加载时请求工单列表→显示骨架屏→渲染Tab和工单卡片默认显示"全部"状态
2. **查询/筛选交互流程**点击状态Tab切换筛选条件→重新加载列表输入关键词搜索工单号/描述;下拉刷新重新加载;上拉加载更多
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:催单时弹出确认弹窗"确认催单?催单后维修人员和主管将收到通知"
5. **行内操作流程**:点击工单卡片→跳转工单详情;点击催单→确认催单→发送催单通知
6. **异常与错误处理**列表加载失败显示重试催单超过每日3次限制提示"今日已催单3次请明日再试";网络异常显示错误提示
7. **联动/级联交互**Tab切换与列表数据联动催单后催单标记实时更新
8. **权限控制交互表现**催单按钮仅处理中工单显示无repair:list:view权限时隐藏催单按钮
9. **H1 防重复请求(强制)**催单按钮点击后loading态+disabledTab切换请求pending时不重复发列表请求搜索输入防抖300ms上拉加载更多时分页用requestTask管理新请求abort前一次下拉刷新abort进行中请求
10. **H2 超时配置(强制)**工单列表GET请求超时15s催单POST请求超时30s>3s保持骨架屏超时后提示"请求超时,请下拉刷新"+重试按钮
11. **H3 操作确认(强制)**催单操作必须弹出uni-popup确认弹窗"确认催单催单后维修人员和主管将收到通知今日剩余X次",含后果说明和次数限制提示
12. **H6 批量限制(建议)**催单每天最多3次服务端校验前端达到限制时直接禁用催单按钮并提示"今日已催单3次请明日再试"
13. **H8 反馈(建议)**催单成功wx.showToast({icon:'success', title:'催单成功'}); 列表加载失败wx.showToast({icon:'none', title:'加载失败,请下拉刷新'}); 催单次数不足wx.showToast({icon:'none', title:'今日已催单3次请明日再试'})
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 状态Tab | `uni-segmented-control` | :values="['全部','待分配','处理中','待验收','已完成','已关闭']" |
| 搜索框 | `uni-search-bar` | placeholder="搜索工单号/描述" |
| 工单列表 | `uni-list` + `uni-list-item` | clickable=true |
| 状态标签 | `uni-tag` | type: success(已完成)/warning(处理中)/error(异常)/default(待分配) |
| 催单标记 | `uni-badge` | content="催"type="error" |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
| 上拉加载 | `uni-load-more` | :status="loadingStatus" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 工单列表 | 加载失败允许重试 | "加载失败,请下拉刷新" |
| 搜索关键词 | 最少2个字符 | "请输入至少2个字符" |
| 催单 | 每天最多3次 | "今日已催单3次请明日再试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应padding 16px
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片展示
- **手势交互规范**工单卡片可点击区域≥44px催单按钮≥44px支持下拉刷新和上拉加载
- **安全区域**:底部列表适配底部安全区
### 页面4待处理工单列表维修人员 ### 页面4待处理工单列表维修人员
- **页面路径**`/pages/repair/pending-orders` - **页面路径**`/pages/repair/pending-orders`
@ -116,6 +244,49 @@
|------|------|------| |------|------|------|
| 接单 | repair:list:update | 接单后工单状态变为"处理中" | | 接单 | repair:list:update | 接单后工单状态变为"处理中" |
#### 交互流程要求
1. **页面加载流程**:页面加载时请求分配给当前维修人员的待处理工单→显示骨架屏→渲染待处理数量统计和工单列表
2. **查询/筛选交互流程**:无手动筛选,自动加载当前人员的待处理工单;下拉刷新重新加载
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:点击接单→弹出确认弹窗"确认接单?"
5. **行内操作流程**:点击工单卡片→查看工单详情;点击接单→确认后工单状态变为处理中→通知报修人
6. **异常与错误处理**:列表加载失败显示重试;接单失败提示错误信息;工单已被他人接单提示"工单已被接单"
7. **联动/级联交互**:接单成功后列表数据更新,待处理数量减少
8. **权限控制交互表现**仅维修人员角色可见此页面无repair:list:update权限时接单按钮置灰
9. **H1 防重复请求(强制)**接单按钮点击后loading态+disabled列表加载pending不重复发上拉分页用requestTask管理新请求abort旧请求下拉刷新abort进行中请求
10. **H2 超时配置(强制)**待处理工单列表GET请求超时15s接单POST请求超时30s>3s保持骨架屏超时后提示"请求超时,请下拉刷新"+重试按钮
11. **H3 操作确认(强制)**接单操作必须弹出uni-popup确认弹窗"确认接单?接单后请在规定时间内完成维修",含后果说明
12. **H6 批量限制(建议)**:本页面主要为单个接单操作,无批量操作场景
13. **H8 反馈(建议)**接单成功wx.showToast({icon:'success', title:'接单成功'}); 工单已被他人接单wx.showToast({icon:'none', title:'工单已被接单,请刷新列表'}); 列表加载失败wx.showToast({icon:'none', title:'加载失败,请重试'})
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 待处理数量 | `uni-badge` | :content="count"type="warning"size="large" |
| 工单列表 | `uni-list` + `uni-list-item` | clickable=true |
| 优先级标签 | `uni-tag` | type: error(高)/warning(中)/default(低) |
| 照片缩略图 | `image` | mode="aspectFill"60px×60px圆角 |
| 接单按钮 | `button` | type="primary"size="mini" |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 工单列表 | 加载失败允许重试 | "加载失败,请下拉刷新" |
| 接单操作 | 工单必须为待分配/已分配状态 | "工单状态已变更,请刷新" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片展示
- **手势交互规范**工单卡片可点击区域≥44px接单按钮≥44px支持下拉刷新
- **安全区域**:底部列表适配底部安全区
### 页面5维修完工页 ### 页面5维修完工页
- **页面路径**`/pages/repair/complete` - **页面路径**`/pages/repair/complete`
@ -135,6 +306,43 @@
| 拍照上传 | — | 拍摄维修后照片 | | 拍照上传 | — | 拍摄维修后照片 |
| 提交完工 | repair:list:update | 工单状态变为"待验收" | | 提交完工 | repair:list:update | 工单状态变为"待验收" |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单基本信息→渲染工单信息摘要
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:输入维修说明→拍照上传维修后照片(自动添加水印)→可选填材料使用记录→点击提交完工→确认弹窗→提交成功跳转工单列表
4. **弹窗/弹层交互流程**:点击拍照弹出选择弹窗(拍照/从相册选择);提交前弹出确认弹窗
5. **行内操作流程**:点击照片上传区域→调起相机/相册;点击提交完工→确认提交
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试;离线时数据暂存本地草稿
7. **联动/级联交互**照片上传数量与限制联动最多9张
8. **权限控制交互表现**仅维修人员可操作无repair:list:update权限时提交按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 维修说明 | `uni-easyinput` | type="textarea"maxlength="500":showWordLimit="true" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image":auto-upload="true" |
| 材料使用记录 | `uni-list` + `uni-list-item` | 可动态添加行 |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 维修说明 | 必填最多500字 | "请填写维修说明" / "维修说明不能超过500字" |
| 维修后照片 | 至少1张 | "请上传维修后照片" |
| 照片水印 | 自动添加时间+位置水印 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**竖屏垂直布局横屏照片区3~4列网格
- **手势交互规范**照片上传区域≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面6延期申请页 ### 页面6延期申请页
- **页面路径**`/pages/repair/extension-apply` - **页面路径**`/pages/repair/extension-apply`
@ -151,6 +359,41 @@
|------|------|------| |------|------|------|
| 提交延期申请 | repair:list:update | 工单状态变为"延期中",等待主管审批 | | 提交延期申请 | repair:list:update | 工单状态变为"延期中",等待主管审批 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单基本信息→渲染工单信息摘要
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:输入延期原因→选择预计完成时间→点击提交延期申请→确认弹窗→提交成功跳转工单详情
4. **弹窗/弹层交互流程**:提交前弹出确认弹窗"确认申请延期?"
5. **行内操作流程**:点击预计完成时间→弹出日期时间选择器→选择时间;点击提交延期申请→确认提交
6. **异常与错误处理**:提交失败显示重试;预计完成时间不能早于当前时间
7. **联动/级联交互**:延期申请提交后工单状态变为"延期中"
8. **权限控制交互表现**仅维修人员可操作无repair:list:update权限时提交按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 延期原因 | `uni-easyinput` | type="textarea"maxlength="500":showWordLimit="true" |
| 预计完成时间 | `uni-datetime-picker` | type="datetime":start="minDate" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 延期原因 | 必填最多500字 | "请填写延期原因" / "延期原因不能超过500字" |
| 预计完成时间 | 必选,必须晚于当前时间 | "请选择预计完成时间" / "预计完成时间不能早于当前时间" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏表单水平分组展示
- **手势交互规范**时间选择器≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面7延期审批页主管 ### 页面7延期审批页主管
- **页面路径**`/pages/repair/extension-approve` - **页面路径**`/pages/repair/extension-approve`
@ -169,6 +412,42 @@
| 通过 | repair:detail:approve | 延期生效,工单继续处理 | | 通过 | repair:detail:approve | 延期生效,工单继续处理 |
| 驳回 | repair:detail:approve | 通知维修人员,工单恢复原状态 | | 驳回 | repair:detail:approve | 通知维修人员,工单恢复原状态 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单基本信息和延期申请详情→渲染工单信息摘要和申请详情
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:查看延期申请详情→输入审批意见→点击通过/驳回→确认弹窗→操作成功跳转列表
4. **弹窗/弹层交互流程**:通过时弹出确认弹窗"确认通过延期申请?";驳回时弹出确认弹窗+必填驳回原因
5. **行内操作流程**:点击通过→确认→延期生效;点击驳回→填写驳回原因→确认→通知维修人员
6. **异常与错误处理**:审批操作失败显示重试;工单状态已变更提示"工单状态已更新,请刷新"
7. **联动/级联交互**:审批结果与工单状态联动更新
8. **权限控制交互表现**仅主管可操作无repair:detail:approve权限时按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 延期申请详情 | `uni-list` + `uni-list-item` | :showArrow="false",展示申请人、原因、预计时间 |
| 审批意见 | `uni-easyinput` | type="textarea"maxlength="200" |
| 通过按钮 | `button` | type="primary"@click="approve" |
| 驳回按钮 | `button` | type="warn"@click="reject" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审批意见 | 驳回时必填 | "请填写驳回原因" |
| 审批意见 | 最多200字 | "审批意见不能超过200字" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏按钮并排展示更宽
- **手势交互规范**:通过/驳回按钮≥44px间距≥16px
- **安全区域**:底部按钮适配底部安全区
### 页面8协助维修申请页 ### 页面8协助维修申请页
- **页面路径**`/pages/repair/assist-apply` - **页面路径**`/pages/repair/assist-apply`
@ -185,6 +464,41 @@
|------|------|------| |------|------|------|
| 提交协助申请 | repair:list:update | 生成协助子工单,等待协助完成 | | 提交协助申请 | repair:list:update | 生成协助子工单,等待协助完成 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单基本信息和班组列表→渲染工单信息摘要
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择协助班组→输入协助原因→点击提交协助申请→确认弹窗→提交成功跳转工单详情
4. **弹窗/弹层交互流程**:提交前弹出确认弹窗"确认申请协助维修?"
5. **行内操作流程**:选择协助班组→输入协助原因→确认提交
6. **异常与错误处理**:提交失败显示重试;无可用协助班组提示"暂无可协助的班组"
7. **联动/级联交互**:协助申请提交后生成协助子工单
8. **权限控制交互表现**仅维修人员可操作无repair:list:update权限时提交按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 协助班组选择 | `uni-data-select` | v-model="team":localdata="teamList" |
| 协助原因 | `uni-easyinput` | type="textarea"maxlength="500":showWordLimit="true" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 协助班组 | 必选 | "请选择协助班组" |
| 协助原因 | 必填最多500字 | "请填写协助原因" / "协助原因不能超过500字" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏表单水平分组展示
- **手势交互规范**班组选择≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面9工单分配页主管 ### 页面9工单分配页主管
- **页面路径**`/pages/repair/assign` - **页面路径**`/pages/repair/assign`
@ -202,6 +516,43 @@
|------|------|------| |------|------|------|
| 确认分配 | repair:list:assign | 分配成功后通知维修人员 | | 确认分配 | repair:list:assign | 分配成功后通知维修人员 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单基本信息和班组列表→渲染工单信息摘要
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择分配班组→联动加载班组下人员列表→选择分配人员→可选填分配备注→点击确认分配→确认弹窗→分配成功通知维修人员
4. **弹窗/弹层交互流程**:提交前弹出确认弹窗"确认分配给XXX"
5. **行内操作流程**:选择班组→人员列表联动刷新→选择人员→确认分配
6. **异常与错误处理**:班组下无人员提示"该班组暂无可用人员";分配失败显示重试
7. **联动/级联交互**:班组选择与人员列表联动,选择班组后动态加载该班组人员
8. **权限控制交互表现**仅主管可操作无repair:list:assign权限时提交按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 班组选择 | `uni-data-select` | v-model="teamId"@change="loadMembers" |
| 人员选择 | `uni-data-select` | v-model="memberId":localdata="memberList" |
| 分配备注 | `uni-easyinput` | type="textarea"maxlength="200" |
| 确认分配 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 分配班组 | 必选 | "请选择分配班组" |
| 分配人员 | 必选 | "请选择分配人员" |
| 分配备注 | 最多200字 | "备注不能超过200字" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏班组人员选择并排展示
- **手势交互规范**选择器≥44px确认按钮≥44px
- **安全区域**:底部按钮适配底部安全区
### 页面10工单验收页 ### 页面10工单验收页
- **页面路径**`/pages/repair/accept` - **页面路径**`/pages/repair/accept`
@ -221,6 +572,43 @@
|------|------|------| |------|------|------|
| 提交验收 | repair:detail:approve | 通过→工单完成;不通过→退回维修 | | 提交验收 | repair:detail:approve | 通过→工单完成;不通过→退回维修 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单信息→渲染工单摘要、维修说明、前后照片对比
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:查看维修前后照片对比→选择验收结果(通过/不通过)→不通过时填写验收意见→点击提交验收→确认弹窗→验收通过工单完成/不通过退回维修
4. **弹窗/弹层交互流程**:提交前弹出确认弹窗"确认验收结果?";点击照片弹出大图预览
5. **行内操作流程**:选择通过/不通过→不通过时展开验收意见输入→提交验收
6. **异常与错误处理**:验收操作失败显示重试;工单状态已变更提示刷新
7. **联动/级联交互**:验收结果选择与验收意见联动,不通过时验收意见必填
8. **权限控制交互表现**:仅主管/报修人可操作无repair:detail:approve权限时按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 照片对比 | `swiper` | 左右滑动对比,:indicator-dots="true" |
| 照片大图 | `uni-popup` | type="center",图片全屏预览 |
| 验收结果 | `uni-data-checkbox` | :localdata="[{value:'pass',text:'通过'},{value:'fail',text:'不通过'}]" |
| 验收意见 | `uni-easyinput` | type="textarea"maxlength="500" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 验收结果 | 必选 | "请选择验收结果" |
| 验收意见 | 不通过时必填最多500字 | "不通过时请填写验收意见" / "验收意见不能超过500字" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px照片区域宽度自适应
- **横竖屏适配策略**:竖屏照片上下对比;横屏照片左右对比
- **手势交互规范**照片可点击放大≥44px提交按钮≥44px左右滑动切换照片
- **安全区域**:底部按钮适配底部安全区
### 页面11工单评价页 ### 页面11工单评价页
- **页面路径**`/pages/repair/evaluate` - **页面路径**`/pages/repair/evaluate`
@ -238,6 +626,43 @@
|------|------|------| |------|------|------|
| 提交评价 | — | 评价提交后不可修改 | | 提交评价 | — | 评价提交后不可修改 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单基本信息→渲染工单信息摘要
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:点击星级选择评分→输入评价内容→可选上传图片→点击提交评价→确认弹窗→评价提交成功跳转工单详情
4. **弹窗/弹层交互流程**:提交前弹出确认弹窗"确认提交评价?提交后不可修改";上传图片弹出选择(拍照/相册)
5. **行内操作流程**:点击星星评分→输入评价→上传图片→提交评价
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试;评价已提交提示"已评价"
7. **联动/级联交互**:评分与评价提交联动,必须选择评分才能提交
8. **权限控制交互表现**:仅报修人可评价;已评价工单不显示评价入口
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 星级评分 | `uni-rate` | :max="5":value="score":size="28" |
| 评价内容 | `uni-easyinput` | type="textarea"maxlength="200":showWordLimit="true" |
| 图片上传 | `uni-file-picker` | limit="3"file-mediatype="image" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 评分 | 必选1~5分 | "请选择评分" |
| 评价内容 | 最多200字 | "评价内容不能超过200字" |
| 图片 | 最多3张 | "评价图片最多上传3张" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏评分与输入并排展示
- **手势交互规范**星级评分每颗星≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面12工单详情页 ### 页面12工单详情页
- **页面路径**`/pages/repair/detail` - **页面路径**`/pages/repair/detail`
@ -268,6 +693,50 @@
| 验收 | 主管/报修人 | 待验收 | 验收工单 | | 验收 | 主管/报修人 | 待验收 | 验收工单 |
| 评价 | 报修人 | 已完成 | 评价工单 | | 评价 | 报修人 | 已完成 | 评价工单 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单详细信息→渲染工单编号、状态、基本信息、照片、维修记录、时间轴
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:本页面无表单提交操作,操作按钮跳转对应子页面
4. **弹窗/弹层交互流程**:点击照片→弹出大图预览;点击催单→弹出确认弹窗
5. **行内操作流程**:点击操作按钮→根据角色和状态跳转对应操作页面(接单→完工页、延期→延期申请页等);查看照片轮播;滚动查看时间轴
6. **异常与错误处理**:工单加载失败显示重试;照片加载失败显示占位图;网络异常显示错误提示
7. **联动/级联交互**:操作按钮根据工单状态和用户角色动态显示/隐藏
8. **权限控制交互表现**:不同角色看到不同操作按钮;无权限操作按钮不显示;催单按钮仅报修人可见
9. **H1 防重复请求(强制)**:各操作按钮(催单/接单/完工等点击后分别做loading+disabled防连击操作跳转子页面由子页面负责防重照片预览大图操作不做loading拦截工单详情GET请求pending期间不重复发
10. **H2 超时配置(强制)**工单详情GET请求超时15s>3s显示骨架屏详情加载超时后提示"加载超时,请重试"+重试按钮
11. **H3 操作确认(强制)**:催单操作在详情页弹出确认弹窗"确认催单今日剩余X次";其他危险操作由各子页面确认
12. **H7 文件上传(建议)**:本页面照片均为只读展示,不涉及上传操作
13. **H8 反馈(建议)**详情加载成功静默渲染无需toast加载失败wx.showToast({icon:'none', title:'工单信息加载失败,请重试'}); 照片加载失败显示占位图网络异常wx.getNetworkType检测+重试按钮
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 状态标签 | `uni-tag` | type: success(已完成)/warning(处理中)/error(异常)/default(待分配) |
| 照片轮播 | `swiper` | :indicator-dots="true":autoplay="false" |
| 照片大图 | `uni-popup` | type="center",图片全屏预览 |
| 维修记录 | `uni-steps` | :options="timeLineData"direction="column" |
| 信息列表 | `uni-list` + `uni-list-item` | :showArrow="false",展示各项信息 |
| 操作按钮区 | `view` + `button` | 固定底部,按钮根据角色动态渲染 |
| 催单确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 工单信息 | 必须返回有效工单数据 | "工单信息加载失败,请重试" |
| 照片 | 加载失败显示占位图 | — |
| 操作权限 | 根据角色和状态校验 | "您暂无此操作权限" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px信息区域宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏信息区和照片区可并排展示
- **手势交互规范**操作按钮≥44px照片可点击放大≥44px左右滑动切换照片
- **安全区域**底部操作按钮区适配底部安全区padding-bottom=env(safe-area-inset-bottom)
## 需求追溯 ## 需求追溯
| 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 | | 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 |

@ -3,6 +3,7 @@
> 模块编码inspection > 模块编码inspection
> 端侧:小程序 > 端侧:小程序
> 关联文档01-模块划分.md §3.2、02-功能清单-小程序端.md §3、03-业务流转逻辑-小程序端.md §3、05-接口规范.md §9.2、06-项目技术要求.md §4.4&5.4 > 关联文档01-模块划分.md §3.2、02-功能清单-小程序端.md §3、03-业务流转逻辑-小程序端.md §3、05-接口规范.md §9.2、06-项目技术要求.md §4.4&5.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -45,6 +46,45 @@
| 开始巡检 | inspection:task:view | 跳转巡检执行页 | | 开始巡检 | inspection:task:view | 跳转巡检执行页 |
| 查看详情 | inspection:task:view | 已完成的任务查看详情 | | 查看详情 | inspection:task:view | 已完成的任务查看详情 |
#### 交互流程要求
1. **页面加载流程**:页面加载时自动请求当日巡检任务列表,显示骨架屏占位;获取任务后渲染统计卡片与任务列表;无任务时显示空状态插图与提示文案
2. **查询/筛选交互流程**:无手动筛选条件,自动按当日+本人过滤;支持下拉刷新重新加载任务数据
3. **表单填写与提交流程**:本页面无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**:点击「开始巡检」→ 校验蓝牙打卡状态 → 跳转巡检执行页;点击「查看详情」→ 跳转巡检详情页
6. **异常与错误处理**:网络异常显示重试提示;蓝牙状态异常在卡片上以图标提示;任务加载失败显示错误页与重试按钮
7. **联动/级联交互**:统计卡片数字与列表数据联动实时更新
8. **权限控制交互表现**:无权限时隐藏「开始巡检」和「查看详情」按钮;按钮置灰且不可点击
9. **H1 防重复提交**:「开始巡检」「查看详情」按钮点击时须设置 `:loading` + `:disabled` 双重锁定;下拉刷新期间禁止重复触发;使用 pending Promise 去重;分页请求切换月份时 abort 上一次请求
10. **H2 超时控制**GET 请求超时 15s页面加载 >3s 时须调用 `wx.showLoading({title:'加载中...', mask:true})`;请求超时时提示"网络请求超时,请重试"并显示重试按钮
11. **H3 操作确认**:点击「开始巡检」前若存在未完成巡检任务,须通过 `wx.showModal` 二次确认,内容含后果说明(如"当前有未完成任务,是否继续?");跳转操作无需二次确认
12. **H8 反馈规范**:任务加载成功后使用 `wx.showToast({icon:'success'})` 反馈;加载失败使用 `wx.showToast({icon:'none', title:'具体错误信息'})` 显示多文字提示;网络异常(`wx.getNetworkType` 判断非 wifi/4g时提示弱网络并提供重试入口
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 统计卡片 | `uni-card` | mode="center",三列等宽布局 |
| 任务列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 状态标签 | `uni-tag` | type: success(已完成)/warning(进行中)/error(异常)/default(待执行) |
| 蓝牙状态图标 | `uni-icons` | type="bluetooth"size="22" |
| 下拉刷新 | `uni-refresher` | @onRefresh 回调threshold=45px |
| 空状态 | `uni-section` | 插槽自定义空状态插图与文案 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 任务列表 | 加载失败时允许重试 | "加载失败,请下拉刷新重试" |
| 统计数据 | 数据为空时显示0 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应屏幕宽度左右padding 16px
- **横竖屏适配策略**:竖屏为默认布局;横屏时统计卡片改为横向平铺,任务列表改为双列卡片
- **手势交互规范**所有可点击区域≥44px下拉刷新触发区域≥50px卡片内按钮间距≥8px
### 页面2蓝牙打卡页 ### 页面2蓝牙打卡页
- **页面路径**`/pages/inspection/bluetooth-checkin` - **页面路径**`/pages/inspection/bluetooth-checkin`
@ -68,6 +108,50 @@
| 重新扫描 | — | 蓝牙扫描超时后可重试最多3次 | | 重新扫描 | — | 蓝牙扫描超时后可重试最多3次 |
| 进入补录模式 | — | 蓝牙无法连接时,进入补录模式 | | 进入补录模式 | — | 蓝牙无法连接时,进入补录模式 |
#### 交互流程要求
1. **页面加载流程**:页面加载时查询蓝牙策略→策略=REQUIRED自动启动蓝牙扫描→显示扫描动画策略=OPTIONAL显示打卡方式选择
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**自动扫描蓝牙Beacon→检测到Beacon→自动连接→连接成功打卡→显示打卡结果扫描超时→提示重新扫描或进入补录模式
4. **弹窗/弹层交互流程**:打卡成功弹出成功提示;扫描超时弹出重试提示;策略=OPTIONAL弹出打卡方式选择弹窗
5. **行内操作流程**:点击重新扫描→重新搜索蓝牙;点击进入补录模式→跳转补录页
6. **异常与错误处理**:蓝牙未开启提示"请开启手机蓝牙"扫描超时3次后自动显示补录入口蓝牙连接失败提示重试离线时提示"网络不可用"
7. **联动/级联交互**:蓝牙策略与扫描行为联动,策略=REQUIRED自动扫描策略=OPTIONAL手动选择
8. **权限控制交互表现**:所有巡检人员可访问;蓝牙权限被拒时引导开启
9. **H1 防重复提交**:「重新扫描」按钮点击时须设置 `:loading="scanning"` + `:disabled` 双重锁定,扫描期间禁止重复点击;「进入补录模式」按钮同样需要 loading+disabled 锁定;使用 pending 标志位防止重复发起扫描
10. **H2 超时控制**:蓝牙扫描超时 3s策略查询 GET 请求超时 15s扫描等待 >3s 时须显示 `wx.showLoading({title:'正在扫描...', mask:true})`;扫描超时或请求超时均需提示用户并提供重试入口
11. **H3 操作确认**:点击「进入补录模式」前须通过 `wx.showModal` 确认,内容说明"进入补录模式后将记录为手动打卡,是否继续?"
12. **H4 脏数据检测**:本页面以自动扫描为主,若用户手动修改过任何数据则标记 isDirty`onUnload` 生命周期中检测到 isDirty 且未提交时弹出 `wx.showModal` 提示"未完成的打卡将丢失,确定离开?"
13. **H7 上传约束**:本页面无上传功能(拍照在巡检执行页完成),此项不适用
14. **H8 反馈规范**:蓝牙连接成功使用 `wx.showToast({icon:'success', title:'打卡成功'})`;扫描失败使用 `wx.showToast({icon:'none', title:'未检测到蓝牙信号,请重试或补录'})`;网络异常通过 `wx.getNetworkType` 判断后提供重试
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 扫描动画 | `view` + CSS动画 | 旋转动画size=80px |
| 状态提示 | `text` | font-size="14px",动态文案 |
| Beacon信息 | `uni-list` + `uni-list-item` | :showArrow="false"显示UUID/RSSI |
| 重新扫描 | `button` | type="default":loading="scanning" |
| 进入补录模式 | `button` | type="warn"@click="goSupplement" |
| 打卡结果 | `uni-popup` | type="dialog",打卡成功/失败提示 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 蓝牙状态 | 必须开启蓝牙 | "请开启手机蓝牙" |
| Beacon信号 | RSSI > -70dBm | "蓝牙信号弱,请靠近打卡点" |
| 扫描超时 | 3秒超时最多重试3次 | "蓝牙扫描超时,请重试或进入补录模式" |
| 打卡点验证 | Beacon必须属于本人班组打卡点 | "未检测到有效打卡点" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px扫描动画居中
- **横竖屏适配策略**:竖屏垂直布局;横屏动画与信息并排展示
- **手势交互规范**按钮可点击区域≥44px动画区域≥80px
- **安全区域**:底部按钮适配底部安全区
### 页面3巡检执行页 ### 页面3巡检执行页
- **页面路径**`/pages/inspection/execute` - **页面路径**`/pages/inspection/execute`
@ -100,6 +184,55 @@
| 一键生成报修 | — | 异常项可直接生成报修工单(数据写入报修模块) | | 一键生成报修 | — | 异常项可直接生成报修工单(数据写入报修模块) |
| 提交巡检结果 | inspection:task:view | 提交巡检记录 | | 提交巡检结果 | inspection:task:view | 提交巡检记录 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取巡检任务清单→渲染检查项列表和进度条;查询蓝牙策略→蓝牙策略=REQUIRED时显示蓝牙连接状态指示
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:逐项标记正常/异常→异常项展开附加信息→选择严重等级→输入描述→拍照上传(蓝牙策略=REQUIRED须蓝牙连接下拍照→点击提交巡检结果→确认弹窗→提交成功
4. **弹窗/弹层交互流程**:点击拍照弹出选择(拍照/相册);提交前弹出确认弹窗"确认提交巡检结果?";点击一键生成报修弹出确认弹窗
5. **行内操作流程**:点击标记正常→该项标记为正常;点击标记异常→展开附加信息区;点击拍照→调起相机;点击一键生成报修→创建报修工单
6. **异常与错误处理**:蓝牙断连时拍照提示"请连接蓝牙后拍照";提交失败显示重试;未完成所有检查项时提示"请完成所有检查项"
7. **联动/级联交互**:检查进度与进度条联动;异常项标记与附加信息展开联动
8. **权限控制交互表现**仅巡检人员可操作无inspection:task:view权限时提交按钮置灰
9. **H1 防重复提交**:「标记正常」「标记异常」「提交巡检结果」按钮点击时须设置 `:loading` + `:disabled` 双重锁定;「一键生成报修」按钮同样需要防重复;提交期间禁止重复操作
10. **H2 超时控制**:获取巡检清单 GET 请求超时 15s提交巡检结果 POST 请求超时 30s请求耗时 >3s 时须调用 `wx.showLoading({mask:true})`;超时提示"提交超时,请检查网络后重试"
11. **H3 操作确认**:点击「提交巡检结果」前须通过 `wx.showModal` 二次确认,内容包括已检查项数、正常/异常统计及后果说明;点击「一键生成报修」前须确认"确认为此异常项生成报修工单?"
12. **H4 脏数据检测**页面加载时对初始巡检清单数据进行快照JSON.stringify 深拷贝);每次标记正常/异常或填写附加信息时对比快照检测 isDirty`onUnload` 中检测到 isDirty 且未提交时弹出 `wx.showModal({content:"巡检数据尚未保存,确定离开?"})` 拦截离开
13. **H7 上传约束**:异常项照片上传单个文件 ≤10MB每项最多 ≤9 张;使用 `uni-file-picker` 配合进度回调 `@progress` 实时展示上传百分比;超出限制时提示"照片大小不能超过10MB"或"最多上传9张照片"
14. **H8 反馈规范**:提交成功使用 `wx.showToast({icon:'success', title:'巡检结果已提交'})`;校验失败(如未完成所有检查项)使用 `wx.showToast({icon:'none', title:'请完成所有检查项后再提交'})`;网络异常时通过 `wx.getNetworkType` 判断并提供重试按钮
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 进度条 | `progress` | :percent="progress"show-info=true |
| 蓝牙状态指示 | `uni-icons` | type="bluetooth"size="18"color动态 |
| 检查项列表 | `uni-list` + `uni-list-item` | :showArrow="false",可展开 |
| 正常/异常切换 | `uni-data-checkbox` | :localdata="[{value:'normal',text:'正常'},{value:'abnormal',text:'异常'}]" |
| 严重等级选择 | `uni-data-select` | :localdata="levelList" |
| 异常描述 | `uni-easyinput` | type="textarea"maxlength="500" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image" |
| 一键生成报修 | `button` | type="warn"size="mini" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 检查项 | 必须逐项完成 | "请完成所有检查项" |
| 异常项严重等级 | 异常时必选 | "请选择严重等级" |
| 异常项描述 | 异常时必填 | "请填写异常描述" |
| 异常项照片 | 异常时至少1张 | "请上传异常照片" |
| 蓝牙状态 | 策略=REQUIRED拍照时须蓝牙连接 | "请连接蓝牙后拍照" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px检查项列表宽度自适应
- **横竖屏适配策略**:竖屏垂直列表;横屏异常项展开区域更宽
- **手势交互规范**检查项可点击区域≥44px按钮≥44px照片上传区域≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面4异常上报页 ### 页面4异常上报页
- **页面路径**`/pages/inspection/report-abnormal` - **页面路径**`/pages/inspection/report-abnormal`
@ -120,6 +253,45 @@
| 拍照上传 | — | 拍摄异常照片 | | 拍照上传 | — | 拍摄异常照片 |
| 提交上报 | — | 上报异常,如选择生成工单则同时创建报修工单 | | 提交上报 | — | 上报异常,如选择生成工单则同时创建报修工单 |
#### 交互流程要求
1. **页面加载流程**:页面加载时初始化表单,获取报修类型字典
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择严重等级→输入异常描述→拍照上传(含水印)→可选开启"生成报修工单"开关→选择报修类型→点击提交上报→确认弹窗→上报成功
4. **弹窗/弹层交互流程**:点击拍照弹出选择(拍照/相册);提交前弹出确认弹窗
5. **行内操作流程**:选择严重等级→输入描述→上传照片→开启生成报修工单→选择报修类型→提交
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试
7. **联动/级联交互**:生成报修工单开关与报修类型选择联动,开启后显示报修类型选择器
8. **权限控制交互表现**:所有巡检人员可操作
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 严重等级 | `uni-data-select` | :localdata="[{value:'minor',text:'一般'},{value:'major',text:'较重'},{value:'critical',text:'严重'}]" |
| 异常描述 | `uni-easyinput` | type="textarea"maxlength="500":showWordLimit="true" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image":auto-upload="true" |
| 生成报修工单 | `switch` | @change="onSwitchChange" |
| 报修类型 | `uni-data-select` | v-model="repairType":localdata="repairTypeList"v-if="generateRepair" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 严重等级 | 必选 | "请选择严重等级" |
| 异常描述 | 必填最多500字 | "请填写异常描述" / "描述不能超过500字" |
| 照片 | 至少1张最多9张 | "请上传异常照片" / "照片最多上传9张" |
| 报修类型 | 开启生成报修工单时必选 | "请选择报修类型" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**竖屏垂直布局横屏照片区3~4列网格
- **手势交互规范**选择器≥44px提交按钮≥44px照片上传区域≥44px
- **安全区域**:底部提交按钮适配底部安全区
### 页面5巡检历史列表 ### 页面5巡检历史列表
- **页面路径**`/pages/inspection/history` - **页面路径**`/pages/inspection/history`
@ -151,6 +323,42 @@
|------|------|------| |------|------|------|
| 查看详情 | inspection:task:view | 查看巡检记录详情 | | 查看详情 | inspection:task:view | 查看巡检记录详情 |
#### 交互流程要求
1. **页面加载流程**:页面加载时默认加载当月巡检记录→显示骨架屏→渲染月份选择器和记录列表
2. **查询/筛选交互流程**:切换月份→重新加载对应月份记录;下拉刷新重新加载;上拉加载更多
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**:点击查看详情→跳转巡检记录详情页;左右滑动切换月份
6. **异常与错误处理**:记录加载失败显示重试;无记录显示空状态插图和文案
7. **联动/级联交互**:月份切换与列表数据联动
8. **权限控制交互表现**仅巡检人员可访问本人数据主管可查看本班组数据无inspection:task:view权限时隐藏查看详情
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 月份选择器 | `uni-datetime-picker` | type="date"fields="month" |
| 记录列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 状态标签 | `uni-tag` | type: success(正常)/error(异常) |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
| 上拉加载 | `uni-load-more` | :status="loadingStatus" |
| 空状态 | `uni-section` | 插槽自定义空状态插图与文案 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 月份 | 必须选择有效月份 | — |
| 记录列表 | 加载失败允许重试 | "加载失败,请下拉刷新重试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应padding 16px
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片展示
- **手势交互规范**记录项可点击区域≥44px月份选择器≥44px支持下拉刷新和上拉加载
- **安全区域**:底部列表适配底部安全区
### 页面6异常数据补录页 ### 页面6异常数据补录页
- **页面路径**`/pages/inspection/supplement` - **页面路径**`/pages/inspection/supplement`
@ -172,6 +380,54 @@
|------|------|------| |------|------|------|
| 提交补录申请 | — | 提交后等待主管审核 | | 提交补录申请 | — | 提交后等待主管审核 |
#### 交互流程要求
1. **页面加载流程**:页面加载时显示补录模式提示(红色警示条)→获取巡检区域和检查项数据
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择补录原因→填写补录说明→上传证明材料→手动选择巡检时间→选择巡检区域→逐项填写检查项结果→点击提交补录申请→确认弹窗→提交成功等待主管审核
4. **弹窗/弹层交互流程**:点击上传弹出选择(拍照/相册);提交前弹出确认弹窗"确认提交补录申请?"
5. **行内操作流程**:选择原因→填写说明→上传照片→选择时间和区域→填写检查项→提交
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试;离线时数据暂存本地
7. **联动/级联交互**:补录原因与其他字段无强联动;巡检区域与检查项联动
8. **权限控制交互表现**仅巡检人员可操作补录数据标记is_supplement=true
9. **H1 防重复提交**:「提交补录申请」按钮点击时须设置 `:loading="submitting"` + `:disabled` 双重锁定;提交期间禁止重复点击
10. **H2 超时控制**:获取区域和检查项数据 GET 请求超时 15s提交补录 POST 请求超时 30s证明材料上传超时 60s>3s 时须 `wx.showLoading({mask:true})`;超时后提示并提供重试
11. **H3 操作确认**:点击「提交补录申请」前须通过 `wx.showModal` 二次确认,内容包括补录原因摘要、说明字数及"提交后需等待主管审核"的后果说明
12. **H4 脏数据检测**:页面初始化时保存空白表单快照;选择补录原因、填写说明、上传材料、选择时间/区域、填写检查项等任一操作触发 isDirty=true`onUnload` 中检测 isDirty 时弹出 `wx.showModal({content:"补录信息尚未提交,确定离开?"})` 拦截
13. **H7 上传约束**:证明材料上传单个文件 ≤10MB最多 ≤3 张(业务限制);使用 `uni-file-picker``@progress` 回调显示上传进度;超出限制时前端拦截并提示
14. **H8 反馈规范**:提交成功使用 `wx.showToast({icon:'success', title:'补录申请已提交,待审核'})`;校验失败使用 `wx.showToast({icon:'none', title:'具体错误信息'})`;离线暂存本地时提示"已暂存至本地,联网后将自动提交"
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 补录原因 | `uni-data-select` | :localdata="[{value:'bluetooth',text:'蓝牙失灵'},{value:'location',text:'定位失败'},{value:'system',text:'系统异常'},{value:'other',text:'其他'}]" |
| 补录说明 | `uni-easyinput` | type="textarea"maxlength="200":showWordLimit="true" |
| 证明材料上传 | `uni-file-picker` | limit="3"file-mediatype="image" |
| 巡检时间 | `uni-datetime-picker` | type="datetime" |
| 巡检区域 | `uni-data-select` | v-model="areaId":localdata="areaList" |
| 检查项结果 | `uni-data-checkbox` | 每项动态渲染,正常/异常 |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 补录原因 | 必选 | "请选择补录原因" |
| 补录说明 | 必填最多200字 | "请填写补录说明" / "补录说明不能超过200字" |
| 证明材料 | 最多3张 | "证明材料最多上传3张" |
| 巡检时间 | 必选 | "请选择巡检时间" |
| 巡检区域 | 必选 | "请选择巡检区域" |
| 检查项结果 | 必须逐项填写 | "请完成所有检查项" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏表单水平分组展示
- **手势交互规范**选择器≥44px提交按钮≥44px上传区域≥44px
- **安全区域**:底部提交按钮适配底部安全区
## 需求追溯 ## 需求追溯
| 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 | | 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 |

@ -3,6 +3,7 @@
> 模块编码cleaning > 模块编码cleaning
> 端侧:微信小程序 > 端侧:微信小程序
> 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§4、03-业务流转逻辑-小程序端.md§4、05-接口规范.md§9、06-项目技术要求.md > 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§4、03-业务流转逻辑-小程序端.md§4、05-接口规范.md§9、06-项目技术要求.md
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -42,6 +43,46 @@
- 「开始执行」→ 进入保洁执行页权限cleaning:task:update - 「开始执行」→ 进入保洁执行页权限cleaning:task:update
- 「查看详情」→ 查看任务详情 - 「查看详情」→ 查看任务详情
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当日保洁任务列表→显示骨架屏→渲染统计卡片和任务列表
2. **查询/筛选交互流程**:无手动筛选,自动按当天+本人过滤;支持下拉刷新
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**:点击「开始执行」→校验蓝牙打卡状态→跳转保洁执行页;点击「查看详情」→查看任务详情
6. **异常与错误处理**:任务加载失败显示重试;无任务显示空状态插图和文案
7. **联动/级联交互**:统计卡片数字与列表数据联动更新
8. **权限控制交互表现**无cleaning:task:update权限时隐藏「开始执行」按钮
9. **H1 防重复提交**:「开始执行」「查看详情」按钮点击时须设置 `:loading` + `:disabled` 双重锁定;下拉刷新期间禁止重复触发;使用 pending Promise 去重
10. **H2 超时控制**:获取保洁任务列表 GET 请求超时 15s页面加载 >3s 时须调用 `wx.showLoading({title:'加载中...', mask:true})`;请求超时时提示"网络请求超时,请下拉刷新重试"
11. **H3 操作确认**:点击「开始执行」前若存在进行中任务,须通过 `wx.showModal` 二次确认,内容含后果说明
12. **H8 反馈规范**:任务加载成功使用静默渲染;加载失败使用 `wx.showToast({icon:'none', title:'加载失败,请重试'})`;网络异常通过 `wx.getNetworkType` 判断后提供重试入口
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 统计卡片 | `uni-card` | mode="center",三列等宽布局 |
| 任务列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 状态标签 | `uni-tag` | type: success(已完成)/warning(进行中)/error(超时)/default(待执行) |
| 蓝牙状态图标 | `uni-icons` | type="bluetooth"size="22" |
| 下拉刷新 | `uni-refresher` | @onRefresh回调threshold=45px |
| 空状态 | `uni-section` | 插槽自定义空状态插图与文案 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 任务列表 | 加载失败时允许重试 | "加载失败,请下拉刷新重试" |
| 统计数据 | 数据为空时显示0 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应padding 16px
- **横竖屏适配策略**:竖屏单列卡片;横屏统计卡片横向平铺,任务列表双列卡片
- **手势交互规范**任务项可点击区域≥44px下拉刷新触发区域≥50px
- **安全区域**:底部适配底部安全区
**需求追溯** **需求追溯**
| 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 | | 功能点编号 | 功能名称 | 文档来源 | 后续服务 | 关联功能 |
@ -82,6 +123,51 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-CL-02 | 蓝牙强制打卡确认 | 02-小程序端 §4 | 保洁执行 | 保洁补录、系统蓝牙配置 | | MP-CL-02 | 蓝牙强制打卡确认 | 02-小程序端 §4 | 保洁执行 | 保洁补录、系统蓝牙配置 |
#### 交互流程要求
1. **页面加载流程**:页面加载时查询蓝牙策略→策略=REQUIRED自动启动蓝牙扫描→显示扫描动画策略=OPTIONAL显示打卡方式选择
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**自动扫描蓝牙Beacon→检测到→连接→打卡成功check_type=BLUETOOTH扫描失败→重试/进入补录模式
4. **弹窗/弹层交互流程**:打卡成功弹出成功提示;扫描超时弹出重试提示
5. **行内操作流程**:点击重新扫描→重新搜索蓝牙;点击进入补录模式→跳转补录申请页;点击确认打卡→记录蓝牙打卡
6. **异常与错误处理**:蓝牙未开启提示"请开启手机蓝牙"扫描超时3次显示补录入口连接失败提示重试
7. **联动/级联交互**:蓝牙策略与扫描行为联动
8. **权限控制交互表现**:所有保洁人员可访问
9. **H1 防重复提交**:「重新扫描」「确认打卡」「进入补录模式」按钮点击时均须设置 `:loading` + `:disabled` 双重锁定;扫描期间使用 pending 标志位防止重复发起蓝牙扫描
10. **H2 超时控制**:蓝牙策略查询 GET 请求超时 15s蓝牙扫描超时 3s>3s 时须显示 `wx.showLoading({title:'正在扫描...', mask:true})`;超时或连接失败均提示并提供重试/补录选项
11. **H3 操作确认**:点击「进入补录模式」前须通过 `wx.showModal` 确认,内容说明"进入补录后将记录为手动打卡,是否继续?";点击「确认打卡」前提示"确认在此位置打卡?"
12. **H4 脏数据检测**:本页面以自动操作为主,若用户手动修改过任何信息则标记 isDirty`onUnload` 中检测到 isDirty 且未完成打卡时弹出 `wx.showModal({content:"未完成的打卡将丢失,确定离开?"})`
13. **H7 上传约束**:本页面无上传功能,此项不适用
14. **H8 反馈规范**:打卡成功使用 `wx.showToast({icon:'success', title:'打卡成功'})`;扫描失败使用 `wx.showToast({icon:'none', title:'未检测到蓝牙信号'})`;网络异常提供重试入口
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 扫描动画 | `view` + CSS动画 | 旋转动画size=80px |
| 蓝牙状态图标 | `uni-icons` | type="bluetooth"size=40 |
| 打卡点名称 | `text` | font-size="18px"font-weight="bold" |
| 信号强度 | `text` | font-size="14px"动态显示RSSI |
| 重新扫描 | `button` | type="default":loading="scanning" |
| 进入补录模式 | `button` | type="warn" |
| 确认打卡 | `button` | type="primary" |
| 打卡结果 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 蓝牙状态 | 必须开启蓝牙 | "请开启手机蓝牙" |
| Beacon信号 | RSSI > -70dBm | "蓝牙信号弱,请靠近打卡点" |
| 扫描超时 | 3秒超时最多重试3次 | "蓝牙扫描超时" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px扫描动画居中
- **横竖屏适配策略**:竖屏垂直布局;横屏动画与信息并排展示
- **手势交互规范**按钮可点击区域≥44px
- **安全区域**:底部按钮适配底部安全区
--- ---
### 页面3保洁执行 ### 页面3保洁执行
@ -119,6 +205,50 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-CL-03 | 保洁执行 | 02-小程序端 §4 | 主管抽查 | 保洁异常反馈 | | MP-CL-03 | 保洁执行 | 02-小程序端 §4 | 主管抽查 | 保洁异常反馈 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取保洁任务详情和清单→渲染任务信息和检查项列表;查询蓝牙拍照策略
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:逐项点击「完成该项」→标记异常项点击「标记异常」→跳转异常反馈页→完成后拍照上传→点击「提交完成」→确认弹窗→提交成功
4. **弹窗/弹层交互流程**:点击拍照弹出选择(拍照/相册);提交前弹出确认弹窗
5. **行内操作流程**:点击完成该项→该项标记完成;点击标记异常→跳转异常反馈页;点击拍照→调起相机(蓝牙策略=REQUIRED须蓝牙连接下拍照
6. **异常与错误处理**:蓝牙断连时拍照提示"请连接蓝牙后拍照";提交失败显示重试
7. **联动/级联交互**:检查项完成进度与任务状态联动
8. **权限控制交互表现**无cleaning:task:update权限时按钮置灰
9. **H1 防重复提交**:「完成该项」「标记异常」「提交完成」按钮点击时均须设置 `:loading` + `:disabled` 双重锁定;提交期间禁止重复操作;拍照调用同样需要防抖处理
10. **H2 超时控制**:获取任务详情和清单 GET 请求超时 15s提交完成 POST 请求超时 30s照片上传超时 60s>3s 时须 `wx.showLoading({mask:true})`;超时后提示具体原因及重试选项
11. **H3 操作确认**:点击「提交完成」前须通过 `wx.showModal` 二次确认,内容包括已完成项数、异常项数及"确认提交后将进入待抽查状态"的后果说明
12. **H4 脏数据检测**:页面加载时对初始清单数据进行快照(深拷贝);每次点击完成/标记异常或上传照片时对比快照检测 isDirty`onUnload` 中检测到 isDirty 且未提交时弹出 `wx.showModal({content:"保洁数据尚未保存,确定离开?"})`
13. **H7 上传约束**:完成照片上传单个文件 ≤10MB最多 ≤9 张;使用 `uni-file-picker``@progress` 回调实时展示上传进度百分比;蓝牙策略=REQUIRED 时拍照前校验蓝牙连接状态
14. **H8 反馈规范**:提交成功使用 `wx.showToast({icon:'success', title:'保洁任务已提交'})`;校验失败使用 `wx.showToast({icon:'none', title:'请完成所有保洁项目'})` 多文字提示;网络异常判断并提供重试
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 任务信息卡片 | `uni-card` | mode="basic",只读展示 |
| 保洁清单列表 | `uni-list` + `uni-list-item` | :showArrow="false",可勾选 |
| 完成该项 | `button` | type="default"size="mini"@click="completeItem" |
| 标记异常 | `button` | type="warn"size="mini"@click="reportAbnormal" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image" |
| 提交完成 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 保洁清单 | 必须逐项完成或标记异常 | "请完成所有保洁项目" |
| 完成照片 | 至少1张 | "请上传完成照片" |
| 蓝牙状态 | 策略=REQUIRED拍照时须蓝牙连接 | "请连接蓝牙后拍照" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px列表宽度自适应
- **横竖屏适配策略**:竖屏垂直列表;横屏清单与照片区并排
- **手势交互规范**列表项可点击区域≥44px按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
--- ---
### 页面4异常反馈 ### 页面4异常反馈
@ -146,6 +276,42 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-CL-04 | 异常反馈 | 02-小程序端 §4 | 通知主管 | 保洁管理Web端 | | MP-CL-04 | 异常反馈 | 02-小程序端 §4 | 通知主管 | 保洁管理Web端 |
#### 交互流程要求
1. **页面加载流程**:页面加载时初始化表单
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:输入异常描述→选择严重程度→拍照上传→点击提交反馈→确认弹窗→提交成功通知主管
4. **弹窗/弹层交互流程**:点击拍照弹出选择(拍照/相册);提交前弹出确认弹窗
5. **行内操作流程**:输入描述→选择严重程度→上传照片→提交
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试
7. **联动/级联交互**:无强联动
8. **权限控制交互表现**:所有保洁人员可操作
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 异常描述 | `uni-easyinput` | type="textarea"maxlength="500":showWordLimit="true" |
| 严重程度 | `uni-data-select` | :localdata="[{value:'minor',text:'一般'},{value:'major',text:'严重'},{value:'critical',text:'紧急'}]" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image":auto-upload="true" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 异常描述 | 必填最多500字 | "请填写异常描述" / "描述不能超过500字" |
| 严重程度 | 必选 | "请选择严重程度" |
| 照片 | 最多9张含水印 | "照片最多上传9张" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**竖屏垂直布局横屏照片区3~4列网格
- **手势交互规范**选择器≥44px提交按钮≥44px上传区域≥44px
- **安全区域**:底部提交按钮适配底部安全区
--- ---
### 页面5保洁抽查 ### 页面5保洁抽查
@ -185,6 +351,45 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-CL-05 | 保洁抽查 | 02-小程序端 §4 | 不合格时重新生成任务 | 保洁管理Web端 | | MP-CL-05 | 保洁抽查 | 02-小程序端 §4 | 不合格时重新生成任务 | 保洁管理Web端 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取待抽查任务列表→显示骨架屏→渲染筛选条件和任务列表
2. **查询/筛选交互流程**:日期/区域/状态筛选条件→切换筛选重新加载列表;下拉刷新
3. **表单填写与提交流程**:点击任务→弹出抽查结果标记弹窗→选择合格/不合格→不合格时填写原因+上传照片→确认提交
4. **弹窗/弹层交互流程**:点击抽查→弹出抽查标记弹窗(合格/不合格选择+原因输入)
5. **行内操作流程**:点击合格→确认完成;点击不合格→填写原因→确认→系统自动重新生成任务
6. **异常与错误处理**:列表加载失败显示重试;抽查操作失败显示重试
7. **联动/级联交互**:不合格标记与任务重新生成联动
8. **权限控制交互表现**仅主管可操作无cleaning:spot-check:approve权限时按钮置灰
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 筛选条件 | `uni-data-select` | 日期/区域/状态三个选择器 |
| 任务列表 | `uni-list` + `uni-list-item` | clickable=true |
| 抽查标记弹窗 | `uni-popup` | type="bottom",弹出抽查操作面板 |
| 合格按钮 | `button` | type="primary"@click="markQualified" |
| 不合格按钮 | `button` | type="warn"@click="markUnqualified" |
| 不合格原因 | `uni-easyinput` | type="textarea"maxlength="200" |
| 不合格照片 | `uni-file-picker` | limit="9"file-mediatype="image" |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 抽查结果 | 必选(合格/不合格) | "请选择抽查结果" |
| 不合格原因 | 不合格时必填 | "请填写不合格原因" |
| 不合格照片 | 不合格时至少1张 | "请上传不合格照片" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px列表宽度自适应
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片+底部弹窗更宽
- **手势交互规范**任务项可点击区域≥44px按钮≥44px
- **安全区域**:底部弹窗适配底部安全区
--- ---
### 页面6保洁历史 ### 页面6保洁历史
@ -220,6 +425,45 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-CL-06 | 保洁历史 | 02-小程序端 §4 | 无 | 保洁执行 | | MP-CL-06 | 保洁历史 | 02-小程序端 §4 | 无 | 保洁执行 |
#### 交互流程要求
1. **页面加载流程**:页面加载时默认加载当月保洁记录→显示骨架屏→渲染月份选择器和记录列表
2. **查询/筛选交互流程**:切换月份→重新加载;状态筛选(全部/正常/异常/补录)→过滤记录;下拉刷新
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**:点击查看详情→查看历史任务详情
6. **异常与错误处理**:记录加载失败显示重试;无记录显示空状态
7. **联动/级联交互**:月份和状态筛选与列表数据联动
8. **权限控制交互表现**:仅保洁人员可访问本人数据
9. **H1 防重复提交**:「查看详情」按钮设置防重复标记;下拉刷新期间禁止重复触发;切换月份/状态筛选时 abort 未完成的上一次请求
10. **H2 超时控制**:获取历史记录 GET 请求超时 15s切换月份加载 >3s 时须 `wx.showLoading({mask:true})`;超时提示"加载超时,请重试"
11. **H3 操作确认**:本页面主要为列表查看,无需操作确认
12. **H8 反馈规范**:记录加载成功静默渲染;加载失败使用 `wx.showToast({icon:'none', title:'记录加载失败,请重试'})`;空数据显示空状态插图;网络异常提供重试
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 月份选择器 | `uni-datetime-picker` | type="date"fields="month" |
| 状态筛选 | `uni-segmented-control` | :values="['全部','正常','异常','补录']" |
| 记录列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 状态标签 | `uni-tag` | type: success(正常)/error(异常)/primary(补录) |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
| 空状态 | `uni-section` | 插槽自定义空状态插图与文案 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 记录列表 | 加载失败允许重试 | "加载失败,请下拉刷新重试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片展示
- **手势交互规范**记录项可点击区域≥44px支持下拉刷新
- **安全区域**:底部适配底部安全区
--- ---
### 页面7异常数据补录 ### 页面7异常数据补录
@ -252,6 +496,53 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-CL-07 | 异常数据补录 | 02-小程序端 §4 | 主管审核补录 | 操作日志、考勤补录审核 | | MP-CL-07 | 异常数据补录 | 02-小程序端 §4 | 主管审核补录 | 操作日志、考勤补录审核 |
#### 交互流程要求
1. **页面加载流程**:页面加载时显示补录模式提示(红色警示条)→获取保洁区域和检查项数据
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择补录原因→填写补录说明→上传证明材料→手动填写保洁记录(时间/区域/检查项)→点击提交补录→确认弹窗→提交成功等待审核
4. **弹窗/弹层交互流程**:点击上传弹出选择(拍照/相册);提交前弹出确认弹窗
5. **行内操作流程**:选择原因→填写说明→上传照片→填写保洁记录→提交
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试;离线暂存本地
7. **联动/级联交互**:补录原因与表单无强联动
8. **权限控制交互表现**仅保洁人员可操作补录数据标记is_supplement=true
9. **H1 防重复提交**:「提交补录」按钮点击时须设置 `:loading="submitting"` + `:disabled` 双重锁定;提交期间禁止重复点击
10. **H2 超时控制**:获取区域和检查项 GET 请求超时 15s提交补录 POST 请求超时 30s证明材料上传超时 60s>3s 时须 `wx.showLoading({mask:true})`;超时后提示并提供重试
11. **H3 操作确认**:点击「提交补录」前须通过 `wx.showModal` 二次确认,内容包括补录原因摘要及"提交后需等待主管审核"的后果说明
12. **H4 脏数据检测**:页面初始化时保存空白表单快照;选择原因、填写说明、上传材料、填写记录等任一操作触发 isDirty=true`onUnload` 中检测 isDirty 时弹出 `wx.showModal({content:"补录信息尚未提交,确定离开?"})`
13. **H7 上传约束**:证明材料上传单个文件 ≤10MB最多 ≤3 张(业务限制);使用 `uni-file-picker``@progress` 回调显示上传进度;超出限制时前端拦截并提示
14. **H8 反馈规范**:提交成功使用 `wx.showToast({icon:'success', title:'补录申请已提交,待审核'})`;校验失败使用 `wx.showToast({icon:'none', title:'具体错误信息'})`;离线暂存本地时提示"已暂存至本地"
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 补录原因 | `uni-data-select` | :localdata="[{value:'bluetooth',text:'蓝牙故障'},{value:'phone',text:'手机异常'},{value:'system',text:'系统异常'},{value:'other',text:'其他'}]" |
| 补录说明 | `uni-easyinput` | type="textarea"maxlength="200":showWordLimit="true" |
| 证明材料上传 | `uni-file-picker` | limit="3"file-mediatype="image" |
| 保洁时间 | `uni-datetime-picker` | type="datetime" |
| 保洁区域 | `uni-data-select` | v-model="areaId":localdata="areaList" |
| 检查项结果 | `uni-data-checkbox` | 逐项渲染 |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 补录原因 | 必选 | "请选择补录原因" |
| 补录说明 | 必填最多200字 | "请填写补录说明" |
| 证明材料 | 最多3张 | "证明材料最多上传3张" |
| 保洁时间 | 必选 | "请选择保洁时间" |
| 保洁区域 | 必选 | "请选择保洁区域" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏表单水平分组
- **手势交互规范**选择器≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
--- ---
## 业务规则 ## 业务规则

@ -3,6 +3,7 @@
> 模块编码attendance > 模块编码attendance
> 端侧:微信小程序 > 端侧:微信小程序
> 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§5、03-业务流转逻辑-小程序端.md§5、05-接口规范.md§9、06-项目技术要求.md > 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§5、03-业务流转逻辑-小程序端.md§5、05-接口规范.md§9、06-项目技术要求.md
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -51,6 +52,55 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-AT-01 | 上班打卡 | 02-小程序端 §5 | 记录操作日志 | 考勤记录、打卡点管理 | | MP-AT-01 | 上班打卡 | 02-小程序端 §5 | 记录操作日志 | 考勤记录、打卡点管理 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当前时间→查询蓝牙策略→策略=REQUIRED自动启动蓝牙扫描显示打卡状态已打卡/未打卡)
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:点击「上班打卡」→策略=REQUIRED自动扫描蓝牙→检测到打卡点Beacon→打卡成功check_method=BLUETOOTH策略=OPTIONAL选择打卡方式→蓝牙/手动打卡
4. **弹窗/弹层交互流程**:打卡成功弹出成功提示(显示打卡时间);蓝牙未检测到弹出提示"未检测到打卡点蓝牙"
5. **行内操作流程**:点击打卡按钮→执行打卡流程→显示结果;点击异常申诉→跳转异常申诉页
6. **异常与错误处理**:蓝牙未开启提示"请开启手机蓝牙";打卡失败显示重试;已打卡提示"今日已打卡";离线时数据暂存本地联网后同步
7. **联动/级联交互**:蓝牙策略与打卡流程联动;打卡状态实时更新
8. **权限控制交互表现**:所有登录用户可打卡;蓝牙权限被拒引导开启
#### 前端硬性约束
- **H1 防重复提交**:打卡按钮点击后立即 `loading=true` + `disabled=true` 防止重复点击;使用 pending 请求去重机制(同一请求未完成前不发送新请求);支持 `wx.requestTask.abort()` 取消前一次未完成的请求
- **H2 超时控制**GET 请求超时 15s、POST 请求超时 30s请求耗时 >3s 时自动调用 `wx.showLoading({ title: '加载中...', mask: true })` 提示用户等待
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为操作型页面,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:打卡成功调用 `wx.showToast({ title: '打卡成功', icon: 'success' })`;网络异常时提示"网络异常,请检查网络后重试",提供重试按钮;离线暂存成功提示"已暂存本地,联网后将自动同步"
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px下拉刷新操作需防重复触发
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 当前时间 | `text` | font-size="48px"font-weight="bold",动态更新 |
| 打卡状态 | `uni-tag` | type: success(已打卡)/warning(未打卡) |
| 打卡按钮 | `button` | type="primary"圆形大按钮size=120px |
| 蓝牙状态指示 | `uni-icons` | type="bluetooth"size="22" |
| 打卡点名称 | `text` | font-size="14px" |
| 异常申诉 | `button` | type="default"size="mini" |
| 打卡结果 | `uni-popup` | type="dialog",显示打卡时间 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 蓝牙状态 | 策略=REQUIRED须开启蓝牙 | "请开启手机蓝牙" |
| Beacon验证 | 须属于本人班组打卡点 | "未检测到有效打卡点" |
| 打卡状态 | 未打卡才可打卡 | "今日已打卡" |
| 打卡时间 | 自动记录精确到秒 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px打卡按钮居中
- **横竖屏适配策略**:竖屏垂直居中布局;横屏信息与按钮并排
- **手势交互规范**打卡按钮≥120px可点击区域申诉按钮≥44px
- **安全区域**:底部按钮适配底部安全区
--- ---
### 页面2下班打卡 ### 页面2下班打卡
@ -81,6 +131,54 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-AT-02 | 下班打卡 | 02-小程序端 §5 | 记录操作日志 | 考勤记录 | | MP-AT-02 | 下班打卡 | 02-小程序端 §5 | 记录操作日志 | 考勤记录 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当前时间和上班打卡时间→查询蓝牙策略→自动扫描蓝牙(策略=REQUIRED显示打卡状态
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:点击「下班打卡」→策略=REQUIRED自动扫描蓝牙→打卡成功策略=OPTIONAL选择打卡方式→打卡
4. **弹窗/弹层交互流程**:打卡成功弹出成功提示(显示上下班时间);蓝牙未检测到弹出提示
5. **行内操作流程**:点击打卡按钮→执行打卡→显示结果;点击异常申诉→跳转异常申诉页
6. **异常与错误处理**:蓝牙未开启提示;下班未打上班卡提示"请先完成上班打卡";离线暂存
7. **联动/级联交互**:上班打卡时间与下班打卡联动展示
8. **权限控制交互表现**:所有登录用户可打卡
#### 前端硬性约束
- **H1 防重复提交**:打卡按钮点击后立即 `loading=true` + `disabled=true` 防止重复点击;使用 pending 请求去重机制(同一请求未完成前不发送新请求);支持 `wx.requestTask.abort()` 取消前一次未完成的请求
- **H2 超时控制**GET 请求超时 15s、POST 请求超时 30s请求耗时 >3s 时自动调用 `wx.showLoading({ title: '加载中...', mask: true })` 提示用户等待
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为操作型页面,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:打卡成功调用 `wx.showToast({ title: '打卡成功', icon: 'success' })`;网络异常时提示"网络异常,请检查网络后重试",提供重试按钮;离线暂存成功提示"已暂存本地,联网后将自动同步"
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 当前时间 | `text` | font-size="48px"font-weight="bold" |
| 上班打卡时间 | `text` | font-size="14px"color="#666" |
| 打卡状态 | `uni-tag` | type: success(已打卡)/warning(未打卡) |
| 打卡按钮 | `button` | type="primary"圆形大按钮size=120px |
| 蓝牙状态指示 | `uni-icons` | type="bluetooth"size="22" |
| 异常申诉 | `button` | type="default"size="mini" |
| 打卡结果 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 上班打卡 | 须先完成上班打卡 | "请先完成上班打卡" |
| 蓝牙状态 | 策略=REQUIRED须蓝牙连接 | "请开启手机蓝牙" |
| 打卡状态 | 未打卡才可打卡 | "今日已打卡" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px打卡按钮居中
- **横竖屏适配策略**:竖屏垂直居中布局;横屏信息与按钮并排
- **手势交互规范**打卡按钮≥120px可点击区域
- **安全区域**:底部按钮适配底部安全区
--- ---
### 页面3打卡记录 ### 页面3打卡记录
@ -119,6 +217,52 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-AT-03 | 打卡记录 | 02-小程序端 §5 | 无 | 考勤日历 | | MP-AT-03 | 打卡记录 | 02-小程序端 §5 | 无 | 考勤日历 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当月打卡记录和统计→渲染月份选择器和记录列表
2. **查询/筛选交互流程**:切换月份→重新加载;状态筛选(全部/正常/迟到/早退/缺卡/补录)→过滤记录
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:点击查看详情→弹出打卡详情弹窗(含打卡点、蓝牙信息)
5. **行内操作流程**:点击查看详情→查看详情弹窗;点击异常申诉→跳转异常申诉页
6. **异常与错误处理**:记录加载失败显示重试;无记录显示空状态
7. **联动/级联交互**:月份和状态筛选与列表数据联动
8. **权限控制交互表现**:仅可查看本人打卡记录
#### 前端硬性约束
- **H1 防重复提交**:月份切换/状态筛选时防重复请求;列表加载期间禁用筛选操作;使用 pending 请求去重
- **H2 超时控制**GET 请求超时 15s列表加载 >3s 时调用 `wx.showLoading({ title: '加载中...', mask: true })`
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为列表查看页,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:加载成功无提示(静默刷新);加载失败调用 `wx.showToast({ title: '加载失败', icon: 'none' })` 并显示重试按钮;网络异常时提示"网络异常,请检查网络后重试"
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px下拉刷新需防重复触发refreshing 标志位)
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 月份选择器 | `uni-datetime-picker` | type="date"fields="month" |
| 统计卡片 | `uni-card` | mode="center",四列等宽 |
| 状态筛选 | `uni-segmented-control` | :values="['全部','正常','迟到','早退','缺卡','补录']" |
| 记录列表 | `uni-list` + `uni-list-item` | clickable=true |
| 状态标签 | `uni-tag` | type: success(正常)/warning(迟到/早退)/error(缺卡)/primary(补录) |
| 详情弹窗 | `uni-popup` | type="bottom",显示打卡详情 |
| 打卡方式标签 | `uni-tag` | type: primary(蓝牙)/default(手动)/warning(补录)size="mini" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 记录列表 | 加载失败允许重试 | "加载失败,请重试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应
- **横竖屏适配策略**:竖屏单列列表;横屏双列卡片
- **手势交互规范**记录项可点击区域≥44px
- **安全区域**:底部适配底部安全区
--- ---
### 页面4异常申诉 ### 页面4异常申诉
@ -148,6 +292,56 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-AT-04 | 异常申诉 | 02-小程序端 §5 | 主管审核 | 考勤审核 | | MP-AT-04 | 异常申诉 | 02-小程序端 §5 | 主管审核 | 考勤审核 |
#### 交互流程要求
1. **页面加载流程**:页面加载时初始化表单,获取异常类型字典
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:选择异常日期→选择异常类型(上班/下班/全天)→选择异常原因→填写补充说明→上传照片→点击提交申诉→确认弹窗→提交成功等待审核
4. **弹窗/弹层交互流程**:点击上传弹出选择(拍照/相册);提交前弹出确认弹窗
5. **行内操作流程**:选择日期→选择类型→选择原因→填写说明→上传照片→提交
6. **异常与错误处理**:图片上传失败提示重传;提交失败显示重试;离线暂存本地
7. **联动/级联交互**:异常类型与日期选择联动
8. **权限控制交互表现**:所有用户可提交异常申诉
#### 前端硬性约束
- **H1 防重复提交**:提交按钮点击后立即 `loading=true` + `disabled=true` 防止重复提交;使用 pending 请求去重机制(提交请求未返回前禁用按钮);支持 `wx.requestTask.abort()` 取消前一次未完成的提交
- **H2 超时控制**GET 请求超时 15s、POST 请求超时 30s、文件上传超时 60s上传耗时 >3s 时调用 `wx.showLoading({ title: '上传中...', mask: true })`
- **H3 二次确认**:提交申诉前必须调用 `wx.showModal({ title: '确认提交', content: '提交后将等待主管审核,确认提交申诉?', confirmText: '确认', cancelText: '取消' })` 进行二次确认,明确告知操作后果
- **H4 脏数据检测**:页面进入时对表单数据做 deep clone 快照;用户编辑过程中维护 isDirty 状态;`onUnload` / `onBackPress` 生命周期中检测到 isDirty 时弹出 `wx.showModal` 提示"未保存的修改将丢失,确定离开吗?",用户确认后才允许离开
- **H7 文件上传**:单张图片 ≤10MB超出时提示"图片大小不能超过10MB"并阻止上传;使用 `uni-file-picker``onProgress` 回调显示上传进度条;支持多图队列上传
- **H8 操作反馈**:提交成功调用 `wx.showToast({ title: '申诉已提交,请等待审核', icon: 'success' })`;网络异常时调用 `wx.showToast({ title: '网络异常,请检查网络后重试', icon: 'none' })` 并提供重试按钮;离线暂存提示"已暂存本地,联网后将自动同步"
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px选择器/输入框/按钮均 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 异常日期 | `uni-datetime-picker` | type="date" |
| 异常类型 | `uni-data-select` | :localdata="[{value:'clock_in',text:'上班'},{value:'clock_out',text:'下班'},{value:'all_day',text:'全天'}]" |
| 异常原因 | `uni-data-select` | :localdata="[{value:'bluetooth',text:'蓝牙故障'},{value:'phone',text:'手机异常'},{value:'system',text:'系统异常'},{value:'forget',text:'忘记打卡'},{value:'other',text:'其他'}]" |
| 补充说明 | `uni-easyinput` | type="textarea"maxlength="200":showWordLimit="true" |
| 照片上传 | `uni-file-picker` | limit="9"file-mediatype="image" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 异常日期 | 必选 | "请选择异常日期" |
| 异常类型 | 必选 | "请选择异常类型" |
| 异常原因 | 必选 | "请选择异常原因" |
| 补充说明 | 最多200字 | "补充说明不能超过200字" |
| 照片 | 最多9张 | "照片最多上传9张" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏表单水平分组
- **手势交互规范**选择器≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
--- ---
### 页面5考勤日历 ### 页面5考勤日历
@ -180,6 +374,51 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-AT-05 | 考勤日历 | 02-小程序端 §5 | 无 | 打卡记录 | | MP-AT-05 | 考勤日历 | 02-小程序端 §5 | 无 | 打卡记录 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当月考勤日历数据→渲染月历视图和每日状态标记
2. **查询/筛选交互流程**:左右滑动切换月份→重新加载月历数据
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:点击日期→弹出当日打卡详情弹窗(上班/下班时间、打卡方式、状态)
5. **行内操作流程**:点击日期→查看详情弹窗;左右滑动切换月份
6. **异常与错误处理**:日历数据加载失败显示重试
7. **联动/级联交互**:月份切换与日历数据联动
8. **权限控制交互表现**:仅可查看本人考勤日历
#### 前端硬性约束
- **H1 防重复提交**:月份切换(左右滑动)时防重复请求;滑动切换期间锁定请求,避免连续滑动触发多次请求
- **H2 超时控制**GET 请求超时 15s日历数据加载 >3s 时调用 `wx.showLoading({ title: '加载中...', mask: true })`
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为只读详情页,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:加载成功无提示(静默刷新);加载失败调用 `wx.showToast({ title: '日历加载失败', icon: 'none' })` 并显示重试按钮;网络异常时提示"网络异常,请检查网络后重试"
- **通用约束**:使用 uni-ui 组件库;日期格子可点击区域 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 月历视图 | 自定义日历组件 | 7列网格日期格子44px |
| 状态标记点 | `view` | 圆形8px颜色动态绿/黄/红/蓝/灰) |
| 月份切换 | `uni-icons` | type="left"/"right"size="20"@click切换 |
| 详情弹窗 | `uni-popup` | type="bottom",显示当日打卡详情 |
| 图例说明 | `view` + `text` | 水平排列,颜色点+文字 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日历数据 | 加载失败允许重试 | "日历加载失败,请重试" |
| 日期选择 | 只能查看,不可选择未来日期 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px日历网格宽度自适应
- **横竖屏适配策略**竖屏7列日历网格横屏日历宽度适当增大详情弹窗更宽
- **手势交互规范**日期格子≥44px左右滑动切换月份点击日期≥44px
- **安全区域**:底部图例适配底部安全区
--- ---
### 页面6考勤审核 ### 页面6考勤审核
@ -233,6 +472,56 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-AT-06 | 考勤审核 | 02-小程序端 §5 | 补录打卡记录 | 操作日志、考勤管理Web端 | | MP-AT-06 | 考勤审核 | 02-小程序端 §5 | 补录打卡记录 | 操作日志、考勤管理Web端 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取待审核申诉列表→显示骨架屏→渲染筛选条件和申诉列表
2. **查询/筛选交互流程**:状态/日期/人员筛选→切换筛选重新加载列表;下拉刷新
3. **表单填写与提交流程**:查看申诉详情→点击通过→系统自动补录打卡记录;点击驳回→填写驳回原因→确认驳回→通知员工
4. **弹窗/弹层交互流程**:点击通过弹出确认弹窗"确认通过申诉?";点击驳回弹出驳回原因输入弹窗
5. **行内操作流程**:点击通过→确认→补录生效;点击驳回→填写原因→确认→通知员工
6. **异常与错误处理**:审核操作失败显示重试;申诉已处理提示"该申诉已处理"
7. **联动/级联交互**审核通过后自动补录打卡记录标记is_supplement=true
8. **权限控制交互表现**仅主管可操作无attendance:appeal:approve权限时按钮置灰
#### 前端硬性约束
- **H1 防重复提交**:通过/驳回按钮点击后立即 `loading=true` + `disabled=true` 防止重复操作;使用 pending 请求去重机制(审核请求未返回前禁用按钮);筛选切换时防重复请求
- **H2 超时控制**GET 请求超时 15s、POST 请求超时 30s列表加载/审核操作 >3s 时调用 `wx.showLoading({ title: '处理中...', mask: true })`
- **H3 二次确认**:审核通过前必须调用 `wx.showModal({ title: '确认通过', content: '确认通过该申诉?通过后系统将自动补录打卡记录。', confirmText: '通过', cancelText: '取消' })`;驳回前必须调用 `wx.showModal({ title: '确认驳回', content: '确认驳回该申诉?驳回后将通知员工重新处理。', confirmText: '驳回', cancelText: '取消' })`
- **H4 脏数据检测**:驳回原因输入框维护 isDirty 状态;用户输入驳回原因但未提交就离开时,在 `onUnload` / `onBackPress` 中弹出提示
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:审核通过成功调用 `wx.showToast({ title: '已通过,已自动补录打卡记录', icon: 'success' })`;审核驳回成功调用 `wx.showToast({ title: '已驳回', icon: 'success' })`;网络异常时调用 `wx.showToast({ title: '网络异常,请检查网络后重试', icon: 'none' })` 并提供重试按钮
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px通过/驳回按钮 ≥44px下拉刷新需防重复触发refreshing 标志位)
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 筛选条件 | `uni-data-select` | 状态/日期/人员三个选择器 |
| 申诉列表 | `uni-list` + `uni-list-item` | clickable=true |
| 审核状态标签 | `uni-tag` | type: warning(待审核)/success(已通过)/error(已驳回) |
| 通过按钮 | `button` | type="primary"size="mini"@click="approve" |
| 驳回按钮 | `button` | type="warn"size="mini"@click="reject" |
| 驳回原因弹窗 | `uni-popup` | type="bottom",含文本输入和确认按钮 |
| 驳回原因输入 | `uni-easyinput` | type="textarea"maxlength="200" |
| 确认弹窗 | `uni-popup` | type="dialog" |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 驳回原因 | 驳回时必填 | "请填写驳回原因" |
| 驳回原因 | 最多200字 | "驳回原因不能超过200字" |
| 审核状态 | 须为待审核状态 | "该申诉已处理" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px列表宽度自适应
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片+底部弹窗更宽
- **手势交互规范**申诉项可点击区域≥44px通过/驳回按钮≥44px
- **安全区域**:底部操作区域适配底部安全区
--- ---
## 业务规则 ## 业务规则

@ -3,6 +3,7 @@
> 模块编码org > 模块编码org
> 端侧:微信小程序 > 端侧:微信小程序
> 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§6、03-业务流转逻辑-小程序端.md§6、05-接口规范.md§9、06-项目技术要求.md > 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§6、03-业务流转逻辑-小程序端.md§6、05-接口规范.md§9、06-项目技术要求.md
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -49,6 +50,52 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-OR-01 | 我的班组 | 02-小程序端 §6 | 无 | 组织架构Web端、通讯录 | | MP-OR-01 | 我的班组 | 02-小程序端 §6 | 无 | 组织架构Web端、通讯录 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取本人所在班组信息→渲染班组卡片和成员列表
2. **查询/筛选交互流程**:无筛选操作,自动加载本人班组
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:点击查看详情→弹出成员详情弹窗(完整信息展示)
5. **行内操作流程**点击拨打电话→调用wx.makePhoneCall点击查看详情→弹出详情
6. **异常与错误处理**:班组信息加载失败显示重试;拨号失败提示"拨号失败"
7. **联动/级联交互**:无强联动
8. **权限控制交互表现**:所有用户可查看本人班组信息;数据为只读
#### 前端硬性约束
- **H1 防重复提交**:页面加载时防重复请求;使用 pending 请求去重机制避免重复加载班组数据
- **H2 超时控制**GET 请求超时 15s数据加载 >3s 时调用 `wx.showLoading({ title: '加载中...', mask: true })`
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为只读详情页,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:加载成功无提示(静默刷新);加载失败调用 `wx.showToast({ title: '班组信息加载失败', icon: 'none' })` 并显示重试按钮;网络异常时提示"网络异常,请检查网络后重试"
- **通用约束**:使用 uni-ui 组件库;成员项/拨号图标可点击区域 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 班组信息卡片 | `uni-card` | mode="basic",展示班组名、部门、班组长 |
| 成员列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 头像 | `image` | mode="aspectFill"40px圆形 |
| 技能标签 | `uni-tag` | type="primary"size="mini"v-for渲染 |
| 拨打电话 | `uni-icons` | type="phone"size="22"color="#007AFF" |
| 成员详情弹窗 | `uni-popup` | type="bottom",展示完整信息 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 班组信息 | 必须返回有效数据 | "班组信息加载失败,请重试" |
| 电话号码 | 脱敏显示,拨号时显示完整号码 | — |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px列表宽度自适应
- **横竖屏适配策略**:竖屏垂直列表;横屏双列展示成员
- **手势交互规范**成员项可点击区域≥44px拨号图标≥44px
- **安全区域**:底部适配底部安全区
--- ---
### 页面2我的排班 ### 页面2我的排班
@ -86,6 +133,50 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-OR-02 | 我的排班 | 02-小程序端 §6 | 无 | 考勤打卡、组织架构排班Web端 | | MP-OR-02 | 我的排班 | 02-小程序端 §6 | 无 | 考勤打卡、组织架构排班Web端 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取当月排班数据→渲染月历视图和排班标记
2. **查询/筛选交互流程**:左右滑动切换月份→重新加载排班数据
3. **表单填写与提交流程**:无表单提交操作(只读查看)
4. **弹窗/弹层交互流程**:点击日期→弹出当日排班详情弹窗(排班类型、上下班时间、打卡点)
5. **行内操作流程**:点击日期→查看排班详情;左右滑动切换月份
6. **异常与错误处理**:排班数据加载失败显示重试;无排班数据显示"暂无排班信息"
7. **联动/级联交互**:月份切换与排班数据联动
8. **权限控制交互表现**:所有用户可查看本人排班;数据只读
#### 前端硬性约束
- **H1 防重复提交**:月份切换(左右滑动)时防重复请求;滑动切换期间锁定请求,避免连续滑动触发多次加载
- **H2 超时控制**GET 请求超时 15s排班数据加载 >3s 时调用 `wx.showLoading({ title: '加载中...', mask: true })`
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为只读详情页,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:加载成功无提示(静默刷新);加载失败调用 `wx.showToast({ title: '排班数据加载失败', icon: 'none' })` 并显示重试按钮;网络异常时提示"网络异常,请检查网络后重试"
- **通用约束**:使用 uni-ui 组件库;日期格子可点击区域 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 月历视图 | 自定义日历组件 | 7列网格日期格子44px |
| 排班标记条 | `view` | 颜色条3px颜色动态蓝/绿/橙/灰) |
| 月份切换 | `uni-icons` | type="left"/"right"size="20" |
| 排班详情弹窗 | `uni-popup` | type="bottom",展示排班类型、时间、打卡点 |
| 图例说明 | `view` + `text` | 水平排列,颜色条+文字 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 排班数据 | 加载失败允许重试 | "排班数据加载失败,请重试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px日历网格宽度自适应
- **横竖屏适配策略**竖屏7列日历网格横屏日历宽度适当增大
- **手势交互规范**日期格子≥44px左右滑动切换月份
- **安全区域**:底部图例适配底部安全区
--- ---
## 业务规则 ## 业务规则

@ -3,6 +3,7 @@
> 模块编码evaluation > 模块编码evaluation
> 端侧:微信小程序 > 端侧:微信小程序
> 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§7、03-业务流转逻辑-小程序端.md§7、05-接口规范.md§9、06-项目技术要求.md > 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§7、03-业务流转逻辑-小程序端.md§7、05-接口规范.md§9、06-项目技术要求.md
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -53,6 +54,51 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-EV-01 | 待评价列表 | 02-小程序端 §7 | 评分留言 | 在线报修(验收完成后触发) | | MP-EV-01 | 待评价列表 | 02-小程序端 §7 | 评分留言 | 在线报修(验收完成后触发) |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取待评价工单列表→显示骨架屏→渲染待评价数量和工单列表
2. **查询/筛选交互流程**:无手动筛选,自动过滤待评价工单
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:无弹窗
5. **行内操作流程**:点击「去评价」→跳转评分留言页
6. **异常与错误处理**:列表加载失败显示重试;无待评价工单显示空状态
7. **联动/级联交互**:评价完成后列表自动移除已评价项
8. **权限控制交互表现**:仅报修人可访问;待评价工单自动过滤展示
#### 前端硬性约束
- **H1 防重复提交**:列表加载时防重复请求;使用 pending 请求去重机制;点击「去评价」跳转时防重复跳转
- **H2 超时控制**GET 请求超时 15s列表加载 >3s 时调用 `wx.showLoading({ title: '加载中...', mask: true })`
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为列表查看页,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:加载成功无提示(静默刷新);加载失败调用 `wx.showToast({ title: '加载失败', icon: 'none' })` 并显示重试按钮;网络异常时提示"网络异常,请检查网络后重试"
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px去评价按钮 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 待评价数量 | `uni-badge` | :content="count"type="warning" |
| 工单列表 | `uni-list` + `uni-list-item` | clickable=trueshowArrow=true |
| 评价状态标签 | `uni-tag` | type: warning(待评价)/success(已评价) |
| 去评价按钮 | `button` | type="primary"size="mini" |
| 空状态 | `uni-section` | 插槽自定义空状态插图与文案 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 工单列表 | 加载失败允许重试 | "加载失败,请重试" |
| 评价时效 | 验收后7天内可评价 | "评价已超期" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片展示
- **手势交互规范**工单项可点击区域≥44px去评价按钮≥44px
- **安全区域**:底部适配底部安全区
--- ---
### 页面2评分留言 ### 页面2评分留言
@ -90,6 +136,56 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-EV-02 | 评分留言 | 02-小程序端 §7 | 物业公司查看回复 | 服务评价Web端 | | MP-EV-02 | 评分留言 | 02-小程序端 §7 | 物业公司查看回复 | 服务评价Web端 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取工单信息→渲染工单摘要卡片
2. **查询/筛选交互流程**:无筛选操作
3. **表单填写与提交流程**:点击星星选择评分→可选点击评分维度标签→输入留言→可选上传图片→开关匿名评价→点击提交评价→确认弹窗→提交成功
4. **弹窗/弹层交互流程**:点击拍照弹出选择(拍照/相册);提交前弹出确认弹窗"确认提交评价?提交后不可修改"
5. **行内操作流程**:选择评分→输入留言→上传图片→设置匿名→提交
6. **异常与错误处理**图片上传失败提示重传提交失败显示重试评分≤3分触发低分预警事件
7. **联动/级联交互**:评分与提交按钮联动(必须选择评分);匿名开关与提交数据联动
8. **权限控制交互表现**:仅报修人可评价;评价不可修改
#### 前端硬性约束
- **H1 防重复提交**:提交按钮点击后立即 `loading=true` + `disabled=true` 防止重复提交;使用 pending 请求去重机制(提交请求未返回前禁用按钮);支持 `wx.requestTask.abort()` 取消前一次未完成的提交
- **H2 超时控制**GET 请求超时 15s、POST 请求超时 30s、文件上传超时 60s上传耗时 >3s 时调用 `wx.showLoading({ title: '上传中...', mask: true })`
- **H3 二次确认**:提交评价前必须调用 `wx.showModal({ title: '确认提交评价', content: '确认提交评价?提交后不可修改。', confirmText: '确认', cancelText: '取消' })` 进行二次确认,明确告知操作后果(不可修改)
- **H4 脏数据检测**:页面进入时对表单数据做 deep clone 快照(评分、留言、图片等);用户编辑过程中维护 isDirty 状态;`onUnload` / `onBackPress` 生命周期中检测到 isDirty 时弹出 `wx.showModal` 提示"未保存的评价内容将丢失,确定离开吗?",用户确认后才允许离开
- **H7 文件上传**:单张图片 ≤10MB超出时提示"图片大小不能超过10MB"并阻止上传;使用 `uni-file-picker``onProgress` 回调显示上传进度条;支持多图队列上传
- **H8 操作反馈**:提交成功调用 `wx.showToast({ title: '评价提交成功', icon: 'success' })`;网络异常时调用 `wx.showToast({ title: '网络异常,请检查网络后重试', icon: 'none' })` 并提供重试按钮低分预警≤3分提示"已通知主管关注"
- **通用约束**:使用 uni-ui 组件库;星级评分每颗星 ≥44px提交按钮 ≥44px
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单信息摘要 | `uni-card` | mode="basic",只读展示 |
| 星级评分 | `uni-rate` | :max="5":value="score":size="28" |
| 评分维度标签 | `uni-data-checkbox` | :localdata="dimensionList"multiple=true |
| 留言输入 | `uni-easyinput` | type="textarea"maxlength="200":showWordLimit="true" |
| 图片上传 | `uni-file-picker` | limit="9"file-mediatype="image" |
| 匿名开关 | `switch` | @change="onAnonymousChange" |
| 提交按钮 | `button` | type="primary":loading="submitting" |
| 确认弹窗 | `uni-popup` | type="dialog" |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 评分 | 必选1~5分 | "请选择评分" |
| 留言 | 最多200字 | "留言不能超过200字" |
| 图片 | 最多9张 | "评价图片最多上传9张" |
| 评价时效 | 验收后7天内 | "评价已超期" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px表单宽度自适应
- **横竖屏适配策略**:竖屏垂直布局;横屏评分与输入并排
- **手势交互规范**星级评分每颗星≥44px提交按钮≥44px
- **安全区域**:底部提交按钮适配底部安全区
--- ---
### 页面3历史评价 ### 页面3历史评价
@ -126,6 +222,52 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-EV-03 | 历史评价 | 02-小程序端 §7 | 无 | 服务评价Web端 | | MP-EV-03 | 历史评价 | 02-小程序端 §7 | 无 | 服务评价Web端 |
#### 交互流程要求
1. **页面加载流程**:页面加载时获取历史评价列表→渲染筛选条件和评价卡片
2. **查询/筛选交互流程**:评分筛选(全部/好评/中评/差评)→过滤列表;时间筛选→过滤列表;下拉刷新
3. **表单填写与提交流程**:无表单提交操作
4. **弹窗/弹层交互流程**:点击查看详情→弹出评价详情弹窗(含物业回复内容)
5. **行内操作流程**:点击查看详情→查看评价详情及物业回复
6. **异常与错误处理**:列表加载失败显示重试;无评价显示空状态
7. **联动/级联交互**:筛选条件与列表数据联动
8. **权限控制交互表现**:仅报修人可查看自己的历史评价
#### 前端硬性约束
- **H1 防重复提交**:评分筛选/时间筛选切换时防重复请求;列表加载期间禁用筛选操作;使用 pending 请求去重下拉刷新需防重复触发refreshing 标志位)
- **H2 超时控制**GET 请求超时 15s列表加载 >3s 时调用 `wx.showLoading({ title: '加载中...', mask: true })`
- **H3 二次确认**:无危险操作,无需二次确认
- **H4 脏数据检测**:本页面为列表查看页,无需表单脏数据检测
- **H7 文件上传**:无上传操作
- **H8 操作反馈**:加载成功无提示(静默刷新);加载失败调用 `wx.showToast({ title: '加载失败', icon: 'none' })` 并显示重试按钮;网络异常时提示"网络异常,请检查网络后重试"
- **通用约束**:使用 uni-ui 组件库;所有可点击元素触控区域 ≥44px下拉刷新需防重复触发refreshing 标志位)
#### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 评分筛选 | `uni-segmented-control` | :values="['全部','好评','中评','差评']" |
| 时间筛选 | `uni-datetime-picker` | type="date"fields="month" |
| 评价列表 | `uni-list` + `uni-list-item` | clickable=true |
| 评分显示 | `uni-rate` | :size="14":readonly="true" |
| 回复状态标签 | `uni-tag` | type: success(已回复)/warning(未回复) |
| 详情弹窗 | `uni-popup` | type="bottom",展示评价详情+物业回复 |
| 下拉刷新 | `uni-refresher` | @onRefresh回调 |
#### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 评价列表 | 加载失败允许重试 | "加载失败,请重试" |
#### 响应式布局
- **适配机型**iPhone SE375px~ iPad mini768px卡片宽度自适应
- **横竖屏适配策略**:竖屏单列卡片;横屏双列卡片展示
- **手势交互规范**评价项可点击区域≥44px
- **安全区域**:底部适配底部安全区
--- ---
## 业务规则 ## 业务规则

@ -3,6 +3,7 @@
> 模块编码statistics > 模块编码statistics
> 端侧:微信小程序 > 端侧:微信小程序
> 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§8、03-业务流转逻辑-小程序端.md§8、05-接口规范.md§9、06-项目技术要求.md > 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§8、03-业务流转逻辑-小程序端.md§8、05-接口规范.md§9、06-项目技术要求.md
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -49,6 +50,49 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-ST-01 | 简版统计概览 | 02-小程序端 §8 | 无 | 统计报表Web端 | | MP-ST-01 | 简版统计概览 | 02-小程序端 §8 | 无 | 统计报表Web端 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载今日统计数据 → 渲染指标卡片 + 趋势图
2. **时间切换**:点击"今日/本周"切换标签 → 重新请求对应时间范围数据 → 刷新卡片和图表
3. **按角色显示**:管理员看到全部指标卡片;主管仅看到管辖模块指标
4. **异常处理**:数据加载失败 → 显示"数据加载失败,点击重试";网络离线 → 显示上次缓存数据+离线标识
5. **权限控制**:无统计权限 → 不显示对应指标卡片
6. **[H1]防重复请求**
- 今日/本周时间切换按钮点击后立即 disabled + wx.showLoading({title:'加载中'})API返回后恢复
- 切换期间再次点击无效pending标志位去重
- 页面卸载时 onUnload 中 abort 未完成的请求
7. **[H2]超时与加载反馈**
- 统计数据查询 API timeout=30秒统计报表档位
- 超过3秒未响应时显示 wx.showLoading 提示
- 超时后提示"请求超时,请检查网络后重试"+ 提供"重试"按钮
8. **[H8]操作结果反馈**
- 数据加载成功wx.showToast({ title: '刷新成功', icon: 'success', duration: 2000 })
- 加载失败wx.showToast({ title: errMsg, icon: 'none', duration: 3000 })
- 网络离线:显示缓存数据 + uni-icons type="warning" + "当前为离线模式"提示条
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 页面容器 | scroll-view | scroll-y, :refresher-enabled, @refresherrefresh |
| 切换标签 | uni-segmented-control | :current, :values="['今日','本周']" |
| 指标卡片 | view | class="stat-card", v-for |
| 指标数值 | text | class="stat-value", font-size=28px |
| 趋势图 | qiun-data-charts | type="line", :chartData |
| 下拉刷新 | scroll-view | refresher-enabled, @refresherrefresh |
### 校验规则
无表单校验(纯展示页面)
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 指标卡片竖屏2列横屏4列
- 趋势图:全宽自适应
- 触摸区域卡片点击区域≥44px
--- ---
### 页面2个人绩效统计 ### 页面2个人绩效统计
@ -83,6 +127,45 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-ST-02 | 个人绩效统计 | 02-小程序端 §8 | 无 | 统计报表Web端 | | MP-ST-02 | 个人绩效统计 | 02-小程序端 §8 | 无 | 统计报表Web端 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载个人本周绩效数据 → 渲染指标卡片 + 趋势图
2. **时间切换**:点击"本周/本月"切换 → 重新请求 → 刷新
3. **异常处理**:数据加载失败 → 显示重试提示;无数据 → 显示"暂无绩效数据"
### 前端硬性约束
> - **H1 防重复提交**
> - 时间切换操作在请求期间必须设置 `loading` 状态 + 切换标签 `disabled`,防止重复点击触发多次请求。
> - 使用 pending 标志位对同一请求进行去重,确保请求完成前不发出相同新请求。
> - 页面卸载时(`onUnload`)必须 `abort` 未完成的请求,避免无效回调。
> - **H2 超时处理**
> - 绩效统计数据接口请求超时时间设置为 **30秒**;超过 3 秒未响应时必须展示 loading 动画提示。
> - 超时后给出明确错误提示并提供"重试"按钮。
> - **H8 操作反馈**
> - 加载成功使用 `uni.showToast({ title: '加载成功', icon: 'success', duration: 2000 })`2 秒自动消失。
> - 错误反馈使用 `icon: 'none'`,需手动关闭或短时自动消失。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 页面容器 | scroll-view | scroll-y, refresher-enabled |
| 时间切换 | uni-segmented-control | :values="['本周','本月']" |
| 绩效卡片 | view | class="stat-card" |
| 评分展示 | uni-rate | :size=20, :readonly=true |
| 趋势图 | qiun-data-charts | type="line" |
### 校验规则
无表单校验(纯展示页面)
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 绩效卡片竖屏2列横屏3列
- 触摸区域≥44px
--- ---
## 业务规则 ## 业务规则

@ -3,6 +3,7 @@
> 模块编码property-manager > 模块编码property-manager
> 端侧:微信小程序 > 端侧:微信小程序
> 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§9、03-业务流转逻辑-小程序端.md§9、05-接口规范.md§9、06-项目技术要求.md > 关联文档01-模块划分.mdv4.0、02-功能清单-小程序端.md§9、03-业务流转逻辑-小程序端.md§9、05-接口规范.md§9、06-项目技术要求.md
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -57,6 +58,62 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-PM-01 | 报修管理 | 02-小程序端 §9 | 通知维修人员 | 在线报修Web端 | | MP-PM-01 | 报修管理 | 02-小程序端 §9 | 通知维修人员 | 在线报修Web端 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 并行调用:统计卡片数据 + 工单列表(默认全部状态)
2. **筛选交互**:选择状态/日期/类型 → 触发列表刷新;搜索框输入 → 回车或点击搜索
3. **分配操作**:点击"分配"→ 弹出人员选择弹窗(按班组筛选)→ 选择人员 → 确认 → 调用API → 成功提示 → 推送通知维修人员
4. **查看详情**:点击工单卡片 → 跳转工单详情页
5. **审批延期**:点击"审批延期"→ 弹窗展示延期原因 → 同意/拒绝 → API调用 → 通知申请人
6. **异常处理**:分配失败提示;网络离线提示
7. **权限控制**:无分配权限 → "分配"按钮不显示;无审批权限 → "审批延期"不显示
8. **[H1]防重复请求**
- 分配操作:点击"分配"后按钮 disabled + loading态API返回后恢复
- 审批延期操作:点击后弹窗底部确认按钮 disabled + loading
- 筛选切换:状态/日期切换时 abort 上一次列表请求再发新的
- 下拉刷新refreshing 标志位防止重复触发
9. **[H2]超时与加载反馈**
- 统计卡片+列表查询 timeout=15秒
- 分配/审批操作 timeout=30秒
- 超过3秒 wx.showLoading({ title: '加载中', mask: true })
- 超时 → wx.showToast({ icon: 'none', title: '请求超时' }) + 重试按钮
10. **[H3]操作确认机制**
- 分配确认:选择人员后 uni.showModal({ content: '确定将工单分配给{人员姓名}', success: res => { if(res.confirm) ... } })
- 审批延期同意uni.showModal({ content: '确定同意该延期申请?', ... })
- 审批延期拒绝:必须填写拒绝原因(弹窗内输入框)
11. **[H8]操作结果反馈**
- 分配成功wx.showToast({ icon: 'success', title: '分配成功' }) + 刷新列表
- 操作失败wx.showToast({ icon: 'none', title: errMsg })
- 网络异常wx.showModal({ title: '提示', content: '网络连接异常', confirmText: '重试' })
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 页面容器 | scroll-view | scroll-y, refresher-enabled, @scrolltolower |
| 统计卡片 | view | class="stat-card", v-for, horizontal scroll |
| 状态筛选 | uni-segmented-control | :values="['全部','待分配','处理中','待验收','已完成']" |
| 工单列表 | view | v-for, class="order-card" |
| 状态标签 | uni-tag | :type按状态变化 |
| 分配按钮 | button | size="mini", type="primary" |
| 人员选择弹窗 | uni-popup | type="bottom" |
| 人员列表 | radio-group | v-for |
### 校验规则
无表单校验
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 统计卡片:横向滚动
- 工单卡片:全宽
- 触摸区域按钮≥44px
--- ---
### 页面2巡检管理 ### 页面2巡检管理
@ -96,6 +153,51 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-PM-02 | 巡检管理 | 02-小程序端 §9 | 无 | 巡检管理Web端 | | MP-PM-02 | 巡检管理 | 02-小程序端 §9 | 无 | 巡检管理Web端 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 并行加载统计卡片 + 巡检记录列表
2. **筛选交互**:选择日期/状态/人员 → 触发列表刷新
3. **查看详情**:点击记录卡片 → 跳转巡检详情页
4. **审核补录**:点击"审核补录"→ 弹窗展示补录原因+证据 → 同意/拒绝 → API调用 → 通知申请人
5. **异常处理**:数据加载失败显示重试;网络离线提示
6. **权限控制**:无补录审核权限 → "审核补录"按钮不显示
7. **[H1]防重复请求**
- 筛选操作点击后 disabled + loading态API返回后恢复
- 审核补录操作点击后 disabled + loading态
- 使用 pending 标志位去重,页面卸载时 onUnload 必须 abort 未完成请求wx.requestTask.abort()
8. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → wx.showToast({icon:'none', title:'请求超时,请检查网络后重试'})
- 加载>2秒显示 wx.showLoading
9. **[H3]操作确认机制**(有不可逆操作时)
- 审核补录: wx.showModal 确认
10. **[H8]操作结果反馈**
- 成功: wx.showToast({icon:'success', duration:2000})
- 失败: wx.showToast({icon:'none'}) 需手动关闭
- 网络/离线: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 页面容器 | scroll-view | scroll-y, refresher-enabled |
| 统计卡片 | view | class="stat-card", v-for |
| 巡检记录列表 | view | v-for, class="inspection-card" |
| 状态标签 | uni-tag | 待执行:type="info", 进行中:type="primary", 已完成:type="success" |
| 审核补录弹窗 | uni-popup | type="bottom" |
| 同意/拒绝按钮 | button | 同意:type="primary", 拒绝:type="warn" |
### 校验规则
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 统计卡片横向排列
- 记录卡片全宽
- 按钮触摸区≥44px
--- ---
### 页面3保洁管理 ### 页面3保洁管理
@ -136,6 +238,47 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-PM-03 | 保洁管理 | 02-小程序端 §9 | 不合格时重新生成任务 | 保洁管理Web端 | | MP-PM-03 | 保洁管理 | 02-小程序端 §9 | 不合格时重新生成任务 | 保洁管理Web端 |
### 交互流程要求
1. **页面加载**:进入页面 → 加载统计卡片 + 保洁任务列表
2. **抽查操作**:点击"抽查"→ 弹窗选择合格/不合格 → 提交 → 不合格时系统自动重新生成任务
3. **审核补录**:同巡检补录审核流程
4. **异常处理**:抽查提交失败提示;不合格重新生成任务失败提示
5. **[H1]防重复请求**
- 抽查操作:点击"抽查"后按钮 disabled + 弹窗确认按钮 loading
- 审核补录:同上
- 筛选切换时 abort 上次请求
6. **[H2]超时与加载反馈**
- 列表查询 timeout=15秒抽查/审核操作 timeout=30秒
- >3秒 wx.showLoading超时提示+重试
7. **[H3]操作确认机制**
- 抽查不合格uni.showModal({ content: '确定标记为不合格?系统将自动重新生成任务', ... })
- 抽查合格uni.showModal({ content: '确定标记为合格?', ... })
8. **[H8]操作结果反馈**
- 成功wx.showToast({ icon: 'success' }) + 刷新
- 失败wx.showToast({ icon: 'none', title: errMsg })
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 抽查弹窗 | uni-popup | type="bottom" |
| 合格/不合格 | radio-group | 合格/不合格 |
| 抽查结果标签 | uni-tag | 合格:type="success", 不合格:type="error", 待抽查:type="info" |
### 校验规则
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 触摸区域≥44px
--- ---
### 页面4考勤管理 ### 页面4考勤管理
@ -175,6 +318,47 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-PM-04 | 考勤管理 | 02-小程序端 §9 | 补录打卡记录 | 考勤打卡Web端 | | MP-PM-04 | 考勤管理 | 02-小程序端 §9 | 补录打卡记录 | 考勤打卡Web端 |
### 交互流程要求
1. **页面加载**:进入页面 → 加载今日出勤统计 + 考勤记录列表
2. **审核申诉**:点击"审核申诉"→ 弹窗展示申诉原因+证据 → 同意/拒绝 → API调用
3. **审核补录**:同上补录审核流程
4. **异常处理**:审核失败提示
5. **[H1]防重复请求**
- 审核申诉/补录:操作按钮点击后 disabled + loading
- 筛选切换 abort 上次请求
6. **[H2]超时与加载反馈**
- 列表查询 timeout=15秒审核操作 timeout=30秒
- >3秒 wx.showLoading超时提示+重试
7. **[H3]操作确认机制**
- 审核通过uni.showModal({ content: '确定同意该申诉/补录?', ... })
- 审核拒绝uni.showModal + 必填拒绝原因
8. **[H8]操作结果反馈**
- 成功wx.showToast({ icon: 'success' }) + 刷新
- 失败wx.showToast({ icon: 'none', title: errMsg })
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 统计卡片 | view | class="stat-card" |
| 状态标签 | uni-tag | 正常:type="success", 迟到:type="warning", 早退:type="warning", 缺卡:type="error" |
| 打卡方式标签 | uni-tag | 蓝牙:type="primary", 手动:type="info", 补录:type="warning" |
| 审核弹窗 | uni-popup | type="bottom" |
### 校验规则
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 触摸区域≥44px
--- ---
### 页面5评价管理 ### 页面5评价管理
@ -213,6 +397,45 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-PM-05 | 评价管理 | 02-小程序端 §9 | 通知报修人 | 服务评价Web端 | | MP-PM-05 | 评价管理 | 02-小程序端 §9 | 通知报修人 | 服务评价Web端 |
### 交互流程要求
1. **页面加载**:进入页面 → 加载评价统计 + 评价列表
2. **回复操作**:点击"回复"→ 弹窗输入回复内容 → 提交 → API调用 → 成功后通知报修人
3. **查看详情**:点击评价卡片 → 跳转评价详情页
4. **异常处理**:回复失败提示
5. **[H1]防重复请求**
- 回复操作:点击"回复"后按钮 disabled + 弹窗提交按钮 loading
- 筛选切换 abort 上次请求
6. **[H2]超时与加载反馈**
- 列表查询 timeout=15秒回复提交 timeout=30秒
- >3秒 wx.showLoading超时提示+重试
7. **[H8]操作结果反馈**
- 回复成功wx.showToast({ icon: 'success', title: '回复成功' }) + 刷新
- 回复失败wx.showToast({ icon: 'none', title: errMsg })
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 评分展示 | uni-rate | :size=16, :readonly=true |
| 回复状态标签 | uni-tag | 已回复:type="success", 未回复:type="warning" |
| 回复弹窗 | uni-popup | type="bottom" |
| 回复输入 | uni-easyinput | type="textarea", maxlength=200 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 回复内容 | 必填, 最大200字符 | "请输入回复内容" |
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 触摸区域≥44px
--- ---
### 页面6组织架构查看 ### 页面6组织架构查看
@ -252,6 +475,48 @@
|------------|----------|----------|----------|----------| |------------|----------|----------|----------|----------|
| MP-PM-06 | 组织架构查看 | 02-小程序端 §9 | 无 | 组织架构Web端 | | MP-PM-06 | 组织架构查看 | 02-小程序端 §9 | 无 | 组织架构Web端 |
### 交互流程要求
1. **页面加载**:进入页面 → 调用API加载班组列表
2. **查看班组**:点击班组卡片 → 展开班组成员列表(手风琴模式,同时只展开一个)
3. **查看人员**:点击人员 → 跳转人员详情页(姓名、职位、电话、技能标签)
4. **拨打电话**:点击"拨打电话"→ 调用 `uni.makePhoneCall` → 拨号
5. **搜索**:输入关键词 → 实时筛选班组/人员
6. **异常处理**:拨号失败(无电话号码)→ 提示"该人员未设置联系电话"
### 前端硬性约束
> - **H1 防重复提交**
> - 搜索/筛选请求使用 pending 标志位去重,避免频繁触发 API 调用。
> - 页面卸载时(`onUnload`)必须 `abort` 未完成的请求。
> - **H2 超时处理**
> - 班组列表GET接口超时 **15秒**;超过 3 秒未响应展示 loading 动画。
> - 搜索防抖处理300ms避免高频输入触发过多请求。
> - **H8 操作反馈**
> - 数据加载成功 `uni.showToast({ icon: 'success', duration: 2000 })`,拨号失败 `icon: 'none'` 提示具体原因。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 搜索框 | uni-easyinput | placeholder="搜索姓名/职位", clearable |
| 班组卡片 | uni-card | :isFull=true, @click="toggleExpand" |
| 展开收起 | uni-icons | type="bottom"/"top" |
| 成员列表 | view | v-for, v-show="expanded" |
| 技能标签 | uni-tag | v-for, type="primary", size="mini" |
| 拨打电话按钮 | button | size="mini", type="primary", @click="makePhoneCall" |
### 校验规则
### 响应式布局
- 适配机型iPhone SE ~ iPad mini
- 班组卡片全宽
- 成员列表全宽
- 触摸区域≥44px
--- ---
## 业务规则 ## 业务规则

@ -3,6 +3,7 @@
> 模块编码repair > 模块编码repair
> 端侧Web + 小程序(双端) > 端侧Web + 小程序(双端)
> 关联文档01-模块划分 §3.1 / 02-功能清单-物业公司 §1 / 03-业务流转逻辑-物业公司 §1 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4 > 关联文档01-模块划分 §3.1 / 02-功能清单-物业公司 §1 / 03-业务流转逻辑-物业公司 §1 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -108,6 +109,67 @@
| 批量关闭 | /api/v1/repair-orders/batch-close | POST | 需填关闭原因 | | 批量关闭 | /api/v1/repair-orders/batch-close | POST | 需填关闭原因 |
| 导出 | /api/v1/repair-orders/export | GET | 导出Excel | | 导出 | /api/v1/repair-orders/export | GET | 导出Excel |
### 交互流程要求
1. **页面加载流程**进入页面后调用列表查询API默认按提交时间倒序排列加载第一页数据每页20条同时并行加载状态下拉选项、报修类型字典、班组列表、区域级联数据页面顶部面包屑显示"在线报修 > 工单列表"。
2. **查询/筛选交互流程**:用户填写筛选条件后点击"查询"按钮携带筛选参数重新请求第1页数据点击"重置"清空所有筛选条件并重新加载默认列表;输入工单号支持回车触发查询;日期范围选择后自动关闭弹窗。
3. **表单填写与提交流程**:点击"新增工单"打开弹窗,必填项标红星;报修类型从字典加载,紧急程度默认"普通"报修区域级联选择至少选到楼层照片上传支持多选单张≤20MB最多9张提交前校验必填项校验通过后调用新增API成功则关闭弹窗并刷新列表。
4. **弹窗/抽屉交互流程**:批量分配弹窗——勾选工单后点击"批量分配",弹窗中选择目标班组和维修人员,确认后批量提交;批量关闭弹窗——勾选后点击"批量关闭"二次确认弹窗中填写关闭原因确认后批量提交新增工单弹窗宽度600px点击遮罩层不关闭。
5. **行内操作流程**:点击工单号跳转详情页(新标签页打开);状态=待分配时显示"分配"和"关闭"操作按钮;点击"分配"弹出分配弹窗选择班组和人员;点击"关闭"弹出二次确认输入关闭原因;点击"查看"跳转详情页。
6. **异常与错误处理**API请求失败时顶部显示el-notification错误提示3秒后自动关闭网络超时提示"请求超时,请稍后重试";批量操作部分失败时显示部分成功提示并列出失败工单号;导出超时提示"数据量较大,导出中请勿关闭页面"。
7. **联动/级联交互**:所属区域级联选择——选择项目后加载区域列表,选择区域后加载楼栋,选择楼栋后加载楼层;报修类型筛选与新增弹窗共用字典数据源;状态多选筛选支持全选/反选。
8. **权限控制交互表现**无权限按钮不渲染v-if控制非disabled维修人员角色操作列仅显示"查看"按钮;主管角色不显示"新增工单"按钮;数据范围由后端控制,前端仅展示返回数据。
9. **【H1 防重复请求】**:查询按钮点击后立即 disabled 并显示 loading 态API返回后恢复行内"分配"/"关闭"操作按钮点击后该行禁用 + loading态避免重复提交分页切换时必须 abort 上一次未完成的列表请求再发新请求;批量分配/批量关闭/新增工单的提交按钮绑定 `:loading` 属性同时 `disabled`;页面加载时并行请求列表、字典、班组、区域等独立数据,互不阻塞。
10. **【H2 统一超时配置】**GET 列表查询超时15秒POST 批量分配/批量关闭/新增工单超时30秒GET 导出Excel超时60秒所有请求超时自动中断并提示"请求超时,请稍后重试"同时自动恢复按钮状态请求耗时超过3秒时显示全局 loadingElLoading.service
11. **【H3 操作确认机制】**"关闭工单"操作前弹出 `ElMessageBox.confirm("确定要关闭「{工单号}」吗?关闭后将无法恢复处理", {type: 'warning'})`"批量关闭"操作前弹出 `ElMessageBox.confirm("确定对 {N} 条数据进行批量关闭吗?", {type: 'warning'})` 需填写关闭原因;删除类操作统一使用 type='error' 级别确认。
12. **【H5 数据权限隔离】**:区分"暂无数据"ElEmpty与"无权限"场景当接口返回403状态码时前端拦截器统一展示"您没有权限访问此数据",不展示具体数据内容。
13. **【H6 批量操作限制】**:批量分配/批量关闭单次最多选择50条记录超过限制时提示"批量操作最多支持50条当前已选{N}条"并在操作栏角色标处显示警告色角标导出Excel单次最多导出500条记录超出提示"数据量过大,请缩小筛选范围后重试"。
14. **【H8 操作结果反馈】**:操作成功使用 `ElMessage.success` 提示duration=2000ms成功后 silent 刷新列表无loading动画操作失败使用 `ElMessage.error` 提示duration=0需手动关闭并展示服务端返回的具体错误信息网络异常/断网时提示中包含"重试"按钮可重新触发上次操作。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 工单号输入 | el-input | placeholder="请输入工单号", clearable, maxlength=20 |
| 状态筛选 | el-select | multiple, collapse-tags, collapse-tags-tooltip, placeholder="请选择状态" |
| 报修类型筛选 | el-select | placeholder="请选择报修类型", clearable, filterable |
| 紧急程度筛选 | el-select | placeholder="请选择紧急程度", clearable |
| 日期范围 | el-date-picker | type="daterange", range-separator="至", start-placeholder="开始日期", end-placeholder="结束日期", value-format="YYYY-MM-DD" |
| 所属班组 | el-select | placeholder="请选择班组", clearable, filterable |
| 所属区域 | el-cascader | :props="{checkStrictly: true, emitPath: false}", placeholder="请选择区域", clearable, filterable |
| 列表 | el-table | stripe, border, :max-height="calc(100vh - 320px)", @selection-change |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next, jumper", :page-sizes="[10,20,50,100]", :page-size="20" |
| 紧急程度标签 | el-tag | :type="danger/warning/info" 紧急=danger/普通=warning/低优先=info |
| 状态标签 | el-tag | :type根据状态映射 启用色系区分 |
| 批量分配弹窗 | el-dialog | title="批量分配", width="500px", :close-on-click-modal="false" |
| 新增工单弹窗 | el-dialog | title="新增工单", width="600px", :close-on-click-modal="false" |
| 操作按钮组 | el-button | type="primary/danger/text", size="default/small" |
| 二次确认 | el-message-box | type="warning", confirmButtonText="确定", cancelButtonText="取消" |
| 勾选列 | el-table-column | type="selection", width="40" |
| 补录标记 | el-tag | type="warning", size="small" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 工单号(筛选) | maxlength=20允许字母数字 | — |
| 状态(筛选) | 数组,可为空 | — |
| 提交日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
| 勾选工单(批量分配) | 至少勾选1条且所有勾选状态=待分配 | "请选择待分配状态的工单" |
| 勾选工单(批量关闭) | 至少勾选1条且所有勾选状态=待分配 | "请选择待分配状态的工单" |
| 关闭原因 | 必填maxlength=200 | "请填写关闭原因" |
| 分配班组 | 必选 | "请选择负责班组" |
| 分配人员 | 必选 | "请选择维修人员" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区单行展示全部筛选项操作栏与表格等宽列表展示全部12列分页完整显示 |
| 1024-1279pxPad横屏 | 查询条件区折行为两行,日期范围缩窄;列表隐藏"补录标记""预约时间"列其余列宽自适应操作按钮缩小为small尺寸 |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠,每行一个筛选项;操作栏仅保留"新增工单"和"导出"按钮,批量操作收入"更多"下拉菜单(el-dropdown);列表隐藏"补录标记""预约时间""维修人员""负责班组"列紧急程度改为图标显示分页简化为prev/next |
--- ---
## 页面2工单详情页 ## 页面2工单详情页
@ -186,6 +248,63 @@
| 延期审批 | /api/v1/repair-orders/{id}/delay-approve | POST | 通过/驳回 | | 延期审批 | /api/v1/repair-orders/{id}/delay-approve | POST | 通过/驳回 |
| 验收 | /api/v1/repair-orders/{id}/accept | POST | 通过/不通过 | | 验收 | /api/v1/repair-orders/{id}/accept | POST | 通过/不通过 |
### 交互流程要求
1. **页面加载流程**根据路由参数id调用详情查询API加载工单基本信息、流转记录、照片附件、评价信息页面顶部状态栏显示工单号和当前状态默认展示"基本信息"标签页;根据当前状态和用户角色动态渲染底部操作按钮。
2. **查询/筛选交互流程**:切换标签页时无额外查询(数据一次性加载),仅切换展示内容;流转记录按时间轴倒序展示。
3. **表单填写与提交流程**:点击"分配工单"弹出分配弹窗,选择班组和维修人员后提交;点击"关闭工单"弹出关闭原因输入弹窗,填写后提交;点击"验收不通过"弹出原因输入弹窗,填写后提交退回;延期审批弹窗选择"通过"或"驳回",驳回需填写原因。
4. **弹窗/抽屉交互流程**分配弹窗——选择班组后联动加载班组内人员列表选择人员后确认提交关闭弹窗——必填关闭原因支持最多200字验收不通过弹窗——必填不通过原因支持最多200字延期审批弹窗——radio选择通过/驳回,驳回时显示原因输入框。所有弹窗点击遮罩层不关闭。
5. **行内操作流程**照片缩略图点击后打开el-image-viewer大图预览支持左右切换评价信息中图片同样支持点击预览流转记录时间轴展示操作人、时间、操作类型、备注。
6. **异常与错误处理**工单不存在时显示404占位页并提示"工单不存在或已被删除"操作失败显示el-notification错误提示网络异常时底部按钮置灰并提示"网络异常,请刷新重试";照片加载失败显示占位图。
7. **联动/级联交互**:分配弹窗中班组选择后联动加载该班组下的人员列表;标签页切换无延迟,数据预加载;补录工单在基本信息区额外显示补录原因和审核状态区块。
8. **权限控制交互表现**:维修人员角色底部操作栏完全隐藏;主管仅可见分配、审批、验收按钮;状态不匹配的按钮隐藏(如待分配时不显示验收按钮);操作按钮根据当前工单状态动态显示/隐藏。
9. **【H1 防重复请求(轻量)】**:详情加载时防止重复请求(路由参数不变时不重复调用);底部操作栏各按钮(分配/关闭/审批/验收)点击后立即 disabled + loading态API返回后恢复标签页切换无需请求数据一次性预加载完成
10. **【H2 统一超时配置】**GET 详情查询超时15秒POST 分配/关闭/延期审批/验收操作超时30秒超时时中断请求 + 提示"请求超时,请稍后重试" + 按钮恢复可用工单不存在404直接展示占位页不做超时二次请求。
11. **【H3 操作确认机制】**"分配工单"前确认选择的目标班组和人员正确(可选轻提示或弹窗确认);"关闭工单"前必须弹出 `ElMessageBox.confirm("确定要关闭工单「{工单号}」?关闭后将无法恢复处理", {type: 'warning'})`"延期审批-驳回"前提示含后果说明"驳回后将退回处理中状态""验收不通过"前提示"将退回返修流程,确认?"。
12. **【H8 操作结果反馈】**:分配/关闭/审批/验收等操作成功后 `ElMessage.success`2s+ 刷新详情数据和流转记录;操作失败 `ElMessage.error`(手动关闭)+ 保留用户已填写的表单内容如原因输入框照片加载失败显示占位图而非错误toast。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 状态栏 | el-descriptions | :column="2", border, title="工单信息" |
| 标签页 | el-tabs | v-model="activeTab", type="card" |
| 基本信息展示 | el-descriptions | :column="2", border |
| 流转记录 | el-timeline | — |
| 流转记录项 | el-timeline-item | :timestamp="item.time", placement="top" |
| 照片附件 | el-image | :preview-src-list="photoList", fit="cover", :lazy="true" |
| 图片预览 | el-image-viewer | :url-list="photoList" |
| 评价信息-评分 | el-rate | disabled, :colors="['#99A9BF', '#F7BA2A', '#FF9900']" |
| 底部操作栏 | el-affix | position="bottom", :offset="0" |
| 分配弹窗 | el-dialog | title="分配工单", width="500px", :close-on-click-modal="false" |
| 关闭弹窗 | el-dialog | title="关闭工单", width="500px", :close-on-click-modal="false" |
| 验收不通过弹窗 | el-dialog | title="验收不通过", width="500px", :close-on-click-modal="false" |
| 延期审批弹窗 | el-dialog | title="延期审批", width="500px", :close-on-click-modal="false" |
| 班组选择 | el-select | placeholder="请选择班组", filterable, @change="loadStaff" |
| 人员选择 | el-select | placeholder="请选择维修人员", filterable |
| 关闭原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit |
| 状态标签 | el-tag | :type根据状态映射size="large" |
| 补录标记区块 | el-alert | type="warning", :closable="false", show-icon |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 分配班组 | 必选 | "请选择负责班组" |
| 分配维修人员 | 必选 | "请选择维修人员" |
| 关闭原因 | 必填maxlength=200 | "请填写关闭原因" |
| 验收不通过原因 | 必填maxlength=200 | "请填写不通过原因" |
| 延期审批-驳回原因 | 驳回时必填maxlength=200 | "请填写驳回原因" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 基本信息el-descriptions两列展示底部操作栏固定在页面底部标签页完整展示4个标签照片缩略图4列网格排列 |
| 1024-1279pxPad横屏 | 基本信息el-descriptions两列展示但标签列缩窄照片缩略图3列网格排列底部操作栏按钮缩小为small尺寸 |
| 768-1023pxPad竖屏 | 基本信息el-descriptions改为单列展示标签页改为el-dropdown切换节省空间照片缩略图2列网格排列底部操作栏按钮组改为堆叠排列主操作按钮full-width |
--- ---
## 页面3新增工单弹窗 ## 页面3新增工单弹窗
@ -228,6 +347,62 @@
| 报修描述 | 多行文本 | 是 | — | 自填 | 最大500字 | | 报修描述 | 多行文本 | 是 | — | 自填 | 最大500字 |
| 照片 | 图片上传 | 否 | — | 拍照/相册 | ≤9张单张≤20MB自动加水印 | | 照片 | 图片上传 | 否 | — | 拍照/相册 | ≤9张单张≤20MB自动加水印 |
### 交互流程要求
1. **页面加载流程**:点击工单列表页"新增工单"按钮后打开弹窗;并行加载报修类型字典选项、区域级联数据、紧急程度选项;紧急程度默认选中"普通";弹窗打开后聚焦第一个必填字段(报修类型)。
2. **查询/筛选交互流程**:不适用(弹窗无筛选功能)。
3. **表单填写与提交流程**:用户依次填写报修类型、紧急程度、报修区域、报修人、联系电话、预约时间、报修描述、照片上传;点击"提交"按钮触发表单校验校验通过后调用新增API提交成功关闭弹窗并刷新列表显示成功提示提交失败显示具体错误信息。点击"取消"关闭弹窗,不保存数据。
4. **弹窗/抽屉交互流程**弹窗宽度600px标题"新增工单";点击右上角×号或"取消"按钮关闭弹窗;点击遮罩层不关闭(防止误操作);照片上传区域点击"+"触发文件选择,支持多选;已上传照片显示缩略图,右上角有删除按钮。
5. **行内操作流程**:报修区域级联选择——选择项目后加载区域,逐级联动;预约时间选择日期时间后自动关闭选择器;照片上传后显示进度条,上传失败显示重试按钮。
6. **异常与错误处理**必填项未填时提交按钮触发el-form校验对应字段下方显示红色错误提示联系电话格式不正确实时提示照片上传失败显示上传失败标识可点击重试上传超过9张时提示"最多上传9张照片"单张超过20MB时提示"单张照片不能超过20MB"提交API失败显示el-notification错误信息。
7. **联动/级联交互**:报修区域级联选择——选择项目→加载区域→选择区域→加载楼栋→选择楼栋→加载楼层,至少选到楼层;照片上传自动添加水印(时间+位置+蓝牙标记)。
8. **权限控制交互表现**:仅物业管理员角色可点击"新增工单"按钮打开此弹窗;主管和维修人员角色无此按钮。
9. **【H1 防重复请求】**:提交按钮点击后立即 `:loading="true"` + `disabled`,防止重复提交;弹窗内级联选择/下拉选项并行加载,互不阻塞表单填写;照片上传每个文件独立的进度跟踪,互不影响。
10. **【H2 统一超时配置】**POST 新增工单API超时30秒照片上传单个文件超时60秒超时自动中断 + ElMessage.error("上传超时,请检查网络后重试") + 按钮恢复;>3秒的请求在弹窗内显示局部loading指示器非全局遮罩
11. **【H4 脏数据检测】**:进入弹窗时初始化空表单作为快照基准;用户修改任意字段后标记 isDirty=true点击"取消"按钮时若 isDirty 为 true弹出 `ElMessageBox.confirm("表单已有未保存的修改,确定关闭吗?", {type: 'warning'})`;点击遮罩层和×号同样触发脏数据检测拦截。
12. **【H7 文件上传约束】**照片单张≤20MB按原文档要求每次最多9张格式白名单 image/jpeg,image/png,image/gif,image/webp超出限制时 el-upload 触发 on-exceed 回调提示每个文件上传进度条实时展示el-progress 或 upload 内置进度);上传失败的单张显示错误标识并提供重试按钮。
13. **【H8 操作结果反馈】**:提交成功 `ElMessage.success("工单创建成功")`2s→ 自动关闭弹窗 → silent 刷新父级列表;提交失败 `ElMessage.error`(手动关闭)保留表单内容便于修改重试;校验失败滚动到首个错误字段并聚焦。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 弹窗容器 | el-dialog | title="新增工单", width="600px", :close-on-click-modal="false", destroy-on-close |
| 表单 | el-form | :model="form", :rules="rules", ref="formRef", label-width="90px", label-position="right" |
| 报修类型 | el-select | placeholder="请选择报修类型", filterable, clearable |
| 紧急程度 | el-select | placeholder="请选择紧急程度" |
| 报修区域 | el-cascader | :props="{checkStrictly: false, emitPath: false}", placeholder="请选择报修区域", filterable, clearable |
| 报修人 | el-input | placeholder="请输入报修人姓名", maxlength=20, clearable |
| 联系电话 | el-input | placeholder="请输入联系电话", maxlength=11, clearable |
| 预约时间 | el-date-picker | type="datetime", placeholder="请选择预约时间", value-format="YYYY-MM-DD HH:mm", :disabled-date="disablePastDate" |
| 报修描述 | el-input | type="textarea", :rows="4", maxlength=500, show-word-limit, placeholder="请描述报修问题" |
| 照片上传 | el-upload | action="/api/v1/files/upload", list-type="picture-card", :limit=9, :file-size=20, accept="image/*", :on-exceed="handleExceed" |
| 提交按钮 | el-button | type="primary", :loading="submitting" |
| 取消按钮 | el-button | @click="dialogVisible=false" |
| 表单项 | el-form-item | :label="字段名", :prop="字段key" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 报修类型 | 必选 | "请选择报修类型" |
| 紧急程度 | 必选 | "请选择紧急程度" |
| 报修区域 | 必选且必须选到楼层级level≥4 | "请选择完整的报修区域,至少选到楼层" |
| 报修人 | 必填maxlength=20不允许纯空格 | "请输入报修人姓名" |
| 联系电话 | 必填,正则/^1[3-9]\d{9}$/ | "请输入正确的手机号码" |
| 预约时间 | 可选,但不早于当前时间 | "预约时间不能早于当前时间" |
| 报修描述 | 必填maxlength=500不允许纯空格 | "请描述报修问题" |
| 照片 | 可选≤9张单张≤20MB | "最多上传9张照片" / "单张照片不能超过20MB" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 弹窗宽度600px表单两列布局报修类型+紧急程度同行,报修人+联系电话同行照片上传区4列网格 |
| 1024-1279pxPad横屏 | 弹窗宽度560px表单两列布局不变字段标签缩窄至80px照片上传区3列网格 |
| 768-1023pxPad竖屏 | 弹窗宽度90vw表单改为单列布局所有字段垂直堆叠照片上传区3列网格底部按钮组full-width堆叠排列 |
--- ---
## 页面4报修类型管理页 ## 页面4报修类型管理页
@ -277,6 +452,55 @@
| 编辑 | /api/v1/repair-types/{id} | PUT | — | | 编辑 | /api/v1/repair-types/{id} | PUT | — |
| 启用/停用 | /api/v1/repair-types/{id}/toggle-status | PUT | — | | 启用/停用 | /api/v1/repair-types/{id}/toggle-status | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用列表查询API GET /api/v1/repair-types → 渲染类型列表表格,默认按序号升序排列;列表无分页(数据量小)
2. **查询/筛选交互流程**:本页无独立筛选条件,所有类型数据一次加载展示
3. **表单填写与提交流程**:点击[新增类型] → 弹窗表单 → 填写类型名称、选择关联班组(多选)、填写描述 → 点击确定 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度480px标题根据操作切换"新增报修类型"/"编辑报修类型"点击遮罩层不关闭关联班组多选时使用collapse-tags折叠展示
5. **行内操作流程**:点击[编辑] → 弹窗回填当前类型数据 → 修改后提交 → 刷新当前行;点击[停用] → 二次确认弹窗"停用后该类型将不再出现在报修选项中,确认停用?" → 确认后调用toggle-status API → 刷新列表有工单关联的不可停用按钮置灰并Tooltip提示"该类型下存在关联工单,不可停用"
6. **异常与错误处理**:类型名称重复提示"该报修类型名称已存在"API请求失败显示ElMessage.error停用关联工单的类型时后端返回400前端提示"该类型下存在关联工单,请先迁移"
7. **联动/级联交互**:关联班组下拉选择时数据来源于组织架构-班组列表;类型列表中"工单数量"为实时统计值
8. **权限控制交互表现**:无 repair:type:create 权限时[新增类型]按钮隐藏;无 repair:type:update 权限时[编辑][停用]按钮隐藏
9. **【H1 防重复请求】**:查询/刷新列表时按钮 disabled + loading态[新增]/[编辑]弹窗的提交按钮 :loading + disabled 防重复提交;[停用]操作点击后该行禁用 + loading态列表无分页但仍需防止并发重复请求。
10. **【H2 统一超时配置】**GET 列表查询超时15秒POST 新增超时30秒PUT 编辑/启用停用超时30秒超时中断 + 提示"请求超时..." + 按钮恢复。
11. **【H3 操作确认机制】**[停用]操作前弹出 `ElMessageBox.confirm("确定停用「{类型名称}」?停用后将不再出现在报修选项中", {type: 'warning'})` 含操作后果说明;[编辑]保存前若检测到名称变更可加轻量提示。
12. **【H4 脏数据检测】**[编辑]弹窗打开时对当前行数据做 deep clone 作为原始快照;用户修改后 isDirty 检测;取消/关闭弹窗时若 isDirty 弹出"修改未保存,确定关闭?"确认框;[新增]弹窗同理检测是否有任何输入内容。
13. **【H8 操作结果反馈】**:新增/编辑成功 `ElMessage.success`2s→ 关闭弹窗 → 刷新列表;停用成功更新该行状态标签为"停用";失败 `ElMessage.error`(手动关闭)+ 保留表单内容。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, :data="typeList", size="default" |
| 新增类型按钮 | el-button | type="primary", icon="Plus", v-hasPermission="['repair:type:create']" |
| 编辑按钮 | el-button | type="primary", link, v-hasPermission="['repair:type:update']" |
| 启用/停用按钮 | el-button | type="warning/danger", link, :disabled="hasOrderBind", v-hasPermission="['repair:type:update']" |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle", width="480px", :close-on-click-modal="false", destroy-on-close |
| 类型名称 | el-input | maxlength=20, show-word-limit, placeholder="请输入类型名称" |
| 关联班组 | el-select | v-model="form.teamIds", multiple, collapse-tags, collapse-tags-tooltip, filterable, placeholder="请选择关联班组" |
| 描述 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, placeholder="请输入描述" |
| 状态标签 | el-tag | 启用=success / 停用=info |
| 工单数量 | el-link | type="primary", @click="viewOrders" |
| 二次确认 | el-message-box | type="warning", confirmButtonText="确定", cancelButtonText="取消" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 类型名称 | 必填maxlength=20同租户内唯一 | "请输入类型名称" / "该报修类型名称已存在" |
| 关联班组 | 必填至少选择1个 | "请选择关联班组" |
| 描述 | 非必填maxlength=200 | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表格完整展示全部列弹窗宽度480px |
| 1024-1279pxPad横屏 | 表格隐藏"描述"列弹窗宽度440px |
| 768-1023pxPad竖屏 | 表格仅显示类型名称、关联班组、状态、操作列弹窗宽度90vw |
--- ---
## 页面5数据补录页 ## 页面5数据补录页
@ -333,6 +557,58 @@
| 列表查询 | /api/v1/repair-orders/supplements | GET | 分页查询 | | 列表查询 | /api/v1/repair-orders/supplements | GET | 分页查询 |
| 审核 | /api/v1/repair-orders/supplements/{id}/approve | POST | 通过/驳回 | | 审核 | /api/v1/repair-orders/supplements/{id}/approve | POST | 通过/驳回 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用补录列表API GET /api/v1/repair-orders/supplements → 渲染补录数据表格默认按补录时间倒序排列每页20条
2. **查询/筛选交互流程**:选择补录状态/输入补录人/选择日期范围 → 点击[查询] → 携带筛选参数重新请求第1页数据 → 点击[重置]清空条件并重新加载
3. **表单填写与提交流程**:本页无新增表单,补录数据由小程序端提交
4. **弹窗/抽屉交互流程**:点击[审核] → 弹出审核弹窗,选择"通过"或"驳回",驳回时必填驳回原因 → 确认后调用审核API → 成功后关闭弹窗并刷新列表;点击[详情] → 弹出补录详情弹窗,展示补录原因、详细说明、补录人工单信息等
5. **行内操作流程**:审核状态=待审核时显示[审核]按钮;点击[审核] → 弹窗选择通过/驳回 → 提交 → 刷新该行状态;点击[详情] → 弹窗展示完整补录信息
6. **异常与错误处理**API请求失败显示ElMessage.error审核已审核过的记录时提示"该记录已审核"列表无数据时显示ElEmpty
7. **联动/级联交互**:审核状态筛选影响列表展示;补录原因枚举来源于系统配置
8. **权限控制交互表现**:无 repair:supplement:approve 权限时[审核]按钮隐藏;无 repair:supplement:view 权限时[详情]按钮隐藏
9. **【H1 防重复请求】**:查询按钮点击后 disabled + loading态API返回后恢复行内[审核]按钮点击后该行禁用 + loading态分页切换 abort上一请求再发新请求。
10. **【H2 统一超时配置】**GET 补录列表查询超时15秒POST 审核操作超时30秒超时自动中断 + "请求超时,请稍后重试" + 按钮恢复;>3秒显示全局 ElLoading。
11. **【H3 操作确认机制】**:审核操作前弹出 `ElMessageBox.confirm("确定要「{通过/驳回}」该条补录记录吗?驳回后将通知补录人重新执行巡检", {type: 通过?'info':'warning'})`,通过/驳回均需含操作后果说明。
12. **【H5 数据权限隔离】**:区分"暂无补录记录"(ElEmpty)与"无权限访问"(403)403时前端拦截器统一展示"您没有权限查看补录数据"。
13. **【H8 操作结果反馈】**:审核成功 `ElMessage.success("审核成功")`2s→ 关闭弹窗 → silent 刷新该行状态;审核失败 `ElMessage.error`(手动关闭);网络异常提示含重试按钮。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 补录状态下拉 | el-select | v-model="query.auditStatus", clearable, placeholder="请选择审核状态" |
| 补录人输入 | el-input | v-model="query.supplementUser", clearable, maxlength=20, placeholder="请输入补录人" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 审核按钮 | el-button | type="primary", link, v-hasPermission="['repair:supplement:approve']" |
| 详情按钮 | el-button | type="primary", link, v-hasPermission="['repair:supplement:view']" |
| 审核状态标签 | el-tag | 待审核=warning / 已通过=success / 已驳回=danger |
| 补录标记标签 | el-tag | type="warning", size="small" |
| 审核弹窗 | el-dialog | title="审核补录", width="500px", :close-on-click-modal="false" |
| 审核结果 | el-radio-group | v-model="auditForm.result" |
| 驳回原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, v-if="auditForm.result==='reject'" |
| 详情弹窗 | el-dialog | title="补录详情", width="600px", :close-on-click-modal="true" |
| 详情展示 | el-descriptions | :column="2", border |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审核结果 | 必选 | "请选择审核结果" |
| 驳回原因 | 驳回时必填maxlength=200 | "请填写驳回原因" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部列弹窗宽度500px/600px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"补录时间"列弹窗宽度460px/560px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示工单号、补录人、审核状态、操作列弹窗宽度90vw |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码inspection > 模块编码inspection
> 端侧Web + 小程序(双端) > 端侧Web + 小程序(双端)
> 关联文档01-模块划分 §3.2 / 02-功能清单-物业公司 §2 / 03-业务流转逻辑-物业公司 §2 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4 > 关联文档01-模块划分 §3.2 / 02-功能清单-物业公司 §2 / 03-业务流转逻辑-物业公司 §2 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -96,6 +97,73 @@
| 编辑 | /api/v1/inspection-plans/{id} | PUT | — | | 编辑 | /api/v1/inspection-plans/{id} | PUT | — |
| 启用/停用 | /api/v1/inspection-plans/{id}/toggle-status | PUT | — | | 启用/停用 | /api/v1/inspection-plans/{id}/toggle-status | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 并行调用列表查询API GET /api/v1/inspection-plans 和下拉选项数据(巡检类型字典、班组列表)→ 渲染表格和筛选条件默认按序号升序排列加载第一页数据每页20条
2. **查询/筛选交互流程**:填写计划名称/选择状态/选择巡检类型/选择负责班组 → 点击[查询] → 携带筛选参数重新请求第1页 → 点击[重置]清空条件并重新加载;计划名称输入框支持回车触发查询
3. **表单填写与提交流程**:点击[新增计划] → 跳转新增页面或打开弹窗 → 填写计划名称、巡检类型、巡检区域级联多选、巡检设备、频次自定义时填cron表达式、负责班组、巡检人员班组内多选、巡检清单、生效日期 → 前端校验通过 → 调用新增API → 成功后跳回列表并刷新
4. **弹窗/抽屉交互流程**编辑弹窗宽度600px回填当前计划数据选择负责班组后联动加载班组内人员列表频次选择"自定义"时显示cron表达式输入框
5. **行内操作流程**:点击[编辑] → 弹窗回填计划数据 → 修改后提交 → 刷新列表;点击[停用] → 二次确认"停用后将不再自动生成巡检任务,确认停用?" → 确认后调用toggle-status API → 刷新该行状态
6. **异常与错误处理**:计划名称重复提示"该计划名称已存在"cron表达式格式错误提示"请输入有效的cron表达式";生效日期早于当前日期提示"生效日期不能早于今天"API请求失败显示ElMessage.error
7. **联动/级联交互**:选择负责班组后联动加载班组内人员下拉选项;巡检区域级联多选——选择项目→加载区域→选择区域→加载楼栋,逐级展开;频次=自定义时显示cron表达式输入框
8. **权限控制交互表现**:无 inspection:plan:create 权限时[新增计划]按钮隐藏;无 inspection:plan:update 权限时[编辑][停用]按钮隐藏
9. **【H1 防重复请求】**:查询按钮点击后立即 disabled + loading态API返回后恢复分页切换时 abort 上一次未完成的列表请求再发新请求;[新增]/[编辑]弹窗提交按钮 :loading + disabled 防重复提交;[停用]操作点击后该行禁用 + loading态页面加载时并行请求列表和字典数据互不阻塞。
10. **【H2 统一超时配置】**GET 列表查询超时15秒POST 新增计划超时30秒PUT 编辑/启用停用超时30秒超时自动中断 + 提示"请求超时,请稍后重试" + 按钮恢复可用;请求>3秒显示全局 ElLoading。
11. **【H3 操作确认机制】**[停用]操作前弹出 `ElMessageBox.confirm("确定停用「{计划名称}」?停用后将不再自动生成巡检任务", {type: 'warning'})` 含操作后果说明。
12. **【H4 脏数据检测】**[编辑]弹窗打开时对当前计划数据做 deep clone 作为原始快照;用户修改后 isDirty 检测;取消/关闭弹窗时若 isDirty 弹出"修改未保存,确定关闭?"确认框(含 type:'warning'[新增]弹窗同理检测是否有任何输入内容。
13. **【H8 操作结果反馈】**:新增/编辑成功 `ElMessage.success`2s→ 关闭弹窗 → silent 刷新列表;停用成功更新该行状态标签为"停用";失败 `ElMessage.error`(手动关闭)保留表单内容便于修改重试。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 计划名称输入 | el-input | placeholder="请输入计划名称", clearable, maxlength=50 |
| 状态筛选 | el-select | placeholder="请选择状态", clearable |
| 巡检类型筛选 | el-select | placeholder="请选择巡检类型", clearable, filterable |
| 负责班组筛选 | el-select | placeholder="请选择负责班组", clearable, filterable |
| 列表 | el-table | stripe, border, :data="tableData", :max-height="calc(100vh - 280px)" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 新增计划按钮 | el-button | type="primary", icon="Plus" |
| 编辑按钮 | el-button | type="primary", link |
| 停用按钮 | el-button | type="warning", link |
| 状态标签 | el-tag | 启用=success / 停用=info |
| 巡检类型标签 | el-tag | type="primary", size="small" |
| 编辑弹窗 | el-dialog | title="编辑巡检计划", width="600px", :close-on-click-modal="false" |
| 计划名称 | el-input | maxlength=50, show-word-limit, placeholder="请输入计划名称" |
| 巡检类型 | el-select | placeholder="请选择巡检类型", filterable |
| 巡检区域 | el-cascader | :props="{multiple:true, checkStrictly:true, emitPath:false}", placeholder="请选择巡检区域", filterable, clearable |
| 巡检设备 | el-select | v-model="form.deviceIds", multiple, collapse-tags, placeholder="请选择巡检设备" |
| 频次 | el-select | placeholder="请选择频次" |
| 自定义cron | el-input | v-if="form.frequency==='custom'", placeholder="请输入cron表达式" |
| 负责班组 | el-select | placeholder="请选择负责班组", filterable, @change="loadStaff" |
| 巡检人员 | el-select | v-model="form.staffIds", multiple, collapse-tags, placeholder="请选择巡检人员" |
| 巡检清单 | el-select | v-model="form.checklistIds", multiple, collapse-tags, placeholder="请选择巡检清单" |
| 生效日期 | el-date-picker | type="date", value-format="YYYY-MM-DD", :disabled-date="disablePastDate" |
| 二次确认 | el-message-box | type="warning", confirmButtonText="确定", cancelButtonText="取消" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 计划名称 | 必填maxlength=50同租户内唯一 | "请输入计划名称" / "该计划名称已存在" |
| 巡检类型 | 必选 | "请选择巡检类型" |
| 巡检区域 | 必选,至少选一个 | "请选择巡检区域" |
| 频次 | 必选 | "请选择频次" |
| 自定义cron | 频次=自定义时必填cron格式校验 | "请输入cron表达式" / "cron表达式格式不正确" |
| 负责班组 | 必选 | "请选择负责班组" |
| 巡检人员 | 必选,至少选一个 | "请选择巡检人员" |
| 巡检清单 | 必选,至少选一个 | "请选择巡检清单" |
| 生效日期 | 必选,不早于当前日期 | "请选择生效日期" / "生效日期不能早于今天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区单行展示表格完整展示全部9列弹窗宽度600px |
| 1024-1279pxPad横屏 | 查询条件区折行两行;表格隐藏"巡检区域""巡检人员"列弹窗宽度560px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠;表格隐藏"巡检区域""巡检人员""频次"列弹窗宽度90vw表单改为单列布局 |
--- ---
## 页面2巡检任务看板页 ## 页面2巡检任务看板页
@ -157,6 +225,54 @@
| 任务列表 | /api/v1/inspection-tasks | GET | 分页查询 | | 任务列表 | /api/v1/inspection-tasks | GET | 分页查询 |
| 日历数据 | /api/v1/inspection-tasks/calendar | GET | 按月返回日历数据 | | 日历数据 | /api/v1/inspection-tasks/calendar | GET | 按月返回日历数据 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认日期为今天,视图为日历视图 → 并行调用任务列表API和日历数据API → 渲染日历视图(按月展示每日上午/下午巡检状态),列表视图渲染任务表格
2. **查询/筛选交互流程**:切换日期(点击左右箭头)→ 重新请求日历/列表数据;选择计划名称/状态/输入巡检人员 → 点击[查询] → 重新加载数据;切换日历/列表视图时无需重新请求(数据共用)
3. **表单填写与提交流程**:本页无表单提交操作,仅查询展示
4. **弹窗/抽屉交互流程**:点击日历某日某时段 → 弹出该时段巡检任务列表弹窗展示具体任务详情弹窗宽度500px
5. **行内操作流程**:列表视图点击[查看] → 跳转巡检记录详情页;异常数>0时点击异常数 → 跳转异常处理跟踪页(带筛选条件)
6. **异常与错误处理**:日历数据加载失败时日历区显示"加载失败,点击重试"列表数据为空时显示ElEmpty"暂无巡检任务"网络异常提示ElMessage.error
7. **联动/级联交互**:日历视图与列表视图数据联动,切换视图不重新请求;日期切换后日历和列表同步刷新
8. **权限控制交互表现**:无 inspection:task:view 权限时[查看]按钮隐藏,仅展示基本信息
9. **【H1 防重复请求(轻量)】**日期切换左右箭头时防抖300ms后发起请求避免快速连续点击产生冗余请求查询按钮点击后 disabled + loading态日历数据和列表数据并行加载互不阻塞视图切换日历/列表)无需重新请求。
10. **【H2 统一超时配置】**GET 任务列表/日历数据API超时15秒超时中断 + 日历区显示"加载失败,点击重试" + 列表区显示 ElEmpty 占位;>3秒的请求在各自区域内显示局部 loading 而非全屏遮罩。
11. **【H8 操作结果反馈】**数据加载完成无额外toast提示静默渲染加载失败 ElMessage.error("数据加载失败,请检查网络")(手动关闭);日历某日点击弹出详情弹窗失败时弹窗内展示错误占位。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 视图切换 | el-radio-group + el-radio-button | v-model="viewMode", size="default" |
| 日期导航 | 自定义组件 | 左右箭头+日期显示,@prev/@next事件 |
| 日历视图 | 自定义日历组件 | 按月渲染,每日分上午/下午格子,颜色标识状态 |
| 列表视图 | el-table | stripe, border, :data="taskList" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 状态标签 | el-tag | 正常=success / 异常=danger / 未执行=warning |
| 日期筛选 | el-date-picker | type="date", value-format="YYYY-MM-DD" |
| 计划名称 | el-select | clearable, placeholder="全部计划" |
| 状态筛选 | el-select | clearable, placeholder="全部状态" |
| 巡检人员 | el-input | clearable, maxlength=20, placeholder="输入巡检人员" |
| 查看按钮 | el-button | type="primary", link |
| 异常数链接 | el-link | type="danger", @click="viewAbnormals" |
| 日历详情弹窗 | el-dialog | title="巡检任务详情", width="500px" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 巡检人员 | 非必填maxlength=20 | — |
| 状态 | 非必填,枚举值 | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 日历视图完整展示,列表视图全部列展示 |
| 1024-1279pxPad横屏 | 日历视图字号略减,列表视图隐藏"打卡方式"列 |
| 768-1023pxPad竖屏 | 日历视图改为简化周视图仅显示本周7天列表视图隐藏"打卡方式""计划名称"列,查询条件垂直堆叠 |
--- ---
## 页面3巡检记录查询页 ## 页面3巡检记录查询页
@ -203,6 +319,58 @@
| 记录列表 | /api/v1/inspection-records | GET | 分页查询 | | 记录列表 | /api/v1/inspection-records | GET | 分页查询 |
| 记录详情 | /api/v1/inspection-records/{id} | GET | 含打卡信息+检查项结果+照片 | | 记录详情 | /api/v1/inspection-records/{id} | GET | 含打卡信息+检查项结果+照片 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询条件为空 → 调用记录列表API GET /api/v1/inspection-records → 渲染表格默认按打卡时间倒序排列每页20条
2. **查询/筛选交互流程**:输入巡检人员/选择日期范围/选择打卡方式/选择结果状态/选择补录标记 → 点击[查询] → 携带筛选参数重新请求第1页 → 点击[重置]清空条件并重新加载
3. **表单填写与提交流程**:本页无表单提交操作,仅查询展示
4. **弹窗/抽屉交互流程**:点击[查看详情] → 调用记录详情API → 弹窗展示完整巡检记录打卡信息、检查项结果、照片弹窗宽度700px使用el-tabs分为"打卡信息""检查项""照片"三个标签页
5. **行内操作流程**:点击[查看异常](异常数>0时显示→ 跳转异常处理跟踪页,自动带入该记录的异常筛选条件;补录标记列点击"补录"标签 → 弹出补录原因浮层
6. **异常与错误处理**列表数据为空时显示ElEmpty"暂无巡检记录";详情弹窗加载失败提示"记录详情加载失败"API请求失败显示ElMessage.error
7. **联动/级联交互**:打卡方式列根据值显示不同颜色标签(蓝牙-蓝/手动-灰/补录-橙);补录标记与打卡方式联动,补录方式=补录时显示补录标签
8. **权限控制交互表现**:无 inspection:task:view 权限时[查看详情][查看异常]按钮隐藏
9. **【H1 防重复请求】**:查询按钮点击后立即 disabled + loading态API返回后恢复行内[查看详情]按钮点击后该行禁用 + loading态防止重复打开弹窗分页切换 abort 上一请求再发新请求详情弹窗打开时防止同一记录重复请求详情API。
10. **【H2 统一超时配置】**GET 记录列表查询超时15秒GET 记录详情查询超时15秒超时自动中断 + 提示"请求超时..." + 按钮/弹窗恢复;>3秒显示全局 ElLoading。
11. **【H5 数据权限隔离】**:区分"暂无巡检记录"(ElEmpty)与"无权限访问"(403)403时前端拦截器统一提示"您没有权限查看巡检记录",不暴露具体数据结构。
12. **【H8 操作结果反馈】**:列表刷新静默无提示;详情弹窗加载失败弹窗内展示"记录详情加载失败,请重试"ElMessage.error 仅在主动操作失败时使用duration=0手动关闭
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 巡检人员输入 | el-input | placeholder="请输入巡检人员", clearable, maxlength=20 |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 打卡方式 | el-select | clearable, placeholder="全部方式" |
| 结果状态 | el-select | clearable, placeholder="全部状态" |
| 补录标记 | el-select | clearable, placeholder="全部" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 打卡方式标签 | el-tag | 蓝牙=primary / 手动=info / 补录=warning |
| 补录标记标签 | el-tag | type="warning", size="small" |
| 查看详情按钮 | el-button | type="primary", link |
| 查看异常按钮 | el-button | type="danger", link |
| 详情弹窗 | el-dialog | title="巡检记录详情", width="700px", :close-on-click-modal="true" |
| 详情标签页 | el-tabs | v-model="detailTab", type="card" |
| 打卡信息 | el-descriptions | :column="2", border |
| 检查项列表 | el-table | :data="checkItems", size="small" |
| 照片展示 | el-image | :preview-src-list="photoList", fit="cover", :lazy="true" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 巡检人员 | 非必填maxlength=20 | — |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部9列详情弹窗宽度700px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"检查项数""补录标记"列详情弹窗宽度650px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示巡检人员、打卡时间、状态、操作列详情弹窗宽度95vw |
--- ---
## 页面4异常处理跟踪页 ## 页面4异常处理跟踪页
@ -254,6 +422,50 @@
| 异常列表 | /api/v1/inspection-abnormals | GET | 分页查询 | | 异常列表 | /api/v1/inspection-abnormals | GET | 分页查询 |
| 生成工单 | /api/v1/inspection-abnormals/{id}/create-order | POST | — | | 生成工单 | /api/v1/inspection-abnormals/{id}/create-order | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用异常列表API GET /api/v1/inspection-abnormals → 渲染表格默认按上报时间倒序排列每页20条同时加载严重等级、处理状态等下拉选项
2. **查询/筛选交互流程**:选择严重等级/选择处理状态/选择日期范围 → 点击[查询] → 携带筛选参数重新请求第1页 → 点击[重置]清空条件并重新加载
3. **表单填写与提交流程**:点击[生成报修工单](处理状态=待处理时显示)→ 弹窗确认"将为此异常生成报修工单,确认?" → 确认后调用生成工单API → 成功后刷新该行状态为"已生成工单",并显示工单号
4. **弹窗/抽屉交互流程**生成工单确认弹窗宽度420px点击[查看] → 弹出异常详情弹窗,展示异常描述、照片、关联巡检记录、处理历史
5. **行内操作流程**:点击[生成报修工单] → 二次确认 → 调用API → 刷新行数据;点击[查看] → 弹窗展示详情;关联工单号可点击跳转工单详情页(新标签页)
6. **异常与错误处理**:重复生成工单提示"该异常已生成工单,不可重复操作"API请求失败显示ElMessage.error列表无数据时显示ElEmpty"暂无异常记录"
7. **联动/级联交互**:处理状态=待处理时才显示"生成报修工单"按钮;生成工单后状态自动变为"已生成工单",关联工单号可点击跳转
8. **权限控制交互表现**:无 repair:list:create 权限时"生成报修工单"按钮隐藏;无 inspection:task:view 权限时"查看"按钮隐藏
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 严重等级筛选 | el-select | clearable, placeholder="全部等级" |
| 处理状态筛选 | el-select | clearable, placeholder="全部状态" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 严重等级标签 | el-tag | 一般=warning / 较重=danger / 严重=danger, effect="dark" |
| 处理状态标签 | el-tag | 待处理=warning / 已生成工单=success / 已关闭=info |
| 生成工单按钮 | el-button | type="primary", link, v-if="row.status==='pending'" |
| 查看按钮 | el-button | type="primary", link |
| 关联工单链接 | el-link | type="primary", @click="openOrderDetail" |
| 确认弹窗 | el-message-box | type="warning", confirmButtonText="确认生成", cancelButtonText="取消" |
| 详情弹窗 | el-dialog | title="异常详情", width="600px", :close-on-click-modal="true" |
| 详情展示 | el-descriptions | :column="2", border |
| 异常照片 | el-image | :preview-src-list="photoList", fit="cover" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部9列详情弹窗宽度600px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"巡检记录""异常描述"列详情弹窗宽度560px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示异常编号、严重等级、处理状态、操作列详情弹窗宽度95vw |
--- ---
## 页面5巡检区域管理页 ## 页面5巡检区域管理页
@ -308,6 +520,56 @@
| 编辑 | /api/v1/inspection-areas/{id} | PUT | — | | 编辑 | /api/v1/inspection-areas/{id} | PUT | — |
| 删除 | /api/v1/inspection-areas/{id} | DELETE | — | | 删除 | /api/v1/inspection-areas/{id} | DELETE | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用区域树API GET /api/v1/inspection-areas/tree → 渲染左侧树形结构 → 默认选中第一个项目节点 → 右侧展示该节点详情蓝牙Beacon信息、关联设备、巡检计划
2. **查询/筛选交互流程**:点击树形节点 → 右侧实时刷新展示选中区域详情;树形节点支持搜索过滤
3. **表单填写与提交流程**:点击[新增区域] → 弹窗表单 → 填写区域名称、选择上级区域级联、选择蓝牙Beacon、选择关联设备 → 点击确定 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新树形结构
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度520px上级区域级联选择器懒加载展开点击遮罩层不关闭
5. **行内操作流程**:点击[编辑] → 弹窗回填当前区域数据 → 修改后提交 → 刷新右侧详情;点击[删除] → 二次确认"删除后不可恢复,确认删除?" → 确认后调用DELETE API → 刷新树形有关联计划的区域不可删除删除按钮置灰并Tooltip提示
6. **异常与错误处理**:区域名称重复提示"该区域名称已存在";有关联计划时删除按钮置灰并提示"该区域存在关联巡检计划,不可删除";网络异常显示全局错误提示
7. **联动/级联交互**上级区域级联选择器联动下级选项选择项目后区域选项刷新选择区域后楼栋选项刷新逐级展开选择蓝牙Beacon后展示设备在线状态
8. **权限控制交互表现**:无 inspection:area:create 权限时[新增区域]按钮隐藏;无 inspection:area:update 权限时[编辑]按钮隐藏;无 inspection:area:delete 权限时[删除]按钮隐藏
9. **【H1 防重复请求】**树形节点点击防抖200ms避免频繁切换导致右侧反复请求[新增]/[编辑]弹窗提交按钮 :loading + disabled[删除]操作点击后该节点禁用 + loading态区域树初始化加载时显示 skeleton 骨架屏。
10. **【H2 统一超时配置】**GET 区域树查询超时15秒POST 新增超时30秒PUT 编辑超时30秒DELETE 删除超时30秒超时中断 + "请求超时..." + 按钮恢复。
11. **【H3 操作确认机制】**[删除]操作前必须弹出 `ElMessageBox.confirm("确定要删除「{区域名称}」吗?删除后将不可恢复", {type: 'error'})` 使用 error 级别强调不可逆;有关联计划的区域删除按钮已置灰(由业务逻辑控制),不触发此确认框。
12. **【H4 脏数据检测】**[编辑]弹窗打开时 deep clone 当前区域数据作为快照基准isDirty 检测;取消/关闭/遮罩层点击时若 isDirty 弹出"修改未保存,确定关闭?"确认框type:'warning'[新增]弹窗同理。
13. **【H8 操作结果反馈】**:新增/编辑成功 `ElMessage.success`2s→ 关闭弹窗 → 刷新树形结构+右侧详情;删除成功 `ElMessage.success("删除成功")`2s→ 刷新树;失败 `ElMessage.error`(手动关闭)。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 区域树 | el-tree | :data="areaTree", node-key="id", :props="{label:'name',children:'children'}", highlight-current, :expand-on-click-node="false", @node-click="handleNodeClick" |
| 新增区域按钮 | el-button | type="primary", icon="Plus" |
| 编辑按钮 | el-button | type="primary", link |
| 删除按钮 | el-button | type="danger", link, :disabled="hasPlanBind" |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle", width="520px", :close-on-click-modal="false" |
| 区域名称 | el-input | maxlength=30, show-word-limit, placeholder="请输入区域名称" |
| 上级区域 | el-cascader | :props="{checkStrictly:true, emitPath:false, value:'id', label:'name'}", placeholder="请选择上级区域", clearable, filterable |
| 蓝牙Beacon | el-select | filterable, placeholder="请选择蓝牙Beacon", clearable |
| 关联设备 | el-select | v-model="form.deviceIds", multiple, collapse-tags, placeholder="请选择关联设备" |
| 详情展示 | el-descriptions | :column="2", border |
| Beacon状态标签 | el-tag | 在线=success / 离线=danger |
| 删除确认 | el-message-box | type="warning", confirmButtonText="确认", cancelButtonText="取消" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 区域名称 | 必填maxlength=30 | "请输入区域名称" / "区域名称不能超过30个字符" |
| 上级区域 | 必填 | "请选择上级区域" |
| 蓝牙Beacon | 必填 | "请选择蓝牙Beacon" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 左侧树形面板固定宽度280px右侧详情区自适应弹窗宽度520px |
| 1024-1279pxPad横屏 | 左侧树形面板宽度缩减至220px右侧详情区自适应弹窗宽度480px |
| 768-1023pxPad竖屏 | 树形面板折叠为顶部下拉选择器详情区全宽展示弹窗宽度90vw |
--- ---
## 页面6数据补录与补录审核页 ## 页面6数据补录与补录审核页
@ -357,6 +619,58 @@
| 补录列表 | /api/v1/inspection-records/supplements | GET | 分页查询 | | 补录列表 | /api/v1/inspection-records/supplements | GET | 分页查询 |
| 审核 | /api/v1/inspection-records/supplements/{id}/approve | POST | — | | 审核 | /api/v1/inspection-records/supplements/{id}/approve | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用补录列表API GET /api/v1/inspection-records/supplements → 渲染补录数据表格默认按补录时间倒序排列每页20条
2. **查询/筛选交互流程**:选择审核状态/输入补录人/选择补录原因/选择日期范围 → 点击[查询] → 携带筛选参数重新请求第1页 → 点击[重置]清空条件并重新加载
3. **表单填写与提交流程**:本页无新增表单,补录数据由小程序端提交
4. **弹窗/抽屉交互流程**:点击[审核](审核状态=待审核时显示)→ 弹出审核弹窗,选择"通过"或"驳回",驳回时必填驳回原因 → 确认后调用审核API → 成功后关闭弹窗并刷新列表;点击[查看] → 弹出补录详情弹窗,展示补录原因、详细说明、巡检记录信息等
5. **行内操作流程**:点击[审核] → 弹窗选择通过/驳回 → 提交 → 刷新该行审核状态和审核人/审核时间;点击[查看] → 弹窗展示完整补录信息
6. **异常与错误处理**:审核已审核过的记录时提示"该记录已审核"API请求失败显示ElMessage.error列表无数据时显示ElEmpty"暂无补录记录"
7. **联动/级联交互**:审核状态筛选影响列表展示;补录原因枚举来源于系统配置(蓝牙故障/系统异常/定位失败/其他)
8. **权限控制交互表现**:无 inspection:supplement:approve 权限时[审核]按钮隐藏;无 inspection:supplement:view 权限时[查看]按钮隐藏
9. **【H1 防重复请求】**:查询按钮点击后立即 disabled + loading态API返回后恢复行内[审核]按钮点击后该行禁用 + loading态防止重复审核分页切换 abort 上一请求再发新请求。
10. **【H2 统一超时配置】**GET 补录列表查询超时15秒POST 审核操作超时30秒超时自动中断 + "请求超时,请稍后重试" + 按钮恢复可用;>3秒显示全局 ElLoading。
11. **【H3 操作确认机制】**:审核操作前弹出 `ElMessageBox.confirm("确定要「{通过/驳回}」该条补录记录吗?驳回后将通知补录人重新执行巡检", {type: 通过?'info':'warning'})`,含操作后果说明;驳回原因必填校验在确认前执行。
12. **【H5 数据权限隔离】**:区分"暂无补录记录"(ElEmpty)与"无权限访问"(403)403时前端拦截器统一提示"您没有权限查看补录数据",不展示任何记录信息。
13. **【H8 操作结果反馈】**:审核成功 `ElMessage.success("审核成功")`2s→ 关闭弹窗 → silent 刷新该行审核状态/审核人/审核时间;审核失败 `ElMessage.error`(手动关闭);网络异常提示含"重试"按钮可重新触发上次操作。
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 审核状态筛选 | el-select | clearable, placeholder="全部状态" |
| 补录人输入 | el-input | clearable, maxlength=20, placeholder="请输入补录人" |
| 补录原因筛选 | el-select | clearable, placeholder="全部原因" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 审核状态标签 | el-tag | 待审核=warning / 已通过=success / 已驳回=danger |
| 审核按钮 | el-button | type="primary", link, v-if="row.auditStatus==='pending'" |
| 查看按钮 | el-button | type="primary", link |
| 审核弹窗 | el-dialog | title="审核补录", width="500px", :close-on-click-modal="false" |
| 审核结果 | el-radio-group | v-model="auditForm.result" |
| 驳回原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, v-if="auditForm.result==='reject'" |
| 详情弹窗 | el-dialog | title="补录详情", width="600px" |
| 详情展示 | el-descriptions | :column="2", border |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审核结果 | 必选 | "请选择审核结果" |
| 驳回原因 | 驳回时必填maxlength=200 | "请填写驳回原因" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部列弹窗宽度500px/600px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"补录时间"列弹窗宽度460px/560px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示补录人、补录原因、审核状态、操作列弹窗宽度90vw |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码cleaning > 模块编码cleaning
> 端侧Web + 小程序(双端) > 端侧Web + 小程序(双端)
> 关联文档01-模块划分 §3.3 / 02-功能清单-物业公司 §3 / 03-业务流转逻辑-物业公司 §3 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4 > 关联文档01-模块划分 §3.3 / 02-功能清单-物业公司 §3 / 03-业务流转逻辑-物业公司 §3 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -80,6 +81,72 @@
| 编辑 | /api/v1/cleaning-areas/{id} | PUT | — | | 编辑 | /api/v1/cleaning-areas/{id} | PUT | — |
| 删除 | /api/v1/cleaning-areas/{id} | DELETE | — | | 删除 | /api/v1/cleaning-areas/{id} | DELETE | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用区域树接口 GET /api/v1/cleaning-areas/tree → 渲染左侧五级树形结构 → 默认选中第一个项目节点 → 右侧展示该节点详情信息
2. **查询/筛选交互流程**:点击树形节点 → 右侧实时刷新展示选中区域的详情区域路径、责任人、蓝牙Beacon、当前排班
3. **表单填写与提交流程**:点击[新增区域] → 弹窗表单 → 填写区域名称、选择上级区域级联、选择责任人、选择蓝牙Beacon、选择保洁清单 → 点击确认 → 提交 POST /api/v1/cleaning-areas → 成功后刷新树形结构并选中新节点
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度520px上级区域级联选择器懒加载展开提交后自动关闭弹窗
5. **行内操作流程**:点击[编辑] → 弹窗回填当前区域数据 → 修改后提交 PUT /api/v1/cleaning-areas/{id} → 刷新右侧详情;点击[删除] → 二次确认弹窗("删除后不可恢复,确认删除?" → 确认后调用 DELETE → 刷新树形
6. **异常与错误处理**:区域名称重复提示"该区域名称已存在"存在排班关联时删除按钮置灰并Tooltip提示"该区域存在排班关联,不可删除";网络异常显示全局错误提示
7. **联动/级联交互**:上级区域级联选择器联动下级选项;选择项目后区域选项刷新,选择区域后楼栋选项刷新,依次类推
8. **权限控制交互表现**:无 cleaning:area:create 权限时[新增区域]按钮隐藏;无 cleaning:area:update 权限时[编辑]按钮隐藏;无 cleaning:area:delete 权限时[删除]按钮隐藏
9. **【H1 防重复请求】**
- 区域树加载:进入页面时调用 GET /api/v1/cleaning-areas/treeloading 状态禁用树形节点点击;切换节点时右侧详情区展示 skeleton 加载占位
- 新增/提交:弹窗确认按钮 `:loading` + `:disabled` 防重复提交
- 行内操作:[编辑][删除]按钮操作中 disabled+loading 图标,防止连续点击
10. **【H2 统一超时】**
- GET区域树15s 超时POST新增/PUT编辑/DELETE删除30s 超时
- 超时后中断请求、提示"请求超时,请检查网络后重试"、按钮状态恢复
- 响应时间 >3s 时自动显示全局 ElLoading 全屏遮罩提示"数据加载中..."
11. **【H3 操作确认(不可逆操作)】**
- 删除操作必须调用 confirm("确定要删除区域「{区域名称}」?删除后将不可恢复且关联排班将被影响", { type: "error", confirmButtonText: "确认删除", cancelButtonText: "取消" })
- 无排班关联时可执行删除,关联存在时按钮置灰并 Tooltip 提示
12. **【H4 脏数据检测(弹窗编辑)】**
- 编辑模式进入时对当前区域数据进行 deep clone 快照JSON.parse(JSON.stringify(row))
- 弹窗内通过 watch/deepWatch 监测 isDirty 状态变化
- 关闭弹窗时若 isDirty 为 true拦截并弹出 confirm("修改未保存,确定要关闭吗?")
13. **【H8 操作结果反馈】**
- 新增成功ElMessage.success("区域创建成功", duration=2000)2秒后 silent 刷新树形结构并选中新节点
- 编辑成功ElMessage.success("区域更新成功")silent 刷新右侧详情
- 删除成功ElMessage.success("删除成功"),刷新树形结构
- 失败ElMessage.error(接口返回错误信息, duration=0)
- 网络异常ElMessage.error("网络连接异常,请检查网络") + 显示重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 区域树 | el-tree | :data="areaTree" node-key="id" :props="{label:'name',children:'children'}" highlight-current :expand-on-click-node="false" @node-click="handleNodeClick" |
| 新增区域按钮 | el-button | type="primary" @click="showAddDialog" v-hasPermission="['cleaning:area:create']" |
| 编辑按钮 | el-button | type="primary" link @click="showEditDialog" v-hasPermission="['cleaning:area:update']" |
| 删除按钮 | el-button | type="danger" link @click="handleDelete" :disabled="hasScheduleBind" v-hasPermission="['cleaning:area:delete']" |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle" v-model="dialogVisible" width="520px" :close-on-click-modal="false" |
| 区域名称 | el-input | v-model="form.name" maxlength="30" show-word-limit placeholder="请输入区域名称" |
| 上级区域 | el-cascader | v-model="form.parentId" :options="areaOptions" :props="{checkStrictly:true,emitPath:false,value:'id',label:'name'}" placeholder="请选择上级区域" clearable |
| 区域责任人 | el-select | v-model="form.responsibleId" filterable placeholder="请选择责任人" |
| 蓝牙Beacon | el-select | v-model="form.beaconId" filterable placeholder="请选择蓝牙Beacon" |
| 保洁清单 | el-select | v-model="form.checklistIds" multiple collapse-tags collapse-tags-tooltip placeholder="请选择保洁清单" |
| 删除确认 | el-message-box | confirmButtonText="确认" cancelButtonText="取消" type="warning" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 区域名称 | 必填长度1-30字符 | 请输入区域名称 / 区域名称不能超过30个字符 |
| 上级区域 | 必填 | 请选择上级区域 |
| 区域责任人 | 必填 | 请选择区域责任人 |
| 蓝牙Beacon | 必填 | 请选择蓝牙Beacon |
| 保洁清单 | 必填至少选择1项 | 请至少选择一个保洁清单 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 左侧树形面板固定宽度280px右侧详情区自适应剩余宽度弹窗宽度520px |
| 1024-1279pxPad横屏 | 左侧树形面板宽度缩减至220px右侧详情区自适应弹窗宽度480px |
| 768-1023pxPad竖屏 | 树形面板折叠为顶部下拉选择器详情区全宽展示弹窗宽度90% |
--- ---
## 页面2保洁任务看板页 ## 页面2保洁任务看板页
@ -130,6 +197,62 @@
|----------|---------|------|------| |----------|---------|------|------|
| 任务列表 | /api/v1/cleaning-tasks | GET | 分页查询+看板数据 | | 任务列表 | /api/v1/cleaning-tasks | GET | 分页查询+看板数据 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询条件为"今天+全部班组+全部状态" → 调用 GET /api/v1/cleaning-tasks → 渲染看板三列(待执行/执行中/已完成)及任务卡片
2. **查询/筛选交互流程**:修改日期/班组/人员/状态筛选条件 → 点击[查询] → 重新请求接口刷新看板数据;点击[重置] → 恢复默认条件并刷新
3. **表单填写与提交流程**:本页面无表单提交操作,仅查询展示
4. **弹窗/抽屉交互流程**:点击任务卡片[查看详情] → 跳转至任务详情页(新页面路由)
5. **行内操作流程**:点击任务卡片 → 展开卡片详情浮层(区域、人员、时间、清单);点击[查看详情]按钮 → 路由跳转至详情页
6. **异常与错误处理**:接口超时时显示"数据加载失败,请重试"无数据时看板列显示空状态el-empty超时任务卡片边框红色高亮
7. **联动/级联交互**:筛选条件中班组与人员联动,选择班组后人员下拉仅显示该班组人员
8. **权限控制交互表现**:无 cleaning:task:view 权限时[查看详情]按钮隐藏,卡片仅展示基本信息
9. **【H1 防重复请求】**
- 查询按钮:点击后 `:loading` + `:disabled` 防止重复点击
- 看板加载:数据请求期间看板三列展示 skeleton 占位卡片
- 分页/翻页:切换日期或筛选条件时 abort 上一个未完成请求再发起新请求
10. **【H2 统一超时】**
- GET 任务列表 15s 超时
- 超时后中断请求、提示"数据加载超时"、查询按钮恢复可用
- 响应时间 >3s 时显示全局 ElLoading 提示"任务数据加载中..."
11. **【H8 操作结果反馈】**
- 数据加载成功:正常渲染看板,无需额外提示
- 加载失败ElMessage.error("任务数据加载失败", duration=0)
- 空数据:各列展示 el-empty "暂无任务数据"
- 超时卡片边框红色高亮,配合 ElMessage.warning("存在超时任务,请及时处理")
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 看板容器 | 自定义看板组件 | 三列布局每列header显示状态名+数量,列内任务卡片纵向排列 |
| 任务卡片 | el-card | shadow="hover" :body-style="{padding:'12px'}" @click="viewDetail" |
| 状态标签 | el-tag | :type="statusType" 待执行=info 执行中=warning 已完成=success 超时=danger |
| 日期筛选 | el-date-picker | v-model="query.date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" |
| 班组筛选 | el-select | v-model="query.teamId" clearable placeholder="全部班组" |
| 人员筛选 | el-input | v-model="query.staffName" clearable placeholder="输入人员姓名" |
| 状态筛选 | el-select | v-model="query.status" clearable placeholder="全部状态" |
| 查询按钮 | el-button | type="primary" @click="handleQuery" |
| 重置按钮 | el-button | @click="handleReset" |
| 查看详情按钮 | el-button | type="primary" link @click="viewDetail" v-hasPermission="['cleaning:task:view']" |
| 空状态 | el-empty | description="暂无任务数据" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期 | 非必填格式YYYY-MM-DD | 日期格式不正确 |
| 人员 | 非必填最大20字符 | — |
| 状态 | 非必填枚举值pending/ongoing/completed/timeout | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 看板三列横向排列,每列宽度均分,查询条件区水平排列 |
| 1024-1279pxPad横屏 | 看板三列横向排列,卡片内容字号略减,查询条件区水平排列 |
| 768-1023pxPad竖屏 | 看板改为纵向堆叠每列占满宽度查询条件区换行排列每行2个条件 |
--- ---
## 页面3人员排班页 ## 页面3人员排班页
@ -182,6 +305,81 @@
| 复制排班 | /api/v1/cleaning-schedules/copy | POST | 从指定周复制 | | 复制排班 | /api/v1/cleaning-schedules/copy | POST | 从指定周复制 |
| 导出 | /api/v1/cleaning-schedules/export | GET | 导出Excel | | 导出 | /api/v1/cleaning-schedules/export | GET | 导出Excel |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认显示本周周视图 → 调用排班查询API GET /api/v1/cleaning-schedules → 渲染周视图排班表格(区域×日期矩阵),同时加载班组下拉选项
2. **查询/筛选交互流程**:切换周视图/月视图 → 重新请求对应维度数据;点击左右箭头切换周/月 → 重新加载;选择班组 → 过滤显示该班组的排班数据
3. **表单填写与提交流程**:点击排班单元格 → 弹出编辑弹窗 → 选择保洁人员、班次、时间范围 → 确认后暂存到前端;点击[保存排班] → 批量提交所有变更 → 成功后刷新排班视图;点击[复制上周排班] → 调用复制API → 成功后刷新本周视图
4. **弹窗/抽屉交互流程**排班编辑弹窗宽度420px点击单元格打开点击[清空本周] → 二次确认"确认清空本周所有排班数据?" → 确认后调用清空API
5. **行内操作流程**:点击排班单元格 → 弹出编辑弹窗 → 修改后暂存;已有排班数据的单元格显示人员和班次信息;空白单元格可点击新增
6. **异常与错误处理**:同一人员同一时段排班冲突时提示"该人员此时段已有排班"保存失败显示ElMessage.error复制上周无数据时提示"上周无排班数据"
7. **联动/级联交互**班次选择后自动填充时间范围早班06:00-14:00/晚班14:00-22:00/全天08:00-17:00周视图/月视图切换联动数据刷新
8. **权限控制交互表现**:无 cleaning:schedule:create 权限时[复制上周排班]按钮隐藏;无 cleaning:schedule:update 权限时排班单元格不可点击编辑;无 cleaning:schedule:delete 权限时[清空本周]按钮隐藏
9. **[H1]防重复请求**
- [保存排班]按钮点击后::loading=true + 文案"保存中..." + disabled + 排班表格区域半透明遮罩API返回后恢复
- [复制上周排班]按钮点击后立即 disabled + loading态API返回后恢复
- [清空本周]按钮点击后 disabled + loading态确认弹窗通过后执行
- 单元格编辑弹窗保存后:弹窗内保存按钮 :loading + 弹窗关闭期间父页面不可操作
- 周视图/月视图切换、左右箭头切换abort上一请求再发新请求
10. **[H2]超时与加载反馈**
- 排班数据查询GET列表/周/月视图timeout=15秒
- 保存排班/复制上周/清空本周POSTtimeout=30秒
- 超时 → ElMessage.error("请求超时,请检查网络后重试") + 按钮恢复
- 加载>2秒显示全局ElLoading
11. **[H3]操作确认机制**
- 清空本周ElMessageBox.confirm("确认清空本周所有排班数据?清开后数据无法恢复", "清空确认", { type: 'warning', confirmButtonText: '确认清空', cancelButtonText: '取消' })
- 复制上周ElMessageBox.confirm("确定复制上周排班数据到本周?将覆盖本周已有排班", { type: 'info' })
12. **[H4]脏数据检测**
- 用户编辑任一单元格后排班表标记 isDirty=true
- 切换周/月视图时:若 isDirty → ElMessageBox.confirm("当前有未保存的排班变更,切换视图将丢失未保存内容,是否继续?")
- 离开页面时beforeRouteLeave 导航守卫拦截isDirty 则弹出确认提示
- 保存成功后将当前状态设为新快照,重置 isDirty=false
13. **[H8]操作结果反馈**
- 保存成功ElMessage.success("排班保存成功", duration=2000) + silent刷新排班视图
- 复制成功ElMessage.success("已复制上周排班到本周") + 刷新
- 清空成功ElMessage.success("已清空本周排班") + 刷新
- 冲突提示ElMessage.warning("该人员此时段已有排班") + 标红冲突单元格
- 失败ElMessage.error(错误信息, duration=0)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 视图切换 | el-radio-group + el-radio-button | v-model="viewMode", size="default" |
| 周导航 | 自定义组件 | 左右箭头+周显示,@prev/@next |
| 班组筛选 | el-select | clearable, filterable, placeholder="全部班组" |
| 周视图表格 | 自定义排班表格 | 行=区域,列=周一~周日,单元格可点击 |
| 排班编辑弹窗 | el-dialog | title="编辑排班", width="420px", :close-on-click-modal="false" |
| 保洁人员 | el-select | v-model="form.staffId", filterable, placeholder="请选择保洁人员" |
| 班次 | el-select | v-model="form.shift", placeholder="请选择班次", @change="fillTimeRange" |
| 时间范围 | el-time-picker | is-range, format="HH:mm", range-separator="至" |
| 保存排班按钮 | el-button | type="primary", :loading="saving" |
| 清空本周按钮 | el-button | type="danger" |
| 复制上周按钮 | el-button | type="success" |
| 导出按钮 | el-button | type="success", icon="Download" |
| 二次确认 | el-message-box | type="warning", confirmButtonText="确认", cancelButtonText="取消" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 保洁人员 | 必选 | "请选择保洁人员" |
| 班次 | 必选 | "请选择班次" |
| 时间范围 | 必选,结束时间>开始时间 | "请选择时间范围" / "结束时间必须晚于开始时间" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 周视图完整7列展示排班编辑弹窗420px |
| 1024-1279pxPad横屏 | 周视图列宽缩窄单元格字号略减弹窗宽度380px |
| 768-1023pxPad竖屏 | 周视图改为3天一组展示今天+后2天左右滑动切换日期范围弹窗宽度90vw |
--- ---
## 页面4蓝牙点位管理页 ## 页面4蓝牙点位管理页
@ -232,6 +430,64 @@
| 绑定 | /api/v1/cleaning-beacon-points/{id}/bind | PUT | 绑定区域 | | 绑定 | /api/v1/cleaning-beacon-points/{id}/bind | PUT | 绑定区域 |
| 解绑 | /api/v1/cleaning-beacon-points/{id}/unbind | PUT | — | | 解绑 | /api/v1/cleaning-beacon-points/{id}/unbind | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用列表查询API GET /api/v1/cleaning-beacon-points → 渲染Beacon列表表格同时加载区域级联数据默认按Beacon名称排列
2. **查询/筛选交互流程**:选择区域/选择Beacon状态/选择电量状态 → 筛选条件变更后自动触发查询(无需手动点击查询按钮);区域级联选择逐级展开
3. **表单填写与提交流程**:点击[编辑绑定] → 弹窗选择要绑定的区域 → 确认后调用绑定API → 成功后刷新列表;点击[解绑] → 二次确认"确认解绑该Beacon与区域的绑定关系" → 确认后调用解绑API
4. **弹窗/抽屉交互流程**编辑绑定弹窗宽度480px展示当前Beacon信息+区域级联选择器;点击遮罩层可关闭
5. **行内操作流程**:点击[编辑绑定] → 弹窗选择新区域 → 提交 → 刷新行数据;点击[解绑](已绑定时显示)→ 二次确认 → 解绑 → 刷新行数据
6. **异常与错误处理**解绑未绑定的Beacon时提示"该Beacon未绑定区域"API请求失败显示ElMessage.errorBeacon离线状态实时刷新每30秒轮询心跳数据
7. **联动/级联交互**区域筛选级联选择——选择项目→加载区域→选择区域→加载楼栋Beacon状态根据心跳数据实时更新电量<20%显示红色低电量标签
8. **权限控制交互表现**:无 cleaning:area:update 权限时[编辑绑定][解绑]按钮隐藏
9. **[H1]防重复请求**
- 行内操作点击后该行禁用 + loading态
- 筛选条件变更自动触发查询时 abort 上一个未完成请求再发新请求
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H3]操作确认机制**(有不可逆操作时)
- 解绑: ElMessageBox.confirm("确认解绑该Beacon与区域的绑定关系", { type: 'warning' })
12. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
13. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 区域筛选 | el-cascader | :props="{checkStrictly:true, emitPath:false}", clearable, filterable, placeholder="选择区域" |
| Beacon状态筛选 | el-select | clearable, placeholder="全部状态" |
| 电量筛选 | el-select | clearable, placeholder="全部" |
| 列表 | el-table | stripe, border, :data="tableData" |
| Beacon状态标签 | el-tag | 在线=success / 离线=danger |
| 电量显示 | el-progress | :percentage="row.battery", :color="batteryColor", :stroke-width="8", :text-inside="true" |
| 绑定区域 | el-link | type="primary", @click="viewArea" |
| 编辑绑定按钮 | el-button | type="primary", link |
| 解绑按钮 | el-button | type="danger", link, v-if="row.bindAreaId" |
| 编辑绑定弹窗 | el-dialog | title="编辑Beacon绑定", width="480px", :close-on-click-modal="true" |
| 区域选择 | el-cascader | :props="{checkStrictly:true, emitPath:false}", filterable, placeholder="请选择绑定区域" |
| 解绑确认 | el-message-box | type="warning", confirmButtonText="确认解绑", cancelButtonText="取消" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 绑定区域 | 编辑绑定时必选 | "请选择绑定区域" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 筛选条件区水平排列表格完整展示全部7列 |
| 1024-1279pxPad横屏 | 筛选条件区换行排列,表格隐藏"UUID"列 |
| 768-1023pxPad竖屏 | 筛选条件区垂直堆叠表格仅显示Beacon名称、绑定区域、状态、操作列 |
--- ---
## 页面5超时预警页 ## 页面5超时预警页
@ -263,6 +519,55 @@
|----------|---------|------|------| |----------|---------|------|------|
| 超时列表 | /api/v1/cleaning-tasks/timeouts | GET | 仅显示超时任务 | | 超时列表 | /api/v1/cleaning-tasks/timeouts | GET | 仅显示超时任务 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用超时列表API GET /api/v1/cleaning-tasks/timeouts → 渲染超时任务表格,默认按超时时长降序排列
2. **查询/筛选交互流程**:本页无独立筛选条件,仅展示超时任务列表;数据由后端自动筛选超时任务
3. **表单填写与提交流程**:点击[催办] → 调用催办API → 发送小程序推送通知对应保洁人员 → 成功后刷新该行状态;点击[查看] → 跳转任务详情页
4. **弹窗/抽屉交互流程**:催办操作弹出确认弹窗"将向保洁人员发送催办通知,确认?" → 确认后发送催办
5. **行内操作流程**:点击[催办] → 二次确认 → 发送催办通知 → 刷新行数据;点击[查看] → 跳转任务详情页
6. **异常与错误处理**:催办失败提示"催办通知发送失败,请重试"列表无数据时显示ElEmpty"暂无超时任务,太棒了!"
7. **联动/级联交互**:超时时长列红色标记,时长越长颜色越深;催办后更新催办状态
8. **权限控制交互表现**:本页仅物业管理员和主管可见;主管仅看本班组超时数据
9. **[H1]防重复请求**
- 催办按钮点击后 disabled + loading态API返回后恢复
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST催办 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H3]操作确认机制**(有不可逆操作时)
- 催办: ElMessageBox.confirm("将向保洁人员发送催办通知,确认?", { type: 'warning' })
12. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, :data="timeoutList", :row-class-name="timeoutRowClass" |
| 超时时长 | el-tag | type="danger", 超时越长字体越大 |
| 当前状态标签 | el-tag | 超时未开始=danger / 超时未完成=danger, effect="dark" |
| 催办按钮 | el-button | type="warning", link, icon="Bell" |
| 查看按钮 | el-button | type="primary", link |
| 催办确认 | el-message-box | type="warning", confirmButtonText="确认催办", cancelButtonText="取消" |
| 空状态 | el-empty | description="暂无超时任务,太棒了!" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| — | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表格完整展示全部6列 |
| 1024-1279pxPad横屏 | 表格隐藏"计划完成时间"列 |
| 768-1023pxPad竖屏 | 表格仅显示任务区域、保洁人员、超时时长、操作列 |
--- ---
## 页面6保洁抽查页 ## 页面6保洁抽查页
@ -305,6 +610,66 @@
| 抽查列表 | /api/v1/cleaning-spot-checks | GET | — | | 抽查列表 | /api/v1/cleaning-spot-checks | GET | — |
| 标记抽查 | /api/v1/cleaning-spot-checks | POST | — | | 标记抽查 | /api/v1/cleaning-spot-checks | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用抽查列表API GET /api/v1/cleaning-spot-checks → 渲染抽查记录表格,默认按抽查时间倒序排列
2. **查询/筛选交互流程**:选择抽查结果/输入抽查人/选择日期范围 → 点击[查询] → 重新加载数据 → 点击[重置]清空条件并重新加载
3. **表单填写与提交流程**:点击[标记抽查] → 弹窗选择合格/不合格,不合格时必填原因 → 确认后调用标记抽查API → 成功后刷新列表
4. **弹窗/抽屉交互流程**标记抽查弹窗宽度480px选择结果后不合格显示原因输入框点击遮罩层不关闭
5. **行内操作流程**:不合格记录点击[重新生成] → 二次确认"将为该区域重新生成保洁任务,确认?" → 确认后调用重新生成API → 刷新行数据
6. **异常与错误处理**:重复标记提示"该任务已抽查过"重新生成失败提示ElMessage.error列表无数据时显示ElEmpty
7. **联动/级联交互**:选择"不合格"时自动显示原因输入框;不合格记录自动触发重新生成按钮
8. **权限控制交互表现**:无 cleaning:spot-check:approve 权限时[标记抽查][重新生成]按钮隐藏
9. **[H1]防重复请求**
- 查询按钮点击后 disabled + loading态API返回后恢复
- 行内操作点击后该行禁用 + loading态
- 分页切换 abort上一请求再发新请求
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H3]操作确认机制**(有不可逆操作时)
- 重新生成: ElMessageBox.confirm("将为该区域重新生成保洁任务,确认?", { type: 'warning' })
12. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
13. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 抽查结果筛选 | el-select | clearable, placeholder="全部结果" |
| 抽查人输入 | el-input | clearable, maxlength=20, placeholder="请输入抽查人" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 抽查结果标签 | el-tag | 合格=success / 不合格=danger |
| 标记抽查按钮 | el-button | type="primary", icon="Check" |
| 重新生成按钮 | el-button | type="warning", link, v-if="row.result==='unqualified'" |
| 标记抽查弹窗 | el-dialog | title="标记抽查", width="480px", :close-on-click-modal="false" |
| 抽查结果 | el-radio-group | v-model="checkForm.result" |
| 不合格原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, v-if="checkForm.result==='unqualified'" |
| 二次确认 | el-message-box | type="warning" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 抽查结果 | 必选 | "请选择抽查结果" |
| 不合格原因 | 不合格时必填maxlength=200 | "请填写不合格原因" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部7列 |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"不合格原因"列 |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠,表格仅显示任务区域、保洁人员、抽查结果、操作列 |
--- ---
## 页面7数据补录与补录审核页 ## 页面7数据补录与补录审核页
@ -328,6 +693,68 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 审核 | cleaning:supplement:approve | 行操作 | 审核状态=待审核 | 通过/驳回 | | 审核 | cleaning:supplement:approve | 行操作 | 审核状态=待审核 | 通过/驳回 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用补录列表API → 渲染补录数据表格默认按补录时间倒序排列每页20条同时加载审核状态下拉选项
2. **查询/筛选交互流程**:选择审核状态/输入补录人/选择日期范围 → 点击[查询] → 重新加载数据 → 点击[重置]清空条件
3. **表单填写与提交流程**:本页无新增表单,补录数据由小程序端提交
4. **弹窗/抽屉交互流程**:点击[审核](审核状态=待审核时显示)→ 弹出审核弹窗,选择"通过"或"驳回",驳回时必填驳回原因 → 确认后调用审核API → 成功后关闭弹窗并刷新列表
5. **行内操作流程**:点击[审核] → 弹窗选择通过/驳回 → 提交 → 刷新该行审核状态和审核人/审核时间
6. **异常与错误处理**:审核已审核过的记录时提示"该记录已审核"API请求失败显示ElMessage.error列表无数据时显示ElEmpty
7. **联动/级联交互**审核状态筛选影响列表展示补录数据标记规范中is_supplement=true的数据在此页面展示
8. **权限控制交互表现**:无 cleaning:supplement:approve 权限时[审核]按钮隐藏
9. **【H1 防重复请求】**
- 查询按钮 `:loading` + `:disabled` 防重复点击
- 分页切换时 abort 上一个未完成请求再发新请求
- [审核]按钮及弹窗确认按钮操作中 disabled+loading
10. **【H2 统一超时】**
- GET补录列表15s 超时PUT审核30s 超时
- 超时后中断请求、提示"请求超时"
- 响应时间 >3s 时全局 ElLoading
11. **【H3 操作确认(不可逆操作)**
- 审核通过confirm("确认通过该补录数据?通过后数据将纳入正常统计", { type: "info", confirmButtonText: "确认通过", cancelButtonText: "取消" })
- 审核驳回confirm("确认驳回该补录数据?驳回后需补录人重新提交", { type: "warning", confirmButtonText: "确认驳回", cancelButtonText: "取消",含后果说明 })
12. **【H4 脏数据检测(审核弹窗)】**
- 审核弹窗打开时初始化空审核表单快照
- 选择通过/驳回或填写驳回原因后检测 isDirty
- 关闭弹窗若 isDirty 为 true 拦截并 confirm("审核结果未提交,确定要关闭?")
13. **【H8 操作结果反馈】**
- 审核成功ElMessage.success(审核结果 === 'pass' ? "审核通过" : "已驳回")silent 刷新列表行状态
- 重复审核ElMessage.warning("该记录已审核")
- 操作失败ElMessage.error(错误信息, duration=0)
- 网络异常ElMessage.error("网络连接异常") + 重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 审核状态筛选 | el-select | clearable, placeholder="全部状态" |
| 补录人输入 | el-input | clearable, maxlength=20, placeholder="请输入补录人" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 审核状态标签 | el-tag | 待审核=warning / 已通过=success / 已驳回=danger |
| 审核按钮 | el-button | type="primary", link, v-if="row.auditStatus==='pending'" |
| 审核弹窗 | el-dialog | title="审核补录", width="500px", :close-on-click-modal="false" |
| 审核结果 | el-radio-group | v-model="auditForm.result" |
| 驳回原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, v-if="auditForm.result==='reject'" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审核结果 | 必选 | "请选择审核结果" |
| 驳回原因 | 驳回时必填maxlength=200 | "请填写驳回原因" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示弹窗宽度500px |
| 1024-1279pxPad横屏 | 查询条件区换行排列表格隐藏次要列弹窗宽度460px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示关键列弹窗宽度90vw |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码org > 模块编码org
> 端侧Web + 小程序(双端) > 端侧Web + 小程序(双端)
> 关联文档01-模块划分 §3.4 / 02-功能清单-物业公司 §4 / 03-业务流转逻辑-物业公司 §4 / 05-接口规范 §9.2 > 关联文档01-模块划分 §3.4 / 02-功能清单-物业公司 §4 / 03-业务流转逻辑-物业公司 §4 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -68,6 +69,70 @@
| 新增 | /api/v1/teams | POST | — | | 新增 | /api/v1/teams | POST | — |
| 编辑 | /api/v1/teams/{id} | PUT | — | | 编辑 | /api/v1/teams/{id} | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 并行调用列表查询API GET /api/v1/teams 和下拉选项数据(班组类型字典)→ 渲染表格和筛选条件,默认加载全部班组数据
2. **查询/筛选交互流程**:输入班组名称/选择班组类型/选择状态 → 点击[查询] → 携带筛选参数重新请求 → 点击[重置]清空条件并重新加载;班组名称支持回车触发查询
3. **表单填写与提交流程**:点击[新增班组] → 弹窗表单 → 填写班组名称、班组类型、班组长、负责区域、描述 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度520px选择班组长时从人员列表中选择支持搜索负责区域使用级联多选点击遮罩层不关闭
5. **行内操作流程**:点击[编辑] → 弹窗回填当前班组数据 → 修改后提交 → 刷新列表;点击[停用] → 二次确认"停用后该班组下人员将无法接收新任务,确认停用?" → 有未完成任务时按钮置灰并提示"该班组存在未完成任务,不可停用"
6. **异常与错误处理**:班组名称重复提示"该班组名称已存在";停用有未完成任务的班组时提示"该班组存在未完成任务,不可停用"API请求失败显示ElMessage.error
7. **联动/级联交互**:选择班组类型后班组长下拉可按类型过滤;负责区域级联选择逐级展开
8. **权限控制交互表现**:无 org:team:create 权限时[新增班组]按钮隐藏;无 org:team:update 权限时[编辑][停用]按钮隐藏
9. **[H1]防重复请求**
- 查询按钮点击后 disabled + loading态API返回后恢复
- 行内操作点击后该行禁用 + loading态
- 分页切换 abort上一请求再发新请求
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H3]操作确认机制**(有不可逆操作时)
- 删除/停用: ElMessageBox.confirm/Web端或wx.showModal/小程序端
12. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
13. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 班组名称输入 | el-input | placeholder="请输入班组名称", clearable, maxlength=30 |
| 班组类型筛选 | el-select | clearable, placeholder="全部类型" |
| 状态筛选 | el-select | clearable, placeholder="全部状态" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 新增班组按钮 | el-button | type="primary", icon="Plus" |
| 编辑按钮 | el-button | type="primary", link |
| 停用按钮 | el-button | type="warning", link, :disabled="hasPendingTasks" |
| 状态标签 | el-tag | 启用=success / 停用=info |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle", width="520px", :close-on-click-modal="false" |
| 班组名称 | el-input | maxlength=30, show-word-limit, placeholder="请输入班组名称" |
| 班组类型 | el-select | placeholder="请选择班组类型", filterable |
| 班组长 | el-select | placeholder="请选择班组长", filterable |
| 负责区域 | el-cascader | :props="{multiple:true, checkStrictly:true, emitPath:false}", filterable, clearable |
| 描述 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit |
| 二次确认 | el-message-box | type="warning" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 班组名称 | 必填maxlength=30同租户唯一 | "请输入班组名称" / "该班组名称已存在" |
| 班组类型 | 必选 | "请选择班组类型" |
| 班组长 | 必选 | "请选择班组长" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部列弹窗宽度520px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"负责区域"列弹窗宽度480px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示班组名称、类型、班组长、状态、操作列弹窗宽度90vw |
--- ---
## 页面2人员管理页 ## 页面2人员管理页
@ -123,6 +188,70 @@
| 新增 | /api/v1/staffs | POST | — | | 新增 | /api/v1/staffs | POST | — |
| 编辑 | /api/v1/staffs/{id} | PUT | — | | 编辑 | /api/v1/staffs/{id} | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 并行调用人员列表API和下拉选项班组列表、技能标签列表→ 渲染表格和筛选条件
2. **查询/筛选交互流程**:输入姓名/选择所属班组/选择技能标签/选择状态 → 点击[查询] → 重新加载数据 → 点击[重置]清空条件
3. **表单填写与提交流程**:点击[新增人员] → 弹窗表单 → 填写姓名、手机号、所属班组(多选)、技能标签、入职日期 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度520px所属班组支持多选一人多班组使用collapse-tags折叠展示点击遮罩层不关闭
5. **行内操作流程**:点击[编辑] → 弹窗回填当前人员数据 → 修改后提交 → 刷新列表;点击[分配] → 弹窗分配到班组/区域;点击[查看排班] → 跳转排班管理页(带该人员筛选条件)
6. **异常与错误处理**:手机号已存在提示"该手机号已被注册"API请求失败显示ElMessage.error技能标签无数据时显示"暂无技能标签"
7. **联动/级联交互**所属班组多选后技能标签可根据班组关联过滤手机号脱敏显示138****1234
8. **权限控制交互表现**:无 org:staff:create 权限时[新增人员]按钮隐藏;无 org:staff:update 权限时[编辑][分配]按钮隐藏
9. **[H1]防重复请求**
- 查询按钮点击后 disabled + loading态API返回后恢复
- 行内操作点击后该行禁用 + loading态
- 分页切换 abort上一请求再发新请求
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
12. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 姓名输入 | el-input | clearable, maxlength=20, placeholder="请输入姓名" |
| 所属班组筛选 | el-select | clearable, filterable, placeholder="全部班组" |
| 技能标签筛选 | el-select | clearable, multiple, collapse-tags, placeholder="全部标签" |
| 状态筛选 | el-select | clearable, placeholder="全部状态" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 新增人员按钮 | el-button | type="primary", icon="Plus" |
| 编辑按钮 | el-button | type="primary", link |
| 分配按钮 | el-button | type="success", link |
| 查看排班按钮 | el-button | type="primary", link |
| 技能标签列 | el-tag | v-for, type="primary", size="small", style="margin:2px" |
| 状态标签 | el-tag | 在职=success / 离职=info |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle", width="520px", :close-on-click-modal="false" |
| 姓名 | el-input | maxlength=20, placeholder="请输入姓名" |
| 手机号 | el-input | maxlength=11, placeholder="请输入手机号" |
| 所属班组 | el-select | v-model="form.teamIds", multiple, collapse-tags, filterable, placeholder="请选择所属班组" |
| 技能标签 | el-select | v-model="form.skillIds", multiple, collapse-tags, placeholder="请选择技能标签" |
| 入职日期 | el-date-picker | type="date", value-format="YYYY-MM-DD" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 姓名 | 必填maxlength=20 | "请输入姓名" |
| 手机号 | 必填,正则/^1[3-9]\d{9}$/ | "请输入正确的手机号码" |
| 所属班组 | 必选至少1个 | "请选择所属班组" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部7列弹窗宽度520px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"技能标签"列弹窗宽度480px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示姓名、班组、状态、操作列弹窗宽度90vw |
--- ---
## 页面3排班管理页 ## 页面3排班管理页
@ -164,6 +293,66 @@
| 排班查询 | /api/v1/team-schedules | GET | — | | 排班查询 | /api/v1/team-schedules | GET | — |
| 保存 | /api/v1/team-schedules/batch | POST | — | | 保存 | /api/v1/team-schedules/batch | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认显示本周周视图 → 调用排班查询API GET /api/v1/team-schedules → 渲染排班表格(人员×日期矩阵),同时加载班组下拉选项
2. **查询/筛选交互流程**:切换周视图/月视图 → 重新请求对应数据;点击左右箭头切换周 → 重新加载;选择班组 → 过滤显示该班组人员排班
3. **表单填写与提交流程**:点击排班单元格 → 弹出编辑弹窗选择班次 → 暂存前端;点击[保存] → 批量提交所有变更 → 成功后刷新排班视图;点击[批量排班] → 选择多人+统一班次 → 批量填充 → 保存;点击[模板排班] → 从已有模板加载
4. **弹窗/抽屉交互流程**排班编辑弹窗宽度400px选择班次批量排班弹窗宽度500px选择人员和班次模板排班弹窗宽度480px选择模板
5. **行内操作流程**:点击排班单元格 → 弹出班次选择 → 确认后暂存;点击已有班次可修改或清除
6. **异常与错误处理**:同一人员同一时段排班冲突时提示"排班冲突"保存失败显示ElMessage.error模板无数据时提示"暂无排班模板"
7. **联动/级联交互**:批量排班选择班次后自动填充时间范围;班组筛选联动人员列表刷新
8. **权限控制交互表现**:无 org:team:create 权限时[批量排班][模板排班]按钮隐藏;无 org:team:update 权限时排班单元格不可编辑;无 org:team:export 权限时[导出]按钮隐藏
9. **[H1]防重复请求**
- 查询/视图切换点击后 disabled + loading态API返回后恢复
- 保存操作点击后 disabled + loading态
- 分页切换 abort上一请求再发新请求
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 视图切换 | el-radio-group + el-radio-button | v-model="viewMode" |
| 周导航 | 自定义组件 | 左右箭头+周显示 |
| 班组筛选 | el-select | clearable, filterable, placeholder="全部班组" |
| 排班表格 | 自定义排班表格 | 行=人员,列=周一~周日,单元格可点击 |
| 排班编辑弹窗 | el-dialog | title="编辑排班", width="400px", :close-on-click-modal="false" |
| 班次选择 | el-select | placeholder="请选择班次" |
| 批量排班弹窗 | el-dialog | title="批量排班", width="500px", :close-on-click-modal="false" |
| 人员多选 | el-select | v-model="batchForm.staffIds", multiple, collapse-tags, filterable |
| 批量班次 | el-select | v-model="batchForm.shift" |
| 模板排班弹窗 | el-dialog | title="选择排班模板", width="480px" |
| 模板列表 | el-select | placeholder="请选择模板" |
| 保存按钮 | el-button | type="primary", :loading="saving" |
| 批量排班按钮 | el-button | type="success" |
| 模板排班按钮 | el-button | type="warning" |
| 导出按钮 | el-button | type="success", icon="Download" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 班次 | 必选 | "请选择班次" |
| 批量排班-人员 | 至少选1人 | "请选择人员" |
| 批量排班-班次 | 必选 | "请选择班次" |
| 模板排班-模板 | 必选 | "请选择排班模板" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 排班表格完整7列展示操作按钮水平排列 |
| 1024-1279pxPad横屏 | 排班表格列宽缩窄,单元格字号略减 |
| 768-1023pxPad竖屏 | 排班表格改为3天一组展示左右滑动切换操作按钮折叠为下拉菜单 |
--- ---
## 页面4技能管理页 ## 页面4技能管理页
@ -189,6 +378,59 @@
| 关联班组 | 下拉多选 | 否 | — | 班组列表 | — | | 关联班组 | 下拉多选 | 否 | — | 班组列表 | — |
| 描述 | 多行文本 | 否 | — | 自填 | 最大200字 | | 描述 | 多行文本 | 否 | — | 自填 | 最大200字 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用技能列表API → 渲染技能表格,默认按持有人员数降序排列
2. **查询/筛选交互流程**:本页无独立筛选条件,所有技能数据一次加载
3. **表单填写与提交流程**:点击[新增技能](如有操作栏按钮)→ 弹窗表单 → 填写技能名称、关联班组、描述 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度480px点击遮罩层不关闭
5. **行内操作流程**:点击[编辑] → 弹窗回填技能数据 → 修改后提交 → 刷新列表;点击[删除] → 二次确认"删除后持有该技能的人员将失去此标签,确认删除?" → 确认后调用删除API → 刷新列表
6. **异常与错误处理**:技能名称重复提示"该技能名称已存在";删除被引用的技能时提示"该技能正在使用中,不可删除"API请求失败显示ElMessage.error
7. **联动/级联交互**:关联班组多选后自动关联到该班组人员;持有人员数为实时统计值
8. **权限控制交互表现**:无新增/编辑/删除权限时对应按钮隐藏
9. **[H1]防重复请求**
- 行内操作点击后该行禁用 + loading态
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
11. **[H3]操作确认机制**(有不可逆操作时)
- 删除: ElMessageBox.confirm
12. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
13. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, :data="skillList" |
| 新增按钮 | el-button | type="primary", icon="Plus" |
| 编辑按钮 | el-button | type="primary", link |
| 删除按钮 | el-button | type="danger", link |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle", width="480px", :close-on-click-modal="false" |
| 技能名称 | el-input | maxlength=30, show-word-limit, placeholder="请输入技能名称" |
| 关联班组 | el-select | v-model="form.teamIds", multiple, collapse-tags, filterable, placeholder="请选择关联班组" |
| 描述 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit |
| 删除确认 | el-message-box | type="warning" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 技能名称 | 必填maxlength=30同租户唯一 | "请输入技能名称" / "该技能名称已存在" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表格完整展示全部4列弹窗宽度480px |
| 1024-1279pxPad横屏 | 表格隐藏"关联班组"列弹窗宽度440px |
| 768-1023pxPad竖屏 | 表格仅显示技能名称、持有人员数、操作列弹窗宽度90vw |
--- ---
## 页面5打卡点分配页 ## 页面5打卡点分配页
@ -218,6 +460,65 @@
| 蓝牙Beacon | 下拉单选 | 是 | — | 蓝牙设备管理 | — | | 蓝牙Beacon | 下拉单选 | 是 | — | 蓝牙设备管理 | — |
| 适用角色 | 下拉多选 | 是 | 全部 | 固定选项 | — | | 适用角色 | 下拉多选 | 是 | 全部 | 固定选项 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用打卡点列表API → 渲染打卡点表格同时加载班组下拉、蓝牙Beacon下拉选项
2. **查询/筛选交互流程**:本页无独立筛选条件,所有打卡点数据一次加载展示;可按班组筛选
3. **表单填写与提交流程**:点击[新增打卡点] → 弹窗表单 → 填写打卡点名称、适用班组、位置描述、蓝牙Beacon、适用角色 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表
4. **弹窗/抽屉交互流程**:新增/编辑弹窗宽度480px选择蓝牙Beacon后展示设备在线状态点击遮罩层不关闭
5. **行内操作流程**:点击[编辑] → 弹窗回填数据 → 修改后提交 → 刷新列表;点击[删除] → 二次确认 → 调用删除API → 刷新列表
6. **异常与错误处理**:打卡点名称重复提示"该打卡点名称已存在";删除被引用的打卡点提示"该打卡点正在使用中,不可删除"API请求失败显示ElMessage.error
7. **联动/级联交互**选择适用班组后蓝牙Beacon下拉可按班组区域过滤适用角色多选支持全选/反选
8. **权限控制交互表现**:无新增/编辑/删除权限时对应按钮隐藏
9. **[H1]防重复请求**
- 行内操作点击后该行禁用 + loading态
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
11. **[H3]操作确认机制**(有不可逆操作时)
- 删除: ElMessageBox.confirm
12. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
13. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, :data="pointList" |
| 新增打卡点按钮 | el-button | type="primary", icon="Plus" |
| 编辑按钮 | el-button | type="primary", link |
| 删除按钮 | el-button | type="danger", link |
| 新增/编辑弹窗 | el-dialog | :title="dialogTitle", width="480px", :close-on-click-modal="false" |
| 打卡点名称 | el-input | maxlength=30, show-word-limit, placeholder="请输入打卡点名称" |
| 适用班组 | el-select | filterable, placeholder="请选择适用班组" |
| 位置描述 | el-input | placeholder="请输入位置描述" |
| 蓝牙Beacon | el-select | filterable, placeholder="请选择蓝牙Beacon" |
| 适用角色 | el-select | v-model="form.roles", multiple, collapse-tags, placeholder="请选择适用角色" |
| 删除确认 | el-message-box | type="warning" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 打卡点名称 | 必填maxlength=30 | "请输入打卡点名称" |
| 适用班组 | 必选 | "请选择适用班组" |
| 位置描述 | 必填 | "请输入位置描述" |
| 蓝牙Beacon | 必选 | "请选择蓝牙Beacon" |
| 适用角色 | 必选至少1项 | "请选择适用角色" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表格完整展示全部6列弹窗宽度480px |
| 1024-1279pxPad横屏 | 表格隐藏"位置"列弹窗宽度440px |
| 768-1023pxPad竖屏 | 表格仅显示打卡点名称、班组、操作列弹窗宽度90vw |
--- ---
## 页面6下属账号管理页 ## 页面6下属账号管理页
@ -309,6 +610,84 @@
| 权限覆盖 | /api/v1/subordinates/{id}/override-permissions | PUT | 四级树形权限 | | 权限覆盖 | /api/v1/subordinates/{id}/override-permissions | PUT | 四级树形权限 |
| 批量操作 | /api/v1/subordinates/batch | POST | 批量启停/分配角色 | | 批量操作 | /api/v1/subordinates/batch | POST | 批量启停/分配角色 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 并行调用下属列表API、班组列表、角色列表 → 渲染表格和筛选条件默认加载第一页数据每页20条
2. **查询/筛选交互流程**:输入姓名/选择班组/选择角色/选择状态 → 点击[查询] → 重新加载数据 → 点击[重置]清空条件
3. **表单填写与提交流程**:点击[新增下属] → 弹窗填写姓名、手机号、所属班组、分配角色、是否自定义权限、数据权限范围 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表;开启"自定义权限"后展示四级权限树形勾选
4. **弹窗/抽屉交互流程**新增弹窗宽度600px编辑弹窗宽度520px分配角色弹窗宽度480px权限覆盖弹窗宽度700px展示四级权限树形结构点击遮罩层不关闭
5. **行内操作流程**:点击[编辑] → 弹窗回填数据 → 修改后提交 → 刷新列表;点击[分配角色] → 选择角色 → 提交 → 角色实时生效;点击[权限覆盖] → 四级权限树形勾选 → 提交 → 权限实时生效;点击[启停] → 二次确认 → 提交 → 刷新行状态
6. **异常与错误处理**:手机号已注册提示"该手机号已被注册"角色变更后毫秒级生效Redis Pub/Sub账号禁用后session失效+小程序下线API请求失败显示ElMessage.error
7. **联动/级联交互**:选择所属班组后角色下拉可按班组类型过滤;开启"自定义权限"后动态展示四级权限树;批量分配角色时勾选人员后统一选择角色
8. **权限控制交互表现**:无 org:staff:create 权限时[新增下属]按钮隐藏;无 permission:user:update 权限时[分配角色][权限覆盖]按钮隐藏;无 org:staff:update 权限时[编辑][启停]按钮隐藏;批量操作按钮需勾选后才可点击
9. **[H1]防重复请求**
- 查询按钮点击后 disabled + loading态API返回后恢复
- 行内操作点击后该行禁用 + loading态
- 分页切换 abort上一请求再发新请求
10. **[H2]超时与加载反馈**
- GET列表查询 timeout=15秒POST/PUT/DELETE写操作 timeout=30秒
- 超时 → 提示"请求超时,请检查网络后重试" + 按钮恢复
- 加载>2秒显示全局loading
11. **[H3]操作确认机制**(有不可逆操作时)
- 启停: ElMessageBox.confirm
12. **[H4]脏数据检测**
- 编辑模式进入时deep clone快照
- isDirty检测 + 取消/离开拦截
13. **[H8]操作结果反馈**
- 成功: success(2s) + silent刷新
- 失败: error(0手动关闭)
- 网络: 异常提示+重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 姓名输入 | el-input | clearable, maxlength=20, placeholder="请输入姓名" |
| 班组筛选 | el-select | clearable, filterable, placeholder="全部班组" |
| 角色筛选 | el-select | clearable, placeholder="全部角色" |
| 状态筛选 | el-select | clearable, placeholder="全部状态" |
| 列表 | el-table | stripe, border, :data="tableData", @selection-change |
| 勾选列 | el-table-column | type="selection", width="40" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 新增下属按钮 | el-button | type="primary", icon="Plus" |
| 批量分配角色按钮 | el-button | type="success", :disabled="!selectedRows.length" |
| 批量启停按钮 | el-button | type="warning", :disabled="!selectedRows.length" |
| 编辑按钮 | el-button | type="primary", link |
| 分配角色按钮 | el-button | type="success", link |
| 权限覆盖按钮 | el-button | type="warning", link |
| 启停按钮 | el-button | type="warning", link |
| 状态标签 | el-tag | 启用=success / 禁用=danger |
| 新增弹窗 | el-dialog | title="新增下属", width="600px", :close-on-click-modal="false" |
| 姓名 | el-input | maxlength=20, placeholder="请输入姓名" |
| 手机号 | el-input | maxlength=11, placeholder="请输入手机号" |
| 所属班组 | el-select | v-model="form.teamIds", multiple, collapse-tags, filterable |
| 分配角色 | el-select | placeholder="请选择角色" |
| 自定义权限开关 | el-switch | v-model="form.customPermission" |
| 数据权限范围 | el-select | placeholder="请选择数据权限范围" |
| 四级权限树 | el-tree | :data="permissionTree", show-checkbox, node-key="id", :props="{label:'name',children:'children'}" |
| 分配角色弹窗 | el-dialog | title="分配角色", width="480px", :close-on-click-modal="false" |
| 权限覆盖弹窗 | el-dialog | title="权限覆盖", width="700px", :close-on-click-modal="false" |
| 二次确认 | el-message-box | type="warning" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 姓名 | 必填maxlength=20 | "请输入姓名" |
| 手机号 | 必填,正则/^1[3-9]\d{9}$/ | "请输入正确的手机号码" |
| 所属班组 | 必选至少1个 | "请选择所属班组" |
| 分配角色 | 必选 | "请选择分配角色" |
| 数据权限范围 | 必选 | "请选择数据权限范围" |
| 批量操作-勾选 | 至少勾选1条 | "请至少选择一条记录" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部8列新增弹窗600px权限弹窗700px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"数据权限""手机号"列弹窗宽度缩减10% |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠,表格仅显示姓名、班组、角色、状态、操作列;批量操作收入"更多"下拉菜单弹窗宽度90vw |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码attendance > 模块编码attendance
> 端侧Web + 小程序(双端) > 端侧Web + 小程序(双端)
> 关联文档01-模块划分 §3.5 / 02-功能清单-物业公司 §5 / 03-业务流转逻辑-物业公司 §5 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4 > 关联文档01-模块划分 §3.5 / 02-功能清单-物业公司 §5 / 03-业务流转逻辑-物业公司 §5 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -67,6 +68,58 @@
| 新增 | /api/v1/attendance-points | POST | — | | 新增 | /api/v1/attendance-points | POST | — |
| 编辑 | /api/v1/attendance-points/{id} | PUT | — | | 编辑 | /api/v1/attendance-points/{id} | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 请求打卡点列表API → 渲染表格数据 → 默认无选中状态
2. **查询/筛选交互流程**:填写筛选条件(打卡点名称/所属班组/状态)→ 点击"查询" → 调用列表API并刷新表格 → 点击"重置"清空条件并重新加载
3. **表单填写与提交流程**:点击"新增打卡点" → 弹出新增弹窗 → 填写表单字段 → 点击确定 → 前端校验通过 → 调用新增API → 成功后关闭弹窗并刷新列表
4. **弹窗/抽屉交互流程**:新增/编辑弹窗居中展示宽度480px点击遮罩层或右上角关闭按钮可关闭未保存变更时提示确认
5. **行内操作流程**:点击"编辑" → 弹出编辑弹窗(预填数据)→ 修改后提交 → 刷新当前行;点击"删除" → 弹出二次确认弹窗"确定删除该打卡点?" → 确认后调用删除API → 刷新列表
6. **异常与错误处理**API请求失败显示ElMessage.error提示删除有关联人员的打卡点时提示"该打卡点下存在关联人员,无法删除"
7. **联动/级联交互**选择所属班组后蓝牙Beacon下拉选项可按班组过滤Beacon状态根据设备管理模块实时数据展示在线/离线
8. **权限控制交互表现**:无`attendance:point:create`权限时隐藏"新增打卡点"按钮;无`attendance:point:update`权限时隐藏行内"编辑"按钮;无`attendance:point:delete`权限时隐藏行内"删除"按钮
9. **H1 防重复请求(强制)**"查询""新增打卡点"按钮点击后立即disabled并显示loading态行内"编辑""删除"操作时整行添加半透明遮罩禁用重复点击;翻页/切换筛选条件时自动abort前一个未完成请求再发新请求列表查询与下拉选项加载等并行请求互不阻塞
10. **H2 超时配置(强制)**列表查询GET请求设置15s超时新增/编辑POST请求设置30s超时任何请求耗时>3s时在页面顶部展示全局ElLoading("数据加载中...")直到响应返回
11. **H3 操作确认(强制)**"删除打卡点"操作弹出ElMessageBox.confirm二次确认type="error",提示文案"确定删除该打卡点?删除后将不可恢复"
12. **H6 批量限制(建议)**当前页面暂无批量操作若后续扩展批量删除功能单次批量删除上限50条记录超出时提示"单次最多选择50条记录进行操作"
13. **H8 反馈规范(建议)**:新增/编辑成功后ElMessage.success显示2秒后自动消失同时silent方式刷新列表不显示loading遮罩失败提示ElMessage.error需用户手动关闭网络异常时显示"网络连接异常,请检查网络后重试"并提供重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 打卡点名称输入框 | ElInput | maxlength=30, showWordLimit=true, placeholder="请输入打卡点名称" |
| 所属班组下拉 | ElSelect | filterable=true, clearable=true, placeholder="请选择所属班组" |
| 位置描述输入框 | ElInput | maxlength=100, showWordLimit=true, type="textarea", :rows=2 |
| 蓝牙Beacon下拉 | ElSelect | filterable=true, clearable=true, placeholder="请选择蓝牙Beacon" |
| 适用角色多选 | ElSelect | multiple=true, collapseTags=true, placeholder="请选择适用角色" |
| 状态下拉 | ElSelect | clearable=true, :options=[{label:"在线",value:"online"},{label:"离线",value:"offline"}] |
| 查询按钮 | ElButton | type="primary", icon="Search" |
| 重置按钮 | ElButton | icon="Refresh" |
| 新增打卡点按钮 | ElButton | type="primary", icon="Plus" |
| 列表表格 | ElTable | stripe=true, border=true, :data=tableData |
| 分页 | ElPagination | layout="total, sizes, prev, pager, next", :page-sizes=[10,20,50] |
| 新增/编辑弹窗 | ElDialog | width="480px", :close-on-click-modal=false, :destroy-on-close=true |
| 删除确认弹窗 | ElMessageBox | type="warning", confirmButtonText="确定", cancelButtonText="取消" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 打卡点名称 | required, maxLength=30 | 请输入打卡点名称打卡点名称不能超过30个字符 |
| 所属班组 | required | 请选择所属班组 |
| 位置描述 | required, maxLength=100 | 请输入位置描述位置描述不能超过100个字符 |
| 蓝牙Beacon | required | 请选择蓝牙Beacon设备 |
| 适用角色 | required | 请选择适用角色 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示所有列弹窗宽度480px |
| 1024-1279pxPad横屏 | 查询条件区换行排列(名称独占一行,班组+状态一行表格隐藏位置描述列弹窗宽度420px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示打卡点名称、所属班组、操作列弹窗宽度90vw |
--- ---
## 页面2打卡规则页 ## 页面2打卡规则页
@ -125,6 +178,62 @@
| 规则查询 | /api/v1/attendance-rules | GET | 按班组查询 | | 规则查询 | /api/v1/attendance-rules | GET | 按班组查询 |
| 保存 | /api/v1/attendance-rules | POST | — | | 保存 | /api/v1/attendance-rules | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 请求规则列表API按班组分组→ 渲染规则卡片列表 → 默认展示全部班组卡片
2. **查询/筛选交互流程**:本页无独立筛选条件,数据按班组以卡片形式展示,所有班组规则一次加载
3. **表单填写与提交流程**:点击卡片"编辑"按钮 → 弹出编辑弹窗(预填当前规则数据)→ 修改时间与阈值 → 点击确定 → 前端校验通过 → 调用保存API → 成功后关闭弹窗并刷新对应卡片数据
4. **弹窗/抽屉交互流程**编辑弹窗居中展示宽度520px弹窗内时间选择器联动显示打卡窗口点击遮罩层或关闭按钮可退出未保存时提示确认
5. **行内操作流程**:无行内操作,仅在卡片上提供"编辑"按钮
6. **异常与错误处理**:严重迟到阈值≤迟到阈值时提示"严重迟到阈值必须大于迟到阈值";严重早退阈值≤早退阈值时提示"严重早退阈值必须大于早退阈值";打卡窗口起止时间逻辑冲突时提示错误
7. **联动/级联交互**修改上班时间时自动调整上班打卡窗口默认值上班前30分~上班后30分修改下班时间同理自动调整下班打卡窗口
8. **权限控制交互表现**:无`attendance:rule:update`权限时隐藏卡片"编辑"按钮,卡片仅展示只读信息
9. **H1 防重复请求(强制)**:卡片"编辑"按钮点击后立即disabled并显示loading态编辑弹窗提交按钮点击后disabled防重复提交
10. **H2 超时配置(强制)**规则查询GET请求设置15s超时保存POST请求设置30s超时请求耗时>3s时展示全局ElLoading
11. **H4 脏数据检测(强制)**打开编辑弹窗时对当前规则数据进行deepClone快照弹窗内监听表单变更计算isDirty状态点击弹窗关闭按钮或遮罩层且isDirty为true时弹出ElMessageBox.confirm提示"修改尚未保存,确定要关闭吗?"路由离开前通过beforeRouteLeave守卫拦截未保存的变更
12. **H8 反馈规范(建议)**保存成功后ElMessage.success("规则保存成功")显示2秒后自动消失并silent刷新对应卡片数据失败提示需手动关闭网络异常提供重试
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 上班时间选择 | ElTimePicker | format="HH:mm", placeholder="请选择上班时间" |
| 上班打卡窗口(起) | ElTimePicker | format="HH:mm", placeholder="上班打卡开始时间" |
| 上班打卡窗口(止) | ElTimePicker | format="HH:mm", placeholder="上班打卡结束时间" |
| 下班时间选择 | ElTimePicker | format="HH:mm", placeholder="请选择下班时间" |
| 下班打卡窗口(起) | ElTimePicker | format="HH:mm", placeholder="下班打卡开始时间" |
| 下班打卡窗口(止) | ElTimePicker | format="HH:mm", placeholder="下班打卡结束时间" |
| 迟到阈值 | ElInputNumber | :min=0, :max=180, :step=5, controls-position="right" |
| 严重迟到阈值 | ElInputNumber | :min=1, :max=180, :step=5, controls-position="right" |
| 早退阈值 | ElInputNumber | :min=0, :max=180, :step=5, controls-position="right" |
| 严重早退阈值 | ElInputNumber | :min=1, :max=180, :step=5, controls-position="right" |
| 规则卡片 | ElCard | shadow="hover", :body-style="{ padding: '20px' }" |
| 编辑按钮 | ElButton | type="primary", link=true, icon="Edit" |
| 编辑弹窗 | ElDialog | width="520px", :close-on-click-modal=false, :destroy-on-close=true |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 上班时间 | required | 请选择上班时间 |
| 上班打卡窗口(起) | required, 必须早于上班时间 | 请选择上班打卡开始时间;上班打卡开始时间必须早于上班时间 |
| 上班打卡窗口(止) | required, 必须晚于上班时间 | 请选择上班打卡结束时间;上班打卡结束时间必须晚于上班时间 |
| 下班时间 | required | 请选择下班时间 |
| 下班打卡窗口(起) | required, 必须早于下班时间 | 请选择下班打卡开始时间;下班打卡开始时间必须早于下班时间 |
| 下班打卡窗口(止) | required, 必须晚于下班时间 | 请选择下班打卡结束时间;下班打卡结束时间必须晚于下班时间 |
| 迟到阈值(分钟) | required, ≥0 | 请输入迟到阈值;迟到阈值不能为负数 |
| 严重迟到阈值(分钟) | required, >迟到阈值 | 请输入严重迟到阈值;严重迟到阈值必须大于迟到阈值 |
| 早退阈值(分钟) | required, ≥0 | 请输入早退阈值;早退阈值不能为负数 |
| 严重早退阈值(分钟) | required, >早退阈值 | 请输入严重早退阈值;严重早退阈值必须大于早退阈值 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 规则卡片双列排列卡片内信息水平展示弹窗宽度520px |
| 1024-1279pxPad横屏 | 规则卡片单列排列卡片内信息水平展示弹窗宽度480px |
| 768-1023pxPad竖屏 | 规则卡片单列排列卡片内信息垂直堆叠标签与值分行显示弹窗宽度90vw |
--- ---
## 页面3考勤记录页 ## 页面3考勤记录页
@ -169,6 +278,53 @@
|----------|---------|------|------| |----------|---------|------|------|
| 记录查询 | /api/v1/attendance-records | GET | 分页查询 | | 记录查询 | /api/v1/attendance-records | GET | 分页查询 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认日期为今天 → 请求考勤记录API → 渲染表格数据
2. **查询/筛选交互流程**:选择日期/班组/人员/打卡状态 → 点击"查询" → 调用API刷新表格 → 点击"重置"恢复默认条件(今天/全部/全部/全部)并重新加载
3. **表单填写与提交流程**:本页无表单提交操作
4. **弹窗/抽屉交互流程**:点击"查看详情" → 弹出详情弹窗展示完整打卡信息打卡时间、方式、状态、打卡点、蓝牙设备信息等宽度500px
5. **行内操作流程**:点击"查看详情" → 调用详情API → 弹窗展示完整考勤记录详情
6. **异常与错误处理**API请求失败显示ElMessage.error提示列表数据为空时展示ElEmpty空状态补录标记列显示ElTag标签"补录"
7. **联动/级联交互**:选择班组后人员筛选可按班组过滤;上班/下班状态字段根据状态值显示不同颜色Tag正常-绿/迟到-橙/严重迟到-红/早退-橙/严重早退-红)
8. **权限控制交互表现**:无`attendance:record:view`权限时隐藏"查看详情"按钮;主管仅看到本班组数据,员工仅看本人数据(后端数据权限控制)
9. **H1 防重复请求(强制-轻量)**"查询"按钮点击后disabled+loading态行内"查看详情"按钮点击后整行半透明遮罩防重复分页切换时abort前一次未完成请求本页以查看为主采用轻量级防重复策略
10. **H2 超时配置(强制)**记录查询GET请求设置15s超时请求耗时>3s时在表格区域展示ElLoading局部加载动画
11. **H8 反馈规范(建议)**详情弹窗加载完成后正常展示无需额外成功反馈API请求失败时ElMessage.error提示需手动关闭网络异常时显示重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 日期选择 | ElDatePicker | type="date", value-format="YYYY-MM-DD", placeholder="选择日期" |
| 班组下拉 | ElSelect | filterable=true, clearable=true, placeholder="请选择班组" |
| 人员输入框 | ElInput | placeholder="请输入人员姓名", clearable=true |
| 打卡状态下拉 | ElSelect | clearable=true, :options=[正常,迟到,早退,缺卡,补录] |
| 查询按钮 | ElButton | type="primary", icon="Search" |
| 重置按钮 | ElButton | icon="Refresh" |
| 列表表格 | ElTable | stripe=true, border=true, :data=tableData, :default-sort={prop:"上班打卡时间",order:"ascending"} |
| 上班状态列 | ElTag | :type=状态映射(正常→success/迟到→warning/严重迟到→danger) |
| 下班状态列 | ElTag | :type=状态映射(正常→success/早退→warning/严重早退→danger) |
| 补录标记列 | ElTag | type="info", v-if="is_supplement" |
| 分页 | ElPagination | layout="total, sizes, prev, pager, next", :page-sizes=[10,20,50] |
| 详情弹窗 | ElDialog | width="500px", :close-on-click-modal=true, :destroy-on-close=true |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期 | 非必填,格式校验 | 日期格式不正确 |
| 人员 | 最大20字 | 人员姓名不能超过20个字符 |
| 打卡状态 | 枚举值校验 | 请选择有效的打卡状态 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示所有10列分页右对齐 |
| 1024-1279pxPad横屏 | 查询条件区换行排列表格隐藏上班打卡方式、下班打卡方式列保留8列 |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠,表格仅显示姓名、班组、上班状态、下班状态、操作列,分页居中 |
--- ---
## 页面4异常审核页 ## 页面4异常审核页
@ -226,6 +382,55 @@
| 申诉列表 | /api/v1/attendance-appeals | GET | — | | 申诉列表 | /api/v1/attendance-appeals | GET | — |
| 审核 | /api/v1/attendance-appeals/{id}/approve | POST | 通过后自动补录打卡 | | 审核 | /api/v1/attendance-appeals/{id}/approve | POST | 通过后自动补录打卡 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用申诉列表API GET /api/v1/attendance-appeals → 渲染异常审核表格默认按申诉日期倒序排列每页20条同时加载审核状态下拉选项
2. **查询/筛选交互流程**:选择审核状态/输入申诉人/选择日期范围 → 点击[查询] → 携带筛选参数重新请求第1页 → 点击[重置]清空条件并重新加载
3. **表单填写与提交流程**:本页无新增表单,申诉数据由小程序端提交
4. **弹窗/抽屉交互流程**:点击[审核](审核状态=待审核时显示)→ 弹出审核弹窗,选择"通过"或"驳回",驳回时必填驳回原因 → 确认后调用审核API → 审核通过后系统自动补录打卡记录 → 成功后关闭弹窗并刷新列表
5. **行内操作流程**:点击[审核] → 弹窗选择通过/驳回 → 提交 → 刷新该行审核状态;点击[查看](如有)→ 弹窗展示申诉详情
6. **异常与错误处理**:审核已审核过的记录提示"该申诉已审核";审核通过后自动补录打卡失败时提示"自动补录失败,请手动处理"API请求失败显示ElMessage.error列表无数据时显示ElEmpty
7. **联动/级联交互**:审核状态筛选影响列表展示;蓝牙双模式下,策略=REQUIRED时异常申诉必须审核策略=OPTIONAL时无需申诉手动打卡
8. **权限控制交互表现**:无 attendance:appeal:approve 权限时[审核]按钮隐藏;主管仅审核本班组人员的申诉
9. **H1 防重复请求(强制)**"查询"按钮点击后disabled+loading态行内[审核]按钮点击后整行禁用+半透明遮罩分页切换时abort前一次未完成请求
10. **H2 超时配置(强制)**申诉列表GET请求设置15s超时审核POST请求设置30s超时请求耗时>3s时展示全局ElLoading
11. **H3 操作确认(强制)**:审核操作(通过/驳回为不可逆操作提交前弹出ElMessageBox.confirm二次确认通过操作type="info"提示"确定通过该申诉?通过后将自动补录打卡记录"驳回操作type="warning"提示"确定驳回该申诉?"
12. **H6 批量限制(建议)**当前页面暂无批量操作若后续扩展批量审核功能单次批量审核上限100条记录超出时提示"单次最多选择100条进行批量审核"
13. **H8 反馈规范(建议)**审核成功后ElMessage.success显示2秒后自动消失silent刷新列表状态失败提示需手动关闭网络异常提供重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 审核状态筛选 | el-select | clearable, placeholder="全部状态" |
| 申诉人输入 | el-input | clearable, maxlength=20, placeholder="请输入申诉人" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 审核状态标签 | el-tag | 待审核=warning / 已通过=success / 已驳回=danger |
| 异常类型标签 | el-tag | type="info", size="small" |
| 审核按钮 | el-button | type="primary", link, v-if="row.auditStatus==='pending'" |
| 审核弹窗 | el-dialog | title="审核异常申诉", width="500px", :close-on-click-modal="false" |
| 审核结果 | el-radio-group | v-model="auditForm.result" |
| 驳回原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, v-if="auditForm.result==='reject'" |
| 蓝牙双模式差异展示 | el-descriptions | :column="2", border, title="蓝牙策略差异" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审核结果 | 必选 | "请选择审核结果" |
| 驳回原因 | 驳回时必填maxlength=200 | "请填写驳回原因" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部7列弹窗宽度500px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"申诉说明"列弹窗宽度460px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示申诉人、异常类型、审核状态、操作列弹窗宽度90vw |
--- ---
## 页面5数据补录页 ## 页面5数据补录页
@ -256,6 +461,54 @@
| 补录列表 | /api/v1/attendance-records/supplements | GET | — | | 补录列表 | /api/v1/attendance-records/supplements | GET | — |
| 审核 | /api/v1/attendance-records/supplements/{id}/approve | POST | — | | 审核 | /api/v1/attendance-records/supplements/{id}/approve | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用补录列表API GET /api/v1/attendance-records/supplements → 渲染补录数据表格默认按补录时间倒序排列每页20条
2. **查询/筛选交互流程**:选择审核状态/输入补录人/选择日期范围 → 点击[查询] → 重新加载数据 → 点击[重置]清空条件
3. **表单填写与提交流程**:本页无新增表单,补录数据来源:①小程序端蓝牙失败后手动补录 ②异常申诉审核通过后系统自动补录
4. **弹窗/抽屉交互流程**:点击[审核](审核状态=待审核时显示)→ 弹出审核弹窗,选择"通过"或"驳回",驳回时必填驳回原因 → 确认后调用审核API → 成功后关闭弹窗并刷新列表
5. **行内操作流程**:点击[审核] → 弹窗选择通过/驳回 → 提交 → 刷新该行审核状态;自动补录的记录(异常申诉审核通过)标记为"自动补录",无需二次审核
6. **异常与错误处理**:审核已审核过的记录提示"该记录已审核"API请求失败显示ElMessage.error列表无数据时显示ElEmpty
7. **联动/级联交互**:审核状态筛选影响列表展示;补录原因枚举来源于系统配置;自动补录记录与手动补录记录通过标签区分
8. **权限控制交互表现**:无 attendance:supplement:approve 权限时[审核]按钮隐藏
9. **H1 防重复请求(强制)**"查询"按钮点击后disabled+loading态行内[审核]按钮点击后整行禁用+半透明遮罩分页切换时abort前一次未完成请求
10. **H2 超时配置(强制)**补录列表GET请求设置15s超时审核POST请求设置30s超时请求耗时>3s时展示全局ElLoading
11. **H3 操作确认(强制)**:审核操作(通过/驳回为不可逆操作提交前弹出ElMessageBox.confirm二次确认通过type="info"提示"确定通过该补录记录?"驳回type="warning"提示"确定驳回该补录记录?"
12. **H6 批量限制(建议)**当前页面暂无批量操作若后续扩展批量审核功能单次批量审核上限100条记录
13. **H8 反馈规范(建议)**审核成功ElMessage.success 2秒自动消失+silent刷新列表失败手动关闭网络异常提供重试
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 审核状态筛选 | el-select | clearable, placeholder="全部状态" |
| 补录人输入 | el-input | clearable, maxlength=20, placeholder="请输入补录人" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 审核状态标签 | el-tag | 待审核=warning / 已通过=success / 已驳回=danger |
| 补录类型标签 | el-tag | 自动补录=primary / 手动补录=warning, size="small" |
| 审核按钮 | el-button | type="primary", link, v-if="row.auditStatus==='pending'" |
| 审核弹窗 | el-dialog | title="审核补录", width="500px", :close-on-click-modal="false" |
| 审核结果 | el-radio-group | v-model="auditForm.result" |
| 驳回原因 | el-input | type="textarea", :rows="3", maxlength=200, show-word-limit, v-if="auditForm.result==='reject'" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 审核结果 | 必选 | "请选择审核结果" |
| 驳回原因 | 驳回时必填maxlength=200 | "请填写驳回原因" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部列弹窗宽度500px |
| 1024-1279pxPad横屏 | 查询条件区换行排列表格隐藏次要列弹窗宽度460px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示关键列弹窗宽度90vw |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码evaluation > 模块编码evaluation
> 端侧Web + 小程序(双端) > 端侧Web + 小程序(双端)
> 关联文档01-模块划分 §3.6 / 02-功能清单-物业公司 §6 / 03-业务流转逻辑-物业公司 §6 / 05-接口规范 §9.2 > 关联文档01-模块划分 §3.6 / 02-功能清单-物业公司 §6 / 03-业务流转逻辑-物业公司 §6 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -61,6 +62,51 @@
|----------|---------|------|------| |----------|---------|------|------|
| 汇总数据 | /api/v1/evaluations/summary | GET | 含统计卡片+图表数据 | | 汇总数据 | /api/v1/evaluations/summary | GET | 含统计卡片+图表数据 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询"本月"时间范围 → 调用汇总数据API GET /api/v1/evaluations/summary → 依次渲染统计卡片区(平均评分/评价总数/待回复数/低评分数)→ 图表区(柱状图+折线图+饼图),加载中显示骨架屏
2. **查询/筛选交互流程**:点击快捷时间按钮(今日/本周/本月/自定义)→ 立即切换并重新请求汇总数据;选择自定义日期范围 → 确认后触发查询
3. **表单填写与提交流程**:本页面无表单提交;点击[导出] → 导出汇总数据Excel
4. **弹窗/抽屉交互流程**:点击低评分数卡片 → 弹出低评分评价列表弹窗宽度700px点击待回复数卡片 → 跳转评价列表页(自动筛选未回复)
5. **行内操作流程**:点击图表数据点 → 弹出该数据点的详细评价列表
6. **异常与错误处理**:汇总数据加载失败时各区域显示"加载失败,点击重试";无评价数据时统计卡片显示"—",图表区显示空状态插图;导出失败提示"导出失败,请重试"
7. **联动/级联交互**:时间范围变更后统计卡片、图表区全区域联动刷新;各模块评分柱状图点击可下钻到该模块的评价列表
8. **权限控制交互表现**:无 evaluation:summary:view 权限时页面显示403提示导出按钮需对应权限
9. **H1 防重复请求(强制)**快捷时间按钮切换时立即disabled并loading导出按钮点击后disabled+loading态直到下载完成或失败时间范围/筛选条件变更触发查询时abort前一次未完成请求
10. **H2 超时配置(强制)**汇总数据GET请求设置30s超时统计档导出操作GET请求设置60s超时上传导出档任何请求耗时>3s时展示全局ElLoading
11. **H8 反馈规范(建议)**数据加载完成后各区域正常渲染无需额外成功提示导出成功后ElMessage.success("导出成功")2秒自动消失加载失败区域显示"点击重试"按钮;网络异常提供重试
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding:'20px'}" |
| 平均评分 | 自定义评分展示 | 大字号数字 + el-rate(disabled) |
| 各模块平均评分图 | echarts (柱状图) | tooltip.trigger="axis", series.type="bar" |
| 评分趋势图 | echarts (折线图) | smooth=true, tooltip.trigger="axis" |
| 星级分布图 | echarts (饼图) | tooltip.trigger="item", radius=["40%","70%"] |
| 导出按钮 | el-button | type="success", icon="Download" |
| 低评卡片点击 | el-link | type="danger", @click="showLowScoreList" |
| 待回复卡片点击 | el-link | type="warning", @click="goUnrepliedList" |
| 低评分列表弹窗 | el-dialog | title="低评分评价列表", width="700px" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | "开始日期不能晚于结束日期" |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | "查询时间范围不能超过1年" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片4列横排图表区2列布局柱状图+折线图下方饼图居中弹窗宽度700px |
| 1024-1279pxPad横屏 | 统计卡片2列×2行排列图表区单列堆叠ECharts图表宽度100%高度最小350px弹窗宽度650px |
| 768-1023pxPad竖屏 | 统计卡片2列×2行排列图表区单列堆叠ECharts图表宽度100%高度最小300px饼图改为环形图节省空间弹窗宽度95vw |
--- ---
## 页面2评价列表页 ## 页面2评价列表页
@ -129,6 +175,60 @@
| 列表查询 | /api/v1/evaluations | GET | 分页查询 | | 列表查询 | /api/v1/evaluations | GET | 分页查询 |
| 回复 | /api/v1/evaluations/{id}/reply | POST | — | | 回复 | /api/v1/evaluations/{id}/reply | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用评价列表API GET /api/v1/evaluations → 渲染表格默认按评价时间倒序排列每页20条同时加载评价类型、评分等级等下拉选项
2. **查询/筛选交互流程**:选择评价类型/选择评分/选择回复状态/选择日期范围 → 点击[查询] → 重新加载数据 → 点击[重置]清空条件
3. **表单填写与提交流程**:点击[回复](未回复或低分评价时显示)→ 弹出回复弹窗 → 填写回复内容 → 前端校验通过 → 调用回复API → 成功后关闭弹窗并刷新该行回复状态
4. **弹窗/抽屉交互流程**:点击[查看详情] → 弹窗展示评价详情评分、评价内容、图片、回复记录宽度600px回复弹窗宽度500px展示评价信息+回复输入框
5. **行内操作流程**:点击[查看详情] → 弹窗展示完整评价信息;点击[回复] → 弹窗填写回复内容 → 提交 → 刷新该行回复状态为"已回复"低分评价≤2分行红色背景高亮
6. **异常与错误处理**:重复回复提示"该评价已回复"API请求失败显示ElMessage.error列表无数据时显示ElEmpty"暂无评价数据"
7. **联动/级联交互**评分列使用星级展示低分≤2分红色标记回复状态标签已回复=绿色/未回复=红色;低评分评价自动通知主管
8. **权限控制交互表现**:无 evaluation:list:view 权限时[查看详情]按钮隐藏;无 evaluation:list:update 权限时[回复]按钮隐藏;主管仅看本班组相关评价
9. **H1 防重复请求(强制)**"查询"按钮点击后disabled+loading态行内[查看详情][回复]按钮点击后整行禁用+半透明遮罩分页切换时abort前一次未完成请求
10. **H2 超时配置(强制)**列表查询GET请求15s超时回复POST请求30s超时>3s展示全局ElLoading
11. **H3 操作确认(强制)**"回复"操作为不可逆操作提交前弹出ElMessageBox.confirm二次确认type="info",提示"确定提交该回复?提交后将不可修改"
12. **H6 批量限制(建议)**当前页面暂无批量操作若后续扩展批量回复功能单次批量回复上限100条记录
13. **H8 反馈规范(建议)**回复成功ElMessage.success 2秒自动消失+silent刷新该行状态重复回复提示需手动关闭网络异常提供重试
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 评价类型筛选 | el-select | clearable, placeholder="全部类型" |
| 评分筛选 | el-select | clearable, placeholder="全部评分" |
| 回复状态筛选 | el-select | clearable, placeholder="全部状态" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData", :row-class-name="lowScoreRowClass" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 评分展示 | el-rate | disabled, :colors="['#99A9BF','#F7BA2A','#FF9900']" |
| 评价类型标签 | el-tag | 报修服务=primary / 巡检服务=success / 保洁服务=warning / 综合评价=info |
| 回复状态标签 | el-tag | 已回复=success / 未回复=danger |
| 低分标记 | el-tag | type="danger", size="small", v-if="score<=2" |
| 查看详情按钮 | el-button | type="primary", link |
| 回复按钮 | el-button | type="success", link, v-if="!row.replied \|\| row.score<=2" |
| 详情弹窗 | el-dialog | title="评价详情", width="600px" |
| 评价详情展示 | el-descriptions | :column="2", border |
| 评价图片 | el-image | :preview-src-list="photoList", fit="cover" |
| 回复弹窗 | el-dialog | title="回复评价", width="500px", :close-on-click-modal="false" |
| 回复内容 | el-input | type="textarea", :rows="4", maxlength=500, show-word-limit, placeholder="请输入回复内容" |
| 提交回复按钮 | el-button | type="primary", :loading="replying" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 回复内容 | 必填maxlength=500 | "请输入回复内容" |
| 日期范围 | 结束日期≥开始日期 | "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部8列弹窗宽度500px/600px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"评价内容"列弹窗宽度460px/560px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示评价类型、评分、评价人、回复状态、操作列弹窗宽度95vw |
--- ---
## 页面3绩效报表页 ## 页面3绩效报表页
@ -169,6 +269,49 @@
| 绩效数据 | /api/v1/evaluations/performance | GET | — | | 绩效数据 | /api/v1/evaluations/performance | GET | — |
| 导出 | /api/v1/evaluations/performance/export | GET | — | | 导出 | /api/v1/evaluations/performance/export | GET | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认时间维度为"本月" → 调用绩效数据API GET /api/v1/evaluations/performance → 渲染绩效表格,加载中显示骨架屏
2. **查询/筛选交互流程**:选择时间维度(天/周/月/年/自定义)→ 重新请求;选择班组 → 过滤该班组绩效数据;维度变更后自动刷新
3. **表单填写与提交流程**:本页无表单提交;点击[导出Excel] → 弹出确认弹窗 → 确认后触发下载
4. **弹窗/抽屉交互流程**导出确认弹窗宽度400px点击遮罩层可关闭
5. **行内操作流程**:点击人员行 → 展开该人员的评价详情(评价数、平均分、低分数等);低分数列红色标记
6. **异常与错误处理**数据加载失败显示ElMessage.error"数据加载失败,请稍后重试"无数据时表格显示ElEmpty导出失败提示"导出失败,请重试"
7. **联动/级联交互**:时间维度变更后表格和导出按钮联动刷新;班组筛选联动数据过滤
8. **权限控制交互表现**:无 evaluation:summary:view 权限时导出按钮隐藏;主管仅看本班组数据
9. **H1 防重复请求(强制)**:时间维度/班组筛选变更时立即disabled+loading导出按钮点击后disabled+loading态直到下载完成翻页/排序时abort前一次未完成请求
10. **H2 超时配置(强制)**绩效数据GET请求30s超时统计档导出GET请求60s超时上传导出档>3s展示全局ElLoading
11. **H6 批量限制(建议)**导出操作单次最多导出500条记录超出时提示"导出数据量过大(>500条请缩小筛选范围后再试"
12. **H8 反馈规范(建议)**数据加载完成正常渲染无额外提示导出成功ElMessage.success 2秒自动消失导出失败手动关闭错误提示网络异常提供重试
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间维度 | el-select | v-model="query.dimension", placeholder="请选择时间维度" |
| 自定义日期 | el-date-picker | type="daterange", v-if="query.dimension==='custom'" |
| 班组筛选 | el-select | clearable, filterable, placeholder="全部班组" |
| 列表 | el-table | stripe, border, :data="performanceData" |
| 导出按钮 | el-button | type="success", icon="Download", :loading="exportLoading" |
| 低分标记 | el-tag | type="danger", v-if="row.lowScoreCount>0" |
| 5星占比 | el-progress | :percentage="row.fiveStarRate", :stroke-width="10" |
| 导出确认弹窗 | el-dialog | title="确认导出", width="400px" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 时间维度 | 必选 | "请选择时间维度" |
| 自定义日期范围 | 维度=自定义时必填,结束日期≥开始日期 | "请选择日期范围" / "结束日期不能早于开始日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部7列 |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"5星占比"列 |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠,表格仅显示班组、人员、评价数、平均评分、低分数列,启用横向滚动 |
--- ---
## 页面4评价配置页 ## 页面4评价配置页
@ -200,6 +343,49 @@
| 查询配置 | /api/v1/evaluation-configs | GET | — | | 查询配置 | /api/v1/evaluation-configs | GET | — |
| 保存 | /api/v1/evaluation-configs | PUT | — | | 保存 | /api/v1/evaluation-configs | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用查询配置API GET /api/v1/evaluation-configs → 回填配置表单数据
2. **查询/筛选交互流程**:本页无筛选功能,直接展示配置表单
3. **表单填写与提交流程**:修改开关/阈值 → 点击[保存] → 前端校验通过 → 调用保存API PUT /api/v1/evaluation-configs → 成功后显示ElMessage.success"配置保存成功"
4. **弹窗/抽屉交互流程**:本页无弹窗交互
5. **行内操作流程**:无行内操作,表单为页面级
6. **异常与错误处理**低评分阈值必须1-5之间否则提示"低评分阈值必须在1-5之间";回复时限必须>0否则提示"回复时限必须大于0"保存失败显示ElMessage.error
7. **联动/级联交互**:开启"工单完成后自动触发评价"后,工单验收通过时系统自动发送评价邀请;低评分阈值变更后影响通知触发规则
8. **权限控制交互表现**:无 evaluation:config:update 权限时[保存]按钮隐藏,表单字段全部禁用(只读模式)
9. **H1 防重复请求(强制)**"保存"按钮点击后立即disabled+loading态防重复提交
10. **H2 超时配置(强制)**查询配置GET请求15s超时保存PUT请求30s超时>3s展示全局ElLoading
11. **H3 操作确认(强制)**保存配置为影响全局的操作低评分阈值变更将影响通知触发规则、时限变更将影响24h强回复规则提交前弹出ElMessageBox.confirm二次确认type="warning",提示"确定保存配置?变更将立即生效并影响相关业务规则"
12. **H4 脏数据检测(强制)**进入页面加载配置数据时进行deepClone快照作为原始值监听表单变更计算isDirty状态当用户尝试路由离开且isDirty为true时通过beforeRouteLeave守卫弹出确认框提示"配置尚未保存,确定要离开吗?"
13. **H8 反馈规范(建议)**保存成功ElMessage.success("配置保存成功")2秒自动消失校验失败/保存失败提示需手动关闭;网络异常提供重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 配置表单 | el-form | :model="configForm", label-width="240px", label-position="right" |
| 工单完成后自动触发评价 | el-switch | v-model="configForm.orderTrigger" |
| 巡检完成后自动触发评价 | el-switch | v-model="configForm.inspectionTrigger" |
| 保洁完成后自动触发评价 | el-switch | v-model="configForm.cleaningTrigger" |
| 低评分阈值 | el-input-number | v-model="configForm.lowScoreThreshold", :min=1, :max=5, :step=1 |
| 低评分必须回复时限 | el-input-number | v-model="configForm.replyDeadline", :min=1, :max=168, :step=1, suffix="小时" |
| 保存按钮 | el-button | type="primary", :loading="saving" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 低评分阈值 | 必填1-5整数 | "低评分阈值必须在1-5之间" |
| 低评分必须回复时限 | 必填,>0单位小时 | "回复时限必须大于0" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单label-width=240px表单区域最大宽度800px居中展示 |
| 1024-1279pxPad横屏 | 表单label-width=200px表单区域最大宽度700px |
| 768-1023pxPad竖屏 | 表单label-position改为top表单区域宽度95%保存按钮full-width |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码statistics > 模块编码statistics
> 端侧Web专属 > 端侧Web专属
> 关联文档01-模块划分 §3.7 / 02-功能清单-物业公司 §7 / 03-业务流转逻辑-物业公司 §7 / 05-接口规范 §9.2 > 关联文档01-模块划分 §3.7 / 02-功能清单-物业公司 §7 / 03-业务流转逻辑-物业公司 §7 / 05-接口规范 §9.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -67,6 +68,55 @@
| 统计数据 | /api/v1/statistics/repair | GET | 含卡片+图表+表格 | | 统计数据 | /api/v1/statistics/repair | GET | 含卡片+图表+表格 |
| 导出 | /api/v1/statistics/repair/export | GET | 支持Excel/PDF | | 导出 | /api/v1/statistics/repair/export | GET | 支持Excel/PDF |
### 交互流程要求
1. **页面加载流程**:页面加载时默认查询"本月"时间范围的报修统计数据依次渲染统计卡片区→图表区→数据表格加载期间各区域显示骨架屏Skeleton全部数据加载完成后隐藏骨架屏
2. **查询/筛选交互流程**:用户点击快捷时间按钮(今天/本周/本月/本年)立即切换并重新请求;选择"自定义"时弹出el-date-picker日期范围选择器确认后触发查询班组、区域、紧急程度下拉筛选变更后自动触发查询多个筛选条件组合生效
3. **表单填写与提交流程**本页面无表单提交仅筛选查询导出操作点击后弹出确认弹窗选择导出格式Excel/PDF确认后触发下载
4. **弹窗/抽屉交互流程**导出格式选择弹窗居中展示宽度400px点击弹窗外部或按ESC键关闭弹窗不触发导出
5. **行内操作流程**:数据表格行暂无行内操作,鼠标悬停行高亮背景色
6. **异常与错误处理**接口请求失败时页面顶部显示el-notification错误提示"数据加载失败,请稍后重试";无数据时图表区显示空状态插图+提示文字"暂无统计数据";导出失败时弹窗提示"导出失败,请重试"
7. **联动/级联交互**:时间范围变更后统计卡片、图表、表格三区联动刷新;筛选条件变更后同样全区域联动
8. **权限控制交互表现**:无`statistics:repair:export`权限时导出Excel和导出PDF按钮隐藏无`statistics:repair:view`权限时页面显示403提示
9. **H1 防重复请求(强制)**:快捷时间按钮/筛选条件变更时按钮组disabled+loading态导出按钮点击后disabled+loading直到下载完成筛选变更触发查询时abort前一次未完成请求统计卡片与图表区并发请求互不阻塞
10. **H2 超时配置(强制)**统计数据GET请求设置30s超时统计档导出操作GET请求设置60s超时上传导出档任何请求耗时>3s时展示全局ElLoading
11. **H6 批量限制(建议)**数据表格行数超过500条时提示"数据量过大,建议缩小时间范围后查看或导出"单次导出上限500条记录
12. **H8 反馈规范(建议)**数据加载完成后各区域正常渲染无额外成功提示导出成功ElMessage.success 2秒自动消失加载失败区域显示"点击重试"及重试按钮;网络异常提供全局重试入口
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange", size="default" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 班组筛选 | el-select | v-model="filters.team", clearable, placeholder="选择班组" |
| 区域筛选 | el-select | v-model="filters.area", clearable, placeholder="选择区域" |
| 紧急程度筛选 | el-select | v-model="filters.urgency", clearable, placeholder="选择紧急程度" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding: '20px'}" |
| 工单趋势图 | echarts (折线图) | smooth=true, tooltip.trigger="axis", grid.containLabel=true |
| 类型分布图 | echarts (饼图) | roseType="radius", tooltip.trigger="item" |
| 班组工单排名 | echarts (柱状图) | tooltip.trigger="axis", series.type="bar" |
| 数据表格 | el-table | stripe, border, size="default", :data="tableData" |
| 导出Excel按钮 | el-button | type="success", icon="Download", :loading="exportLoading" |
| 导出PDF按钮 | el-button | type="danger", icon="Document", :loading="exportLoading" |
| 导出格式弹窗 | el-dialog | title="选择导出格式", width="400px", :close-on-click-modal="false" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | 开始日期不能晚于结束日期 |
| 自定义日期范围-结束日期 | 必须晚于或等于起始日期 | 结束日期不能早于开始日期 |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | 查询时间范围不能超过1年 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片6列横排图表区2列布局趋势图+饼图),下方柱状图全宽;表格全宽展示 |
| 1024-1279pxPad横屏 | 统计卡片3列×2行排列图表区改为单列堆叠ECharts图表宽度100%高度自适应最小350px表格列宽自适应 |
| 768-1023pxPad竖屏 | 统计卡片2列×3行排列图表区单列堆叠ECharts图表宽度100%高度最小300px饼图改为环形图节省空间表格启用横向滚动筛选条件区折叠为展开/收起模式 |
--- ---
## 页面2巡检统计页 ## 页面2巡检统计页
@ -91,6 +141,51 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 导出 | statistics:inspection:export | 操作栏 | 始终 | — | | 导出 | statistics:inspection:export | 操作栏 | 始终 | — |
### 交互流程要求
1. **页面加载流程**:页面加载时默认查询"本月"巡检统计数据,统计卡片区与图表区并发请求,渲染顺序:统计卡片→趋势折线图→异常率分布饼图→数据表格,加载中显示骨架屏
2. **查询/筛选交互流程**:时间快捷按钮(今天/本周/本月/本年)点击即刷新;自定义日期范围确认后触发;班组、区域筛选变更自动刷新;补录数据"包含/排除"切换后立即重新查询
3. **表单填写与提交流程**无表单提交导出操作点击后直接触发文件下载按钮显示loading状态直到下载完成
4. **弹窗/抽屉交互流程**:本页面无弹窗交互
5. **行内操作流程**:数据表格行无行内操作,悬停高亮
6. **异常与错误处理**数据加载失败显示el-notification错误提示无数据时展示空状态插图导出失败提示"导出失败,请重试"
7. **联动/级联交互**:时间范围或筛选条件变更后统计卡片、图表、表格全区域联动刷新
8. **权限控制交互表现**:无`statistics:inspection:export`权限时导出按钮隐藏
9. **H1 防重复请求(强制)**:快捷时间按钮/筛选条件变更时立即disabled+loading导出按钮点击后disabled+loading直到下载完成查询触发时abort前一次未完成请求卡片与图表区并发请求互不阻塞
10. **H2 超时配置(强制)**统计数据GET请求设置30s超时统计档导出操作设置60s超时上传导出档>3s展示全局ElLoading
11. **H6 批量限制(建议)**单次导出上限500条记录超出时提示"数据量过大,请缩小筛选范围"
12. **H8 反馈规范(建议)**加载完成正常渲染无额外提示导出成功ElMessage.success 2秒自动消失加载失败区域提供"点击重试"按钮;网络异常提供重试
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange", size="default" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 班组筛选 | el-select | v-model="filters.team", clearable |
| 区域筛选 | el-select | v-model="filters.area", clearable |
| 补录数据筛选 | el-switch | v-model="filters.includeSupplement", active-text="包含", inactive-text="排除" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding: '20px'}" |
| 完成率趋势图 | echarts (折线图) | smooth=true, tooltip.trigger="axis" |
| 异常率分布图 | echarts (饼图) | tooltip.trigger="item" |
| 数据表格 | el-table | stripe, border, size="default" |
| 导出按钮 | el-button | type="success", icon="Download", :loading="exportLoading" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | 开始日期不能晚于结束日期 |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | 查询时间范围不能超过1年 |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片5列横排图表区2列布局趋势图+饼图),下方表格全宽 |
| 1024-1279pxPad横屏 | 统计卡片3列+2列排列图表区单列堆叠ECharts图表宽度100%高度最小350px表格列宽自适应 |
| 768-1023pxPad竖屏 | 统计卡片2列+3列排列图表区单列堆叠ECharts图表宽度100%高度最小300px表格启用横向滚动筛选区折叠 |
--- ---
## 页面3保洁统计页 ## 页面3保洁统计页
@ -109,6 +204,46 @@
| 抽查合格率 | 抽查合格数/抽查总数 | | 抽查合格率 | 抽查合格数/抽查总数 |
| 补录率 | — | | 补录率 | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询"本月"时间范围 → 调用保洁统计API → 渲染统计卡片区(总任务数/完成率/超时率/抽查合格率/补录率)→ 图表区(趋势图+分布图)→ 数据表格
2. **查询/筛选交互流程**:点击快捷时间按钮立即切换并重新请求;选择自定义日期范围确认后触发查询;班组/区域筛选变更后自动触发查询
3. **表单填写与提交流程**:无表单提交;导出操作点击后触发下载
4. **弹窗/抽屉交互流程**:本页面无弹窗交互
5. **行内操作流程**:数据表格行无行内操作,悬停高亮;超时率列红色标记
6. **异常与错误处理**数据加载失败显示el-notification错误提示无数据时展示空状态插图导出失败提示"导出失败,请重试"
7. **联动/级联交互**:时间范围或筛选条件变更后全区域联动刷新
8. **权限控制交互表现**:无 statistics:cleaning:export 权限时导出按钮隐藏
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 班组筛选 | el-select | clearable, placeholder="选择班组" |
| 区域筛选 | el-select | clearable, placeholder="选择区域" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding:'20px'}" |
| 超时率趋势图 | echarts (折线图) | smooth=true, tooltip.trigger="axis" |
| 任务状态分布图 | echarts (饼图) | tooltip.trigger="item" |
| 数据表格 | el-table | stripe, border |
| 导出按钮 | el-button | type="success", icon="Download", :loading="exportLoading" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | "开始日期不能晚于结束日期" |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | "查询时间范围不能超过1年" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片5列横排图表区2列布局表格全宽展示 |
| 1024-1279pxPad横屏 | 统计卡片3列+2列排列图表区单列堆叠表格列宽自适应 |
| 768-1023pxPad竖屏 | 统计卡片2列+3列排列图表区单列堆叠高度最小300px表格启用横向滚动筛选区折叠 |
--- ---
## 页面4评价统计页 ## 页面4评价统计页
@ -127,6 +262,50 @@
| 低评分占比 | ≤2分评价占比 | | 低评分占比 | ≤2分评价占比 |
| 回复率 | 已回复/总评价数 | | 回复率 | 已回复/总评价数 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询"本月"时间范围 → 调用评价统计API → 渲染统计卡片区(总评价数/平均评分/低评分占比/回复率)→ 图表区(各模块评分对比柱状图+星级分布饼图+评分趋势折线图)→ 数据表格
2. **查询/筛选交互流程**:点击快捷时间按钮立即切换;自定义日期范围确认后触发;班组筛选变更后自动触发查询
3. **表单填写与提交流程**:无表单提交;导出操作点击后触发下载
4. **弹窗/抽屉交互流程**:本页面无弹窗交互
5. **行内操作流程**:数据表格行无行内操作,悬停高亮;低分占比列红色标记
6. **异常与错误处理**数据加载失败显示el-notification错误提示无数据时展示空状态插图导出失败提示"导出失败,请重试"
7. **联动/级联交互**:时间范围或筛选条件变更后全区域联动刷新
8. **权限控制交互表现**:无 statistics:evaluation:export 权限时导出按钮隐藏
9. **H1 防重复请求(强制)**:快捷时间/筛选变更时disabled+loading态导出按钮点击后disabled+loading查询变更时abort前次未完成请求各区域并发请求互不阻塞
10. **H2 超时配置(强制)**统计GET请求30s超时统计档导出60s超时上传导出档>3s展示全局ElLoading
11. **H6 批量限制(建议)**单次导出上限500条超出提示缩小范围
12. **H8 反馈规范(建议)**正常渲染无额外提示导出成功2秒自动消失加载失败区域可重试网络异常提供重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 班组筛选 | el-select | clearable, placeholder="选择班组" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding:'20px'}" |
| 各模块评分对比 | echarts (柱状图) | tooltip.trigger="axis", series.type="bar" |
| 星级分布图 | echarts (饼图) | tooltip.trigger="item", radius=["40%","70%"] |
| 评分趋势图 | echarts (折线图) | smooth=true, tooltip.trigger="axis" |
| 数据表格 | el-table | stripe, border |
| 导出按钮 | el-button | type="success", icon="Download", :loading="exportLoading" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | "开始日期不能晚于结束日期" |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | "查询时间范围不能超过1年" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片5列横排图表区2列+1列布局表格全宽展示 |
| 1024-1279pxPad横屏 | 统计卡片3列+2列排列图表区单列堆叠表格列宽自适应 |
| 768-1023pxPad竖屏 | 统计卡片2列+3列排列图表区单列堆叠高度最小300px表格启用横向滚动筛选区折叠 |
--- ---
## 页面5考勤统计页 ## 页面5考勤统计页
@ -145,6 +324,49 @@
| 异常申诉率 | 申诉数/总打卡次数 | | 异常申诉率 | 申诉数/总打卡次数 |
| 蓝牙打卡率 | 蓝牙打卡数/总打卡数 | | 蓝牙打卡率 | 蓝牙打卡数/总打卡数 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询"本月"时间范围 → 调用考勤统计API → 渲染统计卡片区(出勤率/迟到率/早退率/异常申诉率/蓝牙打卡率)→ 图表区(出勤率趋势折线图+迟到早退分布图)→ 数据表格
2. **查询/筛选交互流程**:点击快捷时间按钮立即切换;自定义日期范围确认后触发查询;班组筛选变更后自动触发查询
3. **表单填写与提交流程**:无表单提交;导出操作点击后触发下载
4. **弹窗/抽屉交互流程**:本页面无弹窗交互
5. **行内操作流程**:数据表格行无行内操作,悬停高亮;迟到率/早退率列橙色标记
6. **异常与错误处理**数据加载失败显示el-notification错误提示无数据时展示空状态插图导出失败提示"导出失败,请重试"
7. **联动/级联交互**:时间范围或筛选条件变更后全区域联动刷新
8. **权限控制交互表现**:无 statistics:attendance:export 权限时导出按钮隐藏
9. **H1 防重复请求(强制)**:快捷时间/筛选变更时disabled+loading态导出按钮点击后disabled+loading查询变更时abort前次未完成请求各区域并发请求互不阻塞
10. **H2 超时配置(强制)**统计GET请求30s超时统计档导出60s超时上传导出档>3s展示全局ElLoading
11. **H6 批量限制(建议)**单次导出上限500条超出提示缩小范围
12. **H8 反馈规范(建议)**正常渲染无额外提示导出成功2秒自动消失加载失败区域可重试网络异常提供重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 班组筛选 | el-select | clearable, placeholder="选择班组" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding:'20px'}" |
| 出勤率趋势图 | echarts (折线图) | smooth=true, tooltip.trigger="axis" |
| 迟到早退分布图 | echarts (柱状图) | tooltip.trigger="axis", series.type="bar" |
| 数据表格 | el-table | stripe, border |
| 导出按钮 | el-button | type="success", icon="Download", :loading="exportLoading" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | "开始日期不能晚于结束日期" |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | "查询时间范围不能超过1年" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片5列横排图表区2列布局表格全宽展示 |
| 1024-1279pxPad横屏 | 统计卡片3列+2列排列图表区单列堆叠表格列宽自适应 |
| 768-1023pxPad竖屏 | 统计卡片2列+3列排列图表区单列堆叠高度最小300px表格启用横向滚动筛选区折叠 |
--- ---
## 页面6综合看板页 ## 页面6综合看板页
@ -176,6 +398,46 @@
└──────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────┘
``` ```
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认查询"本月"时间范围 → 并行调用各模块汇总API → 渲染6个统计卡片报修完成率/巡检完成率/保洁完成率/考勤出勤率/评价均分/待处理工单)→ 趋势图(各模块指标趋势对比折线图)
2. **查询/筛选交互流程**:点击快捷时间按钮(今天/本周/本月/自定义)→ 立即切换并重新请求所有数据;自定义日期范围确认后触发查询
3. **表单填写与提交流程**:本页无表单提交
4. **弹窗/抽屉交互流程**:点击统计卡片 → 跳转到对应模块的统计详情页
5. **行内操作流程**:点击卡片底部"查看详情"链接 → 跳转对应模块统计页
6. **异常与错误处理**:各卡片数据独立加载,某模块加载失败仅该卡片显示"加载失败";趋势图加载失败显示"加载失败,点击重试";全部加载失败显示整页错误提示
7. **联动/级联交互**:时间范围变更后所有卡片和趋势图联动刷新
8. **权限控制交互表现**:无某模块统计查看权限时对应卡片显示"无权限查看"
9. **H1 防重复请求(强制)**:快捷时间切换/自定义日期确认后立即disabled+loading态时间变更触发全量刷新时abort前一次所有未完成并行请求各模块API独立并发加载单模块失败不阻塞其他模块渲染
10. **H2 超时配置(强制)**各模块汇总GET请求设置30s超时统计档>3s时对尚未返回的卡片区域展示局部ElLoading全部超时时显示全局重试入口
11. **H8 反馈规范(建议)**:各卡片数据正常加载完成后直接渲染数值和趋势箭头,无需额外成功提示;单卡片加载失败显示"点击重试";趋势图加载失败显示"加载失败,点击重试"及重试按钮;网络异常时提供全局重试入口
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 时间快捷按钮 | el-radio-group + el-radio-button | v-model="timeRange" |
| 自定义日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 统计卡片 | el-card | shadow="hover", body-style="{padding:'20px'}", @click="goDetail" |
| 卡片数值 | 自定义组件 | 大字号数字+趋势箭头(↑↓)+百分比变化 |
| 趋势图 | echarts (多折线图) | 多条折线对比各模块smooth=true, tooltip.trigger="axis", legend.data=[报修,巡检,保洁,考勤,评价] |
| 查看详情链接 | el-link | type="primary", :underline="false" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 自定义日期范围-起始日期 | 必须早于或等于结束日期 | "开始日期不能晚于结束日期" |
| 自定义日期范围-跨度 | 时间跨度不超过1年 | "查询时间范围不能超过1年" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片3列×2行排列趋势图全宽展示 |
| 1024-1279pxPad横屏 | 统计卡片3列×2行排列卡片字号略减趋势图全宽展示 |
| 768-1023pxPad竖屏 | 统计卡片2列×3行排列趋势图全宽展示高度最小300px时间选择区折叠为下拉 |
--- ---
## 页面7自定义报表页 ## 页面7自定义报表页
@ -220,6 +482,57 @@
| 保存模板 | /api/v1/statistics/custom/templates | POST | — | | 保存模板 | /api/v1/statistics/custom/templates | POST | — |
| 导出 | /api/v1/statistics/custom/export | GET | — | | 导出 | /api/v1/statistics/custom/export | GET | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 加载配置区下拉选项(数据源/维度/指标列表);若存在已保存的模板,在配置区下方展示"最近使用模板"快捷入口
2. **查询/筛选交互流程**:选择数据源、维度、指标、时间范围、添加筛选条件 → 点击[生成报表] → 调用自定义报表API POST /api/v1/statistics/custom → 渲染结果展示区(图表+数据表格)
3. **表单填写与提交流程**:配置报表参数 → 点击[生成报表] → 加载中显示骨架屏 → 渲染结果;点击[保存模板] → 弹窗输入模板名称 → 保存当前配置;点击[导出] → 触发下载
4. **弹窗/抽屉交互流程**保存模板弹窗宽度420px输入模板名称点击遮罩层可关闭
5. **行内操作流程**:动态添加筛选条件行 → 选择字段+运算符+值 → 可删除已有条件行;生成报表后图表区可交互(缩放、筛选、下钻)
6. **异常与错误处理**:数据源和维度至少选一个,否则提示"请选择数据源和维度";指标至少选一个,否则提示"请至少选择一个指标"生成失败显示ElMessage.error导出失败提示"导出失败,请重试"
7. **联动/级联交互**:数据源选择后联动可用维度和指标列表;维度选择后联动图表类型推荐;筛选条件中字段选择后联动运算符和值选项
8. **权限控制交互表现**:无 statistics:custom:view 权限时[生成报表]按钮隐藏;无 statistics:custom:create 权限时[保存模板]按钮隐藏;无 statistics:custom:export 权限时[导出]按钮隐藏
9. **H1 防重复请求(强制)**"生成报表"按钮点击后立即disabled+loading直到结果返回或超时"保存模板""导出"按钮点击后disabled+loading防重复筛选条件行动态添加/删除不影响已生成的报表结果
10. **H2 超时配置(强制)**生成报表POST请求设置30s超时统计档保存模板POST请求30s超时导出GET请求60s超时上传导出档>3s时展示全局ElLoading
11. **H6 批量限制(建议)**导出操作上限500条记录超出提示"数据量过大,请缩小维度范围后重新生成"
12. **H8 反馈规范(建议)**:生成报表成功后图表+表格正常渲染无额外成功提示保存模板成功ElMessage.success("模板保存成功")2秒自动消失导出成功2秒自动消失校验错误/生成失败需手动关闭;网络异常提供重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 数据源选择 | el-select | v-model="config.dataSources", multiple, collapse-tags, placeholder="请选择数据源" |
| 维度选择 | el-select | v-model="config.dimensions", multiple, collapse-tags, placeholder="请选择维度" |
| 指标选择 | el-select | v-model="config.metrics", multiple, collapse-tags, placeholder="请选择指标" |
| 时间范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 筛选条件添加 | el-button | type="primary", link, icon="Plus", @click="addFilter" |
| 筛选条件行 | 自定义组件 | 字段el-select + 运算符el-select + 值el-input/el-select + 删除按钮 |
| 生成报表按钮 | el-button | type="primary", :loading="generating" |
| 保存模板按钮 | el-button | type="success" |
| 导出按钮 | el-button | type="success", icon="Download", :disabled="!hasResult" |
| 结果图表区 | echarts | 动态渲染,根据维度/指标配置图表类型 |
| 结果表格 | el-table | stripe, border, :data="resultData" |
| 保存模板弹窗 | el-dialog | title="保存模板", width="420px" |
| 模板名称 | el-input | maxlength=30, placeholder="请输入模板名称" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 数据源 | 至少选1个 | "请选择数据源" |
| 维度 | 至少选1个 | "请选择维度" |
| 指标 | 至少选1个 | "请至少选择一个指标" |
| 时间范围 | 必选跨度不超过1年 | "请选择时间范围" / "查询时间范围不能超过1年" |
| 模板名称 | 保存时必填maxlength=30 | "请输入模板名称" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 配置区水平排列结果区2列布局图表+表格) |
| 1024-1279pxPad横屏 | 配置区换行排列,结果区单列堆叠 |
| 768-1023pxPad竖屏 | 配置区垂直堆叠使用el-collapse折叠面板组织配置项结果区单列堆叠操作按钮full-width堆叠 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码audit-log > 模块编码audit-log
> 端侧Web专属 > 端侧Web专属
> 关联文档01-模块划分 §3.8 / 02-功能清单-物业公司 §8 / 03-业务流转逻辑-物业公司 §8 / 05-接口规范 §9.2 / 06-项目技术要求 §4.5 > 关联文档01-模块划分 §3.8 / 02-功能清单-物业公司 §8 / 03-业务流转逻辑-物业公司 §8 / 05-接口规范 §9.2 / 06-项目技术要求 §4.5
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -62,6 +63,54 @@
|----------|---------|------|------| |----------|---------|------|------|
| 时间轴查询 | /api/v1/audit-logs/timeline | GET | — | | 时间轴查询 | /api/v1/audit-logs/timeline | GET | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认日期范围为今天 → 调用时间轴查询API GET /api/v1/audit-logs/timeline → 渲染时间轴视图,按时间倒序排列;同时加载模块下拉选项
2. **查询/筛选交互流程**:选择模块/输入操作人/选择日期范围 → 点击[查询] → 重新加载时间轴数据 → 点击[重置]恢复默认条件(今天/全部模块);支持快捷日期(今天/昨天/近7天
3. **表单填写与提交流程**:本页无表单提交操作,仅查询展示
4. **弹窗/抽屉交互流程**:点击[查看详情] → 调用详情API GET /api/v1/audit-logs/{id} → 弹窗展示完整日志信息操作人、时间、IP、模块路径、操作内容、变更前后数据、请求参数、响应状态宽度700px
5. **行内操作流程**:点击时间轴节点的[查看详情] → 弹窗展示向下滚动到底部自动加载更多无限滚动v-infinite-scroll
6. **异常与错误处理**:时间轴数据加载失败显示"加载失败,点击重试"无数据时显示ElEmpty"暂无日志记录"日期范围超过90天提示"查询时间范围不能超过90天"网络异常提示ElMessage.error
7. **联动/级联交互**:模块筛选影响时间轴数据过滤;日期范围变更后时间轴重新渲染;快捷日期按钮联动日期范围选择器
8. **权限控制交互表现**:无 audit-log:list:view 权限时页面不可见;物业管理员仅查看本公司绑定医院的操作日志
9. **[H1] 防重复请求**:查询按钮点击后立即 disabled + loading 状态,防止重复提交;时间轴无限滚动触发 loadMore 时节流处理500ms 内不重复请求);查看详情弹窗打开时,当前行操作按钮禁用
10. **[H2] 超时控制**:时间轴查询 API GET /api/v1/audit-logs/timeline 设置超时 15s超过时提示"查询超时,请稍后重试";详情 API GET /api/v1/audit-logs/{id} 设置超时 15s请求耗时 >3s 时显示全局 Loading 指示器
11. **[H3] 操作确认**:本页主要为只读展示,暂无删除/停用等危险操作需二次确认
12. **[H8] 反馈机制**查询成功后静默刷新数据silent不弹成功提示加载失败显示 ElMessage.error("加载失败,请重试",持续显示需手动关闭);详情加载成功 2s 后自动消失的 ElMessage.success 提示
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 模块筛选 | el-select | clearable, placeholder="全部模块" |
| 操作人输入 | el-input | clearable, maxlength=20, placeholder="请输入操作人" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 快捷日期 | el-radio-group + el-radio-button | v-model="quickDate" |
| 时间轴 | el-timeline | :reverse=false, v-infinite-scroll="loadMore" |
| 时间轴节点 | el-timeline-item | :timestamp="item.time", placement="top" |
| 模块标签 | el-tag | type="primary", size="small" |
| 查看详情按钮 | el-button | type="primary", link, size="small" |
| 详情弹窗 | el-dialog | title="操作日志详情", width="700px" |
| 详情展示 | el-descriptions | :column="2", border |
| 变更前数据 | el-input | type="textarea", :rows="4", readonly, v-if="beforeData" |
| 变更后数据 | el-input | type="textarea", :rows="4", readonly, v-if="afterData" |
| 响应状态标签 | el-tag | 成功=success / 失败=danger |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 操作人 | 非必填maxlength=20 | — |
| 日期范围 | 最多90天 | "查询时间范围不能超过90天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行时间轴居中展示最大宽度900px详情弹窗700px |
| 1024-1279pxPad横屏 | 查询条件两行时间轴宽度100%详情弹窗650px |
| 768-1023pxPad竖屏 | 查询条件竖向堆叠时间轴全宽详情弹窗95vw |
--- ---
## 页面2日志列表页 ## 页面2日志列表页
@ -124,6 +173,55 @@
| 列表查询 | /api/v1/audit-logs | GET | 分页查询 | | 列表查询 | /api/v1/audit-logs | GET | 分页查询 |
| 详情 | /api/v1/audit-logs/{id} | GET | 含完整信息 | | 详情 | /api/v1/audit-logs/{id} | GET | 含完整信息 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 默认日期范围为今天 → 调用列表查询API GET /api/v1/audit-logs → 渲染表格默认按操作时间倒序排列每页20条同时加载模块下拉、操作类型下拉选项
2. **查询/筛选交互流程**:选择模块/输入操作人/选择操作类型(多选,支持全选/反选)/选择日期范围 → 点击[查询] → 重新加载数据 → 点击[重置]恢复默认条件
3. **表单填写与提交流程**:本页无表单提交操作,仅查询展示
4. **弹窗/抽屉交互流程**:点击[查看详情] → 调用详情API GET /api/v1/audit-logs/{id} → 弹窗展示完整日志信息(操作人+班组、操作时间、操作IP、操作模块路径、操作内容、变更前/后数据JSON、请求参数、响应状态宽度700px
5. **行内操作流程**:点击[查看详情] → 弹窗展示变更前后数据使用JSON格式化高亮展示
6. **异常与错误处理**列表数据为空时显示ElEmpty"暂无操作日志";详情加载失败提示"详情加载失败"日期范围超过90天提示"查询时间范围不能超过90天"API请求失败显示ElMessage.error
7. **联动/级联交互**:操作类型多选筛选与模块筛选可组合使用;分页切换保持筛选条件
8. **权限控制交互表现**:无 audit-log:list:view 权限时页面不可见;物业管理员仅查看本公司绑定医院的操作日志
9. **[H1] 防重复请求**:查询按钮点击后 disabled + loading防止重复提交分页切换时 abort 前一次未完成的列表请求再发新请求;行内"查看详情"按钮点击后禁用当前行操作,弹窗关闭后恢复
10. **[H2] 超时控制**:列表查询 GET /api/v1/audit-logs 超时 15s详情 GET /api/v1/audit-logs/{id} 超时 15s请求 >3s 显示全局 v-loading 全屏遮罩
11. **[H3] 操作确认**:本页为只读列表,暂无删除等危险操作需确认
12. **[H8] 反馈机制**查询成功静默刷新silent失败 ElMessage.error 持续显示手动关闭;详情加载成功 ElMessage.success 2s 自动消失
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 模块筛选 | el-select | clearable, placeholder="全部模块" |
| 操作人输入 | el-input | clearable, maxlength=20, placeholder="请输入操作人" |
| 操作类型筛选 | el-select | v-model="query.types", multiple, collapse-tags, collapse-tags-tooltip, filterable, placeholder="全部类型" |
| 日期范围 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", range-separator="至" |
| 列表 | el-table | stripe, border, :data="tableData", v-loading |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50,100]" |
| 操作类型标签 | el-tag | 查看=info / 新增=success / 编辑=primary / 删除=danger / 审批=warning / 导出=info / 分配=primary |
| 响应状态标签 | el-tag | 成功=success / 失败=danger |
| 查看详情按钮 | el-button | type="primary", link |
| 详情弹窗 | el-dialog | title="操作日志详情", width="700px", :close-on-click-modal="true" |
| 详情展示 | el-descriptions | :column="2", border |
| 变更前数据 | el-input | type="textarea", :rows="6", readonly, class="json-viewer" |
| 变更后数据 | el-input | type="textarea", :rows="6", readonly, class="json-viewer" |
| 请求参数 | el-input | type="textarea", :rows="4", readonly |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 操作人 | 非必填maxlength=20 | — |
| 日期范围 | 最多90天 | "查询时间范围不能超过90天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区水平排列表格完整展示全部7列详情弹窗700px |
| 1024-1279pxPad横屏 | 查询条件区换行排列,表格隐藏"模块"列详情弹窗650px |
| 768-1023pxPad竖屏 | 查询条件区垂直堆叠表格仅显示操作时间、操作人、操作类型、操作内容、操作列详情弹窗95vw |
--- ---
## 页面3日志导出 ## 页面3日志导出
@ -138,6 +236,45 @@
|------|----------|------|----------|------| |------|----------|------|----------|------|
| 导出Excel | audit-log:list:export | 列表页操作栏 | 始终 | 导出当前筛选结果 | | 导出Excel | audit-log:list:export | 列表页操作栏 | 始终 | 导出当前筛选结果 |
### 交互流程要求
1. **页面加载流程**:日志导出为日志列表页的操作功能,非独立页面;进入日志列表页时加载导出按钮
2. **查询/筛选交互流程**:导出数据范围跟随列表页当前筛选条件
3. **表单填写与提交流程**:在日志列表页点击[导出Excel] → 弹出导出确认弹窗(确认导出当前筛选条件下的数据)→ 确认 → 后端异步生成Excel → 生成完毕自动下载
4. **弹窗/抽屉交互流程**导出确认弹窗宽度400px展示当前筛选条件和预估数据量点击遮罩层可关闭
5. **行内操作流程**:大量数据导出时显示进度条,支持取消操作
6. **异常与错误处理**:导出失败提示"导出失败,请稍后重试"数据量超10万条提示"数据量过大,请缩小查询范围";导出超时提示"导出超时,请缩小查询范围后重试"
7. **联动/级联交互**:导出数据范围与列表页筛选条件联动
8. **权限控制交互表现**:无 audit-log:list:export 权限时导出按钮隐藏
9. **[H1] 防重复请求**:导出按钮点击后 disabled + loading 状态,防止重复点击;导出进行中时禁用查询/筛选等可能改变数据范围的操作
10. **[H2] 超时控制**:导出异步任务超时设置 60s大文件生成导出确认弹窗 API 调用超时 15s>3s 显示全局 Loading
11. **[H3] 操作确认**导出前必须通过确认弹窗type=warning弹窗内容须包含当前筛选条件和预估数据量用户明确确认后才开始导出
12. **[H8] 反馈机制**:导出成功 ElMessage.success("导出成功"2s 自动消失);导出失败 ElMessage.error 持续显示手动关闭;取消导出不提示
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 导出按钮 | el-button | type="success", icon="Download", plain, :loading="exportLoading" |
| 确认弹窗 | el-message-box | type="warning", confirmButtonText="确认导出", cancelButtonText="取消" |
| 导出进度弹窗 | el-dialog | title="导出中", width="400px", :close-on-click-modal="false", :show-close="false" |
| 进度条 | el-progress | :percentage="exportProgress", :stroke-width="6" |
| 取消导出按钮 | el-button | type="danger", @click="cancelExport" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| — | — | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 导出按钮在列表页操作栏展示弹窗宽度400px |
| 1024-1279pxPad横屏 | 导出按钮缩小为small尺寸 |
| 768-1023pxPad竖屏 | 导出按钮收入"更多"下拉菜单弹窗宽度90vw |
--- ---
## 页面4数据补录日志页 ## 页面4数据补录日志页
@ -179,6 +316,41 @@
|----------|---------|------|------| |----------|---------|------|------|
| 补录日志 | /api/v1/audit-logs/supplement | GET | — | | 补录日志 | /api/v1/audit-logs/supplement | GET | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载补录日志列表
2. **查询交互**:填写条件 → 查询 → 重置到第1页
3. **行内操作**:点击"查看详情"→ 弹窗展示补录详情(补录原因、原始数据、补录后数据、审核信息)
4. **异常处理**:无数据时显示空状态
5. **权限控制**:无 `audit-log:supplement:view` → 页面不可见
6. **[H1] 防重复请求**:查询按钮点击后 disabled + loading防止重复提交分页切换时 abort 前次未完成请求;行内"查看详情"点击后禁用当前行,弹窗关闭后恢复
7. **[H2] 超时控制**:列表查询 GET /api/v1/audit-logs/supplement 超时 15s详情加载超时 15s>3s 全局 Loading
8. **[H3] 操作确认**:本页为只读查看,暂无删除/停用等危险操作需二次确认
9. **[H8] 反馈机制**查询成功静默刷新silent失败 ElMessage.error 手动关闭;详情加载成功 2s 自动消失提示
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, v-loading |
| 审核状态标签 | el-tag | 待审核:type="warning", 通过:type="success", 驳回:type="danger" |
| 补录原因标签 | el-tag | type="info", size="small" |
| 详情弹窗 | el-dialog | width="600px" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期范围 | 最多90天 | "查询时间范围不能超过90天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 查询条件一行;列表完整展示 |
| 1024-1279px | 查询条件两行;"审核人""审核时间"列隐藏 |
| 768-1023px | 查询条件竖向堆叠;列表仅显示:补录时间、补录人、模块、审核状态、操作 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码system > 模块编码system
> 端侧Web专属 > 端侧Web专属
> 关联文档01-模块划分 §3.9 / 02-功能清单-物业公司 §9 / 03-业务流转逻辑-物业公司 §9 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4 > 关联文档01-模块划分 §3.9 / 02-功能清单-物业公司 §9 / 03-业务流转逻辑-物业公司 §9 / 05-接口规范 §9.2 / 06-项目技术要求 §4.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -98,6 +99,50 @@
| 解绑 | /api/v1/beacons/{id}/unbind | PUT | — | | 解绑 | /api/v1/beacons/{id}/unbind | PUT | — |
| 删除 | /api/v1/beacons/{id} | DELETE | — | | 删除 | /api/v1/beacons/{id} | DELETE | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载Beacon列表 + 绑定位置级联数据
2. **查询交互**:填写条件 → 查询 → 重置到第1页
3. **新增/编辑Beacon**:点击"新增Beacon"→ 弹窗 → 填写表单 → 保存 → 刷新列表
4. **解绑操作**:点击"解绑"→ 二次确认 → API调用 → 刷新列表
5. **删除操作**:仅未绑定位置时可删除 → 二次确认 → API调用 → 刷新列表
6. **联动交互**:绑定位置使用级联选择器(楼栋→楼层→区域→点位)
7. **异常处理**UUID格式错误即时提示删除已绑定Beacon提示"请先解绑"
8. **权限控制**:无对应权限 → 操作按钮不渲染
9. **[H1] 防重复请求**:查询/新增/编辑/解绑按钮点击后 disabled + loading分页切换 abort 前次未完成请求;行内操作(编辑/解绑/删除)点击后禁用当前行,弹窗/确认关闭后恢复
10. **[H2] 超时控制**:列表 GET /api/v1/beacons 超时 15s新增 POST /api/v1/beacons 超时 30s编辑 PUT 超时 30s删除 DELETE 超时 30s>3s 全局 Loading
11. **[H3] 操作确认**:删除操作必须二次确认 ElMessageBox.confirmtype=error文案含"确定删除该Beacon设备吗此操作不可恢复"解绑操作二次确认type=warning文案含"确定解绑该Beacon的绑定位置吗"
12. **[H4] 脏数据检测**:新增/编辑弹窗打开时 deep clone 表单初始值;表单字段变更后计算 isDirty用户尝试关闭弹窗时若 isDirty 为 true 则拦截并提示"修改尚未保存,确定离开吗?"
13. **[H8] 反馈机制**:新增/编辑成功 ElMessage.success 2s 自动消失 + silent 刷新列表;失败 ElMessage.error 手动关闭;删除/解绑成功 2s 提示后刷新
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, v-loading |
| 状态标签 | el-tag | 在线:type="success", 离线:type="danger" |
| 电量 | el-progress | :percentage, :stroke-width=10, <20%:color="red" |
| 新增/编辑弹窗 | el-dialog | width="500px" |
| 绑定位置级联 | el-cascader | :options="locationTree", :props="{checkStrictly:true}" |
| 绑定模块下拉 | el-select | :options="[{label:'巡检',value:'inspection'},{label:'保洁',value:'cleaning'},{label:'考勤',value:'attendance'}]" |
| 电量预警阈值 | el-input-number | :min=0, :max=100, :step=5 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| Beacon名称 | 必填, 最大30字符 | "请输入Beacon名称" / "名称不能超过30字符" |
| UUID | 必填, UUID格式 | "请输入UUID" / "UUID格式不正确" |
| 电量预警阈值 | 0-100整数 | "阈值范围为0-100" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 查询条件一行;列表完整展示 |
| 1024-1279px | 查询条件两行;"绑定模块""电量"列隐藏 |
| 768-1023px | 查询条件竖向堆叠;列表仅显示:名称、绑定位置、状态、操作 |
--- ---
## 页面2字典管理页 ## 页面2字典管理页
@ -142,6 +187,42 @@
| 新增类型 | /api/v1/dict-types | POST | — | | 新增类型 | /api/v1/dict-types | POST | — |
| 新增值 | /api/v1/dict-types/{typeCode}/values | POST | — | | 新增值 | /api/v1/dict-types/{typeCode}/values | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载字典类型列表 → 默认选中第一个类型 → 加载对应字典值
2. **左侧类型交互**:点击类型 → 右侧加载对应字典值列表
3. **新增类型**:点击"新增类型"→ 弹窗输入类型名称和编码 → 保存 → 刷新左侧列表
4. **新增字典值**:点击"新增字典值"→ 弹窗输入字典值和排序 → 保存 → 刷新右侧列表
5. **启用/停用**:被业务数据引用的字典值不可停用 → 提示"该字典值正在使用中,无法停用"
6. **联动交互**:字典值变更后,引用该字典的下拉选项实时刷新
7. **异常处理**:类型编码重复 → 提示"该编码已存在"
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 左右分栏 | el-container | 左侧宽度250px |
| 字典类型列表 | el-menu | :default-active, @select |
| 右侧字典值列表 | el-table | stripe, border |
| 状态标签 | el-tag | 启用:type="success", 停用:type="danger" |
| 新增弹窗 | el-dialog | width="400px" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 类型名称 | 必填, 最大30字符 | "请输入类型名称" |
| 类型编码 | 必填, 字母数字下划线, 唯一 | "请输入类型编码" / "该编码已存在" |
| 字典值 | 必填, 最大50字符 | "请输入字典值" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 左右分栏左侧250px |
| 1024-1279px | 左右分栏左侧200px |
| 768-1023px | 上下分栏类型列表改为横向tab切换
--- ---
## 页面3微信配置页 ## 页面3微信配置页
@ -175,6 +256,45 @@
| 保存 | /api/v1/system/wechat-config | PUT | — | | 保存 | /api/v1/system/wechat-config | PUT | — |
| 测试连接 | /api/v1/system/wechat-config/test | POST | — | | 测试连接 | /api/v1/system/wechat-config/test | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载当前微信配置 → 回填表单Secret字段脱敏显示
2. **表单提交流程**:修改配置 → 点击"保存"→ 前端校验 → API调用 → 成功提示
3. **测试连接**:点击"测试连接"→ 调用API → 成功提示"连接成功" / 失败提示具体错误
4. **安全交互**AppSecret字段默认脱敏点击"显示"按钮后明文显示
5. **异常处理**:配置校验失败即时提示;测试连接失败提示具体原因
6. **[H1] 防重复请求**"保存"按钮点击后 disabled + loading防止重复提交"测试连接"按钮同样 disabled + loading
7. **[H2] 超时控制**:查询 GET /api/v1/system/wechat-config 超时 15s保存 PUT 超时 30s测试连接 POST 超时 30s>3s 全局 Loading
8. **[H4] 脏数据检测**:页面加载时 deep clone 原始配置数据为原始快照;表单字段变更后与快照对比计算 isDirty用户尝试离开路由时若 isDirty 通过 beforeRouteLeave 拦截并提示"配置尚未保存,确定离开吗?"
9. **[H8] 反馈机制**:保存成功 ElMessage.success("保存成功"2s 自动消失);测试连接成功/失败均 2s 提示自动消失;失败 ElMessage.error 手动关闭
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 表单 | el-form | label-width="140px", :model, :rules |
| AppID | el-input | clearable |
| AppSecret | el-input | show-password, type="password" |
| 服务器地址 | el-input | clearable |
| 保存按钮 | el-button | type="primary", :loading |
| 测试连接按钮 | el-button | type="success", plain, :loading |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 小程序AppID | 必填 | "请输入小程序AppID" |
| 小程序AppSecret | 必填 | "请输入小程序AppSecret" |
| 服务器地址 | 必填, URL格式 | "请输入服务器地址" / "地址格式不正确" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 表单标签140px输入区400px |
| 1024-1279px | 表单标签120px输入区350px |
| 768-1023px | 表单标签100px输入区100% |
--- ---
## 页面4消息模板管理页 ## 页面4消息模板管理页
@ -211,6 +331,40 @@
| 列表查询 | /api/v1/message-templates | GET | — | | 列表查询 | /api/v1/message-templates | GET | — |
| 编辑 | /api/v1/message-templates/{id} | PUT | — | | 编辑 | /api/v1/message-templates/{id} | PUT | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载模板列表
2. **编辑模板**:点击"编辑"→ 弹窗回填模板内容 → 修改 → 保存 → 刷新列表
3. **模板变量**:编辑时显示可用变量列表,点击变量自动插入到模板内容中
4. **异常处理**模板ID重复 → 提示"该模板ID已存在"
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border |
| 状态标签 | el-tag | 启用:type="success", 停用:type="danger" |
| 编辑弹窗 | el-dialog | width="600px" |
| 模板内容 | el-input | type="textarea", :rows=6, maxlength=500, show-word-limit |
| 变量列表 | el-tag | v-for, type="info", size="small", @click="insertVariable" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 模板名称 | 必填, 最大50字符 | "请输入模板名称" |
| 模板ID | 必填 | "请输入模板ID" |
| 适用场景 | 必填 | "请选择适用场景" |
| 模板内容 | 必填, 最大500字符 | "请输入模板内容" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 列表完整展示编辑弹窗600px |
| 1024-1279px | "模板变量"列隐藏编辑弹窗550px |
| 768-1023px | 列表仅显示模板名称、适用场景、状态、操作编辑弹窗90vw |
--- ---
## 页面5蓝牙策略配置页 ## 页面5蓝牙策略配置页
@ -282,6 +436,46 @@
| 保存 | /api/v1/system/bluetooth-policy | PUT | — | | 保存 | /api/v1/system/bluetooth-policy | PUT | — |
| 提交审核 | /api/v1/system/bluetooth-policy/submit | POST | — | | 提交审核 | /api/v1/system/bluetooth-policy/submit | POST | — |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用API加载当前蓝牙策略 → 回填各场景配置
2. **策略配置交互**每个场景行内下拉选择策略REQUIRED/OPTIONAL→ 实时保存为草稿
3. **保存草稿**:点击"保存"→ 调用API → 成功提示"草稿已保存"
4. **提交审核**:点击"提交审核"→ 二次确认("提交后需医院账号审核,确认提交?")→ 调用API → 成功提示"已提交审核"→ 按钮变为"审核中"不可再次点击
5. **审核状态展示**:待审核时显示"审核中"标签;审核通过显示"已生效";审核拒绝显示拒绝原因+重新编辑按钮
6. **联动交互**:选择"强制蓝牙"策略时,显示补录说明"蓝牙连接失败时自动进入补录流程"
7. **异常处理**:保存失败提示错误;提交审核时策略未变更提示"策略未修改"
8. **[H1] 防重复请求**"保存"按钮点击后 disabled + loading"提交审核"按钮点击后 disabled + loading 且提交中状态不可重复点击
9. **[H2] 超时控制**:查询 GET /api/v1/system/bluetooth-policy 超时 15s保存 PUT 超时 30s提交审核 POST 超时 30s>3s 全局 Loading
10. **[H4] 脏数据检测**:页面加载时 deep clone 当前策略配置为原始快照;任一场景策略下拉变更后计算 isDirty用户尝试离开路由时若 isDirty 通过 beforeRouteLeave 拦截并提示"策略配置有未保存的修改,确定离开吗?"
11. **[H8] 反馈机制**:保存成功 ElMessage.success("草稿已保存"2s);提交审核成功 2s 后按钮变为"审核中";失败 ElMessage.error 手动关闭
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 策略表格 | el-table | :data="policies", border |
| 策略下拉 | el-select | :options="[{label:'强制蓝牙',value:'REQUIRED'},{label:'非强制',value:'OPTIONAL'}]" |
| 补录说明 | el-alert | type="info", :closable=false, v-if="policy==='REQUIRED'" |
| 审核状态标签 | el-tag | 审核中:type="warning", 已生效:type="success", 已拒绝:type="danger" |
| 保存按钮 | el-button | type="primary", :loading |
| 提交审核按钮 | el-button | type="success", :loading, :disabled="审核中" |
| 拒绝原因 | el-alert | type="error", :closable=false, v-if="rejected" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 策略配置 | 每个场景必须选择 | "请完成所有场景的策略配置" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 策略表格全宽 |
| 1024-1279px | 策略表格"说明"列隐藏 |
| 768-1023px | 策略表格改为卡片列表模式,每场景一个卡片 |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码account > 模块编码account
> 端侧Web专属仅超级管理员 > 端侧Web专属仅超级管理员
> 关联文档01-模块划分 §1.1~1.4 / 02-功能清单-超级管理员 §1 / 03-业务流转逻辑-超级管理员 §1~3 / 05-接口规范 §9.2 / 06-项目技术要求 §4.1~4.3 > 关联文档01-模块划分 §1.1~1.4 / 02-功能清单-超级管理员 §1 / 03-业务流转逻辑-超级管理员 §1~3 / 05-接口规范 §9.2 / 06-项目技术要求 §4.1~4.3
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -76,12 +77,83 @@
|------|----------|----------|------| |------|----------|----------|------|
| 超级管理员 | 全部按钮 | 全部医院 | — | | 超级管理员 | 全部按钮 | 全部医院 | — |
### API端点 ### 交互流程要求
| 页面操作 | API路径 | 方法 | 说明 | 1. **页面加载流程**
|----------|---------|------|------| - 进入页面 → 调用 `GET /api/v1/hospitals` 加载列表数据默认第1页每页20条创建时间倒序
| 列表查询 | /api/v1/hospitals | GET | 分页查询 | - 并行加载下拉选项:状态下拉(启用/停用)
| 启用/停用 | /api/v1/hospitals/{id}/toggle-status | PUT | 切换状态 | - 列表为空时显示空状态插图 + "暂无医院信息" 提示文字
- 加载中显示 `el-table` 骨架屏skeleton效果
2. **查询交互流程**
- 用户填写查询条件 → 点击"查询"按钮 → 前端校验无特殊限制 → 调用API携带筛选参数 → 重新渲染列表重置到第1页
- 点击"重置"→ 清空所有查询条件 → 重新加载默认列表
- 支持回车键触发查询
3. **行内操作流程**
- **编辑**:点击"编辑"→ 路由跳转至 `/account/hospitals/:id/edit`
- **启用/停用**:点击"启用"或"停用"→ 弹出 `el-message-box` 二次确认("确认停用XX医院停用后该医院下所有账号将无法登录")→ 确认后调用 `PUT /api/v1/hospitals/{id}/toggle-status` → 成功后 `el-message` 提示"操作成功" → 刷新当前列表
4. **异常处理**
- API请求失败 → `el-message.error` 提示错误信息,列表保持原数据
- 网络超时 → 提示"网络异常,请稍后重试"
- 启停操作失败 → 提示具体错误原因(如"该医院下存在进行中的工单,无法停用"
5. **权限控制交互**
- 无 `permission:user:create` 权限 → "新增医院"按钮不渲染
- 无 `permission:user:update` 权限 → 行操作列不渲染"编辑"和"启用/停用"按钮
6. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(编辑/启停):点击操作按钮后该行所有操作按钮禁用 + 按钮显示loading旋转图标操作完成后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求(列表+下拉数据)之间互不阻塞
7. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 启停等写操作APItimeout=30秒操作按钮:loading态
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
8. **[H3]操作确认机制**
- 启用/停用操作ElMessageBox.confirm("确定要{启用/停用}「{对象名称}」吗?停用后该账号将无法登录", "操作确认", { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
- 确认弹窗期间底层页面不可点击modal遮罩
9. **[H8]操作结果反馈**
- 成功操作ElMessage.success("{操作}成功"duration=2000ms) → silent方式刷新列表数据不带loading闪烁
- 失败ElMessage.error(后端message或"操作失败,请稍后重试"duration=0手动关闭)
- 网络异常:提示"网络连接异常,请检查网络后重试"+ 提供"重试"文字按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 查询条件区 | el-form | inline, label-width="auto" |
| 医院名称/联系人 | el-input | clearable, maxlength=50, placeholder="请输入" |
| 状态下拉 | el-select | clearable, placeholder="请选择" |
| 查询按钮 | el-button | type="primary", icon="Search" |
| 重置按钮 | el-button | icon="Refresh" |
| 列表 | el-table | stripe, border, size="default", v-loading |
| 状态标签 | el-tag | 启用:type="success", 停用:type="danger" |
| 院区数(可点击) | el-link | type="primary", underline=false |
| 操作按钮 | el-button | type="primary", link, size="small" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next", :page-sizes="[10,20,50]" |
| 新增医院 | el-button | type="primary", icon="Plus" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 医院名称(查询) | 最大50字符 | — |
| 联系人(查询) | 最大20字符 | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件区一行排列;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 查询条件区一行排列;"联系人"列隐藏,"创建时间"列隐藏 |
| 768-1023pxPad竖屏 | 查询条件区两行排列;列表仅显示:序号、医院名称、状态、操作;其余列折叠,点击行展开详情 |
--- ---
@ -145,6 +217,100 @@
| 编辑医院 | /api/v1/hospitals/{id} | PUT | 含院区列表 | | 编辑医院 | /api/v1/hospitals/{id} | PUT | 含院区列表 |
| 查询详情 | /api/v1/hospitals/{id} | GET | 编辑时回填 | | 查询详情 | /api/v1/hospitals/{id} | GET | 编辑时回填 |
### 交互流程要求
1. **页面加载流程**
- 新增模式:页面空白表单,状态默认"启用"
- 编辑模式:根据路由参数 `:id` 调用 `GET /api/v1/hospitals/{id}` 回填表单数据
- 加载中显示表单骨架屏
2. **表单填写与提交流程**
- 用户逐项填写基本信息 → 动态添加/删除院区行 → 点击"保存"→ 前端校验 → 调用API → 成功后 `el-message.success` → 路由跳转回列表页
- 编辑模式下保存 → 调用 `PUT /api/v1/hospitals/{id}` → 成功后返回列表页
3. **院区动态表单交互**
- 点击"+添加院区"→ 在院区列表末尾新增一行空表单
- 点击"删除"→ 二次确认后移除该院区行(至少保留一个院区)
- 院区行内字段实时校验
4. **联动交互**
- 无特殊联动逻辑
5. **异常处理**
- 唯一性校验失败(医院名称重复)→ 对应字段下方显示红色错误提示"该医院名称已存在"
- API失败 → `el-message.error` 提示错误信息,表单保持当前数据不丢失
- 编辑模式下详情加载失败 → 提示"数据加载失败"并提供"返回列表"按钮
6. **权限控制交互**
- 无保存权限时 → "保存"按钮禁用
7. **[H1]防重复请求**
- 点击"保存"按钮后:按钮 :loading=true + 文案变为"保存中..." + 按钮disabledAPI返回成功/失败/超时)后恢复
- 保存期间不允许再次点击保存或关闭页面
- 院区行动态增删操作删除院区确认弹窗pending期间禁用其他删除按钮
8. **[H2]超时与加载反馈**
- 新增/编辑提交APIPOST/PUTtimeout=30秒
- 编辑模式详情回填APIGETtimeout=15秒
- 提交中保存按钮保持:loading态 + 表单区域不可编辑(半透明遮罩可选)
- 超时处理:中断请求 → 提示"保存超时,请检查网络后重试" → 按钮恢复
9. **[H3]操作确认机制**
- 删除院区行前 ElMessageBox.confirm("确定要删除该院区吗?删除后数据无法恢复", "删除确认", { type: 'warning' })
10. **[H4]脏数据检测**
- 编辑模式进入时deep clone表单初始数据作为快照
- 用户修改任意字段后标记isDirty=true
- 点击"取消"按钮或浏览器后退时:
* isDirty=true → ElMessageBox.confirm("当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?") → 确认则离开/取消则停留
* isDirty=false → 直接执行离开操作
- 使用vue-router的beforeRouteLeave导航守卫拦截路由切换
- 保存成功后将当前数据设为新快照重置isDirty=false
11. **[H8]操作结果反馈**
- 保存成功ElMessage.success("保存成功", duration=2000) → 延迟300ms后router.back()返回列表页
- 保存失败ElMessage.error(错误信息, duration=0);表单数据保持不丢失
- 唯一性校验失败对应字段下方红色文字提示不弹message
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 基本信息表单 | el-form | label-width="100px", :model, :rules |
| 医院名称 | el-input | maxlength=50, show-word-limit, clearable |
| 状态 | el-radio-group | el-radio label="启用"/"停用" |
| 医院地址 | el-input | maxlength=200, show-word-limit |
| 联系人 | el-input | maxlength=20 |
| 联系电话 | el-input | maxlength=11, type="tel" |
| 院区列表 | 动态表单行 | v-for 渲染, 动态增删 |
| 院区名称 | el-input | maxlength=30, show-word-limit |
| 院区地址 | el-input | maxlength=200 |
| 保存按钮 | el-button | type="primary", :loading |
| 取消按钮 | el-button | @click="router.back()" |
| 添加院区 | el-button | type="primary", plain, icon="Plus" |
| 删除院区 | el-button | type="danger", link, icon="Delete" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 医院名称 | 必填, 2-50字符, 全局唯一 | "请输入医院名称" / "医院名称长度2-50字符" / "该医院名称已存在" |
| 状态 | 必填 | "请选择状态" |
| 医院地址 | 最大200字符 | "医院地址不能超过200字符" |
| 联系人 | 最大20字符 | "联系人不能超过20字符" |
| 联系电话 | 手机号格式(/^1[3-9]\d{9}$/) | "请输入正确的手机号" |
| 院区名称 | 必填, 2-30字符, 同医院内唯一 | "请输入院区名称" / "院区名称长度2-30字符" / "该院区名称已存在" |
| 院区地址 | 必填, 最大200字符 | "请输入院区地址" / "院区地址不能超过200字符" |
| 院区列表 | 至少1个院区 | "请至少添加一个院区" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 基本信息两列排列(医院名称+状态一行,地址独占一行,联系人+电话一行院区信息卡片宽度100% |
| 1024-1279pxPad横屏 | 基本信息两列排列院区信息卡片宽度100% |
| 768-1023pxPad竖屏 | 基本信息单列排列每行一个字段院区信息卡片宽度100%,院内行单列 |
--- ---
## 页面3物业公司信息管理列表页 ## 页面3物业公司信息管理列表页
@ -208,6 +374,74 @@
| 列表查询 | /api/v1/property-companies | GET | 分页查询 | | 列表查询 | /api/v1/property-companies | GET | 分页查询 |
| 启用/停用 | /api/v1/property-companies/{id}/toggle-status | PUT | — | | 启用/停用 | /api/v1/property-companies/{id}/toggle-status | PUT | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 调用 `GET /api/v1/property-companies` 加载列表默认第1页每页20条创建时间倒序
- 并行加载下拉选项:状态下拉
- 列表为空时显示空状态插图 + "暂无物业公司信息"
2. **查询交互流程**
- 填写查询条件 → 点击"查询"→ 调用API → 重置到第1页渲染
- 点击"重置"→ 清空条件 → 重新加载
3. **行内操作流程**
- **编辑**:点击"编辑"→ 路由跳转至 `/account/property-companies/:id/edit`
- **启用/停用**:二次确认弹窗("确认停用XX物业停用后该物业下所有账号将无法登录")→ 确认后调用API → 成功提示 → 刷新列表
4. **异常处理**
- 同页面1通用异常处理规则
5. **权限控制交互**
- 无 `permission:user:create` → "新增物业公司"按钮不渲染
- 无 `permission:user:update` → 行操作列不渲染
6. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(编辑/启停):点击操作按钮后该行所有操作按钮禁用 + 按钮显示loading旋转图标操作完成后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求(列表+下拉数据)之间互不阻塞
7. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 启停等写操作APItimeout=30秒操作按钮:loading态
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
8. **[H3]操作确认机制**
- 启用/停用操作ElMessageBox.confirm("确定要{启用/停用}「{对象名称}」吗?停用后该账号将无法登录", "操作确认", { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
- 确认弹窗期间底层页面不可点击modal遮罩
9. **[H8]操作结果反馈**
- 成功操作ElMessage.success("{操作}成功"duration=2000ms) → silent方式刷新列表数据不带loading闪烁
- 失败ElMessage.error(后端message或"操作失败,请稍后重试"duration=0手动关闭)
- 网络异常:提示"网络连接异常,请检查网络后重试"+ 提供"重试"文字按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 列表 | el-table | stripe, border, v-loading |
| 服务医院(可点击) | el-link | type="primary" |
| 状态标签 | el-tag | 启用:type="success", 停用:type="danger" |
| 操作按钮 | el-button | type="primary", link, size="small" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 公司名称(查询) | 最大50字符 | — |
| 联系人(查询) | 最大20字符 | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行排列;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 查询条件一行排列;"联系人""联系电话"列隐藏 |
| 768-1023pxPad竖屏 | 查询条件两行排列;列表仅显示:序号、公司名称、状态、操作 |
--- ---
## 页面4物业公司信息新增/编辑页 ## 页面4物业公司信息新增/编辑页
@ -232,6 +466,72 @@
| 新增 | /api/v1/property-companies | POST | — | | 新增 | /api/v1/property-companies | POST | — |
| 编辑 | /api/v1/property-companies/{id} | PUT | — | | 编辑 | /api/v1/property-companies/{id} | PUT | — |
### 交互流程要求
1. **页面加载流程**
- 新增模式:空白表单
- 编辑模式:根据路由参数 `:id` 调用 `GET /api/v1/property-companies/{id}` 回填数据
2. **表单填写与提交流程**
- 填写表单 → 点击"保存"→ 前端校验 → 调用API → 成功提示 → 返回列表页
- 校验失败 → 定位到第一个错误字段,滚动到可见区域
3. **异常处理**
- 唯一性校验失败 → 字段下方红色提示"该公司名称已存在"
- API失败 → 提示错误信息,表单数据不丢失
4. **[H1]防重复请求**
- 点击"保存"按钮后:按钮 :loading=true + 文案变为"保存中..." + 按钮disabledAPI返回成功/失败/超时)后恢复
- 保存期间不允许再次点击保存或关闭页面
5. **[H2]超时与加载反馈**
- 新增/编辑提交APIPOST/PUTtimeout=30秒
- 编辑模式详情回填APIGETtimeout=15秒
- 提交中保存按钮保持:loading态 + 表单区域不可编辑(半透明遮罩可选)
- 超时处理:中断请求 → 提示"保存超时,请检查网络后重试" → 按钮恢复
6. **[H4]脏数据检测**
- 编辑模式进入时deep clone表单初始数据作为快照
- 用户修改任意字段后标记isDirty=true
- 点击"取消"按钮或浏览器后退时:
* isDirty=true → ElMessageBox.confirm("当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?") → 确认则离开/取消则停留
* isDirty=false → 直接执行离开操作
- 使用vue-router的beforeRouteLeave导航守卫拦截路由切换
- 保存成功后将当前数据设为新快照重置isDirty=false
7. **[H8]操作结果反馈**
- 保存成功ElMessage.success("保存成功", duration=2000) → 延迟300ms后router.back()返回列表页
- 保存失败ElMessage.error(错误信息, duration=0);表单数据保持不丢失
- 唯一性校验失败对应字段下方红色文字提示不弹message
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 表单 | el-form | label-width="100px", :model, :rules |
| 公司名称 | el-input | maxlength=50, show-word-limit |
| 公司地址 | el-input | maxlength=200 |
| 联系人 | el-input | maxlength=20 |
| 联系电话 | el-input | maxlength=11, type="tel" |
| 保存按钮 | el-button | type="primary", :loading |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 公司名称 | 必填, 2-50字符, 全局唯一 | "请输入公司名称" / "公司名称长度2-50字符" / "该公司名称已存在" |
| 公司地址 | 最大200字符 | "公司地址不能超过200字符" |
| 联系人 | 必填, 最大20字符 | "请输入联系人" / "联系人不能超过20字符" |
| 联系电话 | 必填, 手机号格式 | "请输入联系电话" / "请输入正确的手机号" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单两列排列(公司名称+联系人一行,地址独占一行) |
| 1024-1279pxPad横屏 | 表单两列排列 |
| 768-1023pxPad竖屏 | 表单单列排列 |
--- ---
## 页面5医院账号管理列表页 ## 页面5医院账号管理列表页
@ -308,6 +608,80 @@
| 续期 | /api/v1/accounts/{id}/renew | PUT | 修改有效期 | | 续期 | /api/v1/accounts/{id}/renew | PUT | 修改有效期 |
| 重置密码 | /api/v1/accounts/{id}/reset-password | PUT | — | | 重置密码 | /api/v1/accounts/{id}/reset-password | PUT | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行调用列表查询API + 绑定医院下拉数据 + 状态下拉 + 有效期下拉
- 加载中骨架屏 → 数据渲染列表
2. **查询交互流程**
- 填写查询条件 → 点击"查询"→ 调用API → 重置到第1页
- "绑定医院"下拉支持搜索过滤filterable
3. **行内操作流程**
- **编辑**:点击"编辑"→ 路由跳转编辑页(如存在独立编辑页)
- **续期**:点击"续期"→ 弹出续期弹窗(`el-dialog`,选择新有效期日期)→ 确认后调用 `PUT /api/v1/accounts/{id}/renew` → 成功提示 → 刷新列表
- **启用/禁用**:二次确认("确认禁用该账号?禁用后该账号将立即无法登录")→ 确认后调用API → 成功提示 → 刷新列表
- **重置密码**:二次确认("确认重置密码?重置后密码将恢复为默认密码")→ 确认后调用API → 成功后弹窗展示新密码,支持一键复制
4. **联动交互**
- 绑定医院下拉数据来源于医院信息管理中的启用状态医院
5. **异常处理**
- 禁用失败(账号已禁用等)→ 提示具体原因
- 续期时新有效期早于当前日期 → 前端校验拦截,提示"有效期不能早于当前日期"
- 重置密码失败 → 提示错误信息
6. **权限控制交互**
- 无对应权限 → 行操作按钮不渲染
7. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(编辑/续期/启停/重置密码):点击操作按钮后该行所有操作按钮禁用 + 按钮显示loading旋转图标操作完成后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求(列表+下拉数据)之间互不阻塞
8. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 启停/续期/重置密码等写操作APItimeout=30秒操作按钮:loading态
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
9. **[H3]操作确认机制**
- 启用/禁用操作ElMessageBox.confirm("确定要{启用/禁用}「{对象名称}」吗?禁用后该账号将无法登录", "操作确认", { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
- 重置密码ElMessageBox.confirm("确定要重置「{账号}」的密码吗?重置后将恢复为默认密码", "操作确认", { type: 'warning' })
- 确认弹窗期间底层页面不可点击modal遮罩
10. **[H8]操作结果反馈**
- 成功操作ElMessage.success("{操作}成功"duration=2000ms) → silent方式刷新列表数据不带loading闪烁
- 失败ElMessage.error(后端message或"操作失败,请稍后重试"duration=0手动关闭)
- 网络异常:提示"网络连接异常,请检查网络后重试"+ 提供"重试"文字按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 绑定医院下拉 | el-select | filterable, clearable, remote, placeholder="请选择医院" |
| 有效期至 | el-tag | 正常:type="success", 即将到期:type="warning", 已过期:type="danger" |
| 状态标签 | el-tag | 正常:type="success", 即将到期:type="warning", 已过期:type="danger", 已停用:type="info" |
| 续期弹窗 | el-dialog | width="400px", :close-on-click-modal=false |
| 续期日期选择 | el-date-picker | type="date", :disabled-date="禁用过去日期" |
| 重置密码结果弹窗 | el-dialog | 展示新密码 + 复制按钮 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 续期日期 | 必填, 不早于当前日期 | "请选择有效期" / "有效期不能早于当前日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行排列;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 查询条件一行排列;"角色"列隐藏 |
| 768-1023pxPad竖屏 | 查询条件两行排列;列表仅显示:序号、登录账号、绑定医院、状态、操作 |
--- ---
## 页面6新增医院账号页 ## 页面6新增医院账号页
@ -347,6 +721,83 @@
|----------|---------|------|------| |----------|---------|------|------|
| 新增 | /api/v1/accounts/hospital | POST | — | | 新增 | /api/v1/accounts/hospital | POST | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行加载:绑定医院下拉(仅启用状态医院)+ 角色下拉(仅医院适用范围角色)
- 自动生成初始密码8位随机字母数字显示在密码输入框中
- 有效期默认为当前日期+1年
2. **表单填写与提交流程**
- 填写登录账号 → 实时校验唯一性(失焦时调用后端校验接口)
- 选择绑定医院 → 选择有效期 → 分配角色 → 点击"保存"→ 前端校验全部通过 → 调用API → 成功提示 → 返回列表页
3. **联动交互**
- 绑定医院下拉:数据来源于医院信息管理中启用状态的医院
- 分配角色下拉:数据来源于权限管理中"适用范围=医院账号"的启用角色
4. **异常处理**
- 登录账号重复 → 失焦校验后即时提示"该登录账号已存在"
- 保存失败 → 提示错误信息,表单数据不丢失
5. **权限控制交互**
- 无保存权限时 → "保存"按钮禁用
6. **[H1]防重复请求**
- 点击"保存"按钮后:按钮 :loading=true + 文案变为"保存中..." + 按钮disabledAPI返回成功/失败/超时)后恢复
- 保存期间不允许再次点击保存或关闭页面
7. **[H2]超时与加载反馈**
- 新增提交APIPOSTtimeout=30秒
- 提交中保存按钮保持:loading态 + 表单区域不可编辑(半透明遮罩可选)
- 超时处理:中断请求 → 提示"保存超时,请检查网络后重试" → 按钮恢复
8. **[H4]脏数据检测**
- 页面进入时deep clone表单初始数据作为快照
- 用户修改任意字段后标记isDirty=true
- 点击"取消"按钮或浏览器后退时:
* isDirty=true → ElMessageBox.confirm("当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?") → 确认则离开/取消则停留
* isDirty=false → 直接执行离开操作
- 使用vue-router的beforeRouteLeave导航守卫拦截路由切换
- 保存成功后将当前数据设为新快照重置isDirty=false
9. **[H8]操作结果反馈**
- 保存成功ElMessage.success("保存成功", duration=2000) → 延迟300ms后router.back()返回列表页
- 保存失败ElMessage.error(错误信息, duration=0);表单数据保持不丢失
- 唯一性校验失败对应字段下方红色文字提示不弹message
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 表单 | el-form | label-width="100px", :model, :rules |
| 登录账号 | el-input | maxlength=20, clearable, placeholder="4-20位字母数字" |
| 初始密码 | el-input | maxlength=20, show-password, readonly=false |
| 绑定医院 | el-select | filterable, clearable, placeholder="请选择医院" |
| 有效期至 | el-date-picker | type="date", :disabled-date="禁用过去日期", value-format="YYYY-MM-DD" |
| 分配角色 | el-select | multiple, filterable, collapse-tags, placeholder="请选择角色" |
| 保存按钮 | el-button | type="primary", :loading |
| 取消按钮 | el-button | @click="router.back()" |
| 刷新密码按钮 | el-button | icon="Refresh", circle, @click="generatePassword" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 登录账号 | 必填, 4-20位字母数字(/^[a-zA-Z0-9]{4,20}$/), 全局唯一 | "请输入登录账号" / "登录账号为4-20位字母数字" / "该登录账号已存在" |
| 初始密码 | 必填, 6-20位 | "请输入初始密码" / "密码长度6-20位" |
| 绑定医院 | 必填 | "请选择绑定医院" |
| 有效期至 | 必填, 不早于当前日期 | "请选择有效期" / "有效期不能早于当前日期" |
| 分配角色 | 必填, 至少选一个 | "请选择至少一个角色" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单两列排列(登录账号+初始密码一行,绑定医院+有效期一行,角色独占一行) |
| 1024-1279pxPad横屏 | 表单两列排列 |
| 768-1023pxPad竖屏 | 表单单列排列,每个字段独占一行 |
--- ---
## 页面7物业公司管理员账号管理列表页 ## 页面7物业公司管理员账号管理列表页
@ -421,6 +872,81 @@
| 启用/禁用 | /api/v1/accounts/{id}/toggle-status | PUT | 禁用同步下线下属 | | 启用/禁用 | /api/v1/accounts/{id}/toggle-status | PUT | 禁用同步下线下属 |
| 续期 | /api/v1/accounts/{id}/renew | PUT | — | | 续期 | /api/v1/accounts/{id}/renew | PUT | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行加载:列表数据 + 绑定物业公司下拉 + 服务医院下拉 + 状态下拉
- 加载中骨架屏
2. **查询交互流程**
- 填写条件 → 点击"查询"→ 调用API → 重置到第1页
- "绑定物业公司"和"服务医院"下拉均支持搜索过滤
3. **行内操作流程**
- **编辑**:跳转编辑页
- **续期**:弹出续期弹窗 → 选择新有效期 → 确认 → API调用 → 刷新列表
- **启用/禁用**:二次确认("确认禁用?禁用后该管理员及所有下属账号将同步失效")→ 确认 → API调用 → 刷新列表
- **重置密码**:二次确认 → API调用 → 弹窗展示新密码
4. **联动交互**
- 物业公司下拉数据来源于物业公司信息管理中的启用状态公司
- 选择物业公司后,服务医院下拉可联动过滤该物业关联的医院
5. **异常处理**
- 禁用操作提示级联影响范围:"该操作将同步禁用X个下属账号"
- 其他同页面5
6. **权限控制交互**
- 同页面5
7. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(编辑/续期/启停/重置密码):点击操作按钮后该行所有操作按钮禁用 + 按钮显示loading旋转图标操作完成后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求(列表+下拉数据)之间互不阻塞
8. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 启停/续期/重置密码等写操作APItimeout=30秒操作按钮:loading态
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
9. **[H3]操作确认机制**
- 启用/禁用操作ElMessageBox.confirm("确定要{启用/禁用}「{对象名称}」吗?禁用后将同步禁用所有下属账号", "操作确认", { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
- 重置密码ElMessageBox.confirm("确定要重置「{账号}」的密码吗?重置后将恢复为默认密码", "操作确认", { type: 'warning' })
- 确认弹窗期间底层页面不可点击modal遮罩
10. **[H8]操作结果反馈**
- 成功操作ElMessage.success("{操作}成功"duration=2000ms) → silent方式刷新列表数据不带loading闪烁
- 失败ElMessage.error(后端message或"操作失败,请稍后重试"duration=0手动关闭)
- 网络异常:提示"网络连接异常,请检查网络后重试"+ 提供"重试"文字按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 绑定物业公司下拉 | el-select | filterable, clearable |
| 服务医院下拉 | el-select | filterable, clearable |
| 状态标签 | el-tag | 同页面5颜色规则 |
| 操作按钮 | el-button | type="primary", link, size="small" |
| 续期弹窗 | el-dialog | width="400px" |
| 续期日期选择 | el-date-picker | type="date", :disabled-date |
| 重置密码结果弹窗 | el-dialog | 展示新密码 + 复制按钮 |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 续期日期 | 必填, 不早于当前日期 | "请选择有效期" / "有效期不能早于当前日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行排列;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 查询条件两行排列;"角色"列隐藏 |
| 768-1023pxPad竖屏 | 查询条件两行排列;列表仅显示:序号、登录账号、绑定物业、状态、操作 |
--- ---
## 页面8新增物业管理员账号页 ## 页面8新增物业管理员账号页
@ -446,6 +972,84 @@
|----------|---------|------|------| |----------|---------|------|------|
| 新增 | /api/v1/accounts/property-admin | POST | — | | 新增 | /api/v1/accounts/property-admin | POST | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行加载:绑定物业公司下拉 + 服务医院下拉 + 角色下拉(物业适用范围)
- 自动生成初始密码,有效期默认当前日期+1年
2. **表单填写与提交流程**
- 填写登录账号 → 失焦时校验唯一性
- 选择物业公司 → 选择服务医院 → 选择有效期 → 分配角色 → 点击"保存"→ 校验 → API → 成功返回列表页
3. **联动交互**
- 绑定物业公司下拉:来源于物业公司信息管理中启用状态的公司
- 选择物业公司后,服务医院下拉联动过滤该物业关联的医院
- 分配角色下拉:仅显示"适用范围=物业管理员"的启用角色
4. **异常处理**
- 登录账号重复 → 失焦时即时提示
- API失败 → 提示错误,表单不丢失
5. **权限控制交互**
- 无保存权限时 → "保存"按钮禁用
6. **[H1]防重复请求**
- 点击"保存"按钮后:按钮 :loading=true + 文案变为"保存中..." + 按钮disabledAPI返回成功/失败/超时)后恢复
- 保存期间不允许再次点击保存或关闭页面
7. **[H2]超时与加载反馈**
- 新增提交APIPOSTtimeout=30秒
- 提交中保存按钮保持:loading态 + 表单区域不可编辑(半透明遮罩可选)
- 超时处理:中断请求 → 提示"保存超时,请检查网络后重试" → 按钮恢复
8. **[H4]脏数据检测**
- 页面进入时deep clone表单初始数据作为快照
- 用户修改任意字段后标记isDirty=true
- 点击"取消"按钮或浏览器后退时:
* isDirty=true → ElMessageBox.confirm("当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?") → 确认则离开/取消则停留
* isDirty=false → 直接执行离开操作
- 使用vue-router的beforeRouteLeave导航守卫拦截路由切换
- 保存成功后将当前数据设为新快照重置isDirty=false
9. **[H8]操作结果反馈**
- 保存成功ElMessage.success("保存成功", duration=2000) → 延迟300ms后router.back()返回列表页
- 保存失败ElMessage.error(错误信息, duration=0);表单数据保持不丢失
- 唯一性校验失败对应字段下方红色文字提示不弹message
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 表单 | el-form | label-width="120px", :model, :rules |
| 登录账号 | el-input | maxlength=20, clearable |
| 初始密码 | el-input | maxlength=20, show-password |
| 绑定物业公司 | el-select | filterable, clearable |
| 服务医院 | el-select | filterable, clearable |
| 有效期至 | el-date-picker | type="date", :disabled-date, value-format="YYYY-MM-DD" |
| 分配角色 | el-select | multiple, filterable, collapse-tags |
| 保存按钮 | el-button | type="primary", :loading |
| 刷新密码按钮 | el-button | icon="Refresh", circle |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 登录账号 | 必填, 4-20位字母数字, 全局唯一 | "请输入登录账号" / "登录账号为4-20位字母数字" / "该登录账号已存在" |
| 初始密码 | 必填, 6-20位 | "请输入初始密码" / "密码长度6-20位" |
| 绑定物业公司 | 必填 | "请选择绑定物业公司" |
| 服务医院 | 必填 | "请选择服务医院" |
| 有效期至 | 必填, 不早于当前日期 | "请选择有效期" / "有效期不能早于当前日期" |
| 分配角色 | 必填, 至少选一个 | "请选择至少一个角色" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单两列排列 |
| 1024-1279pxPad横屏 | 表单两列排列 |
| 768-1023pxPad竖屏 | 表单单列排列 |
--- ---
## 页面9到期账号管理页 ## 页面9到期账号管理页
@ -516,6 +1120,79 @@
| 列表查询 | /api/v1/accounts/expiring | GET | 筛选到期账号 | | 列表查询 | /api/v1/accounts/expiring | GET | 筛选到期账号 |
| 续期 | /api/v1/accounts/{id}/renew | PUT | — | | 续期 | /api/v1/accounts/{id}/renew | PUT | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行调用:统计卡片数据 + 列表数据 + 下拉选项
- 统计卡片数据优先渲染,列表加载中显示骨架屏
- 点击统计卡片可快速筛选(点击"已过期"卡片 → 自动填充到期状态=已过期 → 触发查询)
2. **查询交互流程**
- 填写条件 → 点击"查询"→ 调用API → 重置到第1页
- 统计卡片数据随查询条件联动刷新
3. **行内操作流程**
- **续期**:点击"续期"→ 弹出续期弹窗(选择新有效期)→ 确认 → API调用 → 成功提示"续期成功"→ 刷新列表和统计卡片
4. **联动交互**
- 统计卡片点击 → 联动查询条件 → 触发筛选
5. **异常处理**
- 续期失败 → 提示错误信息
- 统计数据加载失败 → 卡片显示"--"占位,不影响列表操作
6. **权限控制交互**
- 无 `permission:user:update` → 行"续期"按钮不渲染
7. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(续期):点击操作按钮后该行所有操作按钮禁用 + 按钮显示loading旋转图标操作完成后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求(统计卡片+列表+下拉数据)之间互不阻塞
8. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 续期写操作APItimeout=30秒操作按钮:loading态
- 统计卡片数据加载APItimeout=15秒
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
9. **[H3]操作确认机制**
- 续期弹窗本身即为操作确认机制:选择新有效期后需用户点击"确定"才提交
- 确认弹窗期间底层页面不可点击modal遮罩
10. **[H8]操作结果反馈**
- 成功操作ElMessage.success("{操作}成功"duration=2000ms) → silent方式刷新列表数据和统计卡片不带loading闪烁
- 失败ElMessage.error(后端message或"操作失败,请稍后重试"duration=0手动关闭)
- 网络异常:提示"网络连接异常,请检查网络后重试"+ 提供"重试"文字按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 统计卡片 | el-card | shadow="hover", :body-style="{padding:'20px',cursor:'pointer'}" |
| 卡片数字 | div | font-size=28px, font-weight=bold |
| 已过期数字 | — | color=var(--el-color-danger) |
| 7天内到期数字 | — | color=var(--el-color-warning) |
| 30天内到期数字 | — | color=var(--el-color-primary) |
| 剩余天数 | el-tag | 已过期:type="danger", ≤7天:type="warning", 其他:type="info" |
| 续期弹窗 | el-dialog | width="400px", :close-on-click-modal=false |
| 续期日期选择 | el-date-picker | type="date", :disabled-date |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 续期日期 | 必填, 不早于当前日期 | "请选择有效期" / "有效期不能早于当前日期" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 统计卡片一行三个;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 统计卡片一行三个;"账号类型"列隐藏 |
| 768-1023pxPad竖屏 | 统计卡片一行三个(缩小间距);列表仅显示:序号、登录账号、绑定单位、剩余天数、操作 |
--- ---
## 页面10到期提醒规则配置页 ## 页面10到期提醒规则配置页
@ -561,6 +1238,84 @@
| 查询配置 | /api/v1/system/configs/expiry-reminder | GET | — | | 查询配置 | /api/v1/system/configs/expiry-reminder | GET | — |
| 保存配置 | /api/v1/system/configs/expiry-reminder | PUT | — | | 保存配置 | /api/v1/system/configs/expiry-reminder | PUT | — |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 调用 `GET /api/v1/system/configs/expiry-reminder` 加载当前配置 → 回填表单
- 加载中表单显示骨架屏
2. **表单填写与提交流程**
- 修改配置项 → 点击"保存"→ 前端校验(至少选一个提醒天数)→ 调用 `PUT` API → 成功提示"配置保存成功"
- 无需返回列表页,停留在当前页
3. **标签多选交互**
- "提前提醒天数"使用标签多选预设7/15/30/60/90天选项点击标签切换选中/取消
- 支持自定义天数输入(输入框+添加按钮)
- 至少保留一个选中项
4. **联动交互**
- "用户登录弹窗"关闭时 → "弹窗关闭后行为"选项才可编辑
- "过期后行为"选择"限制部分功能"时 → 显示附加配置(限制哪些功能)
5. **异常处理**
- 保存失败 → 提示错误信息,表单数据不丢失
- 配置加载失败 → 提示"配置加载失败",提供"重试"按钮
6. **权限控制交互**
- 无 `permission:config:update` → 表单所有控件禁用,"保存"按钮不渲染
7. **[H1]防重复请求**
- 点击"保存"按钮后:按钮 :loading=true + 文案变为"保存中..." + 按钮disabledAPI返回成功/失败/超时)后恢复
- 保存期间不允许再次点击保存或关闭页面
8. **[H2]超时与加载反馈**
- 保存配置提交APIPUTtimeout=30秒
- 查询配置回填APIGETtimeout=15秒
- 提交中保存按钮保持:loading态 + 表单区域不可编辑(半透明遮罩可选)
- 超时处理:中断请求 → 提示"保存超时,请检查网络后重试" → 按钮恢复
9. **[H4]脏数据检测**
- 页面进入时deep clone表单初始数据作为快照
- 用户修改任意字段后标记isDirty=true
- 点击"取消"按钮或浏览器后退时:
* isDirty=true → ElMessageBox.confirm("当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?") → 确认则离开/取消则停留
* isDirty=false → 直接执行离开操作
- 使用vue-router的beforeRouteLeave导航守卫拦截路由切换
- 保存成功后将当前数据设为新快照重置isDirty=false
10. **[H8]操作结果反馈**
- 保存成功ElMessage.success("配置保存成功", duration=2000);停留在当前页不跳转
- 保存失败ElMessage.error(错误信息, duration=0);表单数据保持不丢失
- 唯一性校验失败对应字段下方红色文字提示不弹message本页面无唯一性字段
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 表单 | el-form | label-width="140px", :model, :rules |
| 提前提醒天数 | el-check-tag | 多选模式预设7/15/30/60/90 |
| 自定义天数输入 | el-input-number | :min=1, :max=365, :step=1 |
| 用户登录弹窗 | el-switch | active-text="开启", inactive-text="关闭" |
| 弹窗关闭后行为 | el-radio-group | el-radio label="可正常使用"/"限制部分功能" |
| 过期后行为 | el-radio-group | el-radio label="禁止登录"/"仅提醒" |
| 保存按钮 | el-button | type="primary", :loading |
| 取消按钮 | el-button | @click="resetForm" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 提前提醒天数 | 必填, 至少选一个 | "请至少选择一个提醒天数" |
| 自定义天数 | 1-365整数 | "天数范围1-365" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 表单标签宽度140px输入区域宽度400px |
| 1024-1279pxPad横屏 | 表单标签宽度120px输入区域宽度350px |
| 768-1023pxPad竖屏 | 表单标签宽度100px输入区域宽度100% |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码permission > 模块编码permission
> 端侧Web专属仅超级管理员 > 端侧Web专属仅超级管理员
> 关联文档01-模块划分 §8 / 02-功能清单-超级管理员 §2 / 03-业务流转逻辑-超级管理员 §4~6 / 05-接口规范 §4.2~4.3 / 06-项目技术要求 §4.2 > 关联文档01-模块划分 §8 / 02-功能清单-超级管理员 §2 / 03-业务流转逻辑-超级管理员 §4~6 / 05-接口规范 §4.2~4.3 / 06-项目技术要求 §4.2
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -86,6 +87,86 @@
| 停用角色 | /api/v1/roles/{id}/disable | PUT | — | | 停用角色 | /api/v1/roles/{id}/disable | PUT | — |
| 删除角色 | /api/v1/roles/{id} | DELETE | 仅无关联账号时可删 | | 删除角色 | /api/v1/roles/{id} | DELETE | 仅无关联账号时可删 |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 调用 `GET /api/v1/roles` 加载列表默认第1页创建时间倒序
- 并行加载下拉选项:适用范围、状态
- 列表为空时显示空状态插图 + "暂无角色"
2. **查询交互流程**
- 填写查询条件 → 点击"查询"→ 调用API → 重置到第1页
- 点击"重置"→ 清空条件 → 重新加载
3. **行内操作流程**
- **编辑**:点击"编辑"→ 路由跳转 `/permission/roles/:id/edit`
- **权限预览**:点击"权限预览"→ 弹窗展示该角色的完整权限清单(调用 `GET /api/v1/roles/{id}/permissions`
- **停用**:二次确认("确认停用角色XX停用后关联账号将失去该角色的权限")→ 调用 `PUT /api/v1/roles/{id}/disable` → 成功提示 → 刷新列表
- **删除**:仅关联账号数=0时显示 → 二次确认("确认删除角色XX此操作不可恢复")→ 调用 `DELETE /api/v1/roles/{id}` → 成功提示 → 刷新列表
4. **联动交互**
- "关联账号数"可点击 → 弹窗展示使用该角色的账号列表
5. **异常处理**
- 删除角色失败(存在关联账号)→ 提示"该角色下存在关联账号,无法删除"
- 停用角色失败 → 提示具体原因
- API失败 → 通用错误提示
6. **权限控制交互**
- 无 `permission:role:create` → "新增角色"按钮不渲染
- 无 `permission:role:update` → "编辑""停用"按钮不渲染
- 无 `permission:role:delete` → "删除"按钮不渲染
- 无 `permission:role:view` → "权限预览"按钮不渲染
7. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(停用/删除等):点击操作按钮后该行所有操作按钮禁用 + 按钮显示loading旋转图标操作完成后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求之间互不阻塞
8. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 停用/删除等写操作APItimeout=30秒操作按钮:loading态
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
9. **[H3]操作确认机制**
- 停用角色ElMessageBox.confirm("确定要停用「{角色名}」吗?停用后关联账号将失去该角色的权限", "操作确认", { type: 'warning' })
- 删除角色ElMessageBox.confirm("确定要删除「{角色名}」吗?此操作不可恢复", "删除确认", { type: 'error' })
- 确认弹窗期间底层页面不可点击
10. **[H8]操作结果反馈**
- 成功ElMessage.success(duration=2000ms) → silent刷新列表
- 失败ElMessage.error(duration=0手动关闭)
- 网络异常:提示"网络连接异常"+ 重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 查询条件区 | el-form | inline, label-width="auto" |
| 适用范围下拉 | el-select | clearable, placeholder="请选择" |
| 状态标签 | el-tag | 启用:type="success", 停用:type="danger" |
| 预设模板标签 | el-tag | 是:type="primary", 否:type="info" |
| 关联账号数 | el-link | type="primary", underline=false, @click="showRelatedAccounts" |
| 操作按钮 | el-button | type="primary", link, size="small" |
| 删除按钮 | el-button | type="danger", link, size="small" |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 角色名称(查询) | 最大30字符 | — |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行排列;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 查询条件一行排列;"预设模板"列隐藏 |
| 768-1023pxPad竖屏 | 查询条件两行排列;列表仅显示:序号、角色名称、适用范围、状态、操作 |
--- ---
## 页面2角色新增/编辑页 ## 页面2角色新增/编辑页
@ -175,6 +256,93 @@
| 获取权限树 | /api/v1/permissions/tree | GET | 四级权限树结构 | | 获取权限树 | /api/v1/permissions/tree | GET | 四级权限树结构 |
| 权限预览 | /api/v1/roles/{id}/permissions | GET | 已分配的完整权限列表 | | 权限预览 | /api/v1/roles/{id}/permissions | GET | 已分配的完整权限列表 |
### 交互流程要求
1. **页面加载流程**
- 新增模式:空白表单 + 调用 `GET /api/v1/permissions/tree` 加载权限树
- 编辑模式:并行调用权限树 + 角色详情(`GET /api/v1/roles/{id}`)→ 回填表单 + 勾选已有权限
- 权限树加载中显示加载动画
2. **表单填写与提交流程**
- 填写基本信息 → 选择预设模板(可选)→ 自动填充权限树勾选 → 微调权限 → 点击"保存"
- 保存前校验:角色名称必填、适用范围必选、至少勾选一个权限项
- 保存成功 → 记录权限审计日志 → 权限实时生效Redis Pub/Sub→ 返回列表页
3. **权限树交互**
- 四级展开:功能菜单→页面→功能点→动作
- 勾选父节点 → 自动勾选所有子节点
- 取消父节点 → 自动取消所有子节点
- 子节点全选 → 父节点自动勾选
- 子节点部分选 → 父节点半选indeterminate
- 支持搜索过滤权限名称
4. **预设模板联动**
- 选择预设模板 → 弹出确认("选择模板将覆盖当前权限配置,是否继续?")→ 确认后自动勾选模板权限
- 模板填充后仍可手动微调
5. **权限预览交互**
- 点击"权限预览"→ 弹窗展示当前已勾选的权限清单(非保存后的,而是实时的)
- 权限预览弹窗同页面3格式
6. **异常处理**
- 角色名称重复 → 提示"该角色名称已存在"
- 权限树加载失败 → 提示"权限配置加载失败",提供"重试"按钮
- 保存失败 → 提示错误信息,表单数据不丢失
7. **权限控制交互**
- 无保存权限 → "保存"按钮禁用
8. **[H1]防重复请求**
- 点击"保存"按钮后::loading=true + 文案"保存中..." + disabledAPI返回后恢复
9. **[H2]超时与加载反馈**
- 提交APIPOST/PUTtimeout=30秒回填APIGETtimeout=15秒
- 超时处理:中断 → 提示"保存超时..." → 按钮恢复
10. **[H3]操作确认机制**
- 选择预设模板时ElMessageBox.confirm("选择模板将覆盖当前权限配置,是否继续?", { type: 'warning' })
11. **[H4]脏数据检测**
- 编辑模式进入时deep clone初始数据为快照
- 修改任意字段→isDirty=true
- 取消/路由离开时isDirty则ElMessageBox.confirm("当前修改尚未保存...")
- beforeRouteLeave导航守卫拦截
- 保存成功后重置快照和isDirty
12. **[H8]操作结果反馈**
- 成功ElMessage.success("保存成功") → 延迟300ms返回列表页
- 失败ElMessage.error(duration=0);表单数据保持不丢失
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 表单 | el-form | label-width="100px", :model, :rules |
| 角色名称 | el-input | maxlength=30, show-word-limit, clearable |
| 角色描述 | el-input | type="textarea", maxlength=200, show-word-limit, :rows=3 |
| 适用范围 | el-select | clearable, placeholder="请选择适用范围" |
| 预设模板 | el-select | clearable, placeholder="可选,选择后自动填充权限" |
| 权限树 | el-tree | show-checkbox, check-strictly=false, default-expand-all=false, :data="permissionTree", node-key="code", :filter-node-method |
| 权限搜索 | el-input | placeholder="搜索权限名称", clearable, prefix-icon="Search" |
| 权限预览按钮 | el-button | type="info", plain |
| 保存按钮 | el-button | type="primary", :loading |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 角色名称 | 必填, 2-30字符, 全局唯一 | "请输入角色名称" / "角色名称长度2-30字符" / "该角色名称已存在" |
| 适用范围 | 必填 | "请选择适用范围" |
| 权限勾选 | 至少勾选一个 | "请至少分配一项权限" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 基本信息两列排列权限树区域宽度100%高度500px可滚动 |
| 1024-1279pxPad横屏 | 基本信息两列排列权限树区域高度400px |
| 768-1023pxPad竖屏 | 基本信息单列排列权限树区域高度350px默认折叠到第二级 |
--- ---
## 页面3权限预览弹窗 ## 页面3权限预览弹窗
@ -212,6 +380,54 @@
- 每个动作用 ✓/✗ 标识是否有权限 - 每个动作用 ✓/✗ 标识是否有权限
- 支持搜索功能菜单/页面名称 - 支持搜索功能菜单/页面名称
### 交互流程要求
1. **弹窗打开流程**
- 从角色列表页或角色编辑页触发 → 调用 `GET /api/v1/roles/{id}/permissions` 加载权限数据 → 渲染权限树
- 加载中弹窗内显示loading
2. **弹窗内交互**
- 搜索框输入关键词 → 实时过滤权限树,高亮匹配项
- 点击展开/折叠节点
- 只读模式,不可修改勾选状态
3. **弹窗关闭**
- 点击"关闭"按钮或右上角× → 关闭弹窗
- 支持ESC键关闭
4. **[H1]防重复请求**
- 弹窗打开时调用API期间"关闭"按钮禁用 + 弹窗内显示v-loading遮罩数据返回后恢复
5. **[H2]超时与加载反馈**
- 权限查询APIGETtimeout=15秒加载中弹窗内显示loading骨架屏
- 超时处理:中断 → ElMessage.error("权限数据加载超时") → 提供"重试"按钮
6. **[H8]操作结果反馈**
- 加载失败ElMessage.error(duration=0手动关闭) + 弹窗内展示重试按钮
- 网络异常:提示"网络连接异常"+ 重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 弹窗 | el-dialog | width="600px", :close-on-click-modal=true, title="权限预览" |
| 权限树 | el-tree | :data, default-expand-all, :props, :filter-node-method |
| 搜索框 | el-input | placeholder="搜索权限名称", clearable, prefix-icon="Search" |
| 权限标识 | el-tag | 有权限:type="success", ✓; 无权限:type="info", ✗ |
| 关闭按钮 | el-button | @click="dialogVisible=false" |
### 校验规则
无表单校验(只读展示页)
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 弹窗宽度600px |
| 1024-1279pxPad横屏 | 弹窗宽度550px |
| 768-1023pxPad竖屏 | 弹窗宽度90vw最大500px |
--- ---
## 页面4权限配置注册页 ## 页面4权限配置注册页
@ -274,6 +490,68 @@
| 列表查询 | /api/v1/permissions/registry | GET | 查看已注册的权限配置 | | 列表查询 | /api/v1/permissions/registry | GET | 查看已注册的权限配置 |
| 刷新 | /api/v1/permissions/registry/refresh | POST | 手动触发权限重新扫描 | | 刷新 | /api/v1/permissions/registry/refresh | POST | 手动触发权限重新扫描 |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 调用 `GET /api/v1/permissions/registry` 加载权限配置列表
- 页面顶部显示说明提示条el-alert说明此页面为只读
2. **查询交互流程**
- 填写查询条件 → 点击"查询"→ 筛选列表 → 重置到第1页
3. **操作流程**
- **刷新**:点击"刷新"按钮 → 二次确认("确认重新扫描权限配置?可能需要数秒")→ 调用 `POST /api/v1/permissions/registry/refresh` → 成功提示 → 重新加载列表
- 无其他操作按钮(只读页面)
4. **异常处理**
- 刷新失败 → 提示"权限扫描失败,请稍后重试"
5. **权限控制交互**
- 此页面仅超级管理员可见
6. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 刷新操作:点击"刷新"按钮后:loading=true + disabledAPI返回后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求之间互不阻塞
7. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 刷新操作APIPOSTtimeout=30秒操作按钮:loading态
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
8. **[H3]操作确认机制**
- 刷新权限配置ElMessageBox.confirm("确认重新扫描权限配置?可能需要数秒", "操作确认", { type: 'warning' })
- 确认弹窗期间底层页面不可点击
9. **[H8]操作结果反馈**
- 成功ElMessage.success(duration=2000ms) → silent刷新列表
- 失败ElMessage.error(duration=0手动关闭)
- 网络异常:提示"网络连接异常"+ 重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 说明提示条 | el-alert | type="info", :closable=false, show-icon |
| 列表 | el-table | stripe, border, v-loading |
| 可用动作 | el-tag | v-for 循环渲染, type="info", size="small" |
| 刷新按钮 | el-button | type="warning", icon="Refresh", plain |
| 分页 | el-pagination | layout="total, sizes, prev, pager, next" |
### 校验规则
无表单校验(只读页面)
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 列表完整展示所有列 |
| 1024-1279pxPad横屏 | "功能点编码"列隐藏 |
| 768-1023pxPad竖屏 | 列表仅显示:模块名称、页面名称、功能点名称、可用动作 |
--- ---
## 页面5权限审计日志页 ## 页面5权限审计日志页
@ -354,6 +632,71 @@
| 列表查询 | /api/v1/audit-logs/permission | GET | 分页查询 | | 列表查询 | /api/v1/audit-logs/permission | GET | 分页查询 |
| 详情查询 | /api/v1/audit-logs/permission/{id} | GET | 含变更前后对比 | | 详情查询 | /api/v1/audit-logs/permission/{id} | GET | 含变更前后对比 |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 调用 `GET /api/v1/audit-logs/permission` 加载列表(默认倒序)
- 并行加载下拉选项:操作类型
- 列表为空时显示空状态
2. **查询交互流程**
- 填写条件 → 点击"查询"→ 调用API → 重置到第1页
- 日期范围选择器限制最多查询90天
3. **行内操作流程**
- **查看详情**:点击"查看"→ 弹窗展示变更前后对比(调用 `GET /api/v1/audit-logs/permission/{id}`
- 新增权限用绿色 `[+新增]` 标记,移除权限用红色 `[-移除]` 标记
4. **异常处理**
- 详情加载失败 → 提示错误信息
- 日期范围超限 → 提示"查询时间范围不能超过90天"
5. **权限控制交互**
- 无 `audit-log:permission:view` → 页面不可见
6. **[H1]防重复请求**
- 查询/筛选:点击"查询"按钮后立即 disabled=true + 显示loading态API返回后恢复查询期间再次点击无效
- 行内操作(查看详情):点击"查看"按钮后该行操作按钮禁用 + 按钮显示loading旋转图标弹窗打开后恢复
- 分页切换切换页码时取消上一页未完成请求abortController后再发起新请求
- 页面初始化并行请求之间互不阻塞
7. **[H2]超时与加载反馈**
- 列表查询APItimeout=15秒加载中表格区显示v-loading骨架屏遮罩
- 详情查询APItimeout=15秒加载中弹窗内显示v-loading遮罩
- 超时处理:自动中断 → ElMessage.error("请求超时,请检查网络后重试") → 按钮恢复可用
- 列表加载超过2秒时显示全局ElLoading进度提示
8. **[H8]操作结果反馈**
- 成功ElMessage.success(duration=2000ms) → silent刷新列表
- 失败ElMessage.error(duration=0手动关闭)
- 网络异常:提示"网络连接异常"+ 重试按钮
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 日期范围选择 | el-date-picker | type="daterange", value-format="YYYY-MM-DD", :disabled-date |
| 操作类型标签 | el-tag | 角色创建:type="success", 权限修改:type="warning", 角色分配:type="primary", 角色移除:type="danger" |
| 变更摘要 | span | [+N项] color=green, [-M项] color=red |
| 详情弹窗 | el-dialog | width="650px", :close-on-click-modal=false |
| 新增权限 | div | color=var(--el-color-success), 前缀"[+新增]" |
| 移除权限 | div | color=var(--el-color-danger), 前缀"[-移除]" |
| 关闭按钮 | el-button | @click="dialogVisible=false" |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期范围 | 最多90天 | "查询时间范围不能超过90天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行排列列表完整展示所有列详情弹窗650px |
| 1024-1279pxPad横屏 | 查询条件两行排列;"目标角色"列隐藏详情弹窗600px |
| 768-1023pxPad竖屏 | 查询条件两行排列列表仅显示序号、操作时间、操作人、操作类型、操作详情弹窗90vw |
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码system > 模块编码system
> 端侧Web专属仅超级管理员 > 端侧Web专属仅超级管理员
> 关联文档01-模块划分 §2.3 / 02-功能清单-超级管理员 §3 / 03-业务流转逻辑-超级管理员 §9 / 05-接口规范 §9.2 / 06-项目技术要求 §8.3~8.4 > 关联文档01-模块划分 §2.3 / 02-功能清单-超级管理员 §3 / 03-业务流转逻辑-超级管理员 §9 / 05-接口规范 §9.2 / 06-项目技术要求 §8.3~8.4
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -98,6 +99,91 @@
| 编辑 | /api/v1/system/versions/{id} | PUT | — | | 编辑 | /api/v1/system/versions/{id} | PUT | — |
| 获取最新版本 | /api/v1/system/versions/latest | GET | 小程序启动时调用 | | 获取最新版本 | /api/v1/system/versions/latest | GET | 小程序启动时调用 |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行调用:当前版本信息卡片数据 + 版本列表数据
- 卡片数据优先渲染
2. **操作流程**
- **新增版本**:点击"新增版本"→ 弹出表单弹窗 → 填写版本信息 → 保存 → 刷新列表和卡片
- **编辑**:点击行"编辑"→ 弹出表单弹窗(回填数据)→ 修改 → 保存 → 刷新列表和卡片
3. **弹窗交互**
- 新增/编辑共用一个 `el-dialog` 弹窗
- 保存成功后弹窗关闭,列表刷新
4. **异常处理**
- 版本号格式校验失败 → 字段下方红色提示
- 版本号已存在 → 提示"该版本号已存在"
- 新增版本后小程序端自动检测更新
5. **权限控制交互**
- 无 `system:version:create` → "新增版本"按钮不渲染
- 无 `system:version:update` → 行"编辑"按钮不渲染
6. **[H1]防重复请求**(列表页)
- 行内操作按钮点击后整行操作禁用 + loading态
- 分页切换 abort上一请求后再发新请求
7. **[H2]超时与加载反馈**(列表页)
- 列表查询 timeout=15秒写操作(新增/编辑) timeout=30秒
- 超时 → ElMessage.error("请求超时,请稍后重试") + 按钮恢复
- 加载>2秒显示全局ElLoading
8. **[H3]操作确认机制**
- 版本发布无需额外确认(弹窗内已有保存按钮)
9. **[H8]操作结果反馈**
- 成功 ElMessage.success(2s) → silent刷新
- 失败 ElMessage.error(0手动关闭)
10. **[H1]防重复请求**(弹窗内保存按钮)
- 保存按钮 :loading + disabledAPI返回后恢复
11. **[H2]超时与加载反馈**(弹窗内提交)
- 弹窗内提交 timeout=30秒
12. **[H4]脏数据检测**(编辑模式弹窗)
- 弹窗打开时记录快照
- 关闭弹窗/点击取消时 isDirty 检测 → 确认提示"修改尚未保存,确定关闭吗?"
13. **[H8]操作结果反馈**(弹窗)
- 保存成功 → 关闭弹窗 → 刷新列表
- 保存失败 → 弹窗保持打开,错误提示
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 版本信息卡片 | el-card | shadow="hover", :body-style="{padding:'20px'}" |
| 当前版本号 | div | font-size=20px, font-weight=bold |
| 版本列表 | el-table | stripe, border, v-loading |
| 端侧标签 | el-tag | Web:type="primary", 小程序:type="success" |
| 强制更新标签 | el-tag | 是:type="danger", 否:type="success" |
| 新增/编辑弹窗 | el-dialog | width="500px", :close-on-click-modal=false |
| 版本号输入 | el-input | placeholder="x.y.z格式", clearable |
| 端侧下拉 | el-select | :options="[{label:'Web',value:'web'},{label:'小程序',value:'miniapp'}]" |
| 强制更新开关 | el-switch | active-text="是", inactive-text="否" |
| 更新说明 | el-input | type="textarea", :rows=4, maxlength=500, show-word-limit |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 版本号 | 必填, 语义化格式(/^\d+\.\d+\.\d+$/) | "请输入版本号" / "版本号格式为x.y.z" |
| 端侧 | 必填 | "请选择端侧" |
| 最低兼容版本 | 必填, 语义化格式 | "请输入最低兼容版本" / "版本号格式为x.y.z" |
| 更新说明 | 必填, 最大500字符 | "请输入更新说明" / "更新说明不能超过500字符" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 版本卡片一行两个;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 版本卡片一行两个;"更新说明"列隐藏 |
| 768-1023pxPad竖屏 | 版本卡片一行一个(全宽);列表仅显示:序号、版本号、端侧、是否强制更新、操作
--- ---
## 页面2缓存管理页 ## 页面2缓存管理页
@ -159,6 +245,68 @@
| 清理单模块 | /api/v1/system/cache/clear/{module} | POST | 清理指定模块缓存 | | 清理单模块 | /api/v1/system/cache/clear/{module} | POST | 清理指定模块缓存 |
| 全部清理 | /api/v1/system/cache/clear-all | POST | 清理全部缓存 | | 全部清理 | /api/v1/system/cache/clear-all | POST | 清理全部缓存 |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 并行调用:缓存状态概览(`GET /api/v1/system/cache/status`+ 缓存模块列表
- 概览数据优先渲染
2. **操作流程**
- **清理单模块**:点击行"清理"→ 二次确认("确认清理XX缓存清理后相关数据将从数据库重新加载")→ 调用API → 成功提示 → 刷新缓存状态
- **全部清理**:点击"全部清理"→ 二次确认("确认清理全部缓存?所有在线用户将收到刷新提示")→ 调用API → 成功提示 → 刷新缓存状态
3. **缓存状态实时更新**
- 清理操作完成后自动刷新状态概览和模块列表
- 支持手动刷新(页面顶部刷新按钮)
4. **异常处理**
- Redis连接断开 → 状态概览显示"● 未连接"红色标识,清理按钮禁用
- 清理失败 → 提示错误信息
5. **权限控制交互**
- 无 `system:cache:update` → 所有清理按钮不渲染
6. **[H1]防重复请求**
- 行内清理按钮点击后整行操作禁用 + loading态
- "全部清理"按钮点击后 disabled + loading态API返回后恢复
7. **[H2]超时与加载反馈**
- 写操作(缓存清理) timeout=30秒
- 超时 → ElMessage.error("请求超时,请稍后重试") + 按钮恢复
- 加载>2秒显示全局ElLoading
8. **[H3]操作确认机制**
- 缓存清理ElMessageBox.confirm("确认清理XX缓存清理后相关数据将从数据库重新加载", { type: 'warning' })
- 全部清理ElMessageBox.confirm("确认清理全部缓存?所有在线用户将收到刷新提示", { type: 'error' })
9. **[H8]操作结果反馈**
- 成功 ElMessage.success(2s) → silent刷新
- 失败 ElMessage.error(0手动关闭)
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| Redis状态指示 | el-badge | 已连接:type="success", 未连接:type="danger" |
| 内存使用 | el-progress | :percentage, :stroke-width=10, :color |
| 缓存模块列表 | el-table | stripe, :show-header=true |
| 缓存条数 | el-statistic | :value, 千分位格式化 |
| 清理按钮(行) | el-button | type="danger", link, size="small", icon="Delete" |
| 全部清理按钮 | el-button | type="danger", icon="Delete", plain |
| 刷新按钮 | el-button | icon="Refresh", circle |
### 校验规则
无表单校验(操作类页面)
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 状态概览一行展示;缓存模块列表完整展示 |
| 1024-1279pxPad横屏 | 状态概览一行展示;"最后更新时间"列隐藏 |
| 768-1023pxPad竖屏 | 状态概览竖向堆叠;缓存模块列表仅显示:模块名称、缓存条数、操作
--- ---
## 需求追溯 ## 需求追溯

@ -3,6 +3,7 @@
> 模块编码audit-log > 模块编码audit-log
> 端侧Web专属仅超级管理员 > 端侧Web专属仅超级管理员
> 关联文档01-模块划分 §2.3 / 02-功能清单-超级管理员 §4 / 03-业务流转逻辑-超级管理员 §8 / 05-接口规范 §9.2 / 06-项目技术要求 §4.5 > 关联文档01-模块划分 §2.3 / 02-功能清单-超级管理员 §4 / 03-业务流转逻辑-超级管理员 §8 / 05-接口规范 §9.2 / 06-项目技术要求 §4.5
> 强制规范遵循 `07-前端界面开发规范.md`
## 功能概览 ## 功能概览
@ -95,6 +96,73 @@
| 列表查询 | /api/v1/audit-logs/permission | GET | 分页查询 | | 列表查询 | /api/v1/audit-logs/permission | GET | 分页查询 |
| 详情查询 | /api/v1/audit-logs/permission/{id} | GET | 含变更前后对比 | | 详情查询 | /api/v1/audit-logs/permission/{id} | GET | 含变更前后对比 |
### 交互流程要求
1. **页面加载流程**
- 进入页面 → 调用 `GET /api/v1/audit-logs/permission` 加载列表(默认倒序)
- 并行加载操作类型下拉
- 列表为空时显示空状态
2. **查询交互流程**
- 填写条件 → 点击"查询"→ 调用API → 重置到第1页
- 日期范围最多查询90天
3. **行内操作流程**
- **查看详情**:点击"查看"→ 弹窗展示变更前后对比
- 新增权限绿色 `[+新增]` 标记,移除权限红色 `[-移除]` 标记
4. **异常处理**
- 日期范围超限 → 提示"查询时间范围不能超过90天"
- 详情加载失败 → 提示错误信息
5. **权限控制交互**
- 无 `audit-log:permission:view` → 页面不可见
6. **[H1]防重复请求**(列表页)
- 查询按钮点击后 disabled + loading态API返回后恢复
- 行内"查看"按钮点击后整行操作禁用 + loading态
- 分页切换 abort上一请求后再发新请求
7. **[H2]超时与加载反馈**(列表页)
- 列表查询 timeout=15秒
- 超时 → ElMessage.error("请求超时,请稍后重试") + 按钮恢复
- 加载>2秒显示全局ElLoading
8. **[H8]操作结果反馈**
- 查询成功 → 正常渲染;查询失败 → ElMessage.error(0手动关闭)
9. **[H1]防重复请求**(详情弹窗)
- 详情查看:点击"查看"后该行按钮disabled弹窗加载完成后恢复
10. **[H2]超时与加载反馈**(详情弹窗)
- 详情查询 timeout=15秒
- 弹窗内 v-loading 展示加载态
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 日期范围选择 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 操作类型标签 | el-tag | 角色创建:type="success", 权限修改:type="warning", 角色分配:type="primary", 角色移除:type="danger" |
| 变更摘要 | span | [+N项] color=green, [-M项] color=red |
| 详情弹窗 | el-dialog | width="650px" |
| 新增权限 | div | color=var(--el-color-success) |
| 移除权限 | div | color=var(--el-color-danger) |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期范围 | 最多90天 | "查询时间范围不能超过90天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px桌面端 | 查询条件一行排列;列表完整展示所有列 |
| 1024-1279pxPad横屏 | 查询条件两行排列;"目标角色"列隐藏 |
| 768-1023pxPad竖屏 | 查询条件两行排列;列表仅显示:序号、操作时间、操作人、操作类型、操作 |
--- ---
## 页面2账号操作日志页 ## 页面2账号操作日志页
@ -182,6 +250,58 @@
| 列表查询 | /api/v1/audit-logs/account | GET | 分页查询 | | 列表查询 | /api/v1/audit-logs/account | GET | 分页查询 |
| 详情查询 | /api/v1/audit-logs/account/{id} | GET | 含变更前后数据 | | 详情查询 | /api/v1/audit-logs/account/{id} | GET | 含变更前后数据 |
### 交互流程要求
1. **页面加载流程**:进入页面 → 调用列表API加载默认倒序→ 并行加载操作类型和账号类型下拉
2. **查询交互**:填写条件 → 点击"查询"→ API调用 → 重置到第1页日期范围最多90天
3. **行内操作**:点击"查看"→ 弹窗展示完整操作信息操作人、时间、IP、变更前后数据对比
4. **异常处理**:日期超限提示;详情加载失败提示错误
5. **权限控制**:无 `audit-log:list:view` → 页面不可见
6. **[H1]防重复请求**(列表页)
- 查询按钮点击后 disabled + loading态API返回后恢复
- 行内"查看"按钮点击后整行操作禁用 + loading态
- 分页切换 abort上一请求后再发新请求
7. **[H2]超时与加载反馈**(列表页)
- 列表查询 timeout=15秒
- 超时 → ElMessage.error("请求超时,请稍后重试") + 按钮恢复
- 加载>2秒显示全局ElLoading
8. **[H8]操作结果反馈**
- 查询成功 → 正常渲染;查询失败 → ElMessage.error(0手动关闭)
9. **[H1]防重复请求**(详情弹窗)
- 详情查看:点击"查看"后该行按钮disabled弹窗加载完成后恢复
10. **[H2]超时与加载反馈**(详情弹窗)
- 详情查询 timeout=15秒
- 弹窗内 v-loading 展示加载态
### 组件规范
| 元素 | 组件 | 配置参数 |
|------|------|----------|
| 日期范围选择 | el-date-picker | type="daterange", value-format="YYYY-MM-DD" |
| 操作类型标签 | el-tag | 创建:type="success", 编辑:type="primary", 启用:type="success", 禁用:type="danger", 续期:type="warning", 重置密码:type="info" |
| 详情弹窗 | el-dialog | width="600px" |
| 变更前数据 | el-descriptions | title="变更前", :column=1, border |
| 变更后数据 | el-descriptions | title="变更后", :column=1, border |
### 校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| 日期范围 | 最多90天 | "查询时间范围不能超过90天" |
### 响应式布局
| 断点 | 布局调整 |
|------|----------|
| ≥1280px | 查询条件一行;列表完整展示 |
| 1024-1279px | 查询条件两行;"绑定单位"列隐藏 |
| 768-1023px | 查询条件两行;列表仅显示:序号、操作时间、操作人、操作类型、详情 |
--- ---
## 需求追溯 ## 需求追溯

@ -122,6 +122,8 @@ Repository层数据访问MyBatis-Plus Mapper
- 接口函数命名:`get{Resource}List`、`create{Resource}`、`update{Resource}`、`delete{Resource}` - 接口函数命名:`get{Resource}List`、`create{Resource}`、`update{Resource}`、`delete{Resource}`
- 禁止在组件中直接调用 Axios - 禁止在组件中直接调用 Axios
> **前端界面开发规范**开发流程、Mock数据、组件标准、H约束实现、共享组件管理、自测标准→ 详见 `07-前端界面开发规范.md`
--- ---
## 三、小程序开发规范uni-app ## 三、小程序开发规范uni-app

@ -151,8 +151,24 @@ POST /api/v1/files/upload
- 请求格式:`multipart/form-data` - 请求格式:`multipart/form-data`
- 参数:`file`(文件)、`module`所属模块REPAIR/INSPECTION/CLEANING/EVALUATION/CONTRACT、`add_watermark`是否添加水印默认true - 参数:`file`(文件)、`module`所属模块REPAIR/INSPECTION/CLEANING/EVALUATION/CONTRACT、`add_watermark`是否添加水印默认true
- 存储方式:腾讯云 COS - **存储方式:独立文件存储站点**(详见 `06-项目技术要求.md` 第十一章)
- 存储路径规则:`{module}/{YYYY/MM}/{uuid}.{ext}`
- **图片自动压缩**上传时服务端自动按配置参数压缩质量75、最大1920px并生成缩略图300px
- 大小限制20MB - 大小限制20MB
- 响应示例:
```json
{
"code": 200,
"data": {
"fileId": "550e8400-e29b-41d4-a716-446655440000",
"originalName": "photo.jpg",
"url": "/files/repair/2026/04/550e8400-e29b-41d4-a716-446655440000.jpg",
"thumbnailUrl": "/files/thumbnail/repair/2026/04/550e8400-e29b-41d4-a716-446655440000.jpg",
"size": 256000,
"mimeType": "image/jpeg"
}
}
```
### 3.2 图片水印规范 ### 3.2 图片水印规范

@ -1,8 +1,8 @@
# 医院物业SaaS管理后台 — 项目技术要求 # 医院物业SaaS管理后台 — 项目技术要求
> 版本v1.1 > 版本v1.2
> 定位:内部团队开发标准,所有开发人员必须严格按照此标准执行 > 定位:内部团队开发标准,所有开发人员必须严格按照此标准执行
> 日期2026-04-16 > 日期2026-04-17
--- ---
@ -19,8 +19,6 @@
| MyBatis-Plus | 3.5+ | ORM框架 | | MyBatis-Plus | 3.5+ | ORM框架 |
| Spring Security | 6.x | 认证与授权 | | Spring Security | 6.x | 认证与授权 |
| JWT (jjwt) | 0.12+ | Token生成与校验 | | JWT (jjwt) | 0.12+ | Token生成与校验 |
| 腾讯云COS SDK | 最新稳定版 | 文件存储 |
| ShedLock | 5.x+ | 分布式定时任务锁 |
### 1.2 前端Web技术栈 ### 1.2 前端Web技术栈
@ -41,14 +39,12 @@
|------|----------|------| |------|----------|------|
| uni-app | 3.x | 跨端框架Vue 3模式 | | uni-app | 3.x | 跨端框架Vue 3模式 |
| uni-ui | 最新稳定版 | UI组件库 | | uni-ui | 最新稳定版 | UI组件库 |
| uni-ble | 兼容最新版 | 蓝牙低功耗(BLE)插件 |
### 1.4 基础设施 ### 1.4 基础设施
| 技术 | 用途 | | 技术 | 用途 |
|------|------| |------|------|
| Docker + Docker Compose | 私有云容器化部署 | | Nginx | 反向代理 + 静态资源 + 文件存储映射 |
| Nginx | 反向代理 + 静态资源 |
| Jenkins / GitLab CI | CI/CD流水线 | | Jenkins / GitLab CI | CI/CD流水线 |
| Git | 版本管理 | | Git | 版本管理 |
@ -83,7 +79,7 @@
│ 数据层 │ │ 数据层 │
│ MariaDB主库 ──复制──▶ MariaDB从库读写分离 │ MariaDB主库 ──复制──▶ MariaDB从库读写分离
│ Redis 缓存(权限/字典/菜单) │ │ Redis 缓存(权限/字典/菜单) │
腾讯云COS照片/附件/合同文件) 独立文件存储站点(照片/附件/合同文件)
└──────────────────────────────────────────────────┘ └──────────────────────────────────────────────────┘
``` ```
@ -168,10 +164,10 @@
### 3.4 定时任务 ### 3.4 定时任务
- **替代方案**Spring `@Scheduled` + 数据库分布式锁 - **实现方案**Spring `@Scheduled`(当前为单实例部署,无需分布式锁)
- **分布式锁实现**ShedLock 或数据库行锁(`SELECT ... FOR UPDATE`
- **适用场景**巡检任务自动生成、保洁超时预警、合同到期提醒、Beacon心跳检测 - **适用场景**巡检任务自动生成、保洁超时预警、合同到期提醒、Beacon心跳检测
- **实现要点**:定时方法加 `@Scheduled` 注解声明cron表达式配合 `@ShedLock` 注解声明锁名称、最少/最多持有时间,确保多实例部署时同一时刻仅一个实例执行 - **实现要点**:定时方法加 `@Scheduled` 注解声明cron表达式即可
- **扩展说明**:如未来需要多实例部署,可引入 ShedLock 或数据库行锁(`SELECT ... FOR UPDATE`)防止重复执行
--- ---
@ -358,6 +354,7 @@
- **前后端协作规范** → 详见 `04-开发与测试规范.md` 第五章 - **前后端协作规范** → 详见 `04-开发与测试规范.md` 第五章
- **代码审查要求** → 详见 `04-开发与测试规范.md` 第六章 - **代码审查要求** → 详见 `04-开发与测试规范.md` 第六章
- **测试规范**(单元测试、集成测试、蓝牙测试、性能测试、安全测试)→ 详见 `04-开发与测试规范.md` 第七章 - **测试规范**(单元测试、集成测试、蓝牙测试、性能测试、安全测试)→ 详见 `04-开发与测试规范.md` 第七章
- **前端界面开发规范**开发流程、Mock数据、组件标准、H约束实现、共享组件管理、自测标准→ 详见 `07-前端界面开发规范.md`
--- ---
@ -366,41 +363,67 @@
### 8.1 部署架构(私有云) ### 8.1 部署架构(私有云)
``` ```
┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────────────
│ 私有云服务器 │ │ 私有云服务器 │
│ │ │ │
│ ┌─────────────┐ ┌─────────────────┐ │ │ ┌─────────────────────────────────────────────┐ │
│ │ Nginx │ │ Docker Compose │ │ │ │ Nginx │ │
│ │ 反向代理 │──│ ┌────────────┐ │ │ │ │ :80/:443 │ │
│ │ 静态资源 │ │ │ Spring Boot│ │ │ │ │ ┌──────────┐ ┌───────────┐ ┌────────────┐ │ │
│ └─────────────┘ │ │ 应用容器 │ │ │ │ │ │ API反向代理│ │ 前端静态 │ │ 文件存储 │ │ │
│ │ └────────────┘ │ │ │ │ │ → :8080 │ │ dist目录 │ │ 映射 │ │ │
│ │ ┌────────────┐ │ │ │ │ └──────────┘ └───────────┘ └────────────┘ │ │
│ │ │ Redis │ │ │ │ └─────────────────────┬───────────────────────┘ │
│ │ │ 缓存容器 │ │ │ │ │ │
│ │ └────────────┘ │ │ │ ┌─────────────────────▼─────────────────────┐ │
│ └─────────────────┘ │ │ │ Spring Boot 应用 (:8080) │ │
│ └─────────────────────┬─────────────────────┘ │
│ │ │
│ ┌─────────────────────▼──┐ ┌──────────────┐ │
│ │ Redis (:6379) │ │ 文件存储目录 │ │
│ │ │ │ /data/files/ │ │
│ └────────────────────────┘ └──────────────┘ │
│ │ │ │
│ ┌─────────────┐ ┌─────────────────┐ │ │ ┌────────────┐ ┌────────────┐
│ │ MariaDB主库 │ │ MariaDB从库 │ │ │ │ MariaDB主库 │ │ MariaDB从库 │ │
│ │ 写操作 │──│ 读操作 │ │ │ └────────────┘ └────────────┘ │
│ └─────────────┘ └─────────────────┘ │ └──────────────────────────────────────────────────┘
└──────────────────────────────────────────┘
``` ```
### 8.2 Docker Compose部署要求 ### 8.2 传统部署要求
#### 8.2.1 应用部署
| 组件 | 部署方式 | 说明 |
|------|----------|------|
| Spring Boot应用 | JAR包直接运行`java -jar` | 端口8080建议使用 systemd 管理进程 |
| Redis | 直接安装yum/apt 或官方二进制包) | 版本7.x端口6379开启持久化 |
| MariaDB主库 | 直接安装 | 版本10.6+,负责写操作 |
| MariaDB从库 | 直接安装 | 版本10.6+,配置主从复制,负责读操作 |
| Nginx | 直接安装 | 反向代理API + 前端静态资源 + 文件存储映射 |
- **环境变量注入**:通过 `SPRING_PROFILES_ACTIVE`、`SPRING_DATASOURCE_URL`、`SPRING_DATASOURCE_SLAVE_URL`、`SPRING_REDIS_HOST` 等环境变量配置连接信息,**禁止硬编码**
- **JVM参数建议**`-Xms512m -Xmx1024 -XX:+UseG1GC`
- **应用启动用户**使用非root用户运行Spring Boot应用
部署包含以下服务容器: #### 8.2.2 文件存储站点部署
| 服务 | 镜像 | 说明 | 文件存储作为**独立存储目录**部署,通过 Nginx 提供静态文件访问服务:
|------|------|------|
| app | hospital-mgmt:latest | Spring Boot应用端口8080依赖Redis和MariaDB主库 |
| redis | redis:7-alpine | 缓存服务,持久化存储 |
| mariadb-master | mariadb:10.6 | 主库(写),持久化存储 |
| mariadb-slave | mariadb:10.6 | 从库(读),依赖主库,持久化存储 |
| nginx | nginx:alpine | 反向代理+静态资源端口80/443挂载自定义nginx.conf和前端dist目录 |
- **环境变量注入**:通过 `SPRING_PROFILES_ACTIVE`、`SPRING_DATASOURCE_URL`、`SPRING_DATASOURCE_SLAVE_URL`、`SPRING_REDIS_HOST` 等环境变量配置连接信息,禁止硬编码 ```
/data/file-storage/
├── repair/ # 报修模块
├── inspection/ # 巡检模块
├── cleaning/ # 保洁模块
├── evaluation/ # 评价模块
├── contract/ # 合同模块
└── temp/ # 临时目录
```
- **Nginx 配置**:在 Nginx 中添加 location 映射到 `/data/file-storage/` 目录(如 `/files/` 路径或独立子域名)
- **权限设置**应用启动用户对存储目录有读写权限Nginx worker 用户有读权限
- **磁盘要求**:使用独立磁盘或独立分区,与系统盘分离
- **备份覆盖**:第六章的数据库备份策略需同步覆盖此目录
### 8.3 小程序版本更新策略 ### 8.3 小程序版本更新策略
@ -466,8 +489,8 @@
### 10.2 敏感配置管理 ### 10.2 敏感配置管理
- 数据库密码、Redis密码、JWT密钥、腾讯云SecretKey**禁止硬编码** - 数据库密码、Redis密码、JWT密钥等 **禁止硬编码**
- 使用环境变量注入:`SPRING_DATASOURCE_PASSWORD`, `JWT_SECRET`, `COS_SECRET_KEY` - 使用环境变量注入:`SPRING_DATASOURCE_PASSWORD`, `JWT_SECRET`
- 生产环境密码定期轮换(每季度) - 生产环境密码定期轮换(每季度)
### 10.3 配置项清单 ### 10.3 配置项清单
@ -483,10 +506,130 @@
| `async.pool.core-size` | 异步线程池核心线程数 | CPU核心数 | | `async.pool.core-size` | 异步线程池核心线程数 | CPU核心数 |
| `async.pool.max-size` | 异步线程池最大线程数 | CPU核心数*2 | | `async.pool.max-size` | 异步线程池最大线程数 | CPU核心数*2 |
| `upload.max-size` | 文件上传大小限制MB | 20 | | `upload.max-size` | 文件上传大小限制MB | 20 |
| `cos.bucket-name` | 腾讯云COS存储桶名称 | — | | `upload.local-path` | 本地文件存储根路径 | ./uploads/ |
| `upload.url-prefix` | 文件访问URL前缀Nginx映射路径 | /files/ |
| `image.compress.enabled` | 是否启用图片自动压缩 | true |
| `image.compress.max-width` | 图片最大宽度px超过等比缩放 | 1920 |
| `image.compress.max-height` | 图片最大高度px超过等比缩放 | 1920 |
| `image.compress.quality` | JPEG压缩质量0-100 | 75 |
| `image.compress.thumbnail-enabled` | 是否生成缩略图 | true |
| `image.compress.thumbnail-size` | 缩略图尺寸px | 300 |
| `backup.full-cron` | 全量备份cron | 0 2 * * ? | | `backup.full-cron` | 全量备份cron | 0 2 * * ? |
| `backup.incr-interval-hours` | 增量备份间隔(小时) | 4 | | `backup.incr-interval-hours` | 增量备份间隔(小时) | 4 |
--- ---
## 十一、文件存储站点规范
### 11.1 存储架构
文件存储作为**独立存储目录**部署,与 Spring Boot 主应用分离,通过 Nginx 提供静态文件 HTTP 访问服务:
```
{upload.local-path}/
├── repair/ # 报修模块文件
├── inspection/ # 巡检模块文件
├── cleaning/ # 保洁模块文件
├── evaluation/ # 评价模块文件
├── contract/ # 合同模块文件
├── temp/ # 临时目录(自动清理)
└── thumbnail/ # 缩略图目录
├── repair/
├── inspection/
└── ...
```
### 11.2 文件存储规则
| 规则项 | 说明 |
|--------|------|
| 文件路径格式 | `{module}/{YYYY/MM}/{uuid}.{ext}` |
| 原始文件名 | 记录在数据库中URL 使用 UUID 防冲突和遍历 |
| 模块取值 | REPAIR / INSPECTION / CLEANING / EVALUATION / CONTRACT |
| URL访问方式 | 通过 Nginx location 映射(如 `{url-prefix}{module}/YYYY/MM/uuid.ext` |
| 允许的文件类型 | 图片jpg/jpeg/png/gif/webp文档pdf/doc/docx/xlsx/xls/ppt/pptx其他txt/zip/rar超管可配置白名单 |
### 11.3 图片压缩规范
#### 压缩时机与方式
- **服务端自动压缩**:文件上传时后端自动处理,不依赖客户端
- **技术选型**:使用 Thumbnailator 或 Java ImageIO无额外第三方依赖
- **支持格式**JPG / PNG / WebP 输入;输出为 JPEG透明 PNG 保持 PNG 格式)
#### 压缩参数(可在超管后台配置)
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `image.compress.enabled` | 是否启用图片自动压缩 | true |
| `image.compress.max-width` | 超过此宽度则等比缩放px | 1920 |
| `image.compress.max-height` | 超过此高度则等比缩放px | 1920 |
| `image.compress.quality` | JPEG 输出质量0-100 | 75 |
| `image.compress.thumbnail-enabled` | 是否同时生成缩略图 | true |
| `image.compress.thumbnail-size` | 缩略图宽高px | 300 |
#### 压缩率目标
| 场景 | 原始大小 | 压缩后目标 | 压缩率 |
|------|----------|-----------|--------|
| 普通照片(手机拍摄) | 5-15 MB | 200-500 KB | ~3%-5% |
| 截图等小图片 | <2 MB | <100 KB | ~5% |
| 高清图片(>15MB | >15 MB | ≤1 MB | <1% |
#### EXIF 处理规则
- **保留**:拍摄时间、设备型号等信息
- **去除**GPS 经纬度坐标等敏感位置信息
- **透明图片**PNG 格式且含透明通道的图片保持原格式,不做转 JPEG 处理
### 11.4 超管后台 — 文件存储设置
#### 页面位置
超级管理员后台 → **系统管理 → 文件存储设置**
#### 可配置项
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| 存储根路径 | 文件存储基础路径(需重启生效) | ./uploads/ |
| URL访问前缀 | 文件访问的URL前缀路径 | /files/ |
| 最大文件大小 | 单文件上传上限MB | 20 |
| 允许的文件类型 | 文件扩展名白名单,逗号分隔 | jpg,jpeg,png,gif,webp,pdf,doc,docx,xlsx,xls |
| 图片压缩开关 | 全局开启/关闭图片自动压缩 | 开启 |
| 图片最大宽度 | 超过则等比缩放px | 1920 |
| 图片最大高度 | 超过则等比缩放px | 1920 |
| 图片压缩质量 | JPEG质量0-100 | 75 |
| 缩略图开关 | 是否生成缩略图 | 开启 |
| 缩略图尺寸 | 缩略图宽高px | 300 |
| 磁盘告警阈值 | 使用率超过此值触发告警(% | 85 |
| 临时文件清理天数 | temp目录文件自动清理周期 | 7 |
#### 操作功能
**存储概览面板:**
- 总文件数、总占用空间、今日上传量
- 各模块文件数及空间占比(饼图)
- 近30天上传量趋势折线图
- 磁盘使用率仪表盘
**手动管理:**
- 按模块 / 日期范围筛选查看已上传文件列表
- 支持批量删除文件(二次确认)
- 一键清理超过N天未引用的孤立文件通过数据库关联检查
**监控告警:**
- 磁盘使用率超阈值时触发系统内通知 + 企业微信告警
- 单日异常大量上传时预警(防恶意上传)
### 11.5 安全要求
| 安全项 | 要求 |
|--------|------|
| 文件名安全 | 使用 UUID 重命名,禁止原始文件名直接写入路径,防止路径遍历攻击 |
| 文件类型校验 | 服务端不仅校验扩展名还需校验文件头Magic Number防止伪装类型上传 |
| 文件内容扫描 | 可选集成杀毒引擎对上传文件进行病毒扫描 |
| 访问控制 | 文件访问接口需鉴权(公开资源如头像除外),支持带签名的临时访问链接 |
| 防盗链 | Nginx 配置 Referer 白名单,防止外部网站直接引用 |
| 敏感文件 | 合同文件、身份证件等建议加密存储或设置独立访问权限 |
---
> **本文档为内部开发标准,所有开发人员必须严格按照此标准执行。如有疑问或需要调整,需经技术负责人审批。** > **本文档为内部开发标准,所有开发人员必须严格按照此标准执行。如有疑问或需要调整,需经技术负责人审批。**

File diff suppressed because it is too large Load Diff

@ -0,0 +1,2 @@
VITE_API_MODE=mock
VITE_API_BASE_URL=/api/v1

@ -0,0 +1,2 @@
VITE_API_MODE=real
VITE_API_BASE_URL=/api/v1

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=1920, initial-scale=1" />
<title>物业维修SaaS管理后台 - 超级管理员</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,29 @@
{
"name": "super-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.15.0",
"echarts": "^6.0.0",
"element-plus": "^2.13.7",
"pinia": "^2.3.1",
"sass": "^1.99.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@ -0,0 +1,9 @@
<script setup lang="ts">
</script>
<template>
<router-view />
</template>
<style>
</style>

@ -0,0 +1,39 @@
import type { IAccountApi } from '../types/account'
import { mockAccountApi } from './mock/account'
import type { IPermissionApi } from '../types/permission'
import { mockPermissionApi } from './mock/permission'
import type { ISystemApi, IAuditLogApi } from '../types/system'
import { mockSystemApi, mockAuditLogApi } from './mock/system'
export type ApiMode = 'mock' | 'real'
class ApiFactory {
private mode: ApiMode = (import.meta.env.VITE_API_MODE as ApiMode) || 'mock'
get accountApi(): IAccountApi {
if (this.mode === 'mock') return mockAccountApi as unknown as IAccountApi
// TODO: real API adapter
return mockAccountApi as unknown as IAccountApi
}
get permissionApi(): IPermissionApi {
if (this.mode === 'mock') return mockPermissionApi as unknown as IPermissionApi
return mockPermissionApi as unknown as IPermissionApi
}
get systemApi(): ISystemApi {
if (this.mode === 'mock') return mockSystemApi as unknown as ISystemApi
return mockSystemApi as unknown as ISystemApi
}
get auditLogApi(): IAuditLogApi {
if (this.mode === 'mock') return mockAuditLogApi as unknown as IAuditLogApi
return mockAuditLogApi as unknown as IAuditLogApi
}
}
export const apiFactory = new ApiFactory()
export const accountApi = apiFactory.accountApi
export const permissionApi = apiFactory.permissionApi
export const systemApi = apiFactory.systemApi
export const auditLogApi = apiFactory.auditLogApi

@ -0,0 +1,357 @@
import type { PageResponse } from '../types/common'
import type {
Hospital, HospitalQuery, HospitalFormData,
PropertyCompany, PropertyCompanyQuery, PropertyCompanyFormData,
HospitalAccount, HospitalAccountQuery, HospitalAccountFormData,
PropertyAccount, PropertyAccountQuery, PropertyAccountFormData,
ExpiringAccount, ExpiringAccountQuery, ExpiringStats,
ExpiryReminderConfig
} from '../types/account'
// ===== Mock数据 =====
const hospitalList: Hospital[] = Array.from({ length: 25 }, (_, i) => ({
id: `hosp-${i + 1}`,
name: ['北京协和医院', '上海瑞金医院', '广州中山大学附属医院', '四川华西医院', '武汉同济医院', '南京鼓楼医院', '浙江大学附属医院', '湘雅医院', '山东省立医院', '天津医科大学总医院', '西安交大一附院', '重庆医科大学附属医院', '郑州大学一附院', '哈尔滨医科大学附属医院', '吉林大学一院', '安徽省立医院', '福建省立医院', '南昌大学一附院', '河北医科大学二院', '广西医科大学一附院', '昆明医科大学一附院', '兰州大学一院', '贵州医科大学附属医院', '内蒙古医科大学附属医院', '新疆医科大学一附院'][i],
creditCode: `91110000${String(10000000 + i * 1357911).slice(0, 8)}${String(i + 10).padStart(2, '0')}`,
address: `${['北京市', '上海市', '广州市', '成都市', '武汉市', '南京市', '杭州市', '长沙市', '济南市', '天津市', '西安市', '重庆市', '郑州市', '哈尔滨市', '长春市', '合肥市', '福州市', '南昌市', '石家庄市', '南宁市', '昆明市', '兰州市', '贵阳市', '呼和浩特市', '乌鲁木齐市'][i]}XX路${i + 1}`,
contactPerson: ['张伟', '王芳', '李强', '赵敏', '刘洋', '陈静', '杨光', '周丽', '吴明', '郑华', '孙磊', '马超', '朱俊', '胡波', '林燕', '何勇', '高红', '罗杰', '谢军', '韩冰', '唐亮', '曹敏', '邓涛', '许峰', '范晶'][i],
contactPhone: `1${3 + (i % 7)}${String(10000000 + i * 1234567).slice(0, 8)}`,
status: i === 5 || i === 12 ? 'disabled' as const : 'enabled' as const,
campusCount: i < 5 ? 3 : i < 10 ? 2 : 1,
campuses: [
{ id: `campus-${i + 1}-1`, name: '主院区', address: `主院区地址${i + 1}`, contactPerson: ['张伟', '王芳', '李强'][i % 3] },
...(i < 10 ? [{ id: `campus-${i + 1}-2`, name: '东院区', address: `东院区地址${i + 1}`, contactPerson: ['赵敏', '刘洋'][i % 2] }] : []),
...(i < 5 ? [{ id: `campus-${i + 1}-3`, name: '西院区', address: `西院区地址${i + 1}`, contactPerson: '陈静' }] : [])
],
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 09:00:00`,
updatedAt: `2026-${String((i % 3) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 10:00:00`
}))
const propertyCompanyList: PropertyCompany[] = Array.from({ length: 20 }, (_, i) => ({
id: `prop-co-${i + 1}`,
name: ['万科物业', '绿城服务', '保利物业', '碧桂园服务', '中海物业', '龙湖智创', '金地物业', '融创服务', '招商积余', '华润万象', '新城悦服务', '永升服务', '雅生活', '时代邻里', '卓越商企', '彩生活', '中奥到家', '建业新生活', '弘阳服务', '鑫苑服务'][i],
address: `${['北京市', '上海市', '广州市', '深圳市', '杭州市', '成都市', '南京市', '武汉市', '重庆市', '西安市'][i % 10]}XX大道${i + 100}`,
contactPerson: ['王建国', '李美玲', '张志远', '陈晓峰', '刘德明', '赵丽华', '周大伟', '吴秀英', '郑强', '马丽', '孙国强', '朱美凤', '胡志刚', '林翠花', '何建明', '高小红', '罗建军', '谢美玲', '韩志伟', '曹丽华'][i],
contactPhone: `1${5 + (i % 5)}${String(20000000 + i * 2345678).slice(0, 8)}`,
status: i === 3 || i === 8 ? 'disabled' as const : 'enabled' as const,
serviceHospitals: hospitalList.slice(i % 5, (i % 5) + 2 + (i % 3)).map(h => h.name),
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 08:00:00`,
updatedAt: `2026-${String((i % 4) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 14:00:00`
}))
const hospitalAccountList: HospitalAccount[] = Array.from({ length: 25 }, (_, i) => {
const expireDate = new Date()
expireDate.setDate(expireDate.getDate() + (i < 5 ? -30 + i * 5 : i < 15 ? 7 + i * 2 : 60 + i * 5))
return {
id: `hacc-${i + 1}`,
username: `hospital${String(i + 1).padStart(2, '0')}`,
hospitalId: hospitalList[i % hospitalList.length].id,
hospitalName: hospitalList[i % hospitalList.length].name,
roles: [`role-hosp-${(i % 3) + 1}`],
roleNames: [['医院查看模板', '医院管理模板', '医院审批模板'][i % 3]],
expireDate: expireDate.toISOString().split('T')[0],
status: i < 3 ? 'expired' as const : i < 8 ? 'expiring' as const : i === 20 ? 'stopped' as const : 'normal' as const,
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-15 10:00:00`
}
})
const propertyAccountList: PropertyAccount[] = Array.from({ length: 25 }, (_, i) => {
const expireDate = new Date()
expireDate.setDate(expireDate.getDate() + (i < 4 ? -20 + i * 5 : i < 12 ? 10 + i * 3 : 90 + i * 5))
return {
id: `pacc-${i + 1}`,
username: `propadmin${String(i + 1).padStart(2, '0')}`,
propertyCompanyId: propertyCompanyList[i % propertyCompanyList.length].id,
propertyName: propertyCompanyList[i % propertyCompanyList.length].name,
hospitalId: hospitalList[i % hospitalList.length].id,
hospitalName: hospitalList[i % hospitalList.length].name,
roles: [`role-prop-${(i % 4) + 1}`],
roleNames: [['物业管理员模板', '主管模板', '班组长模板', '维修员模板'][i % 4]],
expireDate: expireDate.toISOString().split('T')[0],
status: i < 2 ? 'expired' as const : i < 6 ? 'expiring' as const : i === 18 ? 'stopped' as const : 'normal' as const,
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-20 11:00:00`
}
})
const expiringAccountList: ExpiringAccount[] = [
...hospitalAccountList.filter(a => a.status === 'expired' || a.status === 'expiring').map(a => ({
id: a.id,
username: a.username,
accountType: 'hospital' as const,
bindUnit: a.hospitalName,
expireDate: a.expireDate,
remainDays: Math.ceil((new Date(a.expireDate).getTime() - Date.now()) / 86400000),
status: a.status
})),
...propertyAccountList.filter(a => a.status === 'expired' || a.status === 'expiring').map(a => ({
id: a.id,
username: a.username,
accountType: 'property_admin' as const,
bindUnit: a.propertyName,
expireDate: a.expireDate,
remainDays: Math.ceil((new Date(a.expireDate).getTime() - Date.now()) / 86400000),
status: a.status
}))
]
let reminderConfig: ExpiryReminderConfig = {
reminderDays: [7, 15, 30],
loginPopup: true,
popupCloseAction: 'normal',
expiredAction: 'block'
}
// ===== 工具函数 =====
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function filterList<T>(list: T[], query: Record<string, any>, page: number, pageSize: number): PageResponse<T> {
let filtered = [...list]
if (query.name) filtered = filtered.filter((item: any) => item.name?.includes(query.name))
if (query.username) filtered = filtered.filter((item: any) => item.username?.includes(query.username))
if (query.contactPerson) filtered = filtered.filter((item: any) => item.contactPerson?.includes(query.contactPerson))
if (query.status && query.status !== '') filtered = filtered.filter((item: any) => item.status === query.status)
if (query.hospitalId) filtered = filtered.filter((item: any) => item.hospitalId === query.hospitalId)
if (query.propertyCompanyId) filtered = filtered.filter((item: any) => item.propertyCompanyId === query.propertyCompanyId)
if (query.accountType && query.accountType !== '') filtered = filtered.filter((item: any) => item.accountType === query.accountType)
if (query.expireFilter) {
const now = new Date()
filtered = filtered.filter((item: any) => {
const exp = new Date(item.expireDate)
const diff = (exp.getTime() - now.getTime()) / 86400000
if (query.expireFilter === 'expired') return diff < 0
if (query.expireFilter === '7days') return diff >= 0 && diff <= 7
if (query.expireFilter === '30days') return diff >= 0 && diff <= 30
if (query.expireFilter === 'normal') return diff > 30
return true
})
}
if (query.expireStatus) {
const now = new Date()
filtered = filtered.filter((item: any) => {
const diff = (new Date(item.expireDate).getTime() - now.getTime()) / 86400000
if (query.expireStatus === 'expired') return diff < 0
if (query.expireStatus === '7days') return diff >= 0 && diff <= 7
if (query.expireStatus === '30days') return diff >= 0 && diff <= 30
return true
})
}
if (query.operator) filtered = filtered.filter((item: any) => item.operator?.includes(query.operator))
if (query.operationType && query.operationType !== '') filtered = filtered.filter((item: any) => item.operationType === query.operationType)
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
const startIdx = (page - 1) * pageSize
const pagedList = filtered.slice(startIdx, startIdx + pageSize)
return { list: pagedList, pagination: { page, pageSize, total, totalPages } }
}
// ===== Mock API实现 =====
export const mockAccountApi = {
async getHospitalList(query: HospitalQuery): Promise<PageResponse<Hospital>> {
await delay(300)
if (query._mockError === 'empty') return { list: [], pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }
if (query._mockError === 'timeout') { await delay(16000); throw new Error('timeout') }
if (query._mockError === '500') throw new Error('服务器内部错误')
return filterList(hospitalList, query, query.page, query.pageSize)
},
async getHospitalDetail(id: string): Promise<Hospital> {
await delay(200)
const hospital = hospitalList.find(h => h.id === id)
if (!hospital) throw new Error('医院不存在')
return { ...hospital }
},
async createHospital(data: HospitalFormData): Promise<{ id: string }> {
await delay(500)
const id = `hosp-${Date.now()}`
hospitalList.unshift({
id,
name: data.name,
creditCode: data.creditCode,
address: data.address,
contactPerson: data.contactPerson,
contactPhone: data.contactPhone,
status: data.status,
campusCount: data.campuses.length,
campuses: data.campuses,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
return { id }
},
async updateHospital(id: string, data: HospitalFormData): Promise<void> {
await delay(500)
const idx = hospitalList.findIndex(h => h.id === id)
if (idx === -1) throw new Error('医院不存在')
hospitalList[idx] = {
...hospitalList[idx],
...data,
campusCount: data.campuses.length,
campuses: data.campuses,
updatedAt: new Date().toISOString()
}
},
async toggleHospitalStatus(id: string): Promise<void> {
await delay(400)
const hospital = hospitalList.find(h => h.id === id)
if (!hospital) throw new Error('医院不存在')
hospital.status = hospital.status === 'enabled' ? 'disabled' : 'enabled'
},
async getPropertyCompanyList(query: PropertyCompanyQuery): Promise<PageResponse<PropertyCompany>> {
await delay(300)
if (query._mockError === 'empty') return { list: [], pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }
return filterList(propertyCompanyList, query, query.page, query.pageSize)
},
async getPropertyCompanyDetail(id: string): Promise<PropertyCompany> {
await delay(200)
const company = propertyCompanyList.find(c => c.id === id)
if (!company) throw new Error('物业公司不存在')
return { ...company }
},
async createPropertyCompany(data: PropertyCompanyFormData): Promise<{ id: string }> {
await delay(500)
const id = `prop-co-${Date.now()}`
propertyCompanyList.unshift({
id,
name: data.name,
address: data.address,
contactPerson: data.contactPerson,
contactPhone: data.contactPhone,
status: 'enabled',
serviceHospitals: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
return { id }
},
async updatePropertyCompany(id: string, data: PropertyCompanyFormData): Promise<void> {
await delay(500)
const idx = propertyCompanyList.findIndex(c => c.id === id)
if (idx === -1) throw new Error('物业公司不存在')
propertyCompanyList[idx] = { ...propertyCompanyList[idx], ...data, updatedAt: new Date().toISOString() }
},
async togglePropertyCompanyStatus(id: string): Promise<void> {
await delay(400)
const company = propertyCompanyList.find(c => c.id === id)
if (!company) throw new Error('物业公司不存在')
company.status = company.status === 'enabled' ? 'disabled' : 'enabled'
},
async getHospitalAccountList(query: HospitalAccountQuery): Promise<PageResponse<HospitalAccount>> {
await delay(300)
return filterList(hospitalAccountList, query, query.page, query.pageSize)
},
async createHospitalAccount(data: HospitalAccountFormData): Promise<{ id: string }> {
await delay(500)
const id = `hacc-${Date.now()}`
const hospital = hospitalList.find(h => h.id === data.hospitalId)
hospitalAccountList.unshift({
id,
username: data.username,
hospitalId: data.hospitalId,
hospitalName: hospital?.name || '',
roles: data.roleIds,
roleNames: data.roleIds.map(() => '医院查看模板'),
expireDate: data.expireDate,
status: 'normal',
createdAt: new Date().toISOString()
})
return { id }
},
async toggleAccountStatus(id: string): Promise<void> {
await delay(400)
const account = [...hospitalAccountList, ...propertyAccountList].find(a => a.id === id)
if (account) account.status = account.status === 'normal' ? 'stopped' : 'normal'
},
async renewAccount(id: string, expireDate: string): Promise<void> {
await delay(400)
const account = [...hospitalAccountList, ...propertyAccountList].find(a => a.id === id)
if (account) {
account.expireDate = expireDate
account.status = 'normal'
}
},
async resetPassword(id: string): Promise<{ newPassword: string }> {
await delay(400)
const pwd = 'Abc' + Math.random().toString(36).slice(2, 8)
return { newPassword: pwd }
},
async getPropertyAccountList(query: PropertyAccountQuery): Promise<PageResponse<PropertyAccount>> {
await delay(300)
return filterList(propertyAccountList, query, query.page, query.pageSize)
},
async createPropertyAccount(data: PropertyAccountFormData): Promise<{ id: string }> {
await delay(500)
const id = `pacc-${Date.now()}`
const company = propertyCompanyList.find(c => c.id === data.propertyCompanyId)
const hospital = hospitalList.find(h => h.id === data.hospitalId)
propertyAccountList.unshift({
id,
username: data.username,
propertyCompanyId: data.propertyCompanyId,
propertyName: company?.name || '',
hospitalId: data.hospitalId,
hospitalName: hospital?.name || '',
roles: data.roleIds,
roleNames: data.roleIds.map(() => '物业管理员模板'),
expireDate: data.expireDate,
status: 'normal',
createdAt: new Date().toISOString()
})
return { id }
},
async getExpiringAccountList(query: ExpiringAccountQuery): Promise<PageResponse<ExpiringAccount>> {
await delay(300)
return filterList(expiringAccountList, query, query.page, query.pageSize)
},
async getExpiringStats(): Promise<ExpiringStats> {
await delay(200)
return {
expired: expiringAccountList.filter(a => a.remainDays < 0).length,
expiringIn7Days: expiringAccountList.filter(a => a.remainDays >= 0 && a.remainDays <= 7).length,
expiringIn30Days: expiringAccountList.filter(a => a.remainDays >= 0 && a.remainDays <= 30).length
}
},
async getExpiryReminderConfig(): Promise<ExpiryReminderConfig> {
await delay(200)
return { ...reminderConfig }
},
async saveExpiryReminderConfig(data: ExpiryReminderConfig): Promise<void> {
await delay(500)
reminderConfig = { ...data }
},
async getHospitalOptions(): Promise<{ id: string; name: string }[]> {
await delay(100)
return hospitalList.filter(h => h.status === 'enabled').map(h => ({ id: h.id, name: h.name }))
},
async getPropertyCompanyOptions(): Promise<{ id: string; name: string }[]> {
await delay(100)
return propertyCompanyList.filter(c => c.status === 'enabled').map(c => ({ id: c.id, name: c.name }))
}
}

@ -0,0 +1,320 @@
import type { PageResponse } from '../types/common'
import type {
Role, RoleQuery, RoleFormData,
PermissionNode,
PermissionRegistryItem, PermissionRegistryQuery,
PermissionAuditLog, PermissionAuditLogQuery, PermissionAuditDetail
} from '../types/permission'
// ===== Mock数据 =====
const permissionTree: PermissionNode[] = [
{
code: 'menu:repair', name: '在线报修', type: 'menu',
children: [
{
code: 'page:repair:list', name: '工单列表', type: 'page',
children: [
{
code: 'feature:repair:order-manage', name: '工单管理', type: 'feature',
children: [
{ code: 'action:repair:order:view', name: '查看', type: 'action' },
{ code: 'action:repair:order:create', name: '新增', type: 'action' },
{ code: 'action:repair:order:update', name: '编辑', type: 'action' },
{ code: 'action:repair:order:delete', name: '删除', type: 'action' },
{ code: 'action:repair:order:approve', name: '审批', type: 'action' },
{ code: 'action:repair:order:export', name: '导出', type: 'action' },
{ code: 'action:repair:order:assign', name: '分配', type: 'action' }
]
},
{
code: 'feature:repair:batch', name: '批量操作', type: 'feature',
children: [
{ code: 'action:repair:batch:view', name: '查看', type: 'action' },
{ code: 'action:repair:batch:export', name: '导出', type: 'action' }
]
}
]
},
{
code: 'page:repair:detail', name: '工单详情', type: 'page',
children: [
{
code: 'feature:repair:delay-approve', name: '延期审批', type: 'feature',
children: [
{ code: 'action:repair:delay:view', name: '查看', type: 'action' },
{ code: 'action:repair:delay:approve', name: '审批', type: 'action' }
]
},
{
code: 'feature:repair:acceptance', name: '工单验收', type: 'feature',
children: [
{ code: 'action:repair:acceptance:view', name: '查看', type: 'action' },
{ code: 'action:repair:acceptance:approve', name: '审批', type: 'action' }
]
}
]
},
{
code: 'page:repair:type-manage', name: '报修类型管理', type: 'page',
children: [
{
code: 'feature:repair:type', name: '类型管理', type: 'feature',
children: [
{ code: 'action:repair:type:view', name: '查看', type: 'action' },
{ code: 'action:repair:type:create', name: '新增', type: 'action' },
{ code: 'action:repair:type:update', name: '编辑', type: 'action' },
{ code: 'action:repair:type:delete', name: '删除', type: 'action' }
]
}
]
}
]
},
{
code: 'menu:inspection', name: '巡检管理', type: 'menu',
children: [
{
code: 'page:inspection:plan', name: '巡检计划', type: 'page',
children: [
{
code: 'feature:inspection:plan-manage', name: '计划管理', type: 'feature',
children: [
{ code: 'action:inspection:plan:view', name: '查看', type: 'action' },
{ code: 'action:inspection:plan:create', name: '新增', type: 'action' },
{ code: 'action:inspection:plan:update', name: '编辑', type: 'action' },
{ code: 'action:inspection:plan:delete', name: '删除', type: 'action' },
{ code: 'action:inspection:plan:export', name: '导出', type: 'action' }
]
}
]
},
{
code: 'page:inspection:task', name: '巡检任务', type: 'page',
children: [
{
code: 'feature:inspection:task-manage', name: '任务管理', type: 'feature',
children: [
{ code: 'action:inspection:task:view', name: '查看', type: 'action' },
{ code: 'action:inspection:task:execute', name: '执行', type: 'action' },
{ code: 'action:inspection:task:export', name: '导出', type: 'action' }
]
}
]
}
]
},
{
code: 'menu:contract', name: '合同管理', type: 'menu',
children: [
{
code: 'page:contract:list', name: '合同列表', type: 'page',
children: [
{
code: 'feature:contract:manage', name: '合同管理', type: 'feature',
children: [
{ code: 'action:contract:view', name: '查看', type: 'action' },
{ code: 'action:contract:create', name: '新增', type: 'action' },
{ code: 'action:contract:update', name: '编辑', type: 'action' },
{ code: 'action:contract:delete', name: '删除', type: 'action' },
{ code: 'action:contract:approve', name: '审批', type: 'action' },
{ code: 'action:contract:export', name: '导出', type: 'action' }
]
}
]
}
]
},
{
code: 'menu:cleaning', name: '保洁管理', type: 'menu',
children: [
{
code: 'page:cleaning:task', name: '保洁任务', type: 'page',
children: [
{
code: 'feature:cleaning:manage', name: '保洁管理', type: 'feature',
children: [
{ code: 'action:cleaning:view', name: '查看', type: 'action' },
{ code: 'action:cleaning:execute', name: '执行', type: 'action' },
{ code: 'action:cleaning:export', name: '导出', type: 'action' }
]
}
]
}
]
}
]
const roleList: Role[] = [
{ id: 'role-1', name: '医院查看模板', description: '医院账号默认查看权限', scope: 'hospital', isPreset: true, accountCount: 15, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-2', name: '医院管理模板', description: '医院账号管理权限', scope: 'hospital', isPreset: true, accountCount: 5, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-3', name: '医院审批模板', description: '医院账号审批权限', scope: 'hospital', isPreset: true, accountCount: 3, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-4', name: '物业管理员模板', description: '物业公司管理员默认权限', scope: 'property_admin', isPreset: true, accountCount: 20, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-5', name: '主管模板', description: '主管级权限', scope: 'property_staff', isPreset: true, accountCount: 8, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-6', name: '班组长模板', description: '班组长权限', scope: 'property_staff', isPreset: true, accountCount: 12, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-7', name: '维修员模板', description: '维修人员默认权限', scope: 'property_staff', isPreset: true, accountCount: 23, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-8', name: '巡检员模板', description: '巡检人员默认权限', scope: 'property_staff', isPreset: true, accountCount: 10, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-9', name: '保洁员模板', description: '保洁人员默认权限', scope: 'property_staff', isPreset: true, accountCount: 15, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
{ id: 'role-10', name: '巡检主管', description: '巡检主管自定义权限', scope: 'property_staff', isPreset: false, accountCount: 3, status: 'enabled', createdAt: '2025-06-01 14:00:00' },
{ id: 'role-11', name: '高级维修员', description: '高级维修员扩展权限', scope: 'property_staff', isPreset: false, accountCount: 5, status: 'enabled', createdAt: '2025-07-20 09:00:00' },
{ id: 'role-12', name: '保洁主管', description: '保洁主管自定义权限', scope: 'property_staff', isPreset: false, accountCount: 2, status: 'enabled', createdAt: '2025-08-10 11:00:00' },
{ id: 'role-13', name: '合同管理员', description: '合同管理专用角色', scope: 'property_admin', isPreset: false, accountCount: 0, status: 'enabled', createdAt: '2025-09-05 16:00:00' },
{ id: 'role-14', name: '已停用测试角色', description: '测试用停用角色', scope: 'hospital', isPreset: false, accountCount: 0, status: 'disabled', createdAt: '2025-10-01 08:00:00' },
{ id: 'role-15', name: '只读审计角色', description: '仅查看审计日志', scope: 'hospital', isPreset: false, accountCount: 1, status: 'enabled', createdAt: '2025-11-15 13:00:00' }
]
const rolePermissionsMap: Record<string, string[]> = {
'role-1': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'menu:contract', 'page:contract:list', 'feature:contract:manage', 'action:contract:view'],
'role-4': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'action:repair:order:create', 'action:repair:order:update', 'action:repair:order:assign', 'action:repair:order:export', 'page:repair:detail', 'feature:repair:delay-approve', 'action:repair:delay:view', 'feature:repair:acceptance', 'action:repair:acceptance:view', 'menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view'],
'role-5': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'action:repair:order:approve', 'action:repair:order:export', 'menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view', 'action:inspection:plan:export'],
'role-7': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'page:repair:detail', 'feature:repair:delay-approve', 'action:repair:delay:view'],
'role-8': ['menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view', 'page:inspection:task', 'feature:inspection:task-manage', 'action:inspection:task:view', 'action:inspection:task:execute'],
'role-10': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'action:repair:order:approve', 'action:repair:order:export', 'menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view', 'action:inspection:plan:create', 'action:inspection:plan:update', 'action:inspection:plan:export', 'page:inspection:task', 'feature:inspection:task-manage', 'action:inspection:task:view', 'action:inspection:task:export']
}
const permissionRegistryList: PermissionRegistryItem[] = [
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_list', pageName: '工单列表', featureCode: 'order_manage', featureName: '工单管理', actions: ['view', 'create', 'update', 'delete', 'approve', 'export', 'assign'] },
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_list', pageName: '工单列表', featureCode: 'batch_operate', featureName: '批量操作', actions: ['view', 'export'] },
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_detail', pageName: '工单详情', featureCode: 'delay_approve', featureName: '延期审批', actions: ['view', 'approve'] },
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_detail', pageName: '工单详情', featureCode: 'order_acceptance', featureName: '工单验收', actions: ['view', 'approve'] },
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_type', pageName: '报修类型管理', featureCode: 'type_manage', featureName: '类型管理', actions: ['view', 'create', 'update', 'delete'] },
{ moduleCode: 'inspection', moduleName: '巡检管理', pageCode: 'insp_plan', pageName: '巡检计划', featureCode: 'plan_manage', featureName: '计划管理', actions: ['view', 'create', 'update', 'delete', 'export'] },
{ moduleCode: 'inspection', moduleName: '巡检管理', pageCode: 'insp_task', pageName: '巡检任务', featureCode: 'task_manage', featureName: '任务管理', actions: ['view', 'execute', 'export'] },
{ moduleCode: 'contract', moduleName: '合同管理', pageCode: 'contract_list', pageName: '合同列表', featureCode: 'contract_manage', featureName: '合同管理', actions: ['view', 'create', 'update', 'delete', 'approve', 'export'] },
{ moduleCode: 'cleaning', moduleName: '保洁管理', pageCode: 'cleaning_task', pageName: '保洁任务', featureCode: 'cleaning_manage', featureName: '保洁管理', actions: ['view', 'execute', 'export'] },
{ moduleCode: 'bidding', moduleName: '分段招标', pageCode: 'bidding_list', pageName: '招标列表', featureCode: 'bidding_manage', featureName: '招标管理', actions: ['view', 'create', 'update', 'delete', 'approve', 'export'] },
{ moduleCode: 'service', moduleName: '服务监督', pageCode: 'service_monitor', pageName: '服务监督', featureCode: 'monitor_manage', featureName: '监督管理', actions: ['view', 'create', 'update', 'export'] },
{ moduleCode: 'evaluation', moduleName: '服务评价', pageCode: 'eval_list', pageName: '评价列表', featureCode: 'eval_manage', featureName: '评价管理', actions: ['view', 'export'] }
]
const permissionAuditLogList: PermissionAuditLog[] = Array.from({ length: 30 }, (_, i) => ({
id: `paudit-${i + 1}`,
operationTime: `2026-04-${String(17 - Math.floor(i / 5)).padStart(2, '0')} ${String(9 + (i % 8)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}:00`,
operator: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'super_admin' : 'system',
operationType: (['role_create', 'permission_modify', 'role_assign', 'role_remove'] as const)[i % 4],
targetRole: roleList[i % roleList.length].name,
changeSummary: i % 4 === 0 ? '初始权限' : `+${2 + (i % 3)}项 -${i % 2}`,
addCount: i % 4 === 0 ? 0 : 2 + (i % 3),
removeCount: i % 4 === 0 ? 0 : i % 2
}))
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)) }
function filterList<T>(list: T[], query: Record<string, any>, page: number, pageSize: number): PageResponse<T> {
let filtered = [...list]
if (query.name) filtered = filtered.filter((item: any) => item.name?.includes(query.name))
if (query.scope && query.scope !== '') filtered = filtered.filter((item: any) => item.scope === query.scope)
if (query.status && query.status !== '') filtered = filtered.filter((item: any) => item.status === query.status)
if (query.moduleName) filtered = filtered.filter((item: any) => item.moduleName?.includes(query.moduleName))
if (query.pageName) filtered = filtered.filter((item: any) => item.pageName?.includes(query.pageName))
if (query.operator) filtered = filtered.filter((item: any) => item.operator?.includes(query.operator))
if (query.operationType && query.operationType !== '') filtered = filtered.filter((item: any) => item.operationType === query.operationType)
if (query.role) filtered = filtered.filter((item: any) => item.targetRole?.includes(query.role))
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
return { list: filtered.slice((page - 1) * pageSize, page * pageSize), pagination: { page, pageSize, total, totalPages } }
}
export const mockPermissionApi = {
async getRoleList(query: RoleQuery): Promise<PageResponse<Role>> {
await delay(300)
return filterList(roleList, query, query.page, query.pageSize)
},
async getRoleDetail(id: string): Promise<Role> {
await delay(200)
const role = roleList.find(r => r.id === id)
if (!role) throw new Error('角色不存在')
return { ...role }
},
async createRole(data: RoleFormData): Promise<{ id: string }> {
await delay(500)
const id = `role-${Date.now()}`
roleList.unshift({
id, name: data.name, description: data.description, scope: data.scope,
isPreset: false, accountCount: 0, status: 'enabled', createdAt: new Date().toISOString()
})
rolePermissionsMap[id] = data.permissions
return { id }
},
async updateRole(id: string, data: RoleFormData): Promise<void> {
await delay(500)
const idx = roleList.findIndex(r => r.id === id)
if (idx === -1) throw new Error('角色不存在')
roleList[idx] = { ...roleList[idx], name: data.name, description: data.description, scope: data.scope }
rolePermissionsMap[id] = data.permissions
},
async disableRole(id: string): Promise<void> {
await delay(400)
const role = roleList.find(r => r.id === id)
if (!role) throw new Error('角色不存在')
if (role.accountCount > 0) throw new Error('该角色下存在关联账号,无法停用')
role.status = 'disabled'
},
async deleteRole(id: string): Promise<void> {
await delay(400)
const idx = roleList.findIndex(r => r.id === id)
if (idx === -1) throw new Error('角色不存在')
if (roleList[idx].accountCount > 0) throw new Error('该角色下存在关联账号,无法删除')
roleList.splice(idx, 1)
},
async getPermissionTree(): Promise<PermissionNode[]> {
await delay(300)
return JSON.parse(JSON.stringify(permissionTree))
},
async getRolePermissions(id: string): Promise<PermissionNode[]> {
await delay(300)
const codes = rolePermissionsMap[id] || []
// Return full tree with checked state based on codes
function markChecked(nodes: PermissionNode[]): PermissionNode[] {
return nodes.map(n => ({
...n,
children: n.children ? markChecked(n.children) : undefined
}))
}
return markChecked(permissionTree)
},
async getPermissionRegistryList(query: PermissionRegistryQuery): Promise<PageResponse<PermissionRegistryItem>> {
await delay(300)
return filterList(permissionRegistryList, query, query.page, query.pageSize)
},
async refreshPermissionRegistry(): Promise<void> {
await delay(1000)
},
async getPermissionAuditLogList(query: PermissionAuditLogQuery): Promise<PageResponse<PermissionAuditLog>> {
await delay(300)
return filterList(permissionAuditLogList, query, query.page, query.pageSize)
},
async getPermissionAuditDetail(id: string): Promise<PermissionAuditDetail> {
await delay(200)
const log = permissionAuditLogList.find(l => l.id === id)
if (!log) throw new Error('日志不存在')
return {
id: log.id,
operationTime: log.operationTime,
operator: log.operator,
operationType: log.operationType,
targetRole: log.targetRole,
operationIp: '192.168.1.100',
addedPermissions: log.addCount > 0 ? [
'在线报修 → 工单详情 → 延期审批 → 审批',
'在线报修 → 工单列表 → 工单管理 → 导出',
...Array.from({ length: Math.max(0, log.addCount - 2) }, (_, i) => `权限项${i + 3}`)
] : [],
removedPermissions: log.removeCount > 0 ? [
'巡检管理 → 巡检计划 → 计划管理 → 删除',
...Array.from({ length: Math.max(0, log.removeCount - 1) }, (_, i) => `移除权限${i + 2}`)
] : []
}
}
}

@ -0,0 +1,131 @@
import type { SystemVersion, SystemVersionFormData, CacheStatus, CacheModule, AccountAuditLog, AccountAuditLogQuery, AccountAuditDetail } from '../types/system'
import type { PageResponse } from '../types/common'
// ===== Mock数据 =====
const versionList: SystemVersion[] = [
{ id: 'v-1', version: '1.2.0', platform: 'web', minCompatibleVersion: '1.0.0', forceUpdate: false, releaseDate: '2026-04-01', description: '新增权限管理模块、优化系统性能、修复已知问题' },
{ id: 'v-2', version: '1.1.0', platform: 'miniapp', minCompatibleVersion: '1.0.0', forceUpdate: false, releaseDate: '2026-03-15', description: '新增巡检打卡功能、优化报修流程、修复蓝牙连接问题' },
{ id: 'v-3', version: '1.0.0', platform: 'web', minCompatibleVersion: '1.0.0', forceUpdate: false, releaseDate: '2026-01-01', description: '系统初始版本,包含基础功能模块' },
{ id: 'v-4', version: '1.0.0', platform: 'miniapp', minCompatibleVersion: '1.0.0', forceUpdate: true, releaseDate: '2026-01-01', description: '小程序初始版本' },
{ id: 'v-5', version: '0.9.0', platform: 'miniapp', minCompatibleVersion: '0.9.0', forceUpdate: true, releaseDate: '2025-12-01', description: '内测版本' }
]
const cacheStatus: CacheStatus = {
redisStatus: 'connected',
memoryUsed: 128,
memoryTotal: 512,
connections: 24
}
const cacheModules: CacheModule[] = [
{ name: '权限缓存', keyPrefix: 'perm:*', count: 1234, lastUpdateTime: '2026-04-17 10:30:25' },
{ name: '字典缓存', keyPrefix: 'dict:*', count: 56, lastUpdateTime: '2026-04-17 09:00:00' },
{ name: '菜单缓存', keyPrefix: 'menu:*', count: 120, lastUpdateTime: '2026-04-17 09:00:00' },
{ name: '业务缓存', keyPrefix: 'biz:*', count: 0, lastUpdateTime: '—' }
]
const accountAuditLogList: AccountAuditLog[] = Array.from({ length: 30 }, (_, i) => ({
id: `alog-${i + 1}`,
operationTime: `2026-04-${String(17 - Math.floor(i / 4)).padStart(2, '0')} ${String(8 + (i % 9)).padStart(2, '0')}:${String((i * 23) % 60).padStart(2, '0')}:00`,
operator: i % 2 === 0 ? 'admin' : 'super_admin',
operationType: (['create', 'edit', 'enable', 'disable', 'renew', 'reset_password'] as const)[i % 6],
targetAccount: i % 2 === 0 ? `hospital${String(i + 1).padStart(2, '0')}` : `propadmin${String(i + 1).padStart(2, '0')}`,
accountType: i % 2 === 0 ? 'hospital' : 'property_admin',
bindUnit: i % 2 === 0 ? '北京协和医院' : '万科物业'
}))
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)) }
function filterList<T>(list: T[], query: Record<string, any>, page: number, pageSize: number): PageResponse<T> {
let filtered = [...list]
if (query.operator) filtered = filtered.filter((item: any) => item.operator?.includes(query.operator))
if (query.operationType && query.operationType !== '') filtered = filtered.filter((item: any) => item.operationType === query.operationType)
if (query.accountType && query.accountType !== '') filtered = filtered.filter((item: any) => item.accountType === query.accountType)
const total = filtered.length
const totalPages = Math.ceil(total / pageSize)
return { list: filtered.slice((page - 1) * pageSize, page * pageSize), pagination: { page, pageSize, total, totalPages } }
}
export const mockSystemApi = {
async getSystemVersionList(): Promise<SystemVersion[]> {
await delay(200)
return [...versionList]
},
async createSystemVersion(data: SystemVersionFormData): Promise<{ id: string }> {
await delay(500)
const id = `v-${Date.now()}`
versionList.unshift({ id, ...data, releaseDate: new Date().toISOString().split('T')[0] })
return { id }
},
async updateSystemVersion(id: string, data: SystemVersionFormData): Promise<void> {
await delay(500)
const idx = versionList.findIndex(v => v.id === id)
if (idx === -1) throw new Error('版本不存在')
versionList[idx] = { ...versionList[idx], ...data }
},
async getLatestVersions(): Promise<{ web: SystemVersion | null; miniapp: SystemVersion | null }> {
await delay(200)
const web = versionList.find(v => v.platform === 'web') || null
const miniapp = versionList.find(v => v.platform === 'miniapp') || null
return { web, miniapp }
},
async getCacheStatus(): Promise<CacheStatus> {
await delay(200)
return { ...cacheStatus }
},
async getCacheModules(): Promise<CacheModule[]> {
await delay(200)
return [...cacheModules]
},
async clearCacheModule(module: string): Promise<void> {
await delay(800)
const mod = cacheModules.find(m => m.keyPrefix.startsWith(module.split(':')[0]))
if (mod) { mod.count = 0; mod.lastUpdateTime = new Date().toISOString() }
},
async clearAllCache(): Promise<void> {
await delay(1500)
cacheModules.forEach(m => { m.count = 0; m.lastUpdateTime = new Date().toISOString() })
}
}
export const mockAuditLogApi = {
async getAccountAuditLogList(query: AccountAuditLogQuery): Promise<PageResponse<AccountAuditLog>> {
await delay(300)
return filterList(accountAuditLogList, query, query.page, query.pageSize)
},
async getAccountAuditDetail(id: string): Promise<AccountAuditDetail> {
await delay(200)
const log = accountAuditLogList.find(l => l.id === id)
if (!log) throw new Error('日志不存在')
return {
id: log.id,
operationTime: log.operationTime,
operator: log.operator,
operationIp: '192.168.1.100',
operationType: log.operationType,
targetAccount: log.targetAccount,
accountType: log.accountType,
bindUnit: log.bindUnit,
beforeData: log.operationType === 'create' ? null : {
username: log.targetAccount,
status: log.operationType === 'disable' ? 'normal' : undefined,
expireDate: log.operationType === 'renew' ? '2026-03-01' : undefined
},
afterData: {
username: log.targetAccount,
status: log.operationType === 'disable' ? 'stopped' : log.operationType === 'enable' ? 'normal' : undefined,
expireDate: log.operationType === 'renew' ? '2027-04-16' : undefined,
roles: log.operationType === 'create' ? ['医院查看模板'] : undefined
}
}
}
}

@ -0,0 +1,194 @@
import type { PageQuery, PageResponse, CommonStatus, AccountStatus } from './common'
// ===== 医院信息 =====
export interface Hospital {
id: string
name: string
creditCode: string
address: string
contactPerson: string
contactPhone: string
status: CommonStatus
campusCount: number
campuses: HospitalCampus[]
createdAt: string
updatedAt: string
}
export interface HospitalCampus {
id?: string
name: string
address: string
contactPerson: string
}
export interface HospitalQuery extends PageQuery {
name?: string
status?: CommonStatus | ''
contactPerson?: string
}
export interface HospitalFormData {
name: string
creditCode: string
address: string
contactPerson: string
contactPhone: string
status: CommonStatus
campuses: HospitalCampus[]
}
// ===== 物业公司信息 =====
export interface PropertyCompany {
id: string
name: string
address: string
contactPerson: string
contactPhone: string
status: CommonStatus
serviceHospitals: string[]
createdAt: string
updatedAt: string
}
export interface PropertyCompanyQuery extends PageQuery {
name?: string
status?: CommonStatus | ''
contactPerson?: string
}
export interface PropertyCompanyFormData {
name: string
address: string
contactPerson: string
contactPhone: string
}
// ===== 医院账号 =====
export interface HospitalAccount {
id: string
username: string
hospitalId: string
hospitalName: string
roles: string[]
roleNames: string[]
expireDate: string
status: AccountStatus
createdAt: string
}
export interface HospitalAccountQuery extends PageQuery {
username?: string
hospitalId?: string
status?: AccountStatus | ''
expireFilter?: string
}
export interface HospitalAccountFormData {
username: string
password: string
hospitalId: string
expireDate: string
roleIds: string[]
}
// ===== 物业管理员账号 =====
export interface PropertyAccount {
id: string
username: string
propertyCompanyId: string
propertyName: string
hospitalId: string
hospitalName: string
roles: string[]
roleNames: string[]
expireDate: string
status: AccountStatus
createdAt: string
}
export interface PropertyAccountQuery extends PageQuery {
username?: string
propertyCompanyId?: string
hospitalId?: string
status?: AccountStatus | ''
}
export interface PropertyAccountFormData {
username: string
password: string
propertyCompanyId: string
hospitalId: string
expireDate: string
roleIds: string[]
}
// ===== 到期账号 =====
export interface ExpiringAccount {
id: string
username: string
accountType: 'hospital' | 'property_admin'
bindUnit: string
expireDate: string
remainDays: number
status: AccountStatus
}
export interface ExpiringAccountQuery extends PageQuery {
accountType?: 'hospital' | 'property_admin' | ''
expireStatus?: string
}
export interface ExpiringStats {
expired: number
expiringIn7Days: number
expiringIn30Days: number
}
// ===== 到期提醒配置 =====
export interface ExpiryReminderConfig {
reminderDays: number[]
loginPopup: boolean
popupCloseAction: 'normal' | 'restrict'
expiredAction: 'block' | 'remind'
}
// ===== 账号API接口 =====
export interface IAccountApi {
// 医院
getHospitalList(query: HospitalQuery): Promise<PageResponse<Hospital>>
getHospitalDetail(id: string): Promise<Hospital>
createHospital(data: HospitalFormData): Promise<{ id: string }>
updateHospital(id: string, data: HospitalFormData): Promise<void>
toggleHospitalStatus(id: string): Promise<void>
// 物业公司
getPropertyCompanyList(query: PropertyCompanyQuery): Promise<PageResponse<PropertyCompany>>
getPropertyCompanyDetail(id: string): Promise<PropertyCompany>
createPropertyCompany(data: PropertyCompanyFormData): Promise<{ id: string }>
updatePropertyCompany(id: string, data: PropertyCompanyFormData): Promise<void>
togglePropertyCompanyStatus(id: string): Promise<void>
// 医院账号
getHospitalAccountList(query: HospitalAccountQuery): Promise<PageResponse<HospitalAccount>>
createHospitalAccount(data: HospitalAccountFormData): Promise<{ id: string }>
toggleAccountStatus(id: string): Promise<void>
renewAccount(id: string, expireDate: string): Promise<void>
resetPassword(id: string): Promise<{ newPassword: string }>
// 物业管理员账号
getPropertyAccountList(query: PropertyAccountQuery): Promise<PageResponse<PropertyAccount>>
createPropertyAccount(data: PropertyAccountFormData): Promise<{ id: string }>
// 到期账号
getExpiringAccountList(query: ExpiringAccountQuery): Promise<PageResponse<ExpiringAccount>>
getExpiringStats(): Promise<ExpiringStats>
// 到期提醒配置
getExpiryReminderConfig(): Promise<ExpiryReminderConfig>
saveExpiryReminderConfig(data: ExpiryReminderConfig): Promise<void>
// 下拉数据
getHospitalOptions(): Promise<{ id: string; name: string }[]>
getPropertyCompanyOptions(): Promise<{ id: string; name: string }[]>
}

@ -0,0 +1,37 @@
// 超级管理员 - 通用类型定义
/** 分页请求参数 */
export interface PageQuery {
page: number
pageSize: number
_mockError?: 'empty' | '403' | 'timeout' | '500'
}
/** 分页响应 */
export interface PageResponse<T> {
list: T[]
pagination: {
page: number
pageSize: number
total: number
totalPages: number
}
}
/** API错误 */
export class ApiError extends Error {
code: number
constructor(code: number, message: string) {
super(message)
this.code = code
}
}
/** 通用状态 */
export type CommonStatus = 'enabled' | 'disabled'
/** 账号状态 */
export type AccountStatus = 'normal' | 'expiring' | 'expired' | 'stopped'
/** 适用范围 */
export type RoleScope = 'hospital' | 'property_admin' | 'property_staff'

@ -0,0 +1,108 @@
import type { PageQuery, PageResponse, CommonStatus, RoleScope } from './common'
// ===== 角色 =====
export interface Role {
id: string
name: string
description: string
scope: RoleScope
isPreset: boolean
accountCount: number
status: CommonStatus
createdAt: string
}
export interface RoleQuery extends PageQuery {
name?: string
scope?: RoleScope | ''
status?: CommonStatus | ''
}
export interface RoleFormData {
name: string
description: string
scope: RoleScope
presetTemplate?: string
permissions: string[]
}
// ===== 权限树 =====
export interface PermissionNode {
code: string
name: string
type: 'menu' | 'page' | 'feature' | 'action'
children?: PermissionNode[]
}
// ===== 权限预览 =====
export interface RolePermission {
menu: string
page: string
feature: string
actions: string[]
}
// ===== 权限配置注册 =====
export interface PermissionRegistryItem {
moduleCode: string
moduleName: string
pageCode: string
pageName: string
featureCode: string
featureName: string
actions: string[]
}
export interface PermissionRegistryQuery extends PageQuery {
moduleName?: string
pageName?: string
}
// ===== 权限审计日志 =====
export interface PermissionAuditLog {
id: string
operationTime: string
operator: string
operationType: 'role_create' | 'permission_modify' | 'role_assign' | 'role_remove'
targetRole: string
changeSummary: string
addCount: number
removeCount: number
}
export interface PermissionAuditLogQuery extends PageQuery {
operator?: string
operationType?: string
role?: string
dateRange?: [string, string]
}
export interface PermissionAuditDetail {
id: string
operationTime: string
operator: string
operationType: string
targetRole: string
operationIp: string
addedPermissions: string[]
removedPermissions: string[]
}
// ===== 权限API接口 =====
export interface IPermissionApi {
getRoleList(query: RoleQuery): Promise<PageResponse<Role>>
getRoleDetail(id: string): Promise<Role>
createRole(data: RoleFormData): Promise<{ id: string }>
updateRole(id: string, data: RoleFormData): Promise<void>
disableRole(id: string): Promise<void>
deleteRole(id: string): Promise<void>
getPermissionTree(): Promise<PermissionNode[]>
getRolePermissions(id: string): Promise<PermissionNode[]>
getPermissionRegistryList(query: PermissionRegistryQuery): Promise<PageResponse<PermissionRegistryItem>>
refreshPermissionRegistry(): Promise<void>
getPermissionAuditLogList(query: PermissionAuditLogQuery): Promise<PageResponse<PermissionAuditLog>>
getPermissionAuditDetail(id: string): Promise<PermissionAuditDetail>
}

@ -0,0 +1,84 @@
import type { PageQuery, PageResponse } from './common'
// ===== 系统版本 =====
export interface SystemVersion {
id: string
version: string
platform: 'web' | 'miniapp'
minCompatibleVersion: string
forceUpdate: boolean
releaseDate: string
description: string
}
export interface SystemVersionFormData {
version: string
platform: 'web' | 'miniapp'
minCompatibleVersion: string
forceUpdate: boolean
description: string
}
// ===== 缓存管理 =====
export interface CacheStatus {
redisStatus: 'connected' | 'disconnected'
memoryUsed: number
memoryTotal: number
connections: number
}
export interface CacheModule {
name: string
keyPrefix: string
count: number
lastUpdateTime: string
}
// ===== 账号操作日志 =====
export interface AccountAuditLog {
id: string
operationTime: string
operator: string
operationType: 'create' | 'edit' | 'enable' | 'disable' | 'renew' | 'reset_password'
targetAccount: string
accountType: 'hospital' | 'property_admin'
bindUnit: string
}
export interface AccountAuditLogQuery extends PageQuery {
operator?: string
operationType?: string
accountType?: 'hospital' | 'property_admin' | ''
dateRange?: [string, string]
}
export interface AccountAuditDetail {
id: string
operationTime: string
operator: string
operationIp: string
operationType: string
targetAccount: string
accountType: string
bindUnit: string
beforeData: Record<string, any> | null
afterData: Record<string, any> | null
}
// ===== 系统API接口 =====
export interface ISystemApi {
getSystemVersionList(): Promise<SystemVersion[]>
createSystemVersion(data: SystemVersionFormData): Promise<{ id: string }>
updateSystemVersion(id: string, data: SystemVersionFormData): Promise<void>
getLatestVersions(): Promise<{ web: SystemVersion | null; miniapp: SystemVersion | null }>
getCacheStatus(): Promise<CacheStatus>
getCacheModules(): Promise<CacheModule[]>
clearCacheModule(module: string): Promise<void>
clearAllCache(): Promise<void>
}
export interface IAuditLogApi {
getAccountAuditLogList(query: AccountAuditLogQuery): Promise<PageResponse<AccountAuditLog>>
getAccountAuditDetail(id: string): Promise<AccountAuditDetail>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

@ -0,0 +1,21 @@
<!-- @shared 操作按钮栏 -->
<script setup lang="ts">
</script>
<template>
<div class="action-bar">
<slot />
</div>
</template>
<style scoped lang="scss">
.action-bar {
background: #fff;
padding: $--spacing-item $--spacing-section;
border-radius: 4px;
margin-bottom: $--spacing-item;
display: flex;
align-items: center;
gap: 8px;
}
</style>

@ -0,0 +1,30 @@
<!-- @shared 面包屑导航 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta?.title)
return matched.map(item => ({
title: item.meta.title as string,
path: item.path,
}))
})
</script>
<template>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<style scoped lang="scss">
.breadcrumb {
margin-bottom: 16px;
}
</style>

@ -0,0 +1,76 @@
<!-- @shared 数据表格 -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { DocumentDelete } from '@element-plus/icons-vue'
const props = defineProps<{
data: any[]
loading?: boolean
selectable?: boolean
rowKey?: string
}>()
const emit = defineEmits<{
'update:selected': [value: any[]]
'sort-change': [value: { prop: string; order: string }]
}>()
const selectedRows = ref<any[]>([])
watch(selectedRows, (val) => {
emit('update:selected', val)
})
function handleSelectionChange(val: any[]) {
selectedRows.value = val
}
function handleSortChange({ prop, order }: { prop: string; order: string | null }) {
emit('sort-change', { prop, order: order || '' })
}
</script>
<template>
<div class="data-table-wrapper">
<el-table
:data="data"
v-loading="loading"
stripe
border
size="default"
:row-key="rowKey || 'id'"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
style="width: 100%"
>
<el-table-column v-if="selectable" type="selection" width="55" />
<slot />
<template #empty>
<div class="empty-state">
<el-icon :size="48" color="#c0c4cc"><DocumentDelete /></el-icon>
<p>暂无数据</p>
</div>
</template>
</el-table>
</div>
</template>
<style scoped lang="scss">
.data-table-wrapper {
background: #fff;
padding: $--spacing-section;
border-radius: 4px;
margin-bottom: $--spacing-item;
}
.empty-state {
padding: 40px 0;
text-align: center;
color: #909399;
p {
margin-top: 12px;
font-size: 14px;
}
}
</style>

@ -0,0 +1,30 @@
<!-- @shared 详情侧滑抽屉 -->
<script setup lang="ts">
const props = defineProps<{
visible: boolean
title: string
width?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-drawer
:model-value="visible"
:title="title"
:size="width || '500px'"
@close="handleClose"
>
<slot />
</el-drawer>
</template>
<style scoped>
</style>

@ -0,0 +1,57 @@
<!-- @shared 表单弹窗 -->
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
const props = defineProps<{
visible: boolean
title: string
loading?: boolean
dirty?: boolean
width?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: []
}>()
async function handleClose() {
if (props.dirty) {
try {
await ElMessageBox.confirm('当前修改尚未保存,确定关闭吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
}
emit('update:visible', false)
}
function handleSubmit() {
emit('submit')
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="title"
:width="width || '500px'"
:close-on-click-modal="false"
@close="handleClose"
>
<slot />
<template #footer>
<el-button @click="handleClose"></el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ loading ? '保存中...' : '保存' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
</style>

@ -0,0 +1,61 @@
<!-- @shared 分页组件 支持对象传参(:pagination)和独立传参(:total/:page/:pageSize)两种模式 -->
<script setup lang="ts">
import { computed } from 'vue'
//
// A(): <Pagination :pagination="{ page, pageSize, total }" @change="..." />
// B(): <Pagination :total="100" :page="1" :pageSize="20" @change="..." />
const props = defineProps<{
pagination?: { page: number; pageSize: number; total: number; totalPages?: number }
total?: number
page?: number
pageSize?: number
pageSizes?: number[]
}>()
const emit = defineEmits<{
'update:page': [value: number]
'update:pageSize': [value: number]
change: []
}>()
// pagination
const _total = computed(() => props.pagination?.total ?? props.total ?? 0)
const _page = computed(() => props.pagination?.page ?? props.page ?? 1)
const _pageSize = computed(() => props.pagination?.pageSize ?? props.pageSize ?? 20)
function handleCurrentChange(val: number) {
emit('update:page', val)
emit('change')
}
function handleSizeChange(val: number) {
emit('update:pageSize', val)
emit('update:page', 1)
emit('change')
}
</script>
<template>
<div class="pagination-wrapper" v-show="_total > 0">
<el-pagination
:current-page="_page"
:page-size="_pageSize"
:page-sizes="pageSizes || [10, 20, 50]"
:total="_total"
layout="total, sizes, prev, pager, next"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</template>
<style scoped lang="scss">
.pagination-wrapper {
background: #fff;
padding: $--spacing-item $--spacing-section;
border-radius: 4px;
display: flex;
justify-content: flex-end;
}
</style>

@ -0,0 +1,51 @@
<!-- @shared 查询条件面板 -->
<script setup lang="ts">
const emit = defineEmits<{
query: []
reset: []
}>()
function handleQuery() {
emit('query')
}
function handleReset() {
emit('reset')
}
</script>
<template>
<div class="query-panel">
<el-form inline label-width="auto" @submit.prevent="handleQuery">
<slot />
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
<el-button icon="Refresh" @click="handleReset"></el-button>
</el-form-item>
</el-form>
</div>
</template>
<style scoped lang="scss">
.query-panel {
background: #fff;
padding: $--spacing-section;
border-radius: 4px;
margin-bottom: $--spacing-item;
:deep(.el-form-item) {
margin-bottom: 12px;
}
// -3 inline
:deep(.el-select) {
min-width: 160px;
}
:deep(.el-input) {
min-width: 160px;
}
:deep(.el-date-editor) {
min-width: 180px;
}
}
</style>

@ -0,0 +1,29 @@
<!-- @shared 状态标签 -->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
status: string
statusMap?: Record<string, { label: string; type: 'success' | 'danger' | 'warning' | 'info' | 'primary' }>
}>()
const defaultMap: Record<string, { label: string; type: 'success' | 'danger' | 'warning' | 'info' | 'primary' }> = {
enabled: { label: '启用', type: 'success' },
disabled: { label: '停用', type: 'danger' },
normal: { label: '正常', type: 'success' },
expiring: { label: '即将到期', type: 'warning' },
expired: { label: '已过期', type: 'danger' },
stopped: { label: '已停用', type: 'info' },
active: { label: '已连接', type: 'success' },
disconnected: { label: '未连接', type: 'danger' },
}
const current = computed(() => {
const map = props.statusMap || defaultMap
return map[props.status] || { label: props.status, type: 'info' as const }
})
</script>
<template>
<el-tag :type="current.type" size="default">{{ current.label }}</el-tag>
</template>

@ -0,0 +1,50 @@
// @shared 共享组件注册表
// 修改任何共享组件前,必须查阅此注册表确认影响范围
// 修改后必须执行100%覆盖测试,并向用户发出变更通知
export const SharedComponentRegistry = {
Breadcrumb: {
path: 'components/shared/Breadcrumb/index.vue',
description: '面包屑导航',
referencedBy: ['所有页面'] as string[],
},
QueryPanel: {
path: 'components/shared/QueryPanel/index.vue',
description: '查询条件面板(折叠/展开)',
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList', 'account:HospitalAccountList', 'account:PropertyAccountList', 'account:ExpiringAccountList', 'permission:RoleList', 'permission:PermissionRegistry', 'permission:PermissionAuditLog', 'audit-log:PermissionLog', 'audit-log:AccountLog'] as string[],
},
ActionBar: {
path: 'components/shared/ActionBar/index.vue',
description: '操作按钮栏(权限控制+批量操作)',
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList', 'account:HospitalAccountList', 'account:PropertyAccountList', 'permission:RoleList', 'system:VersionList', 'system:CacheManagement'] as string[],
},
DataTable: {
path: 'components/shared/DataTable/index.vue',
description: '数据表格(排序/选择/行操作)',
referencedBy: ['所有列表页'] as string[],
},
Pagination: {
path: 'components/shared/Pagination/index.vue',
description: '分页组件',
referencedBy: ['所有列表页'] as string[],
},
FormDialog: {
path: 'components/shared/FormDialog/index.vue',
description: '表单弹窗(新增/编辑)',
referencedBy: ['account:HospitalAccountList', 'account:PropertyAccountList', 'account:ExpiringAccountList', 'permission:RoleList', 'system:VersionList'] as string[],
},
DetailDrawer: {
path: 'components/shared/DetailDrawer/index.vue',
description: '详情侧滑抽屉',
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList'] as string[],
},
StatusTag: {
path: 'components/shared/StatusTag/index.vue',
description: '状态标签(彩色区分)',
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList', 'account:HospitalAccountList', 'account:PropertyAccountList', 'account:ExpiringAccountList', 'permission:RoleList', 'audit-log:PermissionLog', 'audit-log:AccountLog'] as string[],
},
} as const
export function getComponentReferences(componentName: string): string[] {
return SharedComponentRegistry[componentName]?.referencedBy ?? []
}

@ -0,0 +1,21 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_MODE: string
readonly VITE_API_BASE_URL: string
readonly VITE_API_TIMEOUT_GET: string
readonly VITE_API_TIMEOUT_POST: string
readonly VITE_API_TIMEOUT_UPLOAD: string
readonly VITE_API_TIMEOUT_STATS: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/global/useUserStore'
import { useAppStore } from '@/stores/global/useAppStore'
import Sidebar from './sidebar/Sidebar.vue'
import HeaderBar from './header/HeaderBar.vue'
import TagsView from './tags/TagsView.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const appStore = useAppStore()
const sidebarCollapsed = computed(() => userStore.sidebarCollapsed)
</script>
<template>
<el-container class="admin-layout">
<el-aside :width="sidebarCollapsed ? '64px' : '220px'" class="sidebar-container">
<Sidebar />
</el-aside>
<el-container class="main-container">
<el-header class="header-container" height="56px">
<HeaderBar />
</el-header>
<TagsView />
<el-main class="content-container">
<router-view v-slot="{ Component }">
<keep-alive :include="appStore.cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style scoped lang="scss">
.admin-layout {
height: 100vh;
overflow: hidden;
}
.sidebar-container {
background: #304156;
transition: width 0.3s;
overflow: hidden;
}
.main-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.header-container {
padding: 0;
border-bottom: 1px solid #e6e6e6;
background: #fff;
}
.content-container {
background: #f5f7fa;
padding: $--spacing-page;
overflow-y: auto;
}
</style>

@ -0,0 +1,92 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/global/useUserStore'
const router = useRouter()
const userStore = useUserStore()
function toggleSidebar() {
userStore.toggleSidebar()
}
function handleLogout() {
localStorage.removeItem('token')
router.push('/login')
}
</script>
<template>
<div class="header-bar">
<div class="left">
<el-icon class="collapse-btn" @click="toggleSidebar">
<Fold v-if="!userStore.sidebarCollapsed" />
<Expand v-else />
</el-icon>
<span class="page-title">医院物业SaaS管理后台</span>
</div>
<div class="right">
<el-dropdown trigger="click">
<span class="user-info">
<el-icon><UserFilled /></el-icon>
{{ userStore.userInfo.realName }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">退</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style scoped lang="scss">
.header-bar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.left {
display: flex;
align-items: center;
gap: 12px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #333;
&:hover {
color: #409EFF;
}
}
.page-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
color: #333;
font-size: 14px;
&:hover {
color: #409EFF;
}
}
</style>

@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/global/useUserStore'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const sidebarCollapsed = computed(() => userStore.sidebarCollapsed)
const menuList = [
{
title: '账号管理',
icon: 'User',
path: '/account',
children: [
{ title: '医院信息管理', path: '/account/hospitals' },
{ title: '物业公司信息管理', path: '/account/property-companies' },
{ title: '医院账号管理', path: '/account/hospital-accounts' },
{ title: '物业管理员账号管理', path: '/account/property-accounts' },
{ title: '到期账号管理', path: '/account/expiring' },
{ title: '到期提醒规则配置', path: '/account/expiry-settings' },
],
},
{
title: '权限管理',
icon: 'Lock',
path: '/permission',
children: [
{ title: '角色管理', path: '/permission/roles' },
{ title: '权限配置注册', path: '/permission/registry' },
{ title: '权限审计日志', path: '/permission/audit-log' },
],
},
{
title: '系统配置',
icon: 'Setting',
path: '/system',
children: [
{ title: '系统版本管理', path: '/system/versions' },
{ title: '缓存管理', path: '/system/cache' },
],
},
{
title: '操作日志',
icon: 'Document',
path: '/audit-log',
children: [
{ title: '权限变更日志', path: '/audit-log/permission' },
{ title: '账号操作日志', path: '/audit-log/account' },
],
},
]
const defaultActive = computed(() => route.path)
function handleSelect(path: string) {
router.push(path)
}
</script>
<template>
<div class="sidebar">
<div class="logo" :class="{ collapsed: sidebarCollapsed }">
<h1 v-if="!sidebarCollapsed"></h1>
<h1 v-else>SA</h1>
</div>
<el-menu
:default-active="defaultActive"
:collapse="sidebarCollapsed"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
:unique-opened="true"
@select="handleSelect"
>
<el-sub-menu v-for="menu in menuList" :key="menu.path" :index="menu.path">
<template #title>
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.path"
:index="child.path"
>
{{ child.title }}
</el-menu-item>
</el-sub-menu>
</el-menu>
</div>
</template>
<style scoped lang="scss">
.sidebar {
height: 100%;
display: flex;
flex-direction: column;
}
.logo {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: #263445;
color: #fff;
transition: all 0.3s;
h1 {
font-size: 16px;
white-space: nowrap;
margin: 0;
}
&.collapsed h1 {
font-size: 14px;
}
}
.el-menu {
border-right: none;
flex: 1;
overflow-y: auto;
}
</style>

@ -0,0 +1,112 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore, type TagView } from '@/stores/global/useAppStore'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
watch(
() => route.path,
() => {
if (route.name && route.meta.title) {
appStore.addView({
name: route.name as string,
path: route.path,
title: route.meta.title as string,
keepAlive: route.meta.keepAlive as boolean,
})
}
},
{ immediate: true }
)
function handleClose(path: string) {
appStore.removeView(path)
const views = appStore.visitedViews
if (route.path === path) {
const lastView = views[views.length - 1]
if (lastView) {
router.push(lastView.path)
} else {
router.push('/')
}
}
}
function handleClick(path: string) {
router.push(path)
}
</script>
<template>
<div class="tags-view">
<el-scrollbar>
<div class="tags-container">
<div
v-for="tag in appStore.visitedViews"
:key="tag.path"
class="tag-item"
:class="{ active: tag.path === route.path }"
@click="handleClick(tag.path)"
>
<span class="tag-title">{{ tag.title }}</span>
<el-icon class="tag-close" @click.stop="handleClose(tag.path)">
<Close />
</el-icon>
</div>
</div>
</el-scrollbar>
</div>
</template>
<style scoped lang="scss">
.tags-view {
height: 36px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
}
.tags-container {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
gap: 4px;
}
.tag-item {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
height: 26px;
font-size: 12px;
border: 1px solid #d9d9d9;
border-radius: 2px;
cursor: pointer;
white-space: nowrap;
background: #fff;
&.active {
color: #409EFF;
border-color: #409EFF;
background: #ecf5ff;
}
&:hover {
color: #409EFF;
}
}
.tag-close {
font-size: 12px;
border-radius: 50%;
&:hover {
background: #c0c4cc;
color: #fff;
}
}
</style>

@ -0,0 +1,36 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import './styles/reset.scss'
const app = createApp(App)
// 注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 注册 v-hasPermission 自定义指令
app.directive('hasPermission', {
mounted(el: HTMLElement, binding) {
const permissionCode = binding.value as string
if (!permissionCode) return
// 延迟检查,确保 store 已初始化
import('./utils/permission').then(({ hasPermission }) => {
if (!hasPermission(permissionCode)) {
el.parentNode?.removeChild(el)
}
})
}
})
app.use(ElementPlus, { locale: zhCn })
app.use(createPinia())
app.use(router)
app.mount('#app')

@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import superAdminRoutes from './modules/super-admin'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' },
},
...superAdminRoutes,
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '页面不存在' },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

@ -0,0 +1,173 @@
import type { RouteRecordRaw } from 'vue-router'
import AdminLayout from '@/layouts/AdminLayout.vue'
const superAdminRoutes: RouteRecordRaw[] = [
// === 账号管理 ===
{
path: '/account',
name: 'Account',
component: AdminLayout,
meta: { title: '账号管理', icon: 'User' },
redirect: '/account/hospitals',
children: [
{
path: 'hospitals',
name: 'HospitalList',
component: () => import('@/views/super-admin/account/HospitalList.vue'),
meta: { title: '医院信息管理', permissions: ['permission:user:view'], keepAlive: true },
},
{
path: 'hospitals/create',
name: 'HospitalCreate',
component: () => import('@/views/super-admin/account/HospitalForm.vue'),
meta: { title: '新增医院', permissions: ['permission:user:create'] },
},
{
path: 'hospitals/:id/edit',
name: 'HospitalEdit',
component: () => import('@/views/super-admin/account/HospitalForm.vue'),
meta: { title: '编辑医院', permissions: ['permission:user:update'] },
},
{
path: 'property-companies',
name: 'PropertyCompanyList',
component: () => import('@/views/super-admin/account/PropertyCompanyList.vue'),
meta: { title: '物业公司信息管理', permissions: ['permission:user:view'], keepAlive: true },
},
{
path: 'property-companies/create',
name: 'PropertyCompanyCreate',
component: () => import('@/views/super-admin/account/PropertyCompanyForm.vue'),
meta: { title: '新增物业公司', permissions: ['permission:user:create'] },
},
{
path: 'property-companies/:id/edit',
name: 'PropertyCompanyEdit',
component: () => import('@/views/super-admin/account/PropertyCompanyForm.vue'),
meta: { title: '编辑物业公司', permissions: ['permission:user:update'] },
},
{
path: 'hospital-accounts',
name: 'HospitalAccountList',
component: () => import('@/views/super-admin/account/HospitalAccountList.vue'),
meta: { title: '医院账号管理', permissions: ['permission:user:view'], keepAlive: true },
},
{
path: 'hospital-accounts/create',
name: 'HospitalAccountCreate',
component: () => import('@/views/super-admin/account/HospitalAccountCreate.vue'),
meta: { title: '新增医院账号', permissions: ['permission:user:create'] },
},
{
path: 'property-accounts',
name: 'PropertyAccountList',
component: () => import('@/views/super-admin/account/PropertyAccountList.vue'),
meta: { title: '物业管理员账号管理', permissions: ['permission:user:view'], keepAlive: true },
},
{
path: 'property-accounts/create',
name: 'PropertyAccountCreate',
component: () => import('@/views/super-admin/account/PropertyAccountCreate.vue'),
meta: { title: '新增物业管理员账号', permissions: ['permission:user:create'] },
},
{
path: 'expiring',
name: 'ExpiringAccountList',
component: () => import('@/views/super-admin/account/ExpiringAccountList.vue'),
meta: { title: '到期账号管理', permissions: ['permission:user:view'], keepAlive: true },
},
{
path: 'expiry-settings',
name: 'ExpirySettings',
component: () => import('@/views/super-admin/account/ExpirySettings.vue'),
meta: { title: '到期提醒规则配置', permissions: ['permission:config:view'] },
},
],
},
// === 权限管理 ===
{
path: '/permission',
name: 'Permission',
component: AdminLayout,
meta: { title: '权限管理', icon: 'Lock' },
redirect: '/permission/roles',
children: [
{
path: 'roles',
name: 'RoleList',
component: () => import('@/views/super-admin/permission/RoleList.vue'),
meta: { title: '角色管理', permissions: ['permission:role:view'], keepAlive: true },
},
{
path: 'roles/create',
name: 'RoleCreate',
component: () => import('@/views/super-admin/permission/RoleForm.vue'),
meta: { title: '新增角色', permissions: ['permission:role:create'] },
},
{
path: 'roles/:id/edit',
name: 'RoleEdit',
component: () => import('@/views/super-admin/permission/RoleForm.vue'),
meta: { title: '编辑角色', permissions: ['permission:role:update'] },
},
{
path: 'registry',
name: 'PermissionRegistry',
component: () => import('@/views/super-admin/permission/PermissionRegistry.vue'),
meta: { title: '权限配置注册', permissions: ['permission:config:view'], keepAlive: true },
},
{
path: 'audit-log',
name: 'PermissionAuditLog',
component: () => import('@/views/super-admin/permission/PermissionAuditLog.vue'),
meta: { title: '权限审计日志', permissions: ['audit-log:permission:view'], keepAlive: true },
},
],
},
// === 系统配置 ===
{
path: '/system',
name: 'System',
component: AdminLayout,
meta: { title: '系统配置', icon: 'Setting' },
redirect: '/system/versions',
children: [
{
path: 'versions',
name: 'VersionList',
component: () => import('@/views/super-admin/system/VersionManagement.vue'),
meta: { title: '系统版本管理', permissions: ['system:version:view'], keepAlive: true },
},
{
path: 'cache',
name: 'CacheManagement',
component: () => import('@/views/super-admin/system/CacheManagement.vue'),
meta: { title: '缓存管理', permissions: ['system:cache:view'] },
},
],
},
// === 操作日志 ===
{
path: '/audit-log',
name: 'AuditLog',
component: AdminLayout,
meta: { title: '操作日志', icon: 'Document' },
redirect: '/audit-log/permission',
children: [
{
path: 'permission',
name: 'AuditLogPermission',
component: () => import('@/views/super-admin/audit-log/PermissionChangeLog.vue'),
meta: { title: '权限变更日志', permissions: ['audit-log:permission:view'], keepAlive: true },
},
{
path: 'account',
name: 'AuditLogAccount',
component: () => import('@/views/super-admin/audit-log/AccountOperationLog.vue'),
meta: { title: '账号操作日志', permissions: ['audit-log:list:view'], keepAlive: true },
},
],
},
]
export default superAdminRoutes

@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface TagView {
name: string
path: string
title: string
icon?: string
keepAlive?: boolean
}
export const useAppStore = defineStore('app', () => {
const visitedViews = ref<TagView[]>([])
const cachedViews = ref<string[]>([])
function addView(view: TagView) {
if (visitedViews.value.some(v => v.path === view.path)) return
visitedViews.value.push(view)
if (view.keepAlive && !cachedViews.value.includes(view.name)) {
cachedViews.value.push(view.name)
}
}
function removeView(path: string) {
const idx = visitedViews.value.findIndex(v => v.path === path)
if (idx > -1) visitedViews.value.splice(idx, 1)
const name = visitedViews.value[idx]?.name
if (name) {
const cacheIdx = cachedViews.value.indexOf(name)
if (cacheIdx > -1) cachedViews.value.splice(cacheIdx, 1)
}
}
function removeOtherViews(path: string) {
visitedViews.value = visitedViews.value.filter(v => v.path === path)
cachedViews.value = cachedViews.value.filter(name =>
visitedViews.value.some(v => v.name === name)
)
}
return {
visitedViews,
cachedViews,
addView,
removeView,
removeOtherViews,
}
})

@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const userInfo = ref({
id: '1',
username: 'admin',
realName: '超级管理员',
avatar: '',
})
const permissions = ref<string[]>([
// 账号管理权限
'permission:user:view',
'permission:user:create',
'permission:user:update',
'permission:user:delete',
// 权限管理权限
'permission:role:view',
'permission:role:create',
'permission:role:update',
'permission:role:delete',
'permission:config:view',
'permission:config:update',
// 系统配置权限
'system:version:view',
'system:version:create',
'system:version:update',
'system:cache:view',
'system:cache:update',
// 操作日志权限
'audit-log:permission:view',
'audit-log:list:view',
])
const sidebarCollapsed = ref(false)
function hasPermission(code: string): boolean {
return permissions.value.includes(code)
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
return {
userInfo,
permissions,
sidebarCollapsed,
hasPermission,
toggleSidebar,
}
})

@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

@ -0,0 +1,68 @@
//
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: $--font-size-base;
color: #333;
background-color: #f5f7fa;
}
//
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
//
.page-container {
padding: $--spacing-page;
}
//
.query-panel {
background: #fff;
padding: $--spacing-section;
border-radius: 4px;
margin-bottom: $--spacing-item;
}
//
.action-bar {
background: #fff;
padding: $--spacing-item $--spacing-section;
border-radius: 4px;
margin-bottom: $--spacing-item;
}
//
.data-table-wrapper {
background: #fff;
padding: $--spacing-section;
border-radius: 4px;
margin-bottom: $--spacing-item;
}
//
.pagination-wrapper {
background: #fff;
padding: $--spacing-item $--spacing-section;
border-radius: 4px;
display: flex;
justify-content: flex-end;
}

@ -0,0 +1,25 @@
// Element Plus
//
$--color-primary: #409EFF;
$--color-success: #67C23A;
$--color-warning: #E6A23C;
$--color-danger: #F56C6C;
$--color-info: #909399;
//
$--layout-sidebar-width: 220px;
$--layout-sidebar-collapsed-width: 64px;
$--layout-header-height: 56px;
$--layout-tags-height: 36px;
$--layout-footer-height: 48px;
//
$--font-size-base: 14px;
$--font-size-small: 12px;
$--font-size-large: 16px;
//
$--spacing-page: 20px;
$--spacing-section: 16px;
$--spacing-item: 12px;

@ -0,0 +1,39 @@
import { ref } from 'vue'
interface PendingRequest {
key: string
timestamp: number
}
const pendingRequests = new Map<string, PendingRequest>()
/** H1 防重复提交 */
export function useDebounceSubmit() {
const submitLoading = ref(false)
async function debounceSubmit<T>(
key: string,
fn: () => Promise<T>,
options?: { cooldown?: number }
): Promise<T | null> {
if (submitLoading.value) return null
const pending = pendingRequests.get(key)
const cooldown = options?.cooldown ?? 1000
if (pending && Date.now() - pending.timestamp < cooldown) {
return null
}
submitLoading.value = true
pendingRequests.set(key, { key, timestamp: Date.now() })
try {
return await fn()
} finally {
submitLoading.value = false
pendingRequests.delete(key)
}
}
return { submitLoading, debounceSubmit }
}

@ -0,0 +1,57 @@
import { ref, watch, onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'
import { ElMessageBox } from 'element-plus'
/** H4 脏数据检测 */
export function useDirtyCheck<T extends Record<string, any>>(
formData: Ref<T>,
) {
const initialSnapshot = ref('')
const isDirty = ref(false)
function saveSnapshot() {
initialSnapshot.value = JSON.stringify(formData.value)
isDirty.value = false
}
watch(formData, () => {
if (initialSnapshot.value) {
isDirty.value = JSON.stringify(formData.value) !== initialSnapshot.value
}
}, { deep: true })
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isDirty.value) {
e.preventDefault()
e.returnValue = ''
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
async function checkBeforeLeave(): Promise<boolean> {
if (!isDirty.value) return true
try {
await ElMessageBox.confirm(
'当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?',
'提示',
{
confirmButtonText: '离开',
cancelButtonText: '留在此页',
type: 'warning',
}
)
return true
} catch {
return false
}
}
return { isDirty, saveSnapshot, checkBeforeLeave }
}

@ -0,0 +1,13 @@
import { useUserStore } from '@/stores/global/useUserStore'
/** 权限校验 */
export function hasPermission(code: string): boolean {
const userStore = useUserStore()
return userStore.hasPermission(code)
}
/** 权限指令辅助 */
export function checkPermissions(codes: string[]): boolean {
if (!codes || codes.length === 0) return true
return codes.every(code => hasPermission(code))
}

@ -0,0 +1,97 @@
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import { showTimeoutError, showNetworkError } from './result-feedback'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
timeout: Number(import.meta.env.VITE_API_TIMEOUT_GET) || 15000,
})
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null
let loadingCount = 0
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 动态超时
if (config.method === 'post' || config.method === 'put') {
config.timeout = Number(import.meta.env.VITE_API_TIMEOUT_POST) || 30000
}
if (config.url?.includes('/upload')) {
config.timeout = 60000
}
// 模拟Token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
service.interceptors.response.use(
(response) => {
closeLoading()
const { code, message } = response.data
if (code && code !== 0 && code !== 200) {
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
}
return response.data
},
(error) => {
closeLoading()
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
showTimeoutError()
} else if (!window.navigator.onLine) {
showNetworkError()
} else if (error.response) {
const { status, data } = error.response
if (status === 401) {
ElMessage.error('登录已过期,请重新登录')
} else if (status === 403) {
ElMessage.error('无权限访问')
} else if (status === 500) {
ElMessage.error(data?.message || '服务器内部错误')
} else {
ElMessage.error(data?.message || '请求失败')
}
} else {
showNetworkError()
}
return Promise.reject(error)
}
)
function closeLoading() {
loadingCount--
if (loadingCount <= 0) {
loadingCount = 0
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
}
/** 显示全局Loading>2秒时自动触发 */
export function showGlobalLoading() {
loadingCount++
if (!loadingInstance) {
setTimeout(() => {
if (loadingCount > 0 && !loadingInstance) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(255, 255, 255, 0.7)',
})
}
}, 2000)
}
}
export default service

@ -0,0 +1,56 @@
import { ElMessage, ElMessageBox } from 'element-plus'
/** H3 操作确认 */
export async function confirmAction(
message: string,
title = '操作确认',
type: 'warning' | 'error' = 'warning'
): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
confirmButtonText: '确认',
cancelButtonText: '取消',
type,
})
return true
} catch {
return false
}
}
/** H8 操作成功反馈 */
export function showSuccess(message = '操作成功', duration = 2000) {
ElMessage.success({ message, duration })
}
/** H8 操作失败反馈 */
export function showError(message = '操作失败,请稍后重试', duration = 0) {
ElMessage.error({ message, duration })
}
/** H8 网络异常反馈 */
export function showNetworkError() {
ElMessage.error({
message: '网络连接异常,请检查网络后重试',
duration: 0,
showClose: true,
})
}
/** H8 超时反馈 */
export function showTimeoutError() {
ElMessage.error({
message: '请求超时,请检查网络后重试',
duration: 0,
showClose: true,
})
}
/** 统一操作结果反馈 */
export function showResultFeedback(type: 'success' | 'error', message: string) {
if (type === 'success') {
showSuccess(message)
} else {
showError(message)
}
}

@ -0,0 +1,22 @@
<template>
<div class="error-page">
<el-result icon="warning" title="403" sub-title="访">
<template #extra>
<el-button type="primary" @click="$router.push('/')"></el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
/** @shared 403无权限页面 */
</script>
<style scoped lang="scss">
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
</style>

@ -0,0 +1,22 @@
<template>
<div class="error-page">
<el-result icon="warning" title="404" sub-title="访">
<template #extra>
<el-button type="primary" @click="$router.push('/')"></el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
/** @shared 404页面不存在 */
</script>
<style scoped lang="scss">
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
</style>

@ -0,0 +1,22 @@
<template>
<div class="error-page">
<el-result icon="error" title="500" sub-title="">
<template #extra>
<el-button type="primary" @click="$router.push('/')"></el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
/** @shared 500服务器错误页面 */
</script>
<style scoped lang="scss">
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
</style>

@ -0,0 +1,55 @@
<template>
<div class="login-page">
<div class="login-card">
<h2>超级管理员登录</h2>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="0" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input v-model="formData.username" prefix-icon="User" placeholder="请输入账号" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="formData.password" prefix-icon="Lock" type="password" placeholder="请输入密码" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" native-type="submit" size="large" style="width: 100%"> </el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const formRef = ref<FormInstance>()
const formData = reactive({ username: '', password: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
// Mock login
setTimeout(() => {
loading.value = false
router.push('/account/hospitals')
}, 500)
}
</script>
<style scoped lang="scss">
.login-page {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px; padding: 40px; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.1);
h2 { text-align: center; margin-bottom: 24px; color: #303133; }
}
</style>

@ -0,0 +1,169 @@
<template>
<div class="expiring-account-list">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '到期账号管理' }]" />
<!-- 统计卡片 -->
<el-row :gutter="16" class="stats-row">
<el-col :sm="8" :xs="24">
<el-card shadow="hover" :body-style="{ padding: '20px', cursor: 'pointer' }" @click="filterByStatus('expired')">
<div class="stat-label">已过期</div>
<div class="stat-number" style="color: var(--el-color-danger)">{{ stats.expired }}</div>
</el-card>
</el-col>
<el-col :sm="8" :xs="24">
<el-card shadow="hover" :body-style="{ padding: '20px', cursor: 'pointer' }" @click="filterByStatus('7days')">
<div class="stat-label">7天内到期</div>
<div class="stat-number" style="color: var(--el-color-warning)">{{ stats.expiringIn7Days }}</div>
</el-card>
</el-col>
<el-col :sm="8" :xs="24">
<el-card shadow="hover" :body-style="{ padding: '20px', cursor: 'pointer' }" @click="filterByStatus('30days')">
<div class="stat-label">30天内到期</div>
<div class="stat-number" style="color: var(--el-color-primary)">{{ stats.expiringIn30Days }}</div>
</el-card>
</el-col>
</el-row>
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="账号类型">
<el-select v-model="queryForm.accountType" clearable placeholder="请选择">
<el-option label="医院" value="hospital" />
<el-option label="物业管理员" value="property_admin" />
</el-select>
</el-form-item>
<el-form-item label="到期状态">
<el-select v-model="queryForm.expireStatus" clearable placeholder="请选择">
<el-option label="已过期" value="expired" />
<el-option label="7天内到期" value="7days" />
<el-option label="30天内到期" value="30days" />
</el-select>
</el-form-item>
</QueryPanel>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="username" label="登录账号" min-width="130" />
<el-table-column label="账号类型" width="100">
<template #default="{ row }">{{ row.accountType === 'hospital' ? '医院' : '物业' }}</template>
</el-table-column>
<el-table-column prop="bindUnit" label="绑定单位" width="150" />
<el-table-column prop="expireDate" label="有效期至" width="120" sortable />
<el-table-column label="剩余天数" width="90" sortable>
<template #default="{ row }">
<el-tag :type="getRemainTagType(row.remainDays)">{{ row.remainDays > 0 ? row.remainDays + '天' : row.remainDays + '天' }}</el-tag>
</template>
</el-table-column>
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleRenew(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 续期弹窗 -->
<el-dialog v-model="renewDialogVisible" title="账号续期" width="400px" :close-on-click-modal="false">
<el-form label-width="80px">
<el-form-item label="登录账号">{{ currentRow?.username }}</el-form-item>
<el-form-item label="新有效期">
<el-date-picker v-model="renewDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="renewDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="renewLoading" @click="confirmRenew"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { accountApi } from '@/api'
import type { ExpiringAccount, ExpiringAccountQuery, ExpiringStats } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const loading = ref(false)
const tableData = ref<(ExpiringAccount & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const stats = reactive<ExpiringStats>({ expired: 0, expiringIn7Days: 0, expiringIn30Days: 0 })
const queryForm = reactive<ExpiringAccountQuery>({
page: 1, pageSize: 20, accountType: '', expireStatus: ''
})
const renewDialogVisible = ref(false)
const renewLoading = ref(false)
const renewDate = ref('')
const currentRow = ref<ExpiringAccount | null>(null)
function getRemainTagType(days: number) {
if (days < 0) return 'danger'
if (days <= 7) return 'warning'
return 'info'
}
async function loadData() {
loading.value = true
try {
const res = await accountApi.getExpiringAccountList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') }
finally { loading.value = false }
}
async function loadStats() {
try {
const s = await accountApi.getExpiringStats()
Object.assign(stats, s)
} catch { /* stats load failure is non-blocking */ }
}
function handleSearch() { pagination.page = 1; loadData(); loadStats() }
function handleReset() {
queryForm.accountType = ''; queryForm.expireStatus = ''
pagination.page = 1; loadData(); loadStats()
}
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
function filterByStatus(status: string) {
queryForm.expireStatus = status
pagination.page = 1
loadData()
}
function handleRenew(row: ExpiringAccount & { _loading?: boolean }) {
currentRow.value = row
renewDate.value = ''
renewDialogVisible.value = true
}
async function confirmRenew() {
if (!renewDate.value) { showResultFeedback('error', '请选择有效期'); return }
if (new Date(renewDate.value) < new Date()) { showResultFeedback('error', '有效期不能早于当前日期'); return }
renewLoading.value = true
try {
await accountApi.renewAccount(currentRow.value!.id, renewDate.value)
showResultFeedback('success', '续期成功')
renewDialogVisible.value = false
loadData()
loadStats()
} catch (e: any) { showResultFeedback('error', e.message || '续期失败') }
finally { renewLoading.value = false }
}
onMounted(() => { loadData(); loadStats() })
</script>
<style scoped lang="scss">
.stats-row { margin-bottom: 16px; }
.stat-label { font-size: 14px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
.stat-number { font-size: 28px; font-weight: bold; }
</style>

@ -0,0 +1,168 @@
<template>
<div class="expiry-settings">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '到期提醒规则配置' }]" />
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">
配置账号到期提醒规则修改后立即生效
</el-alert>
<el-card v-loading="loading">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="140px" :disabled="saving">
<h3 class="section-title">提醒天数配置</h3>
<el-form-item label="提前提醒天数" prop="reminderDays">
<div class="day-tags">
<el-check-tag
v-for="day in presetDays"
:key="day"
:checked="formData.reminderDays.includes(day)"
@change="(checked: boolean) => toggleDay(day, checked)"
>{{ day }}</el-check-tag>
</div>
<div class="custom-day">
<el-input-number v-model="customDay" :min="1" :max="365" :step="1" placeholder="自定义天数" />
<el-button type="primary" plain size="small" @click="addCustomDay" style="margin-left: 8px">添加</el-button>
</div>
</el-form-item>
<h3 class="section-title">提醒方式</h3>
<el-form-item label="用户登录时弹窗提醒">
<el-switch v-model="formData.loginPopup" active-text="" inactive-text="" />
</el-form-item>
<h3 class="section-title">提醒行为</h3>
<el-form-item label="提醒弹窗关闭后">
<el-radio-group v-model="formData.popupCloseAction">
<el-radio value="normal">可正常使用</el-radio>
<el-radio value="restrict">限制部分功能</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="账号过期后">
<el-radio-group v-model="formData.expiredAction">
<el-radio value="block">禁止登录</el-radio>
<el-radio value="remind">仅提醒</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div class="form-footer">
<el-button @click="handleReset"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '...' : '' }}</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { accountApi } from '@/api'
import type { ExpiryReminderConfig } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const formRef = ref<FormInstance>()
const loading = ref(false)
const saving = ref(false)
const customDay = ref(7)
const presetDays = [7, 15, 30, 60, 90]
const formData = reactive<ExpiryReminderConfig>({
reminderDays: [7, 15, 30],
loginPopup: true,
popupCloseAction: 'normal',
expiredAction: 'block'
})
let snapshot = JSON.parse(JSON.stringify(formData))
let isDirty = ref(false)
const rules: FormRules = {
reminderDays: [
{
validator: (_rule: any, value: number[], callback: (err?: Error) => void) => {
if (!value || value.length === 0) callback(new Error('请至少选择一个提醒天数'))
else callback()
},
trigger: 'change'
}
]
}
function toggleDay(day: number, checked: boolean) {
if (checked) {
if (!formData.reminderDays.includes(day)) formData.reminderDays.push(day)
} else {
formData.reminderDays = formData.reminderDays.filter(d => d !== day)
}
formData.reminderDays.sort((a, b) => a - b)
checkDirty()
}
function addCustomDay() {
if (customDay.value < 1 || customDay.value > 365) {
showResultFeedback('error', '天数范围1-365')
return
}
if (!formData.reminderDays.includes(customDay.value)) {
formData.reminderDays.push(customDay.value)
formData.reminderDays.sort((a, b) => a - b)
}
checkDirty()
}
function checkDirty() {
isDirty.value = JSON.stringify(formData) !== snapshot
}
async function handleSave() {
if (!formRef.value) return
await formRef.value.validate()
saving.value = true
try {
await accountApi.saveExpiryReminderConfig(formData)
showResultFeedback('success', '配置保存成功')
snapshot = JSON.parse(JSON.stringify(formData))
isDirty.value = false
} catch (e: any) {
showResultFeedback('error', e.message || '保存失败')
} finally { saving.value = false }
}
function handleReset() {
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,确定取消吗?', '提示', { type: 'warning' })
.then(() => {
Object.assign(formData, JSON.parse(snapshot))
isDirty.value = false
}).catch(() => {})
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (saving.value) { next(false); return }
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => next()).catch(() => next(false))
} else { next() }
})
onMounted(async () => {
loading.value = true
try {
const config = await accountApi.getExpiryReminderConfig()
Object.assign(formData, config)
snapshot = JSON.parse(JSON.stringify(formData))
} catch (e: any) {
showResultFeedback('error', e.message || '配置加载失败')
} finally { loading.value = false }
})
</script>
<style scoped lang="scss">
.section-title { font-size: 16px; font-weight: 600; color: var(--el-text-color-primary); border-bottom: 1px solid var(--el-border-color-lighter); padding-bottom: 8px; margin-bottom: 16px; }
.day-tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.custom-day { display: flex; align-items: center; margin-top: 8px; }
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
</style>

@ -0,0 +1,154 @@
<template>
<div class="hospital-account-create">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '医院账号管理' }, { label: '新增医院账号' }]" />
<el-card>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="登录账号" prop="username">
<el-input v-model="formData.username" maxlength="20" clearable placeholder="4-20位字母数字" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="初始密码" prop="password">
<div style="display: flex; gap: 8px; width: 100%">
<el-input v-model="formData.password" maxlength="20" show-password placeholder="6-20位" style="flex: 1" />
<el-button :icon="Refresh" circle @click="generatePassword" />
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="绑定医院" prop="hospitalId">
<el-select v-model="formData.hospitalId" filterable clearable placeholder="请选择医院" style="width: 100%">
<el-option v-for="h in hospitalOptions" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="有效期至" prop="expireDate">
<el-date-picker v-model="formData.expireDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分配角色" prop="roleIds">
<el-select v-model="formData.roleIds" multiple filterable collapse-tags placeholder="请选择角色" style="width: 100%">
<el-option v-for="r in roleOptions" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
</el-form>
<div class="form-footer">
<el-button @click="handleCancel"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '...' : '' }}</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { HospitalAccountFormData } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
import { useDirtyCheck } from '@/utils/dirty-check'
const router = useRouter()
const formRef = ref<FormInstance>()
const saving = ref(false)
const hospitalOptions = ref<{ id: string; name: string }[]>([])
const roleOptions = ref<{ id: string; name: string }[]>([])
function generatePwd() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
return 'Abc' + Array.from({ length: 5 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
const formData = reactive<HospitalAccountFormData>({
username: '', password: generatePwd(), hospitalId: '', expireDate: '', roleIds: []
})
let snapshot = JSON.parse(JSON.stringify(formData))
const { isDirty } = useDirtyCheck(formData, () => snapshot)
const rules: FormRules = {
username: [
{ required: true, message: '请输入登录账号', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9]{4,20}$/, message: '登录账号为4-20位字母数字', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入初始密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度6-20位', trigger: 'blur' }
],
hospitalId: [{ required: true, message: '请选择绑定医院', trigger: 'change' }],
expireDate: [{ required: true, message: '请选择有效期', trigger: 'change' }],
roleIds: [{ type: 'array', required: true, message: '请选择至少一个角色', trigger: 'change' }]
}
function generatePassword() {
formData.password = generatePwd()
}
async function handleSave() {
if (!formRef.value) return
await formRef.value.validate()
if (new Date(formData.expireDate) < new Date()) {
showResultFeedback('error', '有效期不能早于当前日期')
return
}
saving.value = true
try {
await accountApi.createHospitalAccount(formData)
showResultFeedback('success', '保存成功')
snapshot = JSON.parse(JSON.stringify(formData))
setTimeout(() => router.push('/account/hospital-accounts'), 300)
} catch (e: any) {
showResultFeedback('error', e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleCancel() {
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => router.push('/account/hospital-accounts')).catch(() => {})
} else {
router.push('/account/hospital-accounts')
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (saving.value) { next(false); return }
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => next()).catch(() => next(false))
} else { next() }
})
onMounted(async () => {
hospitalOptions.value = await accountApi.getHospitalOptions()
// Mock role options for hospital scope
roleOptions.value = [
{ id: 'role-1', name: '医院查看模板' },
{ id: 'role-2', name: '医院管理模板' },
{ id: 'role-3', name: '医院审批模板' }
]
// Default expire date: +1 year
const d = new Date()
d.setFullYear(d.getFullYear() + 1)
formData.expireDate = d.toISOString().split('T')[0]
snapshot = JSON.parse(JSON.stringify(formData))
})
</script>
<style scoped lang="scss">
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
</style>

@ -0,0 +1,232 @@
<template>
<div class="hospital-account-list">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '医院账号管理' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="登录账号">
<el-input v-model="queryForm.username" clearable placeholder="请输入" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="绑定医院">
<el-select v-model="queryForm.hospitalId" filterable clearable placeholder="请选择医院">
<el-option v-for="h in hospitalOptions" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="请选择">
<el-option label="正常" value="normal" />
<el-option label="即将到期" value="expiring" />
<el-option label="已过期" value="expired" />
<el-option label="已停用" value="stopped" />
</el-select>
</el-form-item>
<el-form-item label="有效期">
<el-select v-model="queryForm.expireFilter" clearable placeholder="请选择">
<el-option label="已过期" value="expired" />
<el-option label="7天内到期" value="7days" />
<el-option label="30天内到期" value="30days" />
<el-option label="正常" value="normal" />
</el-select>
</el-form-item>
</QueryPanel>
<ActionBar>
<el-button v-hasPermission="'permission:user:create'" type="primary" :icon="Plus" @click="$router.push('/account/hospital-accounts/create')">
新增医院账号
</el-button>
</ActionBar>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="username" label="登录账号" min-width="130" />
<el-table-column prop="hospitalName" label="绑定医院" width="150" sortable />
<el-table-column label="角色" width="120">
<template #default="{ row }">{{ row.roleNames?.join('、') || '—' }}</template>
</el-table-column>
<el-table-column label="有效期至" width="120" sortable prop="expireDate">
<template #default="{ row }">
<el-tag :type="getExpireTagType(row.expireDate, row.status)">{{ row.expireDate }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" sortable prop="status">
<template #default="{ row }">
<StatusTag :status="getAccountStatusType(row.status)" :text="getAccountStatusText(row.status)" />
</template>
</el-table-column>
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleRenew(row)"></el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleToggleStatus(row)">
{{ row.status === 'stopped' ? '启用' : '禁用' }}
</el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleResetPassword(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 续期弹窗 -->
<el-dialog v-model="renewDialogVisible" title="账号续期" width="400px" :close-on-click-modal="false">
<el-form label-width="80px">
<el-form-item label="登录账号">{{ currentRow?.username }}</el-form-item>
<el-form-item label="新有效期">
<el-date-picker v-model="renewDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="renewDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="renewLoading" @click="confirmRenew"></el-button>
</template>
</el-dialog>
<!-- 重置密码结果弹窗 -->
<el-dialog v-model="resetPwdDialogVisible" title="密码已重置" width="400px" :close-on-click-modal="false">
<el-result icon="success" sub-title="">
<template #extra>
<div style="display: flex; align-items: center; gap: 8px">
<el-input :model-value="newPassword" readonly />
<el-button type="primary" @click="copyPassword"></el-button>
</div>
</template>
</el-result>
<template #footer>
<el-button type="primary" @click="resetPwdDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { HospitalAccount, HospitalAccountQuery } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import ActionBar from '@/components/shared/ActionBar/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import StatusTag from '@/components/shared/StatusTag/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
import { useClipboard } from '@vueuse/core'
const loading = ref(false)
const tableData = ref<(HospitalAccount & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const hospitalOptions = ref<{ id: string; name: string }[]>([])
const queryForm = reactive<HospitalAccountQuery>({
page: 1, pageSize: 20, username: '', hospitalId: '', status: '', expireFilter: ''
})
const renewDialogVisible = ref(false)
const renewLoading = ref(false)
const renewDate = ref('')
const currentRow = ref<HospitalAccount | null>(null)
const resetPwdDialogVisible = ref(false)
const newPassword = ref('')
const { copy } = useClipboard()
function getExpireTagType(expireDate: string, status: string) {
if (status === 'expired') return 'danger'
if (status === 'expiring') return 'warning'
return 'success'
}
function getAccountStatusType(status: string) {
const map: Record<string, string> = { normal: 'success', expiring: 'warning', expired: 'danger', stopped: 'info' }
return map[status] || 'info'
}
function getAccountStatusText(status: string) {
const map: Record<string, string> = { normal: '正常', expiring: '即将到期', expired: '已过期', stopped: '已停用' }
return map[status] || status
}
async function loadData() {
loading.value = true
try {
const res = await accountApi.getHospitalAccountList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) {
showResultFeedback('error', e.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() {
queryForm.username = ''; queryForm.hospitalId = ''; queryForm.status = ''; queryForm.expireFilter = ''
pagination.page = 1; loadData()
}
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
function handleRenew(row: HospitalAccount & { _loading?: boolean }) {
currentRow.value = row
renewDate.value = ''
renewDialogVisible.value = true
}
async function confirmRenew() {
if (!renewDate.value) { showResultFeedback('error', '请选择有效期'); return }
if (new Date(renewDate.value) < new Date()) { showResultFeedback('error', '有效期不能早于当前日期'); return }
renewLoading.value = true
try {
await accountApi.renewAccount(currentRow.value!.id, renewDate.value)
showResultFeedback('success', '续期成功')
renewDialogVisible.value = false
loadData()
} catch (e: any) {
showResultFeedback('error', e.message || '续期失败')
} finally {
renewLoading.value = false
}
}
async function handleToggleStatus(row: HospitalAccount & { _loading?: boolean }) {
const action = row.status === 'stopped' ? '启用' : '禁用'
try {
await ElMessageBox.confirm(
`确定要${action}${row.username}」吗?${action === '禁用' ? '禁用后该账号将无法登录' : ''}`,
'操作确认', { type: 'warning' }
)
row._loading = true
await accountApi.toggleAccountStatus(row.id)
showResultFeedback('success', `${action}成功`)
loadData()
} catch (e: any) {
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
} finally {
row._loading = false
}
}
async function handleResetPassword(row: HospitalAccount & { _loading?: boolean }) {
try {
await ElMessageBox.confirm(`确定要重置「${row.username}」的密码吗?重置后将恢复为默认密码`, '操作确认', { type: 'warning' })
row._loading = true
const res = await accountApi.resetPassword(row.id)
newPassword.value = res.newPassword
resetPwdDialogVisible.value = true
} catch (e: any) {
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
} finally {
row._loading = false
}
}
function copyPassword() {
copy(newPassword.value)
showResultFeedback('success', '已复制到剪贴板')
}
onMounted(async () => {
loadData()
hospitalOptions.value = await accountApi.getHospitalOptions()
})
</script>

@ -0,0 +1,243 @@
<template>
<div class="hospital-form">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '医院信息管理' }, { label: isEdit ? '编辑医院' : '新增医院' }]" />
<el-card v-loading="detailLoading">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
<h3 class="section-title">基本信息</h3>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="医院名称" prop="name">
<el-input v-model="formData.name" maxlength="50" show-word-limit clearable placeholder="请输入医院名称" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="统一社会信用代码" prop="creditCode">
<el-input v-model="formData.creditCode" maxlength="18" show-word-limit clearable placeholder="18位统一社会信用代码" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio value="enabled">启用</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="医院地址" prop="address">
<el-input v-model="formData.address" maxlength="200" show-word-limit placeholder="请输入医院地址" />
</el-form-item>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="联系人" prop="contactPerson">
<el-input v-model="formData.contactPerson" maxlength="20" placeholder="请输入联系人" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="formData.contactPhone" maxlength="11" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
</el-row>
<h3 class="section-title" style="margin-top: 24px">院区信息</h3>
<el-button type="primary" plain :icon="Plus" @click="addCampus" style="margin-bottom: 12px">添加院区</el-button>
<div v-for="(campus, idx) in formData.campuses" :key="idx" class="campus-row">
<el-row :gutter="12">
<el-col :sm="24" :md="6">
<el-form-item :label="`院区${idx + 1}名称`" :prop="`campuses.${idx}.name`" :rules="campusNameRules">
<el-input v-model="campus.name" maxlength="30" show-word-limit placeholder="请输入院区名称" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="8">
<el-form-item label="地址" :prop="`campuses.${idx}.address`" :rules="campusAddressRules">
<el-input v-model="campus.address" maxlength="200" placeholder="请输入院区地址" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="6">
<el-form-item label="联系人">
<el-input v-model="campus.contactPerson" maxlength="20" placeholder="请输入联系人" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="4" class="campus-action">
<el-button type="danger" link :icon="Delete" :disabled="formData.campuses.length <= 1" @click="removeCampus(idx)"></el-button>
</el-col>
</el-row>
</div>
</el-form>
<div class="form-footer">
<el-button @click="handleCancel"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '...' : '' }}</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { HospitalFormData, HospitalCampus } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
import { useDirtyCheck } from '@/utils/dirty-check'
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const detailLoading = ref(false)
const saving = ref(false)
const isEdit = computed(() => !!route.params.id)
const defaultCampus: HospitalCampus = { name: '', address: '', contactPerson: '' }
const formData = reactive<HospitalFormData>({
name: '',
address: '',
contactPerson: '',
contactPhone: '',
status: 'enabled',
campuses: [{ ...defaultCampus }]
})
let snapshot = JSON.parse(JSON.stringify(formData))
const { isDirty, checkBeforeLeave } = useDirtyCheck(formData, () => snapshot)
const rules: FormRules = {
name: [
{ required: true, message: '请输入医院名称', trigger: 'blur' },
{ min: 2, max: 50, message: '医院名称长度2-50字符', trigger: 'blur' }
],
creditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ pattern: /^[0-9A-Z]{18}$/, message: '统一社会信用代码为18位字母数字', trigger: 'blur' }
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
address: [{ max: 200, message: '医院地址不能超过200字符', trigger: 'blur' }],
contactPerson: [{ max: 20, message: '联系人不能超过20字符', trigger: 'blur' }],
contactPhone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
const campusNameRules = [
{ required: true, message: '请输入院区名称', trigger: 'blur' },
{ min: 2, max: 30, message: '院区名称长度2-30字符', trigger: 'blur' }
]
const campusAddressRules = [
{ required: true, message: '请输入院区地址', trigger: 'blur' },
{ max: 200, message: '院区地址不能超过200字符', trigger: 'blur' }
]
function addCampus() {
formData.campuses.push({ ...defaultCampus })
}
async function removeCampus(idx: number) {
if (formData.campuses.length <= 1) return
try {
await ElMessageBox.confirm('确定要删除该院区吗?删除后数据无法恢复', '删除确认', { type: 'warning' })
formData.campuses.splice(idx, 1)
} catch { /* cancel */ }
}
async function handleSave() {
if (!formRef.value) return
await formRef.value.validate()
if (formData.campuses.length === 0) {
ElMessage.error('请至少添加一个院区')
return
}
saving.value = true
try {
if (isEdit.value) {
await accountApi.updateHospital(route.params.id as string, formData)
} else {
await accountApi.createHospital(formData)
}
showResultFeedback('success', '保存成功')
snapshot = JSON.parse(JSON.stringify(formData))
setTimeout(() => router.push('/account/hospitals'), 300)
} catch (e: any) {
showResultFeedback('error', e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleCancel() {
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => router.push('/account/hospitals'))
.catch(() => {})
} else {
router.push('/account/hospitals')
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (saving.value) { next(false); return }
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => next()).catch(() => next(false))
} else {
next()
}
})
onMounted(async () => {
if (isEdit.value) {
detailLoading.value = true
try {
const detail = await accountApi.getHospitalDetail(route.params.id as string)
Object.assign(formData, {
name: detail.name,
creditCode: detail.creditCode,
address: detail.address,
contactPerson: detail.contactPerson,
contactPhone: detail.contactPhone,
status: detail.status,
campuses: detail.campuses.length ? detail.campuses : [{ ...defaultCampus }]
})
snapshot = JSON.parse(JSON.stringify(formData))
} catch (e: any) {
showResultFeedback('error', e.message || '数据加载失败')
} finally {
detailLoading.value = false
}
}
})
</script>
<style scoped lang="scss">
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 8px;
margin-bottom: 16px;
}
.campus-row {
background: var(--el-fill-color-lighter);
padding: 12px;
border-radius: 4px;
margin-bottom: 8px;
}
.campus-action {
display: flex;
align-items: flex-end;
padding-bottom: 18px;
}
.form-footer {
text-align: right;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>

@ -0,0 +1,202 @@
<template>
<div class="hospital-list">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '医院信息管理' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="医院名称">
<el-input v-model="queryForm.name" clearable maxlength="50" placeholder="请输入" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="请选择">
<el-option label="启用" value="enabled" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="queryForm.contactPerson" clearable maxlength="20" placeholder="请输入" @keyup.enter="handleSearch" />
</el-form-item>
</QueryPanel>
<ActionBar>
<el-button v-hasPermission="'permission:user:create'" type="primary" :icon="Plus" @click="$router.push('/account/hospitals/create')">
新增医院
</el-button>
</ActionBar>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="医院名称" min-width="180" sortable />
<el-table-column label="院区数" width="80">
<template #default="{ row }">
<el-link type="primary" underline="never" @click="showCampusList(row)">{{ row.campusCount }}</el-link>
</template>
</el-table-column>
<el-table-column prop="contactPerson" label="联系人" width="100" />
<el-table-column label="联系电话" width="130">
<template #default="{ row }">{{ maskPhone(row.contactPhone) }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="150" sortable />
<el-table-column label="状态" width="80" sortable prop="status">
<template #default="{ row }">
<StatusTag :status="row.status === 'enabled' ? 'success' : 'danger'" :text="row.status === 'enabled' ? '启用' : '停用'" />
</template>
</el-table-column>
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleView(row)"></el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleEdit(row)"></el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleToggleStatus(row)">
{{ row.status === 'enabled' ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 院区列表弹窗 -->
<el-dialog v-model="campusDialogVisible" title="院区列表" width="500px">
<el-table :data="currentCampuses" border size="small">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="院区名称" />
<el-table-column prop="address" label="院区地址" />
<el-table-column prop="contactPerson" label="联系人" width="100" />
</el-table>
<template #footer>
<el-button @click="campusDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 查看详情弹窗 -->
<el-dialog v-model="viewDialogVisible" title="医院详情" width="600px">
<el-descriptions v-if="currentViewHospital" :column="2" border>
<el-descriptions-item label="医院名称" :span="2">{{ currentViewHospital.name }}</el-descriptions-item>
<el-descriptions-item label="统一社会信用代码" :span="2">{{ currentViewHospital.creditCode }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ currentViewHospital.contactPerson }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ maskPhone(currentViewHospital.contactPhone) }}</el-descriptions-item>
<el-descriptions-item label="医院地址" :span="2">{{ currentViewHospital.address }}</el-descriptions-item>
<el-descriptions-item label="院区数">{{ currentViewHospital.campusCount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<StatusTag :status="currentViewHospital.status === 'enabled' ? 'success' : 'danger'" :text="currentViewHospital.status === 'enabled' ? '启用' : '停用'" />
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ currentViewHospital.createdAt }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="viewDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Download } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { Hospital, HospitalQuery } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import ActionBar from '@/components/shared/ActionBar/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import StatusTag from '@/components/shared/StatusTag/index.vue'
import { useDebounceSubmit } from '@/utils/debounce-submit'
import { showResultFeedback } from '@/utils/result-feedback'
const router = useRouter()
const loading = ref(false)
const tableData = ref<(Hospital & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const campusDialogVisible = ref(false)
const currentCampuses = ref<any[]>([])
const viewDialogVisible = ref(false)
const currentViewHospital = ref<Hospital | null>(null)
const queryForm = reactive<HospitalQuery>({
page: 1,
pageSize: 20,
name: '',
status: '',
contactPerson: ''
})
const { wrapSubmit } = useDebounceSubmit()
async function loadData() {
loading.value = true
try {
const res = await accountApi.getHospitalList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) {
showResultFeedback('error', e.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
queryForm.name = ''
queryForm.status = ''
queryForm.contactPerson = ''
pagination.page = 1
loadData()
}
function handlePageChange(page: number, pageSize: number) {
pagination.page = page
pagination.pageSize = pageSize
loadData()
}
function handleEdit(row: Hospital) {
router.push(`/account/hospitals/${row.id}/edit`)
}
function handleView(row: Hospital) {
currentViewHospital.value = row
viewDialogVisible.value = true
}
function handleExport() {
showResultFeedback('success', '导出文件已生成Mock模式')
}
async function handleToggleStatus(row: Hospital & { _loading?: boolean }) {
const action = row.status === 'enabled' ? '停用' : '启用'
try {
await ElMessageBox.confirm(
`确定要${action}${row.name}」吗?${action === '停用' ? '停用后该医院下所有账号将无法登录' : ''}`,
'操作确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
row._loading = true
await accountApi.toggleHospitalStatus(row.id)
showResultFeedback('success', `${action}成功`)
loadData()
} catch (e: any) {
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
} finally {
row._loading = false
}
}
function showCampusList(row: Hospital) {
currentCampuses.value = row.campuses || []
campusDialogVisible.value = true
}
function maskPhone(phone: string) {
if (!phone || phone.length < 7) return phone
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
onMounted(loadData)
</script>

@ -0,0 +1,180 @@
<template>
<div class="property-account-create">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业管理员账号管理' }, { label: '新增物业管理员账号' }]" />
<el-card>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="saving">
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="登录账号" prop="username">
<el-input v-model="formData.username" maxlength="20" clearable placeholder="4-20位字母数字" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="初始密码" prop="password">
<div style="display: flex; gap: 8px; width: 100%">
<el-input v-model="formData.password" maxlength="20" show-password placeholder="6-20位" style="flex: 1" />
<el-button :icon="Refresh" circle @click="generatePassword" />
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="绑定物业公司" prop="propertyCompanyId">
<el-select v-model="formData.propertyCompanyId" filterable clearable placeholder="请选择物业公司" style="width: 100%" @change="handleCompanyChange">
<el-option v-for="p in propertyCompanyOptions" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="服务医院" prop="hospitalId">
<el-select v-model="formData.hospitalId" filterable clearable placeholder="请选择医院" style="width: 100%">
<el-option v-for="h in filteredHospitalOptions" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="有效期至" prop="expireDate">
<el-date-picker v-model="formData.expireDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分配角色" prop="roleIds">
<el-select v-model="formData.roleIds" multiple filterable collapse-tags placeholder="请选择角色" style="width: 100%">
<el-option v-for="r in roleOptions" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
</el-form>
<div class="form-footer">
<el-button @click="handleCancel"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '...' : '' }}</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { PropertyAccountFormData } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const router = useRouter()
const formRef = ref<FormInstance>()
const saving = ref(false)
const propertyCompanyOptions = ref<{ id: string; name: string }[]>([])
const hospitalOptions = ref<{ id: string; name: string }[]>([])
const roleOptions = ref<{ id: string; name: string }[]>([])
function generatePwd() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
return 'Abc' + Array.from({ length: 5 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
const formData = reactive<PropertyAccountFormData>({
username: '', password: generatePwd(), propertyCompanyId: '', hospitalId: '', expireDate: '', roleIds: []
})
let snapshot = JSON.parse(JSON.stringify(formData))
let isDirty = ref(false)
// Simple dirty check
function checkDirty() {
isDirty.value = JSON.stringify(formData) !== snapshot
}
const filteredHospitalOptions = computed(() => {
if (!formData.propertyCompanyId) return hospitalOptions.value
// Mock: show all hospitals when a company is selected (real API would filter)
return hospitalOptions.value
})
function handleCompanyChange() {
formData.hospitalId = ''
checkDirty()
}
const rules: FormRules = {
username: [
{ required: true, message: '请输入登录账号', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9]{4,20}$/, message: '登录账号为4-20位字母数字', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入初始密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度6-20位', trigger: 'blur' }
],
propertyCompanyId: [{ required: true, message: '请选择绑定物业公司', trigger: 'change' }],
hospitalId: [{ required: true, message: '请选择服务医院', trigger: 'change' }],
expireDate: [{ required: true, message: '请选择有效期', trigger: 'change' }],
roleIds: [{ type: 'array', required: true, message: '请选择至少一个角色', trigger: 'change' }]
}
function generatePassword() {
formData.password = generatePwd()
checkDirty()
}
async function handleSave() {
if (!formRef.value) return
await formRef.value.validate()
if (new Date(formData.expireDate) < new Date()) {
showResultFeedback('error', '有效期不能早于当前日期')
return
}
saving.value = true
try {
await accountApi.createPropertyAccount(formData)
showResultFeedback('success', '保存成功')
snapshot = JSON.parse(JSON.stringify(formData))
isDirty.value = false
setTimeout(() => router.push('/account/property-accounts'), 300)
} catch (e: any) {
showResultFeedback('error', e.message || '保存失败')
} finally { saving.value = false }
}
function handleCancel() {
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => router.push('/account/property-accounts')).catch(() => {})
} else {
router.push('/account/property-accounts')
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (saving.value) { next(false); return }
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => next()).catch(() => next(false))
} else { next() }
})
onMounted(async () => {
propertyCompanyOptions.value = await accountApi.getPropertyCompanyOptions()
hospitalOptions.value = await accountApi.getHospitalOptions()
roleOptions.value = [
{ id: 'role-4', name: '物业管理员模板' },
{ id: 'role-5', name: '主管模板' },
{ id: 'role-6', name: '班组长模板' },
{ id: 'role-7', name: '维修员模板' }
]
const d = new Date()
d.setFullYear(d.getFullYear() + 1)
formData.expireDate = d.toISOString().split('T')[0]
snapshot = JSON.parse(JSON.stringify(formData))
})
</script>
<style scoped lang="scss">
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
</style>

@ -0,0 +1,218 @@
<template>
<div class="property-account-list">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业管理员账号管理' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="登录账号">
<el-input v-model="queryForm.username" clearable placeholder="请输入" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="绑定物业公司">
<el-select v-model="queryForm.propertyCompanyId" filterable clearable placeholder="请选择物业公司">
<el-option v-for="p in propertyCompanyOptions" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="服务医院">
<el-select v-model="queryForm.hospitalId" filterable clearable placeholder="请选择医院">
<el-option v-for="h in hospitalOptions" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="请选择">
<el-option label="正常" value="normal" />
<el-option label="即将到期" value="expiring" />
<el-option label="已过期" value="expired" />
<el-option label="已停用" value="stopped" />
</el-select>
</el-form-item>
</QueryPanel>
<ActionBar>
<el-button v-hasPermission="'permission:user:create'" type="primary" :icon="Plus" @click="$router.push('/account/property-accounts/create')">
新增物业管理员
</el-button>
</ActionBar>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="username" label="登录账号" min-width="130" />
<el-table-column prop="propertyName" label="绑定物业公司" width="150" sortable />
<el-table-column prop="hospitalName" label="服务医院" width="150" sortable />
<el-table-column label="角色" width="120">
<template #default="{ row }">{{ row.roleNames?.join('、') || '—' }}</template>
</el-table-column>
<el-table-column label="有效期至" width="120" sortable prop="expireDate">
<template #default="{ row }">
<el-tag :type="getExpireTagType(row.status)">{{ row.expireDate }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" sortable prop="status">
<template #default="{ row }">
<StatusTag :status="row.status" />
</template>
</el-table-column>
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleEdit(row)"></el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleRenew(row)"></el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleToggleStatus(row)">
{{ row.status === 'stopped' ? '启用' : '禁用' }}
</el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleResetPassword(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 续期弹窗 -->
<el-dialog v-model="renewDialogVisible" title="账号续期" width="400px" :close-on-click-modal="false">
<el-form label-width="80px">
<el-form-item label="登录账号">{{ currentRow?.username }}</el-form-item>
<el-form-item label="新有效期">
<el-date-picker v-model="renewDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="renewDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="renewLoading" @click="confirmRenew"></el-button>
</template>
</el-dialog>
<!-- 重置密码结果弹窗 -->
<el-dialog v-model="resetPwdDialogVisible" title="密码已重置" width="400px" :close-on-click-modal="false">
<el-result icon="success" sub-title="">
<template #extra>
<div style="display: flex; align-items: center; gap: 8px">
<el-input :model-value="newPassword" readonly />
<el-button type="primary" @click="copyPassword"></el-button>
</div>
</template>
</el-result>
<template #footer>
<el-button type="primary" @click="resetPwdDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { PropertyAccount, PropertyAccountQuery } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import ActionBar from '@/components/shared/ActionBar/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import StatusTag from '@/components/shared/StatusTag/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const router = useRouter()
const loading = ref(false)
const tableData = ref<(PropertyAccount & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const propertyCompanyOptions = ref<{ id: string; name: string }[]>([])
const hospitalOptions = ref<{ id: string; name: string }[]>([])
const queryForm = reactive<PropertyAccountQuery>({
page: 1, pageSize: 20, username: '', propertyCompanyId: '', hospitalId: '', status: ''
})
const renewDialogVisible = ref(false)
const renewLoading = ref(false)
const renewDate = ref('')
const currentRow = ref<PropertyAccount | null>(null)
const resetPwdDialogVisible = ref(false)
const newPassword = ref('')
function getExpireTagType(status: string) {
if (status === 'expired') return 'danger'
if (status === 'expiring') return 'warning'
return 'success'
}
async function copyPassword() {
try { await navigator.clipboard.writeText(newPassword.value) } catch { /* fallback */ }
showResultFeedback('success', '已复制到剪贴板')
}
async function loadData() {
loading.value = true
try {
const res = await accountApi.getPropertyAccountList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) {
showResultFeedback('error', e.message || '加载失败')
} finally { loading.value = false }
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() {
queryForm.username = ''; queryForm.propertyCompanyId = ''; queryForm.hospitalId = ''; queryForm.status = ''
pagination.page = 1; loadData()
}
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
function handleEdit(row: PropertyAccount) {
//
showResultFeedback('success', '编辑功能开发中')
}
function handleRenew(row: PropertyAccount & { _loading?: boolean }) {
currentRow.value = row
renewDate.value = ''
renewDialogVisible.value = true
}
async function confirmRenew() {
if (!renewDate.value) { showResultFeedback('error', '请选择有效期'); return }
if (new Date(renewDate.value) < new Date()) { showResultFeedback('error', '有效期不能早于当前日期'); return }
renewLoading.value = true
try {
await accountApi.renewAccount(currentRow.value!.id, renewDate.value)
showResultFeedback('success', '续期成功')
renewDialogVisible.value = false
loadData()
} catch (e: any) { showResultFeedback('error', e.message || '续期失败') }
finally { renewLoading.value = false }
}
async function handleToggleStatus(row: PropertyAccount & { _loading?: boolean }) {
const action = row.status === 'stopped' ? '启用' : '禁用'
try {
await ElMessageBox.confirm(
`确定要${action}${row.username}」吗?${action === '禁用' ? '禁用后该管理员及所有下属账号将同步失效' : ''}`,
'操作确认', { type: 'warning' }
)
row._loading = true
await accountApi.toggleAccountStatus(row.id)
showResultFeedback('success', `${action}成功`)
loadData()
} catch (e: any) {
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
} finally { row._loading = false }
}
async function handleResetPassword(row: PropertyAccount & { _loading?: boolean }) {
try {
await ElMessageBox.confirm(`确定要重置「${row.username}」的密码吗?重置后将恢复为默认密码`, '操作确认', { type: 'warning' })
row._loading = true
const res = await accountApi.resetPassword(row.id)
newPassword.value = res.newPassword
resetPwdDialogVisible.value = true
} catch (e: any) {
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
} finally { row._loading = false }
}
onMounted(async () => {
loadData()
propertyCompanyOptions.value = await accountApi.getPropertyCompanyOptions()
hospitalOptions.value = await accountApi.getHospitalOptions()
})
</script>

@ -0,0 +1,131 @@
<template>
<div class="property-company-form">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业公司信息管理' }, { label: isEdit ? '编辑物业公司' : '新增物业公司' }]" />
<el-card v-loading="detailLoading">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="公司名称" prop="name">
<el-input v-model="formData.name" maxlength="50" show-word-limit clearable placeholder="请输入公司名称" />
</el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="联系人" prop="contactPerson">
<el-input v-model="formData.contactPerson" maxlength="20" placeholder="请输入联系人" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="公司地址" prop="address">
<el-input v-model="formData.address" maxlength="200" show-word-limit placeholder="请输入公司地址" />
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="formData.contactPhone" maxlength="11" placeholder="请输入联系电话" />
</el-form-item>
</el-form>
<div class="form-footer">
<el-button @click="handleCancel"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '...' : '' }}</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { accountApi } from '@/api'
import type { PropertyCompanyFormData } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
import { useDirtyCheck } from '@/utils/dirty-check'
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const detailLoading = ref(false)
const saving = ref(false)
const isEdit = computed(() => !!route.params.id)
const formData = reactive<PropertyCompanyFormData>({
name: '', address: '', contactPerson: '', contactPhone: ''
})
let snapshot = JSON.parse(JSON.stringify(formData))
const { isDirty } = useDirtyCheck(formData, () => snapshot)
const rules: FormRules = {
name: [
{ required: true, message: '请输入公司名称', trigger: 'blur' },
{ min: 2, max: 50, message: '公司名称长度2-50字符', trigger: 'blur' }
],
address: [{ max: 200, message: '公司地址不能超过200字符', trigger: 'blur' }],
contactPerson: [
{ required: true, message: '请输入联系人', trigger: 'blur' },
{ max: 20, message: '联系人不能超过20字符', trigger: 'blur' }
],
contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
async function handleSave() {
if (!formRef.value) return
await formRef.value.validate()
saving.value = true
try {
if (isEdit.value) {
await accountApi.updatePropertyCompany(route.params.id as string, formData)
} else {
await accountApi.createPropertyCompany(formData)
}
showResultFeedback('success', '保存成功')
snapshot = JSON.parse(JSON.stringify(formData))
setTimeout(() => router.push('/account/property-companies'), 300)
} catch (e: any) {
showResultFeedback('error', e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleCancel() {
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => router.push('/account/property-companies')).catch(() => {})
} else {
router.push('/account/property-companies')
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (saving.value) { next(false); return }
if (isDirty.value) {
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
.then(() => next()).catch(() => next(false))
} else { next() }
})
onMounted(async () => {
if (isEdit.value) {
detailLoading.value = true
try {
const detail = await accountApi.getPropertyCompanyDetail(route.params.id as string)
Object.assign(formData, { name: detail.name, address: detail.address, contactPerson: detail.contactPerson, contactPhone: detail.contactPhone })
snapshot = JSON.parse(JSON.stringify(formData))
} catch (e: any) {
showResultFeedback('error', e.message || '数据加载失败')
} finally {
detailLoading.value = false
}
}
})
</script>
<style scoped lang="scss">
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
</style>

@ -0,0 +1,145 @@
<template>
<div class="property-company-list">
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业公司信息管理' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="公司名称">
<el-input v-model="queryForm.name" clearable maxlength="50" placeholder="请输入" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="请选择">
<el-option label="启用" value="enabled" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="queryForm.contactPerson" clearable maxlength="20" placeholder="请输入" @keyup.enter="handleSearch" />
</el-form-item>
</QueryPanel>
<ActionBar>
<el-button v-hasPermission="'permission:user:create'" type="primary" :icon="Plus" @click="$router.push('/account/property-companies/create')">
新增物业公司
</el-button>
</ActionBar>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="公司名称" min-width="180" sortable />
<el-table-column label="服务医院" width="150">
<template #default="{ row }">
<el-link type="primary" underline="never" @click="showHospitals(row.serviceHospitals)">{{ row.serviceHospitals?.join('') || '' }}</el-link>
</template>
</el-table-column>
<el-table-column prop="contactPerson" label="联系人" width="100" />
<el-table-column label="联系电话" width="130">
<template #default="{ row }">{{ maskPhone(row.contactPhone) }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="150" sortable />
<el-table-column label="状态" width="80" sortable prop="status">
<template #default="{ row }">
<StatusTag :status="row.status === 'enabled' ? 'success' : 'danger'" :text="row.status === 'enabled' ? '启用' : '停用'" />
</template>
</el-table-column>
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleEdit(row)"></el-button>
<el-button type="primary" link size="small" :loading="row._loading" @click="handleToggleStatus(row)">
{{ row.status === 'enabled' ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<el-dialog v-model="hospitalDialogVisible" title="服务医院列表" width="400px">
<div v-if="currentHospitals.length">
<el-tag v-for="h in currentHospitals" :key="h" style="margin: 4px">{{ h }}</el-tag>
</div>
<el-empty v-else description="暂无关联医院" />
<template #footer>
<el-button @click="hospitalDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import type { PropertyCompany, PropertyCompanyQuery } from '@/api/types/account'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import ActionBar from '@/components/shared/ActionBar/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import StatusTag from '@/components/shared/StatusTag/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const router = useRouter()
const loading = ref(false)
const tableData = ref<(PropertyCompany & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const hospitalDialogVisible = ref(false)
const currentHospitals = ref<string[]>([])
const queryForm = reactive<PropertyCompanyQuery>({
page: 1, pageSize: 20, name: '', status: '', contactPerson: ''
})
async function loadData() {
loading.value = true
try {
const res = await accountApi.getPropertyCompanyList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) {
showResultFeedback('error', e.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() { queryForm.name = ''; queryForm.status = ''; queryForm.contactPerson = ''; pagination.page = 1; loadData() }
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
function handleEdit(row: PropertyCompany) {
router.push(`/account/property-companies/${row.id}/edit`)
}
async function handleToggleStatus(row: PropertyCompany & { _loading?: boolean }) {
const action = row.status === 'enabled' ? '停用' : '启用'
try {
await ElMessageBox.confirm(
`确定要${action}${row.name}」吗?${action === '停用' ? '停用后该物业下所有账号将无法登录' : ''}`,
'操作确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
row._loading = true
await accountApi.togglePropertyCompanyStatus(row.id)
showResultFeedback('success', `${action}成功`)
loadData()
} catch (e: any) {
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
} finally {
row._loading = false
}
}
function showHospitals(hospitals: string[]) {
currentHospitals.value = hospitals || []
hospitalDialogVisible.value = true
}
function maskPhone(phone: string) {
if (!phone || phone.length < 7) return phone
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
onMounted(loadData)
</script>

@ -0,0 +1,152 @@
<template>
<div class="account-operation-log">
<Breadcrumb :items="[{ label: '操作日志' }, { label: '账号操作日志' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="操作人"><el-input v-model="queryForm.operator" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="操作类型">
<el-select v-model="queryForm.operationType" clearable placeholder="请选择">
<el-option label="创建" value="create" /><el-option label="编辑" value="edit" />
<el-option label="启用" value="enable" /><el-option label="禁用" value="disable" />
<el-option label="续期" value="renew" /><el-option label="重置密码" value="reset_password" />
</el-select>
</el-form-item>
<el-form-item label="账号类型">
<el-select v-model="queryForm.accountType" clearable placeholder="请选择">
<el-option label="医院" value="hospital" /><el-option label="物业管理员" value="property_admin" />
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker v-model="queryForm.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="" end-placeholder="" @change="validateDateRange" />
</el-form-item>
</QueryPanel>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="operationTime" label="操作时间" min-width="150" sortable />
<el-table-column prop="operator" label="操作人" width="100" />
<el-table-column label="操作类型" width="100">
<template #default="{ row }">
<el-tag :type="opTypeMap[row.operationType]?.type || 'info'" size="small">{{ opTypeMap[row.operationType]?.label || row.operationType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="targetAccount" label="目标账号" width="120" />
<el-table-column label="账号类型" width="100">
<template #default="{ row }">{{ row.accountType === 'hospital' ? '医院' : '物业管理员' }}</template>
</el-table-column>
<el-table-column prop="bindUnit" label="绑定单位" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleDetail(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="账号操作详情" width="600px" :close-on-click-modal="false">
<div v-loading="detailLoading">
<template v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="操作人">{{ detail.operator }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ detail.operationTime }}</el-descriptions-item>
<el-descriptions-item label="操作IP">{{ detail.operationIp }}</el-descriptions-item>
<el-descriptions-item label="操作类型">{{ opTypeMap[detail.operationType]?.label || detail.operationType }}</el-descriptions-item>
<el-descriptions-item label="目标账号">{{ detail.targetAccount }}</el-descriptions-item>
<el-descriptions-item label="账号类型">{{ detail.accountType === 'hospital' ? '医院账号' : '物业管理员账号' }}</el-descriptions-item>
<el-descriptions-item label="绑定单位" :span="2">{{ detail.bindUnit }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<h4 style="margin-bottom: 8px">变更前数据</h4>
<el-descriptions v-if="detail.beforeData" :column="1" border size="small">
<el-descriptions-item v-for="(val, key) in detail.beforeData" :key="key" :label="fieldLabel(key as string)">{{ val ?? '' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="无(新增操作)" :image-size="40" />
<h4 style="margin: 12px 0 8px">变更后数据</h4>
<el-descriptions v-if="detail.afterData" :column="1" border size="small">
<el-descriptions-item v-for="(val, key) in detail.afterData" :key="key" :label="fieldLabel(key as string)">{{ val ?? '' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="无" :image-size="40" />
</template>
</div>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { auditLogApi } from '@/api'
import type { AccountAuditLog, AccountAuditLogQuery, AccountAuditDetail } from '@/api/types/system'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const loading = ref(false)
const tableData = ref<(AccountAuditLog & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const opTypeMap: Record<string, { label: string; type: string }> = {
create: { label: '创建', type: 'success' },
edit: { label: '编辑', type: 'primary' },
enable: { label: '启用', type: 'success' },
disable: { label: '禁用', type: 'danger' },
renew: { label: '续期', type: 'warning' },
reset_password: { label: '重置密码', type: 'info' }
}
const fieldLabelMap: Record<string, string> = {
username: '登录账号', status: '状态', expireDate: '有效期至', roles: '分配角色'
}
function fieldLabel(key: string) { return fieldLabelMap[key] || key }
const queryForm = reactive<AccountAuditLogQuery>({
page: 1, pageSize: 20, operator: '', operationType: '', accountType: '', dateRange: undefined as any
})
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<AccountAuditDetail | null>(null)
function validateDateRange(val: [string, string] | null) {
if (val && val[0] && val[1]) {
const start = new Date(val[0]).getTime()
const end = new Date(val[1]).getTime()
if (end - start > 90 * 24 * 60 * 60 * 1000) {
ElMessage.warning('查询时间范围不能超过90天')
queryForm.dateRange = undefined as any
}
}
}
async function loadData() {
loading.value = true
try {
const res = await auditLogApi.getAccountAuditLogList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') }
finally { loading.value = false }
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() {
queryForm.operator = ''; queryForm.operationType = ''; queryForm.accountType = ''; queryForm.dateRange = undefined as any
pagination.page = 1; loadData()
}
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
async function handleDetail(row: AccountAuditLog & { _loading?: boolean }) {
detailVisible.value = true; detailLoading.value = true; row._loading = true
try { detail.value = await auditLogApi.getAccountAuditDetail(row.id) }
catch (e: any) { showResultFeedback('error', e.message || '详情加载失败') }
finally { detailLoading.value = false; row._loading = false }
}
onMounted(loadData)
</script>

@ -0,0 +1,133 @@
<template>
<div class="permission-change-log">
<Breadcrumb :items="[{ label: '操作日志' }, { label: '权限变更日志' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="操作人"><el-input v-model="queryForm.operator" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="操作类型">
<el-select v-model="queryForm.operationType" clearable placeholder="请选择">
<el-option label="角色创建" value="role_create" /><el-option label="权限修改" value="permission_modify" />
<el-option label="角色分配" value="role_assign" /><el-option label="角色移除" value="role_remove" />
</el-select>
</el-form-item>
<el-form-item label="角色"><el-input v-model="queryForm.role" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="日期范围">
<el-date-picker v-model="queryForm.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="" end-placeholder="" @change="validateDateRange" />
</el-form-item>
</QueryPanel>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="operationTime" label="操作时间" min-width="150" sortable />
<el-table-column prop="operator" label="操作人" width="100" />
<el-table-column label="操作类型" width="100">
<template #default="{ row }">
<el-tag :type="opTypeMap[row.operationType]?.type || 'info'" size="small">{{ opTypeMap[row.operationType]?.label || row.operationType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="targetRole" label="目标角色" width="120" />
<el-table-column label="变更摘要" width="150">
<template #default="{ row }">
<span v-if="row.addCount" style="color:var(--el-color-success)">+{{ row.addCount }}</span>
<span v-if="row.removeCount" style="color:var(--el-color-danger);margin-left:4px">-{{ row.removeCount }}</span>
<span v-if="!row.addCount && !row.removeCount">{{ row.changeSummary }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleDetail(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="权限变更详情" width="650px" :close-on-click-modal="false">
<div v-loading="detailLoading">
<el-descriptions :column="2" border v-if="detail">
<el-descriptions-item label="角色">{{ detail.targetRole }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ detail.operator }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ detail.operationTime }}</el-descriptions-item>
<el-descriptions-item label="操作类型">{{ opTypeMap[detail.operationType]?.label || detail.operationType }}</el-descriptions-item>
<el-descriptions-item label="操作IP" :span="2">{{ detail.operationIp }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top: 16px">变更对比</h4>
<div v-if="detail?.addedPermissions?.length">
<div v-for="p in detail.addedPermissions" :key="p" style="color: var(--el-color-success); margin: 4px 0">[+] {{ p }}</div>
</div>
<div v-if="detail?.removedPermissions?.length">
<div v-for="p in detail.removedPermissions" :key="p" style="color: var(--el-color-danger); margin: 4px 0">[-] {{ p }}</div>
</div>
<el-empty v-if="!detail?.addedPermissions?.length && !detail?.removedPermissions?.length" description="无权限变更" />
</div>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { permissionApi } from '@/api'
import type { PermissionAuditLog, PermissionAuditLogQuery, PermissionAuditDetail } from '@/api/types/permission'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const tableData = ref<(PermissionAuditLog & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const opTypeMap: Record<string, { label: string; type: string }> = {
role_create: { label: '角色创建', type: 'success' },
permission_modify: { label: '权限修改', type: 'warning' },
role_assign: { label: '角色分配', type: 'primary' },
role_remove: { label: '角色移除', type: 'danger' }
}
const queryForm = reactive<PermissionAuditLogQuery>({
page: 1, pageSize: 20, operator: '', operationType: '', role: '', dateRange: undefined as any
})
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<PermissionAuditDetail | null>(null)
function validateDateRange(val: [string, string] | null) {
if (val && val[0] && val[1]) {
const start = new Date(val[0]).getTime()
const end = new Date(val[1]).getTime()
if (end - start > 90 * 24 * 60 * 60 * 1000) {
ElMessage.warning('查询时间范围不能超过90天')
queryForm.dateRange = undefined as any
}
}
}
async function loadData() {
loading.value = true
try {
const res = await permissionApi.getPermissionAuditLogList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list
pagination.total = res.pagination.total
pagination.totalPages = res.pagination.totalPages
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') }
finally { loading.value = false }
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() {
queryForm.operator = ''; queryForm.operationType = ''; queryForm.role = ''; queryForm.dateRange = undefined as any
pagination.page = 1; loadData()
}
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
async function handleDetail(row: PermissionAuditLog & { _loading?: boolean }) {
detailVisible.value = true; detailLoading.value = true; row._loading = true
try { detail.value = await permissionApi.getPermissionAuditDetail(row.id) }
catch (e: any) { showResultFeedback('error', e.message || '详情加载失败') }
finally { detailLoading.value = false; row._loading = false }
}
onMounted(loadData)
</script>

@ -0,0 +1,100 @@
<template>
<div class="permission-audit-log">
<Breadcrumb :items="[{ label: '权限管理' }, { label: '权限审计日志' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="操作人"><el-input v-model="queryForm.operator" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="操作类型">
<el-select v-model="queryForm.operationType" clearable placeholder="请选择">
<el-option label="角色创建" value="role_create" /><el-option label="权限修改" value="permission_modify" />
<el-option label="角色分配" value="role_assign" /><el-option label="角色移除" value="role_remove" />
</el-select>
</el-form-item>
<el-form-item label="角色"><el-input v-model="queryForm.role" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="日期范围">
<el-date-picker v-model="queryForm.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="" end-placeholder="" />
</el-form-item>
</QueryPanel>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="operationTime" label="操作时间" min-width="150" sortable />
<el-table-column prop="operator" label="操作人" width="100" />
<el-table-column label="操作类型" width="100">
<template #default="{ row }"><el-tag :type="opTypeMap[row.operationType]?.type || 'info'" size="small">{{ opTypeMap[row.operationType]?.label || row.operationType }}</el-tag></template>
</el-table-column>
<el-table-column prop="targetRole" label="目标角色" width="120" />
<el-table-column label="变更详情" width="120">
<template #default="{ row }">
<span v-if="row.addCount" style="color:var(--el-color-success)">+{{ row.addCount }}</span>
<span v-if="row.removeCount" style="color:var(--el-color-danger);margin-left:4px">-{{ row.removeCount }}</span>
<span v-if="!row.addCount && !row.removeCount">{{ row.changeSummary }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" :loading="row._loading" @click="handleDetail(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="权限变更详情" width="650px" :close-on-click-modal="false">
<div v-loading="detailLoading">
<el-descriptions :column="2" border v-if="detail">
<el-descriptions-item label="角色">{{ detail.targetRole }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ detail.operator }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ detail.operationTime }}</el-descriptions-item>
<el-descriptions-item label="操作类型">{{ detail.operationType }}</el-descriptions-item>
<el-descriptions-item label="操作IP">{{ detail.operationIp }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top:16px">变更对比</h4>
<div v-for="p in detail?.addedPermissions" :key="p" style="color:var(--el-color-success);margin:4px 0">[+] {{ p }}</div>
<div v-for="p in detail?.removedPermissions" :key="p" style="color:var(--el-color-danger);margin:4px 0">[-] {{ p }}</div>
</div>
<template #footer><el-button @click="detailVisible=false"></el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { permissionApi } from '@/api'
import type { PermissionAuditLog, PermissionAuditLogQuery, PermissionAuditDetail } from '@/api/types/permission'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const loading = ref(false); const tableData = ref<(PermissionAuditLog & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const opTypeMap: Record<string, { label: string; type: string }> = {
role_create: { label: '角色创建', type: 'success' }, permission_modify: { label: '权限修改', type: 'warning' },
role_assign: { label: '角色分配', type: 'primary' }, role_remove: { label: '角色移除', type: 'danger' }
}
const queryForm = reactive<PermissionAuditLogQuery>({ page: 1, pageSize: 20, operator: '', operationType: '', role: '', dateRange: undefined as any })
const detailVisible = ref(false); const detailLoading = ref(false); const detail = ref<PermissionAuditDetail | null>(null)
async function loadData() {
loading.value = true
try {
const res = await permissionApi.getPermissionAuditLogList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list; pagination.total = res.pagination.total; pagination.totalPages = res.pagination.totalPages
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') } finally { loading.value = false }
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() { queryForm.operator = ''; queryForm.operationType = ''; queryForm.role = ''; queryForm.dateRange = undefined; pagination.page = 1; loadData() }
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
async function handleDetail(row: PermissionAuditLog & { _loading?: boolean }) {
detailVisible.value = true; detailLoading.value = true; row._loading = true
try { detail.value = await permissionApi.getPermissionAuditDetail(row.id) }
catch (e: any) { showResultFeedback('error', e.message || '详情加载失败') }
finally { detailLoading.value = false; row._loading = false }
}
onMounted(loadData)
</script>

@ -0,0 +1,69 @@
<template>
<div class="permission-registry">
<Breadcrumb :items="[{ label: '权限管理' }, { label: '权限配置注册' }]" />
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
此页面展示系统自动注册的权限配置来源IModulePlugin超级管理员可查看但不可手动编辑
</el-alert>
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="模块名称"><el-input v-model="queryForm.moduleName" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="页面名称"><el-input v-model="queryForm.pageName" clearable placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
</QueryPanel>
<ActionBar>
<el-button type="warning" :icon="Refresh" plain :loading="refreshing" @click="handleRefresh"></el-button>
</ActionBar>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column prop="moduleCode" label="模块编码" width="100" />
<el-table-column prop="moduleName" label="模块名称" width="100" />
<el-table-column prop="pageCode" label="页面编码" width="130" />
<el-table-column prop="pageName" label="页面名称" width="120" />
<el-table-column prop="featureCode" label="功能点编码" width="140" />
<el-table-column prop="featureName" label="功能点名称" width="120" />
<el-table-column label="可用动作" min-width="200">
<template #default="{ row }">
<el-tag v-for="action in row.actions" :key="action" type="info" size="small" style="margin:2px">{{ action }}</el-tag>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { permissionApi } from '@/api'
import type { PermissionRegistryItem, PermissionRegistryQuery } from '@/api/types/permission'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import ActionBar from '@/components/shared/ActionBar/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const loading = ref(false); const refreshing = ref(false)
const tableData = ref<PermissionRegistryItem[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const queryForm = reactive<PermissionRegistryQuery>({ page: 1, pageSize: 20, moduleName: '', pageName: '' })
async function loadData() {
loading.value = true
try {
const res = await permissionApi.getPermissionRegistryList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list; pagination.total = res.pagination.total; pagination.totalPages = res.pagination.totalPages
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') } finally { loading.value = false }
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() { queryForm.moduleName = ''; queryForm.pageName = ''; pagination.page = 1; loadData() }
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
async function handleRefresh() {
try {
await ElMessageBox.confirm('确认重新扫描权限配置?可能需要数秒', '操作确认', { type: 'warning' })
refreshing.value = true; await permissionApi.refreshPermissionRegistry(); showResultFeedback('success', '刷新成功'); loadData()
} catch (e: any) { if (e !== 'cancel') showResultFeedback('error', e.message || '刷新失败') } finally { refreshing.value = false }
}
onMounted(loadData)
</script>

@ -0,0 +1,171 @@
<template>
<div class="role-form">
<Breadcrumb :items="[{ label: '权限管理' }, { label: '角色管理' }, { label: isEdit ? '编辑角色' : '新增角色' }]" />
<el-card v-loading="detailLoading">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
<h3 class="section-title">基本信息</h3>
<el-row :gutter="20">
<el-col :sm="24" :md="12">
<el-form-item label="角色名称" prop="name"><el-input v-model="formData.name" maxlength="30" show-word-limit clearable placeholder="请输入角色名称" /></el-form-item>
</el-col>
<el-col :sm="24" :md="12">
<el-form-item label="适用范围" prop="scope">
<el-select v-model="formData.scope" clearable placeholder="请选择适用范围" style="width:100%">
<el-option label="医院账号" value="hospital" /><el-option label="物业管理员" value="property_admin" /><el-option label="物业下属" value="property_staff" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="角色描述"><el-input v-model="formData.description" type="textarea" maxlength="200" show-word-limit :rows="3" placeholder="请输入角色描述" /></el-form-item>
<el-form-item label="预设模板">
<el-select v-model="formData.presetTemplate" clearable placeholder="可选,选择后自动填充权限" style="width:100%" @change="handleTemplateChange">
<el-option v-for="t in presetTemplates" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<h3 class="section-title" style="margin-top:24px">权限分配</h3>
<el-input v-model="treeSearch" placeholder="搜索权限名称" clearable prefix-icon="Search" style="margin-bottom:12px" />
<div class="permission-tree-wrapper">
<el-tree ref="treeRef" :data="permissionTree" show-checkbox check-strictly=false node-key="code" :default-expand-all="false"
:filter-node-method="(val:string, data:any) => val ? data.name.includes(val) : true"
:props="{ label: 'name', children: 'children' }" @check="handleCheck" />
</div>
</el-form>
<div class="form-footer">
<el-button @click="handleCancel"></el-button>
<el-button type="info" plain @click="handlePreview"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '...' : '' }}</el-button>
</div>
</el-card>
<!-- 权限预览弹窗 -->
<el-dialog v-model="previewVisible" title="权限预览" width="600px">
<el-tree :data="permissionTree" default-expand-all node-key="code" :props="{ label: 'name', children: 'children' }">
<template #default="{ data }">
<span>{{ data.name }}</span>
<el-tag v-if="data.type === 'action'" :type="currentCheckedKeys.includes(data.code) ? 'success' : 'info'" size="small" style="margin-left:8px">
{{ currentCheckedKeys.includes(data.code) ? '✓' : '✗' }}
</el-tag>
</template>
</el-tree>
<template #footer><el-button @click="previewVisible=false"></el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { permissionApi } from '@/api'
import type { RoleFormData, PermissionNode } from '@/api/types/permission'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const route = useRoute(); const router = useRouter()
const formRef = ref<FormInstance>(); const treeRef = ref<any>()
const detailLoading = ref(false); const saving = ref(false)
const isEdit = computed(() => !!route.params.id)
const permissionTree = ref<PermissionNode[]>([])
const treeSearch = ref(''); const previewVisible = ref(false); const currentCheckedKeys = ref<string[]>([])
const presetTemplates = [
{ value: 'property_admin', label: '物业管理员模板' }, { value: 'supervisor', label: '主管模板' },
{ value: 'team_leader', label: '班组长模板' }, { value: 'repairman', label: '维修员模板' },
{ value: 'inspector', label: '巡检员模板' }, { value: 'cleaner', label: '保洁员模板' },
{ value: 'hospital_view', label: '医院查看模板' }
]
const formData = reactive<RoleFormData>({ name: '', description: '', scope: 'hospital', presetTemplate: '', permissions: [] })
let snapshot = JSON.parse(JSON.stringify(formData)); let isDirty = ref(false)
const rules: FormRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }, { min: 2, max: 30, message: '角色名称长度2-30字符', trigger: 'blur' }],
scope: [{ required: true, message: '请选择适用范围', trigger: 'change' }]
}
watch(treeSearch, val => treeRef.value?.filter(val))
function handleCheck() {
const checked = treeRef.value?.getCheckedKeys() || []
const halfChecked = treeRef.value?.getHalfCheckedKeys() || []
formData.permissions = [...checked, ...halfChecked]
isDirty.value = JSON.stringify(formData) !== snapshot
}
async function handleTemplateChange(val: string) {
if (!val) return
try {
await ElMessageBox.confirm('选择模板将覆盖当前权限配置,是否继续?', '操作确认', { type: 'warning' })
// Mock: auto-check some nodes based on template
const templatePermMap: Record<string, string[]> = {
property_admin: ['menu:repair', 'menu:inspection'], supervisor: ['menu:repair', 'menu:inspection'],
team_leader: ['menu:repair'], repairman: ['menu:repair'], inspector: ['menu:inspection'],
cleaner: ['menu:cleaning'], hospital_view: ['menu:repair', 'menu:contract']
}
const keys = templatePermMap[val] || []
treeRef.value?.setCheckedKeys(keys)
handleCheck()
} catch { formData.presetTemplate = '' }
}
function handlePreview() {
const checked = treeRef.value?.getCheckedKeys() || []
currentCheckedKeys.value = checked; previewVisible.value = true
}
async function handleSave() {
if (!formRef.value) return
await formRef.value.validate()
handleCheck()
if (formData.permissions.length === 0) { showResultFeedback('error', '请至少分配一项权限'); return }
saving.value = true
try {
if (isEdit.value) await permissionApi.updateRole(route.params.id as string, formData)
else await permissionApi.createRole(formData)
showResultFeedback('success', '保存成功')
snapshot = JSON.parse(JSON.stringify(formData)); isDirty.value = false
setTimeout(() => router.push('/permission/roles'), 300)
} catch (e: any) { showResultFeedback('error', e.message || '保存失败') } finally { saving.value = false }
}
function handleCancel() {
if (isDirty.value) ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' }).then(() => router.push('/permission/roles')).catch(() => {})
else router.push('/permission/roles')
}
onBeforeRouteLeave((_to, _from, next) => {
if (saving.value) { next(false); return }
if (isDirty.value) ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' }).then(() => next()).catch(() => next(false))
else next()
})
onMounted(async () => {
try { permissionTree.value = await permissionApi.getPermissionTree() } catch { /* */ }
if (isEdit.value) {
detailLoading.value = true
try {
const detail = await permissionApi.getRoleDetail(route.params.id as string)
Object.assign(formData, { name: detail.name, description: detail.description, scope: detail.scope, presetTemplate: '', permissions: [] })
// Load role permissions and set checked
const perms = await permissionApi.getRolePermissions(route.params.id as string)
const codes = extractAllCodes(perms.length ? perms : permissionTree.value)
setTimeout(() => treeRef.value?.setCheckedKeys(codes), 100)
snapshot = JSON.parse(JSON.stringify(formData))
} catch (e: any) { showResultFeedback('error', e.message || '数据加载失败') } finally { detailLoading.value = false }
}
})
function extractAllCodes(nodes: PermissionNode[]): string[] {
const codes: string[] = []
function walk(list: PermissionNode[]) { for (const n of list) { codes.push(n.code); if (n.children) walk(n.children) } }
walk(nodes); return codes
}
</script>
<style scoped lang="scss">
.section-title { font-size:16px; font-weight:600; color:var(--el-text-color-primary); border-bottom:1px solid var(--el-border-color-lighter); padding-bottom:8px; margin-bottom:16px; }
.permission-tree-wrapper { border:1px solid var(--el-border-color); border-radius:4px; padding:12px; max-height:500px; overflow-y:auto; }
.form-footer { text-align:right; padding-top:16px; border-top:1px solid var(--el-border-color-lighter); }
</style>

@ -0,0 +1,159 @@
<template>
<div class="role-list">
<Breadcrumb :items="[{ label: '权限管理' }, { label: '角色管理' }]" />
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
<el-form-item label="角色名称"><el-input v-model="queryForm.name" clearable maxlength="30" placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="适用范围">
<el-select v-model="queryForm.scope" clearable placeholder="请选择">
<el-option label="医院账号" value="hospital" /><el-option label="物业管理员" value="property_admin" /><el-option label="物业下属" value="property_staff" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="请选择">
<el-option label="启用" value="enabled" /><el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
</QueryPanel>
<ActionBar>
<el-button v-hasPermission="'permission:role:create'" type="primary" :icon="Plus" @click="$router.push('/permission/roles/create')"></el-button>
</ActionBar>
<DataTable :loading="loading" :data="tableData" stripe border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="角色名称" min-width="150" sortable />
<el-table-column label="适用范围" width="120">
<template #default="{ row }">{{ scopeMap[row.scope] || row.scope }}</template>
</el-table-column>
<el-table-column label="预设模板" width="80">
<template #default="{ row }"><el-tag :type="row.isPreset ? 'primary' : 'info'" size="small">{{ row.isPreset ? '是' : '否' }}</el-tag></template>
</el-table-column>
<el-table-column label="关联账号数" width="100" sortable>
<template #default="{ row }"><el-link type="primary" underline="never" @click="showRelatedAccounts(row)">{{ row.accountCount }}</el-link></template>
</el-table-column>
<el-table-column label="状态" width="80" sortable prop="status">
<template #default="{ row }"><StatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button v-hasPermission="'permission:role:update'" type="primary" link size="small" :loading="row._loading" @click="handleEdit(row)"></el-button>
<el-button v-hasPermission="'permission:role:view'" type="primary" link size="small" @click="handlePreview(row)"></el-button>
<el-button v-if="row.status === 'enabled'" v-hasPermission="'permission:role:update'" type="primary" link size="small" :loading="row._loading" @click="handleDisable(row)"></el-button>
<el-button v-if="row.accountCount === 0" v-hasPermission="'permission:role:delete'" type="danger" link size="small" :loading="row._loading" @click="handleDelete(row)"></el-button>
</template>
</el-table-column>
</DataTable>
<Pagination :pagination="pagination" @change="handlePageChange" />
<!-- 关联账号弹窗 -->
<el-dialog v-model="relatedDialogVisible" title="关联账号" width="500px">
<el-table :data="relatedAccounts" border size="small"><el-table-column prop="username" label="登录账号" /><el-table-column prop="unit" label="绑定单位" /></el-table>
<template #footer><el-button @click="relatedDialogVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 权限预览弹窗 -->
<el-dialog v-model="previewDialogVisible" title="权限预览" width="600px" :close-on-click-modal="true">
<div v-loading="previewLoading">
<el-input v-model="previewSearch" placeholder="搜索权限名称" clearable prefix-icon="Search" style="margin-bottom: 12px" />
<el-tree :data="previewTree" default-expand-all :filter-node-method="filterNode" ref="previewTreeRef" node-key="code" :props="{ label: 'name', children: 'children' }">
<template #default="{ data }">
<span>{{ data.name }}</span>
<el-tag v-if="data.type === 'action'" :type="previewCheckedKeys.includes(data.code) ? 'success' : 'info'" size="small" style="margin-left: 8px">
{{ previewCheckedKeys.includes(data.code) ? '✓' : '✗' }}
</el-tag>
</template>
</el-tree>
</div>
<template #footer><el-button @click="previewDialogVisible = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { permissionApi } from '@/api'
import type { Role, RoleQuery, PermissionNode } from '@/api/types/permission'
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
import ActionBar from '@/components/shared/ActionBar/index.vue'
import DataTable from '@/components/shared/DataTable/index.vue'
import Pagination from '@/components/shared/Pagination/index.vue'
import StatusTag from '@/components/shared/StatusTag/index.vue'
import { showResultFeedback } from '@/utils/result-feedback'
const router = useRouter()
const loading = ref(false)
const tableData = ref<(Role & { _loading?: boolean })[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const scopeMap: Record<string, string> = { hospital: '医院账号', property_admin: '物业管理员', property_staff: '物业下属' }
const queryForm = reactive<RoleQuery>({ page: 1, pageSize: 20, name: '', scope: '', status: '' })
const relatedDialogVisible = ref(false)
const relatedAccounts = ref<{ username: string; unit: string }[]>([])
const previewDialogVisible = ref(false)
const previewLoading = ref(false)
const previewTree = ref<PermissionNode[]>([])
const previewCheckedKeys = ref<string[]>([])
const previewSearch = ref('')
const previewTreeRef = ref<any>(null)
watch(previewSearch, (val) => { previewTreeRef.value?.filter(val) })
function filterNode(value: string, data: any) { if (!value) return true; return data.name.includes(value) }
async function loadData() {
loading.value = true
try {
const res = await permissionApi.getRoleList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
tableData.value = res.list; pagination.total = res.pagination.total; pagination.totalPages = res.pagination.totalPages
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') } finally { loading.value = false }
}
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() { queryForm.name = ''; queryForm.scope = ''; queryForm.status = ''; pagination.page = 1; loadData() }
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
function handleEdit(row: Role) { router.push(`/permission/roles/${row.id}/edit`) }
function showRelatedAccounts(row: Role) {
// Mock related accounts
relatedAccounts.value = Array.from({ length: Math.min(row.accountCount, 10) }, (_, i) => ({
username: `user_${row.id}_${i + 1}`, unit: '示例单位'
}))
relatedDialogVisible.value = true
}
async function handlePreview(row: Role) {
previewDialogVisible.value = true; previewLoading.value = true
try {
const tree = await permissionApi.getPermissionTree()
previewTree.value = tree
const permData = await permissionApi.getRolePermissions(row.id)
// Extract checked keys from the permission data (simplified)
previewCheckedKeys.value = extractCodes(permData.length ? permData : tree)
} catch (e: any) { showResultFeedback('error', e.message || '权限加载失败') } finally { previewLoading.value = false }
}
function extractCodes(nodes: PermissionNode[]): string[] {
const codes: string[] = []
function walk(list: PermissionNode[]) { for (const n of list) { codes.push(n.code); if (n.children) walk(n.children) } }
walk(nodes); return codes
}
async function handleDisable(row: Role & { _loading?: boolean }) {
try {
await ElMessageBox.confirm(`确定要停用「${row.name}」吗?停用后关联账号将失去该角色的权限`, '操作确认', { type: 'warning' })
row._loading = true; await permissionApi.disableRole(row.id); showResultFeedback('success', '停用成功'); loadData()
} catch (e: any) { if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败') } finally { row._loading = false }
}
async function handleDelete(row: Role & { _loading?: boolean }) {
try {
await ElMessageBox.confirm(`确定要删除「${row.name}」吗?此操作不可恢复`, '删除确认', { type: 'error' })
row._loading = true; await permissionApi.deleteRole(row.id); showResultFeedback('success', '删除成功'); loadData()
} catch (e: any) { if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败') } finally { row._loading = false }
}
onMounted(loadData)
</script>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save