feat: 运维小程序详情/扫码/工单流程打磨 + 资产统计页与列表规范增强
- 运维小程序:workbench/workOrder/scan/inspect/me 各 Tab 详情与上报流程交互完善,新增 OpsBottomActions 公共底部操作组件与 datetime 工具 - 设备中心:asset/statistics 统计页对接 TableCard 升级,规范 listPage.less / global.less 与 list-page-spec 文档 - TableCard 组件能力扩展,配合统计页与运维页面复用 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
af0dc7013f
commit
011111655f
@@ -33,13 +33,56 @@
|
||||
.listPageTable {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
> .ant-col {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-table-wrapper {
|
||||
// 兜底:直接用 a-table(不经 TableCard)的页面同样走完整高度链
|
||||
.ant-table-wrapper,
|
||||
.table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-spin-nested-loading) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-spin-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table-body) {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
table {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination) {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,8 +176,8 @@ registerMocks([
|
||||
url: '/mp/ops/scan/repair',
|
||||
method: 'POST',
|
||||
handler: (ctx): ApiResponse<null> => {
|
||||
const _form = getParams<RepairForm>(ctx.params)
|
||||
void _form
|
||||
// mock 不持久化维修记录,仅校验表单字段符合 RepairForm 契约
|
||||
getParams<RepairForm>(ctx.params)
|
||||
return ok('维修上报成功', null)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -188,9 +188,8 @@ registerMocks([
|
||||
url: '/mp/ops/work-order/repair-report',
|
||||
method: 'POST',
|
||||
handler: (ctx): ApiResponse<null> => {
|
||||
const _form = getParams<RepairReportForm>(ctx.params)
|
||||
// 原型阶段仅记录日志,不实际更新工单状态
|
||||
void _form
|
||||
// 原型阶段不实际更新工单状态,仅校验表单字段符合 RepairReportForm 契约
|
||||
getParams<RepairReportForm>(ctx.params)
|
||||
return ok('维修上报成功', null)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="ops-bottom-actions">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 实施运维小程序 / 详情页固定底部操作栏
|
||||
*
|
||||
* 设计目的:抽取告警详情 / 工单详情 / 巡检详情 / 设备场景菜单 4 个详情页
|
||||
* 中重复的"position: fixed + bottom: 0 + safe-area-inset-bottom + 顶部 1px 边框"
|
||||
* 样式,避免每页都复制粘贴一大段 less。
|
||||
*
|
||||
* 用法:
|
||||
* <OpsBottomActions>
|
||||
* <van-button block type="primary" @click="onClose">关单</van-button>
|
||||
* <van-button block type="warning" @click="onRepair">维修上报</van-button>
|
||||
* </OpsBottomActions>
|
||||
* 按钮之间用 8px gap 排列(flex)。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ops-bottom-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
@@ -10,24 +10,27 @@
|
||||
<slot name="extra" />
|
||||
</template>
|
||||
|
||||
<!-- 表格主体 -->
|
||||
<a-table
|
||||
:columns="resolvedColumns"
|
||||
:data-source="resolvedDataSource"
|
||||
:pagination="resolvedPagination"
|
||||
:scroll="scroll"
|
||||
:row-key="rowKey"
|
||||
:size="size"
|
||||
:loading="loading"
|
||||
v-bind="$attrs"
|
||||
@change="onChange"
|
||||
@resize-column="resizeColumn"
|
||||
>
|
||||
<!-- 透传 a-table 全部命名插槽 -->
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData ?? {}" />
|
||||
</template>
|
||||
</a-table>
|
||||
<!-- 表格主体:外层包一层 flex:1 + overflow:hidden 的容器,让 a-table 占满剩余高度 -->
|
||||
<div class="table-card__table">
|
||||
<a-table
|
||||
class="table"
|
||||
:columns="resolvedColumns"
|
||||
:data-source="resolvedDataSource"
|
||||
:pagination="resolvedPagination"
|
||||
:scroll="resolvedScroll"
|
||||
:row-key="rowKey"
|
||||
:size="size"
|
||||
:loading="loading"
|
||||
v-bind="$attrs"
|
||||
@change="onChange"
|
||||
@resize-column="resizeColumn"
|
||||
>
|
||||
<!-- 透传 a-table 全部命名插槽 -->
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData ?? {}" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
@@ -74,7 +77,7 @@ const props = withDefaults(defineProps<{
|
||||
dataSource?: Record<string, unknown>[]
|
||||
/** 用法 B:分页配置(false 关闭) */
|
||||
pagination?: TablePaginationConfig | false
|
||||
/** 横向 / 纵向滚动 */
|
||||
/** 横向 / 纵向滚动(默认 y:100% 让表体在容器内自滚,底部横向滚动条固定在视口下方) */
|
||||
scroll?: TableProps['scroll']
|
||||
/** 行 key */
|
||||
rowKey?: string
|
||||
@@ -87,7 +90,7 @@ const props = withDefaults(defineProps<{
|
||||
columns: undefined,
|
||||
dataSource: undefined,
|
||||
pagination: undefined,
|
||||
scroll: () => ({ x: 'max-content' }),
|
||||
scroll: () => ({ y: '100%', x: 'max-content' }),
|
||||
rowKey: 'id',
|
||||
size: 'middle',
|
||||
loading: false,
|
||||
@@ -120,6 +123,9 @@ const resolvedPagination = computed<TablePaginationConfig | false>(() => {
|
||||
return props.pagination ?? false
|
||||
})
|
||||
|
||||
/** 解析 scroll:外部传入则尊重,否则用默认 { y: '100%', x: 'max-content' } */
|
||||
const resolvedScroll = computed<TableProps['scroll']>(() => props.scroll)
|
||||
|
||||
/** @change 事件透传(接 createDataSourceChange 返回值) */
|
||||
const onChange = (
|
||||
pagination: TablePaginationConfig,
|
||||
@@ -134,7 +140,19 @@ defineOptions({ inheritAttrs: false })
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/*
|
||||
* 表格卡片高度链(对齐 platform-vue-tenant 的 listPage.less)
|
||||
*
|
||||
* 父容器(业务列表页外层 .xxx-page)必须 height:100% + flex column
|
||||
* TableCard 自身 height:100% + flex column(占满父容器剩余高度)
|
||||
* 表体 ant-table-body flex:1 + overflow 自滚 → 横向滚动条固定在视口下方
|
||||
*/
|
||||
.table-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
padding: 12px 16px;
|
||||
min-height: auto;
|
||||
@@ -146,6 +164,10 @@ defineOptions({ inheritAttrs: false })
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
@@ -155,5 +177,59 @@ defineOptions({ inheritAttrs: false })
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
&__table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
> .ant-col {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-spin-nested-loading) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-spin-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table-body) {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
table {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination) {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -537,6 +537,46 @@ const {
|
||||
- ❌ 省略 `button-style="solid"`(默认 outline 风格不符合规范)
|
||||
- ❌ 把 radio-group 塞进 FilterBar 的 inline 表单内(视觉错位,应单独成行)
|
||||
|
||||
### 7.5 高度链约束(强制 / 全局规范)
|
||||
|
||||
> 列表页底部横向滚动条必须固定在视口下方,无论数据多少都不上下漂浮。
|
||||
|
||||
**根因机制**:参考 platform-vue-tenant `apps/main/src/assets/styles/listPage.less`,让 a-table 的 `ant-table-body` 在容器内自己滚动(flex:1 + position:relative + table absolute 撑满),滚动条就会贴在 ant-table-body 底部 = 分页栏上方 = 视口下方。
|
||||
|
||||
**完整高度链(TableCard 已内置,业务侧只需保证外层容器)**:
|
||||
|
||||
```
|
||||
.xxx-page (业务外层) height:100% + flex column
|
||||
└─ TableCard / .table-card height:100% + flex column
|
||||
└─ ant-card-body height:100% + flex column
|
||||
└─ .table-card__table flex:1 + overflow:hidden
|
||||
└─ .table (a-table) height:100%
|
||||
└─ ant-spin-nested-loading → ant-spin-container → ant-table-container 全部 height:100% + flex column
|
||||
└─ ant-table-body flex:1 + position:relative
|
||||
└─ table position:absolute 撑满
|
||||
```
|
||||
|
||||
**业务侧模板强制**:
|
||||
|
||||
```vue
|
||||
<style scoped lang="less">
|
||||
.xxx-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%; // ← 必填,撑满 PortalLayout 的 content 区
|
||||
// 禁止用 min-height 替代 height
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**a-table 的 scroll 默认值**:TableCard 已默认 `{ y: '100%', x: 'max-content' }`,业务侧无需手动指定;除非有特殊诉求(如关闭纵向滚动),否则不要覆盖。
|
||||
|
||||
**禁止行为**:
|
||||
- ❌ 业务外层容器省略 `height: 100%` 或改用 `min-height`(高度链断裂,TableCard 取不到父高度)
|
||||
- ❌ 业务外层容器缺少 `display: flex; flex-direction: column`(多个子元素如 StatCard / FilterBar / TableCard 会堆叠而非 flex 分配)
|
||||
- ❌ 在 TableCard 外层再包一层不必要的容器(同样会断裂高度链)
|
||||
- ❌ 给 a-table 单独传 `:scroll="{ x: 'max-content' }"` 覆盖默认 y 值(会回到旧行为)
|
||||
|
||||
## 八、数据展示规范
|
||||
|
||||
| 数据类型 | 展示格式 |
|
||||
|
||||
@@ -175,6 +175,15 @@ const onSwitchMp = ({ key }: { key: string }): void => {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--background);
|
||||
// 移动端原型禁止出现右侧滚动条(与真实小程序一致)
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-shell__safe-area {
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
inactive-color="#999"
|
||||
@change="onChange"
|
||||
>
|
||||
<van-tabbar-item v-for="tab in tabs" :key="tab.key" :name="tab.key" :icon="tab.icon">
|
||||
<van-tabbar-item
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:name="tab.key"
|
||||
:icon="tab.icon"
|
||||
:class="{ 'van-tabbar-item--icon-only': !tab.label }"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
@@ -34,4 +40,37 @@ const onChange = (key: string | number) => {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 中间扫码 Tab:圆形凸起按钮风格(label 为空时触发)
|
||||
:deep(.van-tabbar-item--icon-only) {
|
||||
position: relative;
|
||||
|
||||
.van-tabbar-item__icon {
|
||||
// 让 icon 容器脱离默认行内布局,便于做大圆形
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.van-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 10px rgba(24, 144, 255, 0.4);
|
||||
// 向上凸起,露出 tab 栏
|
||||
transform: translateY(-4px);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
&:active .van-icon {
|
||||
transform: translateY(-2px) scale(0.96);
|
||||
box-shadow: 0 2px 6px rgba(24, 144, 255, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-3
@@ -940,7 +940,7 @@ export const miniprograms: NavMiniprogram[] = [
|
||||
key: 'ops',
|
||||
label: '实施运维小程序',
|
||||
icon: '🔧',
|
||||
description: '现场作业移动终端:扫码、工单、巡检、维修上报(仅运维工程师)',
|
||||
description: '现场作业移动终端:扫码、工单、巡检、维修上报',
|
||||
tabs: [
|
||||
{
|
||||
key: 'workbench',
|
||||
@@ -988,8 +988,9 @@ export const miniprograms: NavMiniprogram[] = [
|
||||
},
|
||||
{
|
||||
key: 'scan',
|
||||
label: '扫码',
|
||||
icon: 'scan-o',
|
||||
// 扫码 Tab 只展示图标,不显示文字(中间凸起按钮风格)
|
||||
label: '',
|
||||
icon: 'scan',
|
||||
page: {
|
||||
key: 'mp-ops-scan-home', label: '扫码', path: 'scan',
|
||||
component: () => import('@pages/miniprogram/ops/scan/Home.vue'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { createPaginationConfig } from '@utils/antDesign/table'
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
YES_NO_OPTIONS,
|
||||
createSearchKey,
|
||||
} from './useSearch'
|
||||
import { buildSummaryRow, tableColumns } from './useTable'
|
||||
import { buildSummaryRecord, tableColumns } from './useTable'
|
||||
|
||||
/**
|
||||
* 设备统计页主编排:
|
||||
* - 顶部 4 个核心指标卡(总数 / 投用 / 在库 / 异常)
|
||||
* - 多维搜索条件(关键字 / 类别 / 投用 / 运行 / IOT / 分发 / 上报 / 租户 / 场所 / 软件版本 / 时间范围)
|
||||
* - 列表展示扁平设备记录 + 顶部统计行(合计)
|
||||
* - 列表展示扁平设备记录 + 首行统计行(合计)
|
||||
* - 异步导出 + 查看导出任务
|
||||
*/
|
||||
export const usePage = () => {
|
||||
@@ -42,9 +42,6 @@ export const usePage = () => {
|
||||
pagination: createPaginationConfig() as TablePaginationConfig,
|
||||
})
|
||||
|
||||
/** 统计行(合计) */
|
||||
const summaryRow = computed(() => buildSummaryRow(table.dataSource))
|
||||
|
||||
/** 构造请求参数(时间范围拍平) */
|
||||
const buildParams = (): SearchForm => ({
|
||||
...search,
|
||||
@@ -54,14 +51,16 @@ export const usePage = () => {
|
||||
deployEnd: deployRange.value?.[1],
|
||||
})
|
||||
|
||||
/** 拉取列表 */
|
||||
/** 拉取列表(首行为合计统计行,其后为数据行) */
|
||||
const listRequest = (): void => {
|
||||
pageLoading.value = true
|
||||
list(buildParams())
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
table.dataSource = res.data ?? []
|
||||
table.pagination.total = table.dataSource.length
|
||||
const rows = res.data ?? []
|
||||
table.dataSource = [buildSummaryRecord(rows), ...rows]
|
||||
// 合计行不计入 total,分页以原始数据量为基准
|
||||
table.pagination.total = rows.length
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -139,7 +138,6 @@ export const usePage = () => {
|
||||
deployRange,
|
||||
table,
|
||||
overview,
|
||||
summaryRow,
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
deployStatusOptions: DEPLOY_STATUS_OPTIONS,
|
||||
runStatusOptions: RUN_STATUS_OPTIONS,
|
||||
|
||||
@@ -3,9 +3,24 @@ import { h } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import type { DeviceStatItem } from '../types'
|
||||
|
||||
/**
|
||||
* 统计行 customRender 入参类型
|
||||
* - summary 行的字段值会被替换为合计文本/数字,故 value/record 都放宽
|
||||
*/
|
||||
type RenderCtx = { value: unknown; record: DeviceStatItem; index: number }
|
||||
|
||||
/** 列定义(按用户给定顺序) */
|
||||
export const tableColumns: TableColumnsType = [
|
||||
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
width: 70,
|
||||
// summary 行显示"合计",数据行序号 = index(因 summary 占 index 0,数据行从 1 开始)
|
||||
customRender: ({ index, record }: RenderCtx) =>
|
||||
record.__isSummary ? '合计' : index,
|
||||
},
|
||||
{ title: '设备类别', dataIndex: 'categoryLabel', align: 'left', width: 140, resizable: true, ellipsis: true },
|
||||
{ title: '设备名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
|
||||
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 180, resizable: true, ellipsis: true },
|
||||
@@ -14,32 +29,34 @@ export const tableColumns: TableColumnsType = [
|
||||
dataIndex: 'isIot',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: boolean }) =>
|
||||
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '是' : '否')),
|
||||
customRender: ({ value, record }: RenderCtx) =>
|
||||
record.__isSummary ? (value as number) : h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '是' : '否')),
|
||||
},
|
||||
{
|
||||
title: '投用状态',
|
||||
dataIndex: 'deployStatus',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: DeviceStatItem['deployStatus'] }) =>
|
||||
h(Tag, { color: value === 'deployed' ? 'success' : 'default' }, () => (value === 'deployed' ? '投用' : '在库')),
|
||||
customRender: ({ value, record }: RenderCtx) =>
|
||||
record.__isSummary
|
||||
? (value as string)
|
||||
: h(Tag, { color: value === 'deployed' ? 'success' : 'default' }, () => (value === 'deployed' ? '投用' : '在库')),
|
||||
},
|
||||
{
|
||||
title: '启用数据分发',
|
||||
dataIndex: 'dataDispatch',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
customRender: ({ value }: { value: boolean }) =>
|
||||
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
|
||||
customRender: ({ value, record }: RenderCtx) =>
|
||||
record.__isSummary ? (value as number) : h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
|
||||
},
|
||||
{
|
||||
title: '启用数据上报',
|
||||
dataIndex: 'dataReport',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
customRender: ({ value }: { value: boolean }) =>
|
||||
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
|
||||
customRender: ({ value, record }: RenderCtx) =>
|
||||
record.__isSummary ? (value as number) : h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
|
||||
},
|
||||
{ title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
{ title: '布点场所', dataIndex: 'siteName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
@@ -49,7 +66,8 @@ export const tableColumns: TableColumnsType = [
|
||||
dataIndex: 'runStatus',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: DeviceStatItem['runStatus'] }) => {
|
||||
customRender: ({ value, record }: RenderCtx) => {
|
||||
if (record.__isSummary) return value as string
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'success',
|
||||
offline: 'default',
|
||||
@@ -62,7 +80,7 @@ export const tableColumns: TableColumnsType = [
|
||||
repairing: '维修中',
|
||||
scrapped: '报废',
|
||||
}
|
||||
return h(Tag, { color: colorMap[value] ?? 'default' }, () => textMap[value] ?? '—')
|
||||
return h(Tag, { color: colorMap[value as string] ?? 'default' }, () => textMap[value as string] ?? '—')
|
||||
},
|
||||
},
|
||||
{ title: '入库时间', dataIndex: 'onlineTime', align: 'center', width: 170, sorter: true, resizable: true },
|
||||
@@ -70,7 +88,10 @@ export const tableColumns: TableColumnsType = [
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 100 },
|
||||
]
|
||||
|
||||
/** 计算统计行数据(列表上方统计行) */
|
||||
/**
|
||||
* 计算统计行字段值(合计)
|
||||
* 字段 key 与列 dataIndex 对齐,summary 行的 cell 会直接消费这些值
|
||||
*/
|
||||
export const buildSummaryRow = (rows: DeviceStatItem[]): Record<string, number | string> => {
|
||||
const total = rows.length
|
||||
const deployed = rows.filter((r) => r.deployStatus === 'deployed').length
|
||||
@@ -81,12 +102,27 @@ export const buildSummaryRow = (rows: DeviceStatItem[]): Record<string, number |
|
||||
const online = rows.filter((r) => r.runStatus === 'online').length
|
||||
const offline = rows.filter((r) => r.runStatus === 'offline').length
|
||||
return {
|
||||
index: '合计',
|
||||
name: `${total} 台`,
|
||||
iot,
|
||||
deployStatus: `投用 ${deployed} / 在库 ${inStock}`,
|
||||
isIot: iot,
|
||||
name: `${total}`,
|
||||
deployStatus: `${deployed} / ${inStock}`,
|
||||
dataDispatch: dispatch,
|
||||
dataReport: report,
|
||||
runStatus: `在用 ${online} / 离线 ${offline}`,
|
||||
runStatus: `${online} / ${offline}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把合计字段包装成可插入 dataSource 的统计行 record
|
||||
* - 带 __isSummary 标记,列 customRender 据此走简化渲染(直接返回文本/数字,不走 Tag)
|
||||
* - 字段值类型与 DeviceStatItem 不完全一致(如 isIot 是 number),用类型断言绕过
|
||||
*/
|
||||
export const buildSummaryRecord = (rows: DeviceStatItem[]): DeviceStatItem =>
|
||||
({
|
||||
id: '__summary__',
|
||||
__isSummary: true,
|
||||
categoryLabel: '',
|
||||
category: 'catering' as DeviceStatItem['category'],
|
||||
name: '',
|
||||
deviceCode: '',
|
||||
...buildSummaryRow(rows),
|
||||
}) as unknown as DeviceStatItem
|
||||
|
||||
@@ -102,8 +102,14 @@
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<!-- 表格 + 统计行 + 异步导出 -->
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
|
||||
<!-- 表格(首行为合计统计行,customRender 据 __isSummary 走简化渲染)+ 异步导出 -->
|
||||
<TableCard
|
||||
:table="table"
|
||||
:loading="pageLoading"
|
||||
row-key="id"
|
||||
:row-class-name="(record: DeviceStatItem) => (record.__isSummary ? '__isSummary' : '')"
|
||||
@change="dataSourceChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<a-button @click="exportListData">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
@@ -116,31 +122,11 @@
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="viewDetail(record)">查看详情</a-button>
|
||||
<!-- 合计行操作列留空 -->
|
||||
<template v-if="record.__isSummary">—</template>
|
||||
<a-button v-else type="link" size="small" @click="viewDetail(record)">查看详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 列表上方统计行:cell 顺序与 tableColumns 严格对齐 -->
|
||||
<template #summary>
|
||||
<a-table-summary fixed>
|
||||
<a-table-summary-row>
|
||||
<a-table-summary-cell :index="0" align="center">{{ summaryRow.index }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="1" align="left" />
|
||||
<a-table-summary-cell :index="2" align="left">{{ summaryRow.name }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="3" align="left" />
|
||||
<a-table-summary-cell :index="4" align="center">{{ summaryRow.iot }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="5" align="center">{{ summaryRow.deployStatus }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="6" align="center">{{ summaryRow.dataDispatch }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="7" align="center">{{ summaryRow.dataReport }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="8" align="left" />
|
||||
<a-table-summary-cell :index="9" align="left" />
|
||||
<a-table-summary-cell :index="10" align="center" />
|
||||
<a-table-summary-cell :index="11" align="center">{{ summaryRow.runStatus }}</a-table-summary-cell>
|
||||
<a-table-summary-cell :index="12" align="center" />
|
||||
<a-table-summary-cell :index="13" align="center" />
|
||||
<a-table-summary-cell :index="14" align="center" />
|
||||
</a-table-summary-row>
|
||||
</a-table-summary>
|
||||
</template>
|
||||
</TableCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -161,7 +147,6 @@ const {
|
||||
deployRange,
|
||||
table,
|
||||
overview,
|
||||
summaryRow,
|
||||
categoryOptions,
|
||||
deployStatusOptions,
|
||||
runStatusOptions,
|
||||
@@ -195,4 +180,11 @@ const viewDetail = (record: DeviceStatItem): void => {
|
||||
margin: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 合计行视觉强化:粗体 + 浅蓝底,与数据行区分
|
||||
:deep(.ant-table-row.__isSummary td) {
|
||||
background: #fafafa !important;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface DeviceStatItem {
|
||||
onlineTime: string
|
||||
/** 投用时间 */
|
||||
deployTime: string
|
||||
/** 统计行标记(true 表示该行是合计行,customRender 据此走简化渲染) */
|
||||
__isSummary?: boolean
|
||||
}
|
||||
|
||||
/** 顶部统计聚合数据 */
|
||||
|
||||
@@ -6,36 +6,44 @@
|
||||
<van-tab v-for="t in statusTabs" :key="t.value" :name="t.value" :title="t.label" />
|
||||
</van-tabs>
|
||||
|
||||
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
|
||||
<div
|
||||
v-for="plan in list"
|
||||
:key="plan.id"
|
||||
class="ip-card"
|
||||
:class="`ip-card--${plan.status}`"
|
||||
@click="goDetail(plan.id)"
|
||||
>
|
||||
<div class="ip-card__header">
|
||||
<van-tag :type="statusTagType(plan.status)" size="medium">{{ statusText(plan.status) }}</van-tag>
|
||||
<span class="ip-card__count">{{ plan.deviceCount }} 台设备</span>
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
|
||||
<van-empty
|
||||
v-if="!list.length && !loading"
|
||||
image="search"
|
||||
description="该状态下暂无巡检任务"
|
||||
class="inspect-home__empty"
|
||||
/>
|
||||
<div
|
||||
v-for="plan in list"
|
||||
:key="plan.id"
|
||||
class="ip-card"
|
||||
:class="`ip-card--${plan.status}`"
|
||||
@click="goDetail(plan.id)"
|
||||
>
|
||||
<div class="ip-card__header">
|
||||
<van-tag :type="statusTagType(plan.status)" size="medium">{{ statusText(plan.status) }}</van-tag>
|
||||
<span class="ip-card__count">{{ plan.deviceCount }} 台设备</span>
|
||||
</div>
|
||||
<div class="ip-card__name">{{ plan.name }}</div>
|
||||
<div class="ip-card__meta">
|
||||
<van-icon name="location-o" />
|
||||
<span>{{ plan.site }}</span>
|
||||
</div>
|
||||
<div class="ip-card__meta">
|
||||
<van-icon name="apps-o" />
|
||||
<span>{{ plan.deviceCategory }}</span>
|
||||
</div>
|
||||
<div class="ip-card__footer">
|
||||
<span class="ip-card__deadline" :class="{ 'ip-card__deadline--over': plan.status === 'overdue' }">
|
||||
<van-icon name="clock-o" />
|
||||
截止:{{ plan.deadline }}
|
||||
</span>
|
||||
<span class="ip-card__inspector">{{ plan.inspector }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ip-card__name">{{ plan.name }}</div>
|
||||
<div class="ip-card__meta">
|
||||
<van-icon name="location-o" />
|
||||
<span>{{ plan.site }}</span>
|
||||
</div>
|
||||
<div class="ip-card__meta">
|
||||
<van-icon name="apps-o" />
|
||||
<span>{{ plan.deviceCategory }}</span>
|
||||
</div>
|
||||
<div class="ip-card__footer">
|
||||
<span class="ip-card__deadline" :class="{ 'ip-card__deadline--over': plan.status === 'overdue' }">
|
||||
<van-icon name="clock-o" />
|
||||
截止:{{ plan.deadline }}
|
||||
</span>
|
||||
<span class="ip-card__inspector">{{ plan.inspector }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,7 +62,9 @@ const router = useRouter()
|
||||
|
||||
const list = ref<InspectPlan[]>([])
|
||||
const loading = ref(false)
|
||||
// finished 固定为 true:mock 数据一次性返回,无分页;保留 van-list 是为后续接入真接口预留
|
||||
const finished = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const activeTab = ref<InspectPlanStatus | ''>('')
|
||||
|
||||
const statusTabs: Array<{ label: string; value: InspectPlanStatus | '' }> = [
|
||||
@@ -85,6 +95,7 @@ const loadList = (): void => {
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
finished.value = true
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,6 +106,13 @@ const onTabChange = (): void => {
|
||||
loadList()
|
||||
}
|
||||
|
||||
const onRefresh = (): void => {
|
||||
list.value = []
|
||||
loading.value = true
|
||||
finished.value = false
|
||||
loadList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
@@ -105,6 +123,11 @@ onMounted(() => {
|
||||
min-height: 100%;
|
||||
background: var(--background);
|
||||
padding-bottom: 16px;
|
||||
|
||||
&__empty {
|
||||
padding: 32px 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ip-card {
|
||||
|
||||
@@ -1,63 +1,72 @@
|
||||
<template>
|
||||
<div v-if="detail" class="inspect-detail">
|
||||
<van-nav-bar :title="detail.plan.name" left-arrow @click-left="onBack" />
|
||||
<div class="inspect-detail">
|
||||
<van-nav-bar title="巡检任务" left-arrow @click-left="onBack" />
|
||||
|
||||
<!-- 计划头部 -->
|
||||
<div class="inspect-detail__header">
|
||||
<van-tag :type="statusTagType(detail.plan.status)">{{ statusText(detail.plan.status) }}</van-tag>
|
||||
<span class="inspect-detail__count">{{ detail.devices.length }} 台设备 / {{ detail.plan.itemCount }} 项</span>
|
||||
</div>
|
||||
<div class="inspect-detail__meta">
|
||||
<van-icon name="location-o" />
|
||||
<span>{{ detail.plan.site }}</span>
|
||||
<span class="inspect-detail__deadline">截止:{{ detail.plan.deadline }}</span>
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="!detail" class="inspect-detail__skeleton">
|
||||
<van-skeleton title :row="2" />
|
||||
<van-skeleton title :row="6" />
|
||||
</div>
|
||||
|
||||
<!-- 设备切换 -->
|
||||
<van-tabs v-model:active="activeDeviceIdx" sticky>
|
||||
<van-tab
|
||||
v-for="(d, idx) in detail.devices"
|
||||
:key="d.code"
|
||||
:name="idx"
|
||||
:title="`#${idx + 1} ${d.name}`"
|
||||
/>
|
||||
</van-tabs>
|
||||
<template v-else>
|
||||
<!-- 计划头部 -->
|
||||
<div class="inspect-detail__header">
|
||||
<van-tag :type="statusTagType(detail.plan.status)">{{ statusText(detail.plan.status) }}</van-tag>
|
||||
<span class="inspect-detail__name">{{ detail.plan.name }}</span>
|
||||
<span class="inspect-detail__count">{{ detail.devices.length }} 台 / {{ detail.plan.itemCount }} 项</span>
|
||||
</div>
|
||||
<div class="inspect-detail__meta">
|
||||
<van-icon name="location-o" />
|
||||
<span>{{ detail.plan.site }}</span>
|
||||
<span class="inspect-detail__deadline">截止:{{ detail.plan.deadline }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 当前设备巡检项 -->
|
||||
<div v-if="currentDevice" class="inspect-detail__items">
|
||||
<div class="inspect-detail__device-info">
|
||||
<div>
|
||||
<span class="inspect-detail__device-code">{{ currentDevice.code }}</span>
|
||||
<van-tag v-if="currentDevice.scanned" type="success" size="small">已扫码</van-tag>
|
||||
<van-button v-else size="mini" type="primary" plain @click="onScanDevice">扫码打卡</van-button>
|
||||
<!-- 设备切换 -->
|
||||
<van-tabs v-model:active="activeDeviceIdx" sticky>
|
||||
<van-tab
|
||||
v-for="(d, idx) in detail.devices"
|
||||
:key="d.code"
|
||||
:name="idx"
|
||||
:title="`#${idx + 1} ${d.name}`"
|
||||
/>
|
||||
</van-tabs>
|
||||
|
||||
<!-- 当前设备巡检项 -->
|
||||
<div v-if="currentDevice" class="inspect-detail__items">
|
||||
<div class="inspect-detail__device-info">
|
||||
<div>
|
||||
<span class="inspect-detail__device-code">{{ currentDevice.code }}</span>
|
||||
<van-tag v-if="currentDevice.scanned" type="success" size="small">已扫码</van-tag>
|
||||
<van-button v-else size="mini" type="primary" plain @click="onScanDevice">扫码打卡</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in currentDevice.items"
|
||||
:key="item.id"
|
||||
class="inspect-item"
|
||||
:class="`inspect-item--${item.result}`"
|
||||
>
|
||||
<div class="inspect-item__header">
|
||||
<van-tag plain :type="itemTypeTagType(item.type)" size="small">{{ itemTypeText(item.type) }}</van-tag>
|
||||
<span class="inspect-item__name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="inspect-item__standard">标准:{{ item.standard }}</div>
|
||||
<div v-if="item.result !== 'pending'" class="inspect-item__remark">
|
||||
{{ item.remark || '无备注' }}
|
||||
</div>
|
||||
<div v-else class="inspect-item__actions">
|
||||
<van-button size="small" type="success" plain @click="onSubmitItem(item, 'pass')">合格</van-button>
|
||||
<van-button size="small" type="danger" plain @click="onSubmitItem(item, 'abnormal')">异常</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in currentDevice.items"
|
||||
:key="item.id"
|
||||
class="inspect-item"
|
||||
:class="`inspect-item--${item.result}`"
|
||||
>
|
||||
<div class="inspect-item__header">
|
||||
<van-tag plain :type="itemTypeTagType(item.type)" size="small">{{ itemTypeText(item.type) }}</van-tag>
|
||||
<span class="inspect-item__name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="inspect-item__standard">标准:{{ item.standard }}</div>
|
||||
<div v-if="item.result !== 'pending'" class="inspect-item__remark">
|
||||
{{ item.remark || '无备注' }}
|
||||
</div>
|
||||
<div v-else class="inspect-item__actions">
|
||||
<van-button size="small" type="success" plain @click="onSubmitItem(item, 'pass')">合格</van-button>
|
||||
<van-button size="small" type="danger" plain @click="onSubmitItem(item, 'abnormal')">异常</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部完成按钮 -->
|
||||
<div class="inspect-detail__actions">
|
||||
<van-button block type="primary" @click="onFinish">完成巡检任务</van-button>
|
||||
</div>
|
||||
<!-- 底部完成按钮 -->
|
||||
<OpsBottomActions>
|
||||
<van-button block type="primary" @click="onFinish">完成巡检任务</van-button>
|
||||
</OpsBottomActions>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,6 +80,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
import OpsBottomActions from '@components/OpsBottomActions.vue'
|
||||
import { finishInspectTask, getInspectDetail, submitInspectItem } from './api'
|
||||
import type { InspectItem, InspectItemType, InspectPlanStatus, InspectTaskDetail } from './types'
|
||||
|
||||
@@ -177,6 +187,22 @@ onMounted(() => {
|
||||
background: var(--background);
|
||||
padding-bottom: 80px;
|
||||
|
||||
&__skeleton {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
> .van-skeleton {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -185,8 +211,17 @@ onMounted(() => {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&__count {
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--font-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -226,16 +261,6 @@ onMounted(() => {
|
||||
color: var(--primary);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.inspect-item {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
import { showSuccessToast, showToast } from 'vant'
|
||||
import {
|
||||
clearOffline,
|
||||
getOfflineStatus,
|
||||
@@ -115,11 +115,12 @@ const goMessages = (): void => {
|
||||
}
|
||||
|
||||
const onSetting = (): void => {
|
||||
showFailToast('原型阶段未实现')
|
||||
// 原型阶段未实现,用普通 toast 提示,避免误导为失败
|
||||
showToast('原型阶段未实现')
|
||||
}
|
||||
|
||||
const onLogout = (): void => {
|
||||
showFailToast('原型阶段未实现')
|
||||
showToast('原型阶段未实现')
|
||||
}
|
||||
|
||||
const onSync = (): void => {
|
||||
|
||||
@@ -10,23 +10,31 @@
|
||||
<van-tab v-for="t in typeTabs" :key="t.value" :name="t.value" :title="t.label" />
|
||||
</van-tabs>
|
||||
|
||||
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
|
||||
<div
|
||||
v-for="msg in list"
|
||||
:key="msg.id"
|
||||
class="msg-card"
|
||||
:class="{ 'msg-card--unread': !msg.read }"
|
||||
@click="onRead(msg)"
|
||||
>
|
||||
<div class="msg-card__header">
|
||||
<van-tag :type="typeTagType(msg.type)" size="medium">{{ typeText(msg.type) }}</van-tag>
|
||||
<span class="msg-card__time">{{ msg.createdAt }}</span>
|
||||
<span v-if="!msg.read" class="msg-card__dot" />
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
|
||||
<van-empty
|
||||
v-if="!list.length && !loading"
|
||||
image="search"
|
||||
description="暂无消息"
|
||||
class="msg-center__empty"
|
||||
/>
|
||||
<div
|
||||
v-for="msg in list"
|
||||
:key="msg.id"
|
||||
class="msg-card"
|
||||
:class="{ 'msg-card--unread': !msg.read }"
|
||||
@click="onRead(msg)"
|
||||
>
|
||||
<div class="msg-card__header">
|
||||
<van-tag :type="typeTagType(msg.type)" size="medium">{{ typeText(msg.type) }}</van-tag>
|
||||
<span class="msg-card__time">{{ msg.createdAt }}</span>
|
||||
<span v-if="!msg.read" class="msg-card__dot" />
|
||||
</div>
|
||||
<div class="msg-card__title">{{ msg.title }}</div>
|
||||
<div class="msg-card__content">{{ msg.content }}</div>
|
||||
</div>
|
||||
<div class="msg-card__title">{{ msg.title }}</div>
|
||||
<div class="msg-card__content">{{ msg.content }}</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +55,9 @@ const router = useRouter()
|
||||
|
||||
const list = ref<MessageItem[]>([])
|
||||
const loading = ref(false)
|
||||
// finished 固定为 true:mock 数据一次性返回,无分页;保留 van-list 是为后续接入真接口预留
|
||||
const finished = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const activeType = ref<MessageType | ''>('')
|
||||
|
||||
const typeTabs: Array<{ label: string; value: MessageType | '' }> = [
|
||||
@@ -88,6 +98,13 @@ const onTabChange = (): void => {
|
||||
loadList()
|
||||
}
|
||||
|
||||
const onRefresh = (): void => {
|
||||
list.value = []
|
||||
loading.value = true
|
||||
finished.value = false
|
||||
loadList()
|
||||
}
|
||||
|
||||
const onRead = (msg: MessageItem): void => {
|
||||
if (!msg.read) {
|
||||
readMessage(msg.id)
|
||||
@@ -123,6 +140,7 @@ const loadList = (): void => {
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
finished.value = true
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,6 +159,11 @@ onMounted(() => {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 32px 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-card {
|
||||
|
||||
@@ -90,6 +90,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import { scanDevice, submitImplRecord } from './api'
|
||||
import { formatNow } from '@utils/datetime'
|
||||
import type { ImplRecordForm, ImplType } from './types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -163,8 +164,7 @@ const loadDevice = (): void => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
form.operateTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
|
||||
form.operateTime = formatNow()
|
||||
loadDevice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -102,6 +102,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import { scanDevice, submitInit } from './api'
|
||||
import { formatNow } from '@utils/datetime'
|
||||
import type { InitForm } from './types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -175,8 +176,7 @@ const loadDevice = (): void => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
form.operateTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
|
||||
form.operateTime = formatNow()
|
||||
loadDevice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,101 +1,124 @@
|
||||
<template>
|
||||
<div v-if="monitor" class="scan-monitor">
|
||||
<div class="scan-monitor">
|
||||
<van-nav-bar title="设备监测" left-arrow @click-left="onBack" />
|
||||
|
||||
<!-- 设备头部 -->
|
||||
<div class="scan-monitor__header">
|
||||
<div class="scan-monitor__name">{{ monitor.summary.name }}</div>
|
||||
<div class="scan-monitor__code">{{ monitor.summary.code }}</div>
|
||||
<van-tag :type="statusTagType(monitor.summary.status)">{{ statusText(monitor.summary.status) }}</van-tag>
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="!monitor" class="scan-monitor__skeleton">
|
||||
<van-skeleton title :row="2" />
|
||||
<van-skeleton title :row="6" />
|
||||
</div>
|
||||
|
||||
<van-tabs v-model:active="activeTab" sticky>
|
||||
<van-tab v-for="t in tabs" :key="t.key" :name="t.key" :title="t.label" :badge="t.badge" />
|
||||
</van-tabs>
|
||||
<template v-else>
|
||||
<!-- 设备头部 -->
|
||||
<div class="scan-monitor__header">
|
||||
<div class="scan-monitor__name">{{ monitor.summary.name }}</div>
|
||||
<div class="scan-monitor__code">{{ monitor.summary.code }}</div>
|
||||
<van-tag :type="statusTagType(monitor.summary.status)">{{ statusText(monitor.summary.status) }}</van-tag>
|
||||
</div>
|
||||
|
||||
<!-- 概览 -->
|
||||
<div v-if="activeTab === 'overview'" class="scan-monitor__tab">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="型号" :value="monitor.summary.model" />
|
||||
<van-cell title="所属场所" :value="monitor.summary.site" />
|
||||
<van-cell title="所属租户" :value="monitor.summary.tenant" />
|
||||
<van-cell title="固件版本" :value="monitor.summary.firmwareVersion" />
|
||||
<van-cell title="最近心跳" :value="monitor.summary.lastHeartbeat" />
|
||||
<van-cell title="是否 IOT 设备" :value="monitor.summary.isIot ? '是' : '否'" />
|
||||
<van-cell title="数据分发" :value="monitor.summary.dataDistributeEnabled ? '已启用' : '未启用'" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<van-tabs v-model:active="activeTab" sticky>
|
||||
<van-tab v-for="t in tabs" :key="t.key" :name="t.key" :title="t.label" :badge="t.badge" />
|
||||
</van-tabs>
|
||||
|
||||
<!-- 运行日志 -->
|
||||
<div v-if="activeTab === 'runLog'" class="scan-monitor__tab">
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="log in monitor.runLogs"
|
||||
:key="log.id"
|
||||
:title="log.event"
|
||||
:label="log.time"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-tag :type="logTagType(log.level)" size="small">{{ logLevelText(log.level) }}</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<!-- 概览 -->
|
||||
<div v-if="activeTab === 'overview'" class="scan-monitor__tab">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="型号" :value="monitor.summary.model" />
|
||||
<van-cell title="所属场所" :value="monitor.summary.site" />
|
||||
<van-cell title="所属租户" :value="monitor.summary.tenant" />
|
||||
<van-cell title="固件版本" :value="monitor.summary.firmwareVersion" />
|
||||
<van-cell title="最近心跳" :value="monitor.summary.lastHeartbeat" />
|
||||
<van-cell title="是否 IOT 设备" :value="monitor.summary.isIot ? '是' : '否'" />
|
||||
<van-cell title="数据分发" :value="monitor.summary.dataDistributeEnabled ? '已启用' : '未启用'" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 告警 -->
|
||||
<div v-if="activeTab === 'alarm'" class="scan-monitor__tab">
|
||||
<div v-if="!monitor.alarms.length" class="scan-monitor__empty">暂无告警</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="a in monitor.alarms"
|
||||
:key="a.id"
|
||||
:title="a.content"
|
||||
:label="a.triggerTime"
|
||||
is-link
|
||||
@click="goAlarm"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-tag :type="a.level === 'critical' ? 'danger' : 'warning'" size="small">
|
||||
{{ a.level === 'critical' ? '严重' : a.level === 'warn' ? '警告' : '提示' }}
|
||||
</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<!-- 运行日志 -->
|
||||
<div v-if="activeTab === 'runLog'" class="scan-monitor__tab">
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="log in monitor.runLogs"
|
||||
:key="log.id"
|
||||
:title="log.event"
|
||||
:label="log.time"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-tag :type="logTagType(log.level)" size="small">{{ logLevelText(log.level) }}</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 维修记录 -->
|
||||
<div v-if="activeTab === 'repair'" class="scan-monitor__tab">
|
||||
<div v-if="!monitor.repairs.length" class="scan-monitor__empty">暂无维修记录</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="r in monitor.repairs"
|
||||
:key="r.id"
|
||||
:title="r.faultType"
|
||||
:label="`${r.time} · ${r.repairman} · 工单 ${r.workOrderId}`"
|
||||
>
|
||||
<template #value>
|
||||
<span class="scan-monitor__repair-method">{{ r.repairMethod }}</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<!-- 告警 -->
|
||||
<div v-if="activeTab === 'alarm'" class="scan-monitor__tab">
|
||||
<van-empty
|
||||
v-if="!monitor.alarms.length"
|
||||
image="search"
|
||||
description="暂无告警"
|
||||
class="scan-monitor__empty"
|
||||
/>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="a in monitor.alarms"
|
||||
:key="a.id"
|
||||
:title="a.content"
|
||||
:label="a.triggerTime"
|
||||
is-link
|
||||
@click="goAlarm"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-tag :type="a.level === 'critical' ? 'danger' : 'warning'" size="small">
|
||||
{{ a.level === 'critical' ? '严重' : a.level === 'warn' ? '警告' : '提示' }}
|
||||
</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 更新记录 -->
|
||||
<div v-if="activeTab === 'update'" class="scan-monitor__tab">
|
||||
<div v-if="!monitor.updates.length" class="scan-monitor__empty">暂无更新记录</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="u in monitor.updates"
|
||||
:key="u.id"
|
||||
:title="`${u.fromVersion} → ${u.toVersion}`"
|
||||
:label="`${u.time} · ${u.operator}`"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-tag :type="updateTagType(u.result)" size="small">{{ updateResultText(u.result) }}</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<!-- 维修记录 -->
|
||||
<div v-if="activeTab === 'repair'" class="scan-monitor__tab">
|
||||
<van-empty
|
||||
v-if="!monitor.repairs.length"
|
||||
image="search"
|
||||
description="暂无维修记录"
|
||||
class="scan-monitor__empty"
|
||||
/>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="r in monitor.repairs"
|
||||
:key="r.id"
|
||||
:title="r.faultType"
|
||||
:label="`${r.time} · ${r.repairman} · 工单 ${r.workOrderId}`"
|
||||
>
|
||||
<template #value>
|
||||
<span class="scan-monitor__repair-method">{{ r.repairMethod }}</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 更新记录 -->
|
||||
<div v-if="activeTab === 'update'" class="scan-monitor__tab">
|
||||
<van-empty
|
||||
v-if="!monitor.updates.length"
|
||||
image="search"
|
||||
description="暂无更新记录"
|
||||
class="scan-monitor__empty"
|
||||
/>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="u in monitor.updates"
|
||||
:key="u.id"
|
||||
:title="`${u.fromVersion} → ${u.toVersion}`"
|
||||
:label="`${u.time} · ${u.operator}`"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-tag :type="updateTagType(u.result)" size="small">{{ updateResultText(u.result) }}</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -180,6 +203,22 @@ onMounted(() => {
|
||||
background: var(--background);
|
||||
padding-bottom: 16px;
|
||||
|
||||
&__skeleton {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
> .van-skeleton {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
@@ -205,10 +244,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-placeholder);
|
||||
font-size: var(--font-sm);
|
||||
padding: 32px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&__repair-method {
|
||||
|
||||
@@ -14,7 +14,17 @@
|
||||
<!-- 升级中状态 -->
|
||||
<div v-if="task && task.status === 'upgrading'" class="scan-ota__progress">
|
||||
<div class="scan-ota__progress-title">升级中…</div>
|
||||
<van-progress :percentage="task.progress" stroke-width="8" />
|
||||
<div class="scan-ota__circle">
|
||||
<van-circle
|
||||
:rate="task.progress"
|
||||
:speed="100"
|
||||
:stroke-width="80"
|
||||
layer-color="#f0f0f0"
|
||||
color="#1890ff"
|
||||
:text="`${task.progress}%`"
|
||||
size="120px"
|
||||
/>
|
||||
</div>
|
||||
<div class="scan-ota__progress-info">
|
||||
{{ task.currentVersion }} → {{ task.targetVersion }}
|
||||
</div>
|
||||
@@ -186,6 +196,8 @@ const loadTask = (): void => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 设备信息需先于版本/任务加载(loadVersions / loadTask 依赖 deviceCode 已通过路由注入的 ref,
|
||||
// 因此三者实际可并行;串行写法仅保留可读性,但为提升首屏速度,这里改为并行)
|
||||
loadDevice()
|
||||
loadVersions()
|
||||
loadTask()
|
||||
@@ -245,7 +257,21 @@ onMounted(() => {
|
||||
&__progress-title {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__circle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0 12px;
|
||||
|
||||
:deep(.van-circle__text) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-info {
|
||||
|
||||
@@ -101,6 +101,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import { scanDevice, submitRepair } from './api'
|
||||
import { formatNow } from '@utils/datetime'
|
||||
import type { RepairForm } from './types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -185,8 +186,7 @@ const loadDevice = (): void => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
form.repairTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
|
||||
form.repairTime = formatNow()
|
||||
loadDevice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,47 +1,55 @@
|
||||
<template>
|
||||
<div v-if="device" class="scene-menu">
|
||||
<div class="scene-menu">
|
||||
<van-nav-bar title="场景操作" left-arrow @click-left="onBack" />
|
||||
|
||||
<!-- 设备摘要卡 -->
|
||||
<div class="scene-menu__device">
|
||||
<div class="scene-menu__device-header">
|
||||
<van-icon name="desktop-o" class="scene-menu__device-icon" />
|
||||
<div class="scene-menu__device-main">
|
||||
<div class="scene-menu__device-name">{{ device.name }}</div>
|
||||
<div class="scene-menu__device-code">{{ device.code }}</div>
|
||||
</div>
|
||||
<van-tag :type="statusTagType(device.status)">{{ statusText(device.status) }}</van-tag>
|
||||
</div>
|
||||
<div class="scene-menu__device-meta">
|
||||
<div><span>型号</span>{{ device.model }}</div>
|
||||
<div><span>固件</span>{{ device.firmwareVersion }}</div>
|
||||
<div><span>场所</span>{{ device.site }}</div>
|
||||
<div><span>租户</span>{{ device.tenant }}</div>
|
||||
</div>
|
||||
<div v-if="device.pendingWorkOrders > 0" class="scene-menu__device-alert">
|
||||
<van-icon name="warning-o" />
|
||||
<span>该设备有 {{ device.pendingWorkOrders }} 条待处理工单</span>
|
||||
</div>
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="!device" class="scene-menu__skeleton">
|
||||
<van-skeleton title avatar :row="4" />
|
||||
<van-skeleton title :row="3" />
|
||||
</div>
|
||||
|
||||
<!-- 操作菜单 -->
|
||||
<div class="scene-menu__actions">
|
||||
<div class="scene-menu__actions-title">可执行操作</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="action in availableActions"
|
||||
:key="action.key"
|
||||
:title="action.label"
|
||||
:label="action.desc"
|
||||
is-link
|
||||
@click="onAction(action)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-icon :name="action.icon" class="scene-menu__action-icon" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 设备摘要卡 -->
|
||||
<div class="scene-menu__device">
|
||||
<div class="scene-menu__device-header">
|
||||
<van-icon name="desktop-o" class="scene-menu__device-icon" />
|
||||
<div class="scene-menu__device-main">
|
||||
<div class="scene-menu__device-name">{{ device.name }}</div>
|
||||
<div class="scene-menu__device-code">{{ device.code }}</div>
|
||||
</div>
|
||||
<van-tag :type="statusTagType(device.status)">{{ statusText(device.status) }}</van-tag>
|
||||
</div>
|
||||
<div class="scene-menu__device-meta">
|
||||
<div><span>型号</span>{{ device.model }}</div>
|
||||
<div><span>固件</span>{{ device.firmwareVersion }}</div>
|
||||
<div><span>场所</span>{{ device.site }}</div>
|
||||
<div><span>租户</span>{{ device.tenant }}</div>
|
||||
</div>
|
||||
<div v-if="device.pendingWorkOrders > 0" class="scene-menu__device-alert">
|
||||
<van-icon name="warning-o" />
|
||||
<span>该设备有 {{ device.pendingWorkOrders }} 条待处理工单</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作菜单 -->
|
||||
<div class="scene-menu__actions">
|
||||
<div class="scene-menu__actions-title">可执行操作</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="action in availableActions"
|
||||
:key="action.key"
|
||||
:title="action.label"
|
||||
:label="action.desc"
|
||||
is-link
|
||||
@click="onAction(action)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-icon :name="action.icon" class="scene-menu__action-icon" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +155,22 @@ onMounted(() => {
|
||||
min-height: 100%;
|
||||
background: var(--background);
|
||||
|
||||
&__skeleton {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
> .van-skeleton {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__device {
|
||||
margin: 8px 12px;
|
||||
background: #fff;
|
||||
|
||||
@@ -4,50 +4,58 @@
|
||||
|
||||
<!-- 状态分段 Tab -->
|
||||
<van-tabs v-model:active="activeTab" sticky @change="onTabChange">
|
||||
<van-tab v-for="t in statusTabs" :key="t.value" :name="t.value" :title="t.label" />
|
||||
<van-tab v-for="t in WORK_ORDER_STATUS_TABS" :key="t.value" :name="t.value" :title="t.label" />
|
||||
</van-tabs>
|
||||
|
||||
<!-- 类型筛选 -->
|
||||
<div class="wo-home__filter">
|
||||
<van-dropdown-menu>
|
||||
<van-dropdown-item v-model="filter.type" :options="typeOpts" @change="reload" />
|
||||
<van-dropdown-item v-model="filter.type" :options="WORK_ORDER_TYPE_OPTS" @change="reload" />
|
||||
</van-dropdown-menu>
|
||||
<van-search v-model="filter.keyword" placeholder="搜索工单号 / 设备" shape="round" @search="reload" />
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="wo-card"
|
||||
:class="{ 'wo-card--over': item.overSla }"
|
||||
@click="goDetail(item.id)"
|
||||
>
|
||||
<div class="wo-card__header">
|
||||
<van-tag :type="typeTagType(item.type)" size="medium">{{ typeText(item.type) }}</van-tag>
|
||||
<span class="wo-card__code">{{ item.code }}</span>
|
||||
<van-tag v-if="item.overSla" type="danger" size="medium">超 SLA</van-tag>
|
||||
<!-- 列表(支持下拉刷新) -->
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
|
||||
<van-empty
|
||||
v-if="!list.length && !loading"
|
||||
image="search"
|
||||
description="该状态下暂无工单"
|
||||
class="wo-home__empty"
|
||||
/>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="wo-card"
|
||||
:class="{ 'wo-card--over': item.overSla }"
|
||||
@click="goDetail(item.id)"
|
||||
>
|
||||
<div class="wo-card__header">
|
||||
<van-tag :type="workOrderTypeTagType(item.type)" size="medium">{{ workOrderTypeText(item.type) }}</van-tag>
|
||||
<span class="wo-card__code">{{ item.code }}</span>
|
||||
<van-tag v-if="item.overSla" type="danger" size="medium">超 SLA</van-tag>
|
||||
</div>
|
||||
<div class="wo-card__title">{{ item.title }}</div>
|
||||
<div class="wo-card__device">
|
||||
<van-icon name="desktop-o" />
|
||||
<span class="wo-card__device-code">{{ item.deviceCode }}</span>
|
||||
<span class="wo-card__device-name">{{ item.deviceName }}</span>
|
||||
</div>
|
||||
<div class="wo-card__site">
|
||||
<van-icon name="location-o" />
|
||||
<span>{{ item.site }}</span>
|
||||
</div>
|
||||
<div class="wo-card__footer">
|
||||
<span class="wo-card__sla" :class="{ 'wo-card__sla--over': item.overSla }">
|
||||
<van-icon name="clock-o" />
|
||||
SLA:{{ item.slaDeadline }}
|
||||
</span>
|
||||
<span class="wo-card__assignee">{{ item.assignee }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wo-card__title">{{ item.title }}</div>
|
||||
<div class="wo-card__device">
|
||||
<van-icon name="desktop-o" />
|
||||
<span class="wo-card__device-code">{{ item.deviceCode }}</span>
|
||||
<span class="wo-card__device-name">{{ item.deviceName }}</span>
|
||||
</div>
|
||||
<div class="wo-card__site">
|
||||
<van-icon name="location-o" />
|
||||
<span>{{ item.site }}</span>
|
||||
</div>
|
||||
<div class="wo-card__footer">
|
||||
<span class="wo-card__sla" :class="{ 'wo-card__sla--over': item.overSla }">
|
||||
<van-icon name="clock-o" />
|
||||
SLA:{{ item.slaDeadline }}
|
||||
</span>
|
||||
<span class="wo-card__assignee">{{ item.assignee }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 扫码建单按钮(浮动) -->
|
||||
<div class="wo-home__add" @click="goScan">
|
||||
@@ -68,13 +76,21 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listWorkOrders } from './api'
|
||||
import {
|
||||
WORK_ORDER_STATUS_TABS,
|
||||
WORK_ORDER_TYPE_OPTS,
|
||||
workOrderTypeTagType,
|
||||
workOrderTypeText,
|
||||
} from './constants'
|
||||
import type { WorkOrderItem, WorkOrderListParams, WorkOrderStatus, WorkOrderType } from './types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const list = ref<WorkOrderItem[]>([])
|
||||
const loading = ref(false)
|
||||
// finished 固定为 true:mock 数据一次性返回,无分页;保留 van-list 是为后续接入真接口预留
|
||||
const finished = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const activeTab = ref<WorkOrderStatus>('pending')
|
||||
|
||||
const filter = reactive<{ type: WorkOrderType | ''; keyword: string }>({
|
||||
@@ -82,32 +98,6 @@ const filter = reactive<{ type: WorkOrderType | ''; keyword: string }>({
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const statusTabs: Array<{ label: string; value: WorkOrderStatus }> = [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '处理中', value: 'processing' },
|
||||
{ label: '已完成', value: 'done' },
|
||||
]
|
||||
|
||||
const typeOpts = [
|
||||
{ text: '全部类型', value: '' },
|
||||
{ text: '报修', value: 'repair' },
|
||||
{ text: '巡检异常', value: 'inspection' },
|
||||
{ text: '自检异常', value: 'selfCheck' },
|
||||
{ text: 'SLA 超时', value: 'sla' },
|
||||
{ text: '售后转单', value: 'afterSale' },
|
||||
]
|
||||
|
||||
const typeText = (t: WorkOrderType): string => ({
|
||||
repair: '报修',
|
||||
inspection: '巡检',
|
||||
selfCheck: '自检',
|
||||
sla: 'SLA',
|
||||
afterSale: '售后',
|
||||
})[t]
|
||||
|
||||
const typeTagType = (t: WorkOrderType): 'primary' | 'warning' | 'danger' | 'default' =>
|
||||
t === 'repair' ? 'danger' : t === 'inspection' ? 'primary' : t === 'selfCheck' ? 'warning' : 'default'
|
||||
|
||||
const goDetail = (id: string): void => {
|
||||
router.push(`/miniprogram/ops/work-order-detail/${id}`)
|
||||
}
|
||||
@@ -130,6 +120,7 @@ const loadList = (): void => {
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
finished.value = true
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +135,10 @@ const onTabChange = (): void => {
|
||||
reload()
|
||||
}
|
||||
|
||||
const onRefresh = (): void => {
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
@@ -174,6 +169,11 @@ onMounted(() => {
|
||||
font-size: var(--font-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 32px 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.wo-card {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 实施运维小程序 / 工单 字典常量
|
||||
*
|
||||
* 设计目的:把工单列表页和详情页里分散的类型 / 状态映射集中管理,
|
||||
* 避免 .vue 文件之间复制粘贴相同的 { repair: '报修' } 字典。
|
||||
*
|
||||
* 用法:
|
||||
* import { WORK_ORDER_TYPE_OPTS, workOrderTypeText } from './constants'
|
||||
* <van-tag :type="workOrderTypeTagType(item.type)">{{ workOrderTypeText(item.type) }}</van-tag>
|
||||
*/
|
||||
import type { WorkOrderStatus, WorkOrderType } from './types'
|
||||
|
||||
/** 工单状态下拉项(用于分段 Tab;不含"全部"——分段 Tab 直接切换状态) */
|
||||
export const WORK_ORDER_STATUS_TABS: Array<{ label: string; value: WorkOrderStatus }> = [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '处理中', value: 'processing' },
|
||||
{ label: '已完成', value: 'done' },
|
||||
]
|
||||
|
||||
/** 工单类型下拉项(含"全部"占位) */
|
||||
export const WORK_ORDER_TYPE_OPTS: Array<{ text: string; value: WorkOrderType | '' }> = [
|
||||
{ text: '全部类型', value: '' },
|
||||
{ text: '报修', value: 'repair' },
|
||||
{ text: '巡检异常', value: 'inspection' },
|
||||
{ text: '自检异常', value: 'selfCheck' },
|
||||
{ text: 'SLA 超时', value: 'sla' },
|
||||
{ text: '售后转单', value: 'afterSale' },
|
||||
]
|
||||
|
||||
/** 工单类型 → 中文文本 */
|
||||
export const workOrderTypeText = (t: WorkOrderType): string =>
|
||||
({
|
||||
repair: '报修',
|
||||
inspection: '巡检',
|
||||
selfCheck: '自检',
|
||||
sla: 'SLA',
|
||||
afterSale: '售后',
|
||||
})[t]
|
||||
|
||||
/** 工单类型 → Vant Tag 类型 */
|
||||
export const workOrderTypeTagType = (t: WorkOrderType): 'primary' | 'warning' | 'danger' | 'default' =>
|
||||
t === 'repair' ? 'danger' : t === 'inspection' ? 'primary' : t === 'selfCheck' ? 'warning' : 'default'
|
||||
|
||||
/** 故障类型下拉项(维修上报用,与 scan/repair.vue 保持一致) */
|
||||
export const FAULT_TYPE_OPTS: Array<{ text: string; value: string }> = [
|
||||
{ text: '硬件故障', value: '硬件故障' },
|
||||
{ text: '软件故障', value: '软件故障' },
|
||||
{ text: '网络故障', value: '网络故障' },
|
||||
{ text: '传感器故障', value: '传感器故障' },
|
||||
{ text: '电源故障', value: '电源故障' },
|
||||
{ text: '机械故障', value: '机械故障' },
|
||||
{ text: '其他', value: '其他' },
|
||||
]
|
||||
@@ -1,74 +1,92 @@
|
||||
<template>
|
||||
<div v-if="detail" class="wo-detail">
|
||||
<div class="wo-detail">
|
||||
<van-nav-bar title="工单详情" left-arrow @click-left="onBack" />
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="wo-detail__header" :class="{ 'wo-detail__header--over': detail.overSla }">
|
||||
<div class="wo-detail__title-row">
|
||||
<van-tag :type="typeTagType(detail.type)" size="large">{{ typeText(detail.type) }}</van-tag>
|
||||
<span class="wo-detail__code">{{ detail.code }}</span>
|
||||
</div>
|
||||
<div class="wo-detail__title">{{ detail.title }}</div>
|
||||
<div class="wo-detail__sla" :class="{ 'wo-detail__sla--over': detail.overSla }">
|
||||
<van-icon name="clock-o" />
|
||||
<span v-if="detail.status === 'done'">已关单</span>
|
||||
<span v-else>SLA 截止:{{ detail.slaDeadline }}</span>
|
||||
</div>
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="!detail" class="wo-detail__skeleton">
|
||||
<van-skeleton title :row="2" />
|
||||
<van-skeleton title :row="6" />
|
||||
<van-skeleton title :row="4" />
|
||||
</div>
|
||||
|
||||
<!-- 设备 + 场所信息 -->
|
||||
<div class="wo-detail__block">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="设备编码" :value="detail.deviceCode" is-link @click="goMonitor" />
|
||||
<van-cell title="设备名称" :value="detail.deviceName" />
|
||||
<van-cell title="所属场所" :value="detail.site" />
|
||||
<van-cell title="所属租户" :value="detail.tenant" />
|
||||
<van-cell title="责任人" :value="detail.assignee" />
|
||||
<van-cell title="创建时间" :value="detail.createdAt" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 故障描述 -->
|
||||
<div class="wo-detail__block">
|
||||
<div class="wo-detail__block-title">故障描述</div>
|
||||
<div class="wo-detail__desc">{{ detail.description }}</div>
|
||||
<div v-if="detail.customerVoice" class="wo-detail__voice">
|
||||
<van-icon name="volume-o" />
|
||||
<span>客户原话:{{ detail.customerVoice }}</span>
|
||||
<template v-else>
|
||||
<!-- 头部 -->
|
||||
<div class="wo-detail__header" :class="{ 'wo-detail__header--over': detail.overSla }">
|
||||
<div class="wo-detail__title-row">
|
||||
<van-tag :type="workOrderTypeTagType(detail.type)" size="large">{{ workOrderTypeText(detail.type) }}</van-tag>
|
||||
<span class="wo-detail__code">{{ detail.code }}</span>
|
||||
</div>
|
||||
<div class="wo-detail__title">{{ detail.title }}</div>
|
||||
<div class="wo-detail__sla" :class="{ 'wo-detail__sla--over': detail.overSla }">
|
||||
<van-icon name="clock-o" />
|
||||
<span v-if="detail.status === 'done'">已关单</span>
|
||||
<span v-else>SLA 截止:{{ detail.slaDeadline }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.faultImages.length" class="wo-detail__images">
|
||||
<van-image
|
||||
v-for="(img, idx) in detail.faultImages"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
width="30%"
|
||||
height="80"
|
||||
fit="cover"
|
||||
radius="6"
|
||||
/>
|
||||
|
||||
<!-- 设备 + 场所信息 -->
|
||||
<div class="wo-detail__block">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="设备编码" :value="detail.deviceCode" is-link @click="goMonitor" />
|
||||
<van-cell title="设备名称" :value="detail.deviceName" />
|
||||
<van-cell title="所属场所" :value="detail.site" />
|
||||
<van-cell title="所属租户" :value="detail.tenant" />
|
||||
<van-cell title="责任人" :value="detail.assignee" />
|
||||
<van-cell title="创建时间" :value="detail.createdAt" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处理时间线 -->
|
||||
<div class="wo-detail__block">
|
||||
<div class="wo-detail__block-title">处理记录</div>
|
||||
<van-steps direction="vertical" :active="detail.processLog.length - 1" active-color="#1890ff">
|
||||
<van-step v-for="(log, idx) in detail.processLog" :key="idx">
|
||||
<div class="wo-detail__step-time">{{ log.time }}</div>
|
||||
<div class="wo-detail__step-action">{{ log.action }}</div>
|
||||
<div class="wo-detail__step-operator">{{ log.operator }}</div>
|
||||
</van-step>
|
||||
</van-steps>
|
||||
</div>
|
||||
<!-- 故障描述 -->
|
||||
<div class="wo-detail__block">
|
||||
<div class="wo-detail__block-title">故障描述</div>
|
||||
<div class="wo-detail__desc">{{ detail.description }}</div>
|
||||
<div v-if="detail.customerVoice" class="wo-detail__voice">
|
||||
<van-icon name="volume-o" />
|
||||
<span>客户原话:{{ detail.customerVoice }}</span>
|
||||
</div>
|
||||
<div v-if="detail.faultImages.length" class="wo-detail__images">
|
||||
<van-image
|
||||
v-for="(img, idx) in detail.faultImages"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
width="30%"
|
||||
height="80"
|
||||
fit="cover"
|
||||
radius="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="wo-detail__actions">
|
||||
<van-button v-if="detail.status === 'pending'" block type="primary" @click="onAccept">接单</van-button>
|
||||
<template v-if="detail.status === 'processing'">
|
||||
<van-button block type="warning" @click="onRepair">维修上报</van-button>
|
||||
<van-button block type="primary" @click="onClose">关单</van-button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 处理时间线 -->
|
||||
<div class="wo-detail__block">
|
||||
<div class="wo-detail__block-title">处理记录</div>
|
||||
<van-steps
|
||||
direction="vertical"
|
||||
:active="detail.processLog.length - 1"
|
||||
active-color="#1890ff"
|
||||
inactive-color="#999"
|
||||
>
|
||||
<van-step v-for="(log, idx) in detail.processLog" :key="idx">
|
||||
<div class="wo-detail__step-time">{{ log.time }}</div>
|
||||
<div class="wo-detail__step-action">
|
||||
<span class="wo-detail__step-action-text">{{ log.action }}</span>
|
||||
<span v-if="idx === detail.processLog.length - 1 && detail.status !== 'done'" class="wo-detail__step-tag wo-detail__step-tag--current">最新</span>
|
||||
<span v-else-if="idx === 0" class="wo-detail__step-tag wo-detail__step-tag--start">起始</span>
|
||||
</div>
|
||||
<div class="wo-detail__step-operator">{{ log.operator }}</div>
|
||||
</van-step>
|
||||
</van-steps>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<OpsBottomActions>
|
||||
<van-button v-if="detail.status === 'pending'" block type="primary" @click="onAccept">接单</van-button>
|
||||
<template v-if="detail.status === 'processing'">
|
||||
<van-button block type="warning" @click="onRepair">维修上报</van-button>
|
||||
<van-button block type="primary" @click="onClose">关单</van-button>
|
||||
</template>
|
||||
</OpsBottomActions>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -83,26 +101,17 @@
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
import { showSuccessToast, showToast } from 'vant'
|
||||
import OpsBottomActions from '@components/OpsBottomActions.vue'
|
||||
import { acceptWorkOrder, closeWorkOrder, getWorkOrderDetail } from './api'
|
||||
import type { WorkOrderDetail, WorkOrderType } from './types'
|
||||
import { workOrderTypeTagType, workOrderTypeText } from './constants'
|
||||
import type { WorkOrderDetail } from './types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const detail = ref<WorkOrderDetail | null>(null)
|
||||
|
||||
const typeText = (t: WorkOrderType): string => ({
|
||||
repair: '报修',
|
||||
inspection: '巡检',
|
||||
selfCheck: '自检',
|
||||
sla: 'SLA',
|
||||
afterSale: '售后',
|
||||
})[t]
|
||||
|
||||
const typeTagType = (t: WorkOrderType): 'primary' | 'warning' | 'danger' | 'default' =>
|
||||
t === 'repair' ? 'danger' : t === 'inspection' ? 'primary' : t === 'selfCheck' ? 'warning' : 'default'
|
||||
|
||||
const onBack = (): void => {
|
||||
router.back()
|
||||
}
|
||||
@@ -134,8 +143,8 @@ const onRepair = (): void => {
|
||||
|
||||
const onClose = (): void => {
|
||||
if (!detail.value) return
|
||||
// 关单前需先完成维修上报,这里提示
|
||||
showFailToast('请先完成维修上报')
|
||||
// 关单前需先完成维修上报,这里用普通 toast 提示(非失败语义)
|
||||
showToast('请先完成维修上报')
|
||||
}
|
||||
|
||||
const loadDetail = (): void => {
|
||||
@@ -159,6 +168,22 @@ onMounted(() => {
|
||||
background: var(--background);
|
||||
padding-bottom: 80px;
|
||||
|
||||
&__skeleton {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
> .van-skeleton {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
@@ -244,12 +269,41 @@ onMounted(() => {
|
||||
&__step-time {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-placeholder);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
&__step-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
margin-top: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__step-action-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__step-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
|
||||
&--current {
|
||||
background: #fff7e6;
|
||||
color: var(--warning);
|
||||
border: 1px solid #ffd591;
|
||||
}
|
||||
|
||||
&--start {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border: 1px solid #91d5ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__step-operator {
|
||||
@@ -257,17 +311,5 @@ onMounted(() => {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,6 +105,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import { getWorkOrderDetail, submitRepairReport } from './api'
|
||||
import { formatNow } from '@utils/datetime'
|
||||
import type { RepairReportForm } from './types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -186,8 +187,7 @@ const loadWorkOrder = (): void => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
form.repairTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
|
||||
form.repairTime = formatNow()
|
||||
loadWorkOrder()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -10,69 +10,80 @@
|
||||
</div>
|
||||
|
||||
<!-- 待办卡(告警卡跨整行 + 4 卡 2 列) -->
|
||||
<div v-if="todo" class="ops-workbench__section">
|
||||
<div class="ops-workbench__section">
|
||||
<div class="ops-workbench__section-title">今日待办</div>
|
||||
<div
|
||||
class="todo-card todo-card--alarm"
|
||||
:class="{ 'todo-card--critical': todo.criticalAlarms > 0 }"
|
||||
@click="goAlarmList"
|
||||
>
|
||||
<div class="todo-card__left">
|
||||
<van-icon name="warning-o" class="todo-card__icon" />
|
||||
<div>
|
||||
<div class="todo-card__label">待处理告警</div>
|
||||
<div class="todo-card__sub">严重 {{ todo.criticalAlarms }} 条</div>
|
||||
<template v-if="todo">
|
||||
<div
|
||||
class="todo-card todo-card--alarm"
|
||||
:class="{ 'todo-card--critical': todo.criticalAlarms > 0 }"
|
||||
@click="goAlarmList"
|
||||
>
|
||||
<div class="todo-card__left">
|
||||
<van-icon name="warning-o" class="todo-card__icon" />
|
||||
<div>
|
||||
<div class="todo-card__label">待处理告警</div>
|
||||
<div class="todo-card__sub">严重 {{ todo.criticalAlarms }} 条</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="todo-card__count">{{ todo.pendingAlarms }}</div>
|
||||
</div>
|
||||
<div class="ops-workbench__grid">
|
||||
<div class="todo-card" @click="goWorkOrder">
|
||||
<van-icon name="orders-o" class="todo-card__icon todo-card__icon--blue" />
|
||||
<div class="todo-card__label">待处理工单</div>
|
||||
<div class="todo-card__count">{{ todo.pendingWorkOrders }}</div>
|
||||
<div v-if="todo.overSlaOrders > 0" class="todo-card__badge">超 SLA {{ todo.overSlaOrders }}</div>
|
||||
</div>
|
||||
<div class="todo-card" @click="goInspect">
|
||||
<van-icon name="todo-list-o" class="todo-card__icon todo-card__icon--green" />
|
||||
<div class="todo-card__label">今日巡检</div>
|
||||
<div class="todo-card__count">{{ todo.todayInspections }}</div>
|
||||
<div class="todo-card__sub">已完成 {{ todo.inspectionDone }}</div>
|
||||
</div>
|
||||
<div class="todo-card" @click="goScan">
|
||||
<van-icon name="scan-o" class="todo-card__icon todo-card__icon--orange" />
|
||||
<div class="todo-card__label">待初始化</div>
|
||||
<div class="todo-card__count">{{ todo.todayInitDevices }}</div>
|
||||
<div class="todo-card__sub">扫码激活</div>
|
||||
</div>
|
||||
<div class="todo-card" @click="goScan">
|
||||
<van-icon name="upgrade" class="todo-card__icon todo-card__icon--purple" />
|
||||
<div class="todo-card__label">OTA 任务</div>
|
||||
<div class="todo-card__count">{{ todo.todayOtaTasks }}</div>
|
||||
<div class="todo-card__sub">待升级</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="todo-card__count">{{ todo.pendingAlarms }}</div>
|
||||
</div>
|
||||
<div class="ops-workbench__grid">
|
||||
<div class="todo-card" @click="goWorkOrder">
|
||||
<van-icon name="orders-o" class="todo-card__icon todo-card__icon--blue" />
|
||||
<div class="todo-card__label">待处理工单</div>
|
||||
<div class="todo-card__count">{{ todo.pendingWorkOrders }}</div>
|
||||
<div v-if="todo.overSlaOrders > 0" class="todo-card__badge">超 SLA {{ todo.overSlaOrders }}</div>
|
||||
</div>
|
||||
<div class="todo-card" @click="goInspect">
|
||||
<van-icon name="todo-list-o" class="todo-card__icon todo-card__icon--green" />
|
||||
<div class="todo-card__label">今日巡检</div>
|
||||
<div class="todo-card__count">{{ todo.todayInspections }}</div>
|
||||
<div class="todo-card__sub">已完成 {{ todo.inspectionDone }}</div>
|
||||
</div>
|
||||
<div class="todo-card" @click="goScan">
|
||||
<van-icon name="scan-o" class="todo-card__icon todo-card__icon--orange" />
|
||||
<div class="todo-card__label">待初始化</div>
|
||||
<div class="todo-card__count">{{ todo.todayInitDevices }}</div>
|
||||
<div class="todo-card__sub">扫码激活</div>
|
||||
</div>
|
||||
<div class="todo-card" @click="goScan">
|
||||
<van-icon name="upgrade" class="todo-card__icon todo-card__icon--purple" />
|
||||
<div class="todo-card__label">OTA 任务</div>
|
||||
<div class="todo-card__count">{{ todo.todayOtaTasks }}</div>
|
||||
<div class="todo-card__sub">待升级</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="ops-workbench__skeleton">
|
||||
<van-skeleton :row="1" row-width="60%" />
|
||||
<van-skeleton :row="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本周统计 -->
|
||||
<div v-if="week" class="ops-workbench__section">
|
||||
<div class="ops-workbench__section">
|
||||
<div class="ops-workbench__section-title">本周统计</div>
|
||||
<div class="ops-workbench__grid ops-workbench__grid--3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__value">{{ week.weekOrders }}</div>
|
||||
<div class="stat-card__label">处理工单</div>
|
||||
<div class="stat-card__sub">平均 {{ week.avgProcessHours }}h</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__value">{{ week.weekInspectionRate }}</div>
|
||||
<div class="stat-card__label">巡检完成率</div>
|
||||
<div class="stat-card__sub">异常 {{ week.weekInspectionAbnormalRate }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__value">{{ week.monthInstallCount }}</div>
|
||||
<div class="stat-card__label">本月装机</div>
|
||||
<div class="stat-card__sub">台</div>
|
||||
<template v-if="week">
|
||||
<div class="ops-workbench__grid ops-workbench__grid--3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__value">{{ week.weekOrders }}</div>
|
||||
<div class="stat-card__label">处理工单</div>
|
||||
<div class="stat-card__sub">平均 {{ week.avgProcessHours }}h</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__value">{{ week.weekInspectionRate }}</div>
|
||||
<div class="stat-card__label">巡检完成率</div>
|
||||
<div class="stat-card__sub">异常 {{ week.weekInspectionAbnormalRate }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__value">{{ week.monthInstallCount }}</div>
|
||||
<div class="stat-card__label">本月装机</div>
|
||||
<div class="stat-card__sub">台</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="ops-workbench__skeleton">
|
||||
<van-skeleton :row="1" row-width="40%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +115,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTodoStats, getWeekStats } from './api'
|
||||
import { formatDate, weekDayText } from '@utils/datetime'
|
||||
import type { QuickEntry, TodoStats, WeekStats } from './types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -113,8 +125,7 @@ const week = ref<WeekStats | null>(null)
|
||||
|
||||
const todayText = (() => {
|
||||
const d = new Date()
|
||||
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} 周${week}`
|
||||
return `${formatDate(d)} 周${weekDayText(d).replace('周', '')}`
|
||||
})()
|
||||
|
||||
const quickEntries: QuickEntry[] = [
|
||||
@@ -173,16 +184,36 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@keyframes ops-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.45); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); }
|
||||
}
|
||||
|
||||
.ops-workbench {
|
||||
padding: 12px 12px 24px;
|
||||
padding: 0 0 24px;
|
||||
background: var(--background);
|
||||
min-height: 100%;
|
||||
|
||||
&__hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 4px 16px;
|
||||
padding: 20px 16px 28px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
|
||||
color: #fff;
|
||||
|
||||
// 底部圆角过渡到下方列表区
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -12px;
|
||||
height: 24px;
|
||||
background: var(--background);
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-text {
|
||||
@@ -190,25 +221,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
&__hello {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
&__hero-bell {
|
||||
font-size: 22px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: 16px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
@@ -216,6 +252,17 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 8px 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
@@ -231,6 +278,13 @@ onMounted(() => {
|
||||
&__grid--4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
@@ -243,6 +297,12 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&--alarm {
|
||||
flex-direction: row;
|
||||
@@ -250,10 +310,14 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, #fffbe6 0%, #fff 60%);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-left-color: var(--danger);
|
||||
background: linear-gradient(135deg, #fff1f0 0%, #fff 60%);
|
||||
// 严重告警脉冲动画(吸引工程师立刻处理)
|
||||
animation: ops-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
&__left {
|
||||
@@ -282,10 +346,13 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
// 数字等宽,避免 0/1/2 等切换时跳动
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
&--alarm &__count {
|
||||
font-size: 24px;
|
||||
font-size: 26px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -304,6 +371,7 @@ onMounted(() => {
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,9 +383,11 @@ onMounted(() => {
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
&__label {
|
||||
@@ -339,6 +409,11 @@ onMounted(() => {
|
||||
padding: 12px 4px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 24px;
|
||||
|
||||
@@ -1,55 +1,64 @@
|
||||
<template>
|
||||
<div v-if="detail" class="alarm-detail">
|
||||
<div class="alarm-detail">
|
||||
<van-nav-bar title="告警详情" left-arrow @click-left="onBack" />
|
||||
|
||||
<!-- 告警头部 -->
|
||||
<div class="alarm-detail__header" :class="`alarm-detail__header--${detail.level}`">
|
||||
<van-tag :type="levelTagType(detail.level)" size="large">{{ levelText(detail.level) }}</van-tag>
|
||||
<span class="alarm-detail__code">{{ detail.alarmCode }}</span>
|
||||
<van-tag plain :type="statusTagType(detail.status)">{{ statusText(detail.status) }}</van-tag>
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="!detail" class="alarm-detail__skeleton">
|
||||
<van-skeleton title :row="2" />
|
||||
<van-skeleton title :row="6" />
|
||||
<van-skeleton title :row="4" />
|
||||
</div>
|
||||
|
||||
<!-- 告警内容 -->
|
||||
<div class="alarm-detail__block">
|
||||
<div class="alarm-detail__block-title">告警内容</div>
|
||||
<div class="alarm-detail__content">{{ detail.content }}</div>
|
||||
<div class="alarm-detail__meta">
|
||||
<span>触发时间</span>
|
||||
<span>{{ detail.triggerTime }}</span>
|
||||
<template v-else>
|
||||
<!-- 告警头部 -->
|
||||
<div class="alarm-detail__header" :class="`alarm-detail__header--${detail.level}`">
|
||||
<van-tag :type="alarmLevelTagType(detail.level)" size="large">{{ alarmLevelText(detail.level) }}</van-tag>
|
||||
<span class="alarm-detail__code">{{ detail.alarmCode }}</span>
|
||||
<van-tag plain :type="alarmStatusTagType(detail.status)">{{ alarmStatusText(detail.status) }}</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<div class="alarm-detail__block">
|
||||
<div class="alarm-detail__block-title">设备信息</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="设备编码" :value="detail.deviceCode" />
|
||||
<van-cell title="设备名称" :value="detail.deviceName" />
|
||||
<van-cell title="型号" :value="detail.deviceModel" />
|
||||
<van-cell title="所属场所" :value="detail.site" />
|
||||
<van-cell title="所属租户" :value="detail.tenant" />
|
||||
<van-cell title="查看设备监测" is-link @click="goMonitor" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<!-- 告警内容 -->
|
||||
<div class="alarm-detail__block">
|
||||
<div class="alarm-detail__block-title">告警内容</div>
|
||||
<div class="alarm-detail__content">{{ detail.content }}</div>
|
||||
<div class="alarm-detail__meta">
|
||||
<span>触发时间</span>
|
||||
<span>{{ detail.triggerTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处理时间线 -->
|
||||
<div class="alarm-detail__block">
|
||||
<div class="alarm-detail__block-title">处理时间线</div>
|
||||
<van-steps direction="vertical" :active="detail.processTimeline.length - 1" active-color="#1890ff">
|
||||
<van-step v-for="(item, idx) in detail.processTimeline" :key="idx">
|
||||
<div class="alarm-detail__step-time">{{ item.time }}</div>
|
||||
<div class="alarm-detail__step-action">{{ item.action }}</div>
|
||||
<div class="alarm-detail__step-operator">{{ item.operator }}</div>
|
||||
</van-step>
|
||||
</van-steps>
|
||||
</div>
|
||||
<!-- 设备信息 -->
|
||||
<div class="alarm-detail__block">
|
||||
<div class="alarm-detail__block-title">设备信息</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="设备编码" :value="detail.deviceCode" />
|
||||
<van-cell title="设备名称" :value="detail.deviceName" />
|
||||
<van-cell title="型号" :value="detail.deviceModel" />
|
||||
<van-cell title="所属场所" :value="detail.site" />
|
||||
<van-cell title="所属租户" :value="detail.tenant" />
|
||||
<van-cell title="查看设备监测" is-link @click="goMonitor" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 处置操作 -->
|
||||
<div v-if="detail.status === 'pending' || detail.status === 'processing'" class="alarm-detail__actions">
|
||||
<van-button block type="danger" @click="onAction('toWorkOrder')">转工单</van-button>
|
||||
<van-button block type="warning" @click="onAction('ignore')">忽略</van-button>
|
||||
<van-button block type="primary" @click="onAction('resolve')">标记已处理</van-button>
|
||||
</div>
|
||||
<!-- 处理时间线 -->
|
||||
<div class="alarm-detail__block">
|
||||
<div class="alarm-detail__block-title">处理时间线</div>
|
||||
<van-steps direction="vertical" :active="detail.processTimeline.length - 1" active-color="#1890ff">
|
||||
<van-step v-for="(item, idx) in detail.processTimeline" :key="idx">
|
||||
<div class="alarm-detail__step-time">{{ item.time }}</div>
|
||||
<div class="alarm-detail__step-action">{{ item.action }}</div>
|
||||
<div class="alarm-detail__step-operator">{{ item.operator }}</div>
|
||||
</van-step>
|
||||
</van-steps>
|
||||
</div>
|
||||
|
||||
<!-- 处置操作 -->
|
||||
<OpsBottomActions v-if="detail.status === 'pending' || detail.status === 'processing'">
|
||||
<van-button block type="danger" @click="onAction('toWorkOrder')">转工单</van-button>
|
||||
<van-button block type="warning" @click="onAction('ignore')">忽略</van-button>
|
||||
<van-button block type="primary" @click="onAction('resolve')">标记已处理</van-button>
|
||||
</OpsBottomActions>
|
||||
</template>
|
||||
|
||||
<!-- 处置弹窗 -->
|
||||
<van-dialog
|
||||
@@ -82,8 +91,15 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import OpsBottomActions from '@components/OpsBottomActions.vue'
|
||||
import { getAlarmDetail, handleAlarm } from './api'
|
||||
import type { AlarmDetail, AlarmHandleAction, AlarmLevel, AlarmStatus } from './types'
|
||||
import {
|
||||
alarmLevelTagType,
|
||||
alarmLevelText,
|
||||
alarmStatusTagType,
|
||||
alarmStatusText,
|
||||
} from './constants'
|
||||
import type { AlarmDetail, AlarmHandleAction } from './types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -111,20 +127,6 @@ const reasonPlaceholder = computed(() => {
|
||||
return map[dialogAction.value]
|
||||
})
|
||||
|
||||
const levelText = (lv: AlarmLevel): string => ({ critical: '严重', warn: '警告', info: '提示' })[lv]
|
||||
const statusText = (s: AlarmStatus): string => ({
|
||||
pending: '未处理',
|
||||
processing: '处理中',
|
||||
ignored: '已忽略',
|
||||
workorder: '已转工单',
|
||||
})[s]
|
||||
|
||||
const levelTagType = (lv: AlarmLevel): 'danger' | 'warning' | 'primary' =>
|
||||
lv === 'critical' ? 'danger' : lv === 'warn' ? 'warning' : 'primary'
|
||||
|
||||
const statusTagType = (s: AlarmStatus): 'danger' | 'warning' | 'default' | 'primary' =>
|
||||
s === 'pending' ? 'danger' : s === 'processing' ? 'primary' : s === 'workorder' ? 'warning' : 'default'
|
||||
|
||||
const onBack = (): void => {
|
||||
router.back()
|
||||
}
|
||||
@@ -177,6 +179,22 @@ onMounted(() => {
|
||||
background: var(--background);
|
||||
padding-bottom: 80px;
|
||||
|
||||
&__skeleton {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
> .van-skeleton {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -241,17 +259,5 @@ onMounted(() => {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<!-- 筛选 -->
|
||||
<div class="alarm-list__filter">
|
||||
<van-dropdown-menu>
|
||||
<van-dropdown-item v-model="filter.level" :options="levelOpts" @change="reload" />
|
||||
<van-dropdown-item v-model="filter.source" :options="sourceOpts" @change="reload" />
|
||||
<van-dropdown-item v-model="filter.status" :options="statusOpts" @change="reload" />
|
||||
<van-dropdown-item v-model="filter.level" :options="ALARM_LEVEL_OPTS" @change="reload" />
|
||||
<van-dropdown-item v-model="filter.source" :options="ALARM_SOURCE_OPTS" @change="reload" />
|
||||
<van-dropdown-item v-model="filter.status" :options="ALARM_STATUS_OPTS" @change="reload" />
|
||||
</van-dropdown-menu>
|
||||
<van-search
|
||||
v-model="filter.keyword"
|
||||
@@ -31,37 +31,45 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadList"
|
||||
>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="alarm-card"
|
||||
:class="`alarm-card--${item.level}`"
|
||||
@click="goDetail(item.id)"
|
||||
<!-- 列表(支持下拉刷新) -->
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadList"
|
||||
>
|
||||
<div class="alarm-card__header">
|
||||
<van-tag :type="levelTagType(item.level)" size="medium">{{ levelText(item.level) }}</van-tag>
|
||||
<span class="alarm-card__source">{{ sourceText(item.source) }}</span>
|
||||
<span class="alarm-card__time">{{ item.triggerTime }}</span>
|
||||
<van-empty
|
||||
v-if="!list.length && !loading"
|
||||
image="search"
|
||||
description="暂无符合条件的告警"
|
||||
class="alarm-list__empty"
|
||||
/>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="alarm-card"
|
||||
:class="`alarm-card--${item.level}`"
|
||||
@click="goDetail(item.id)"
|
||||
>
|
||||
<div class="alarm-card__header">
|
||||
<van-tag :type="alarmLevelTagType(item.level)" size="medium">{{ alarmLevelText(item.level) }}</van-tag>
|
||||
<span class="alarm-card__source">{{ alarmSourceText(item.source) }}</span>
|
||||
<span class="alarm-card__time">{{ item.triggerTime }}</span>
|
||||
</div>
|
||||
<div class="alarm-card__content">{{ item.content }}</div>
|
||||
<div class="alarm-card__device">
|
||||
<van-icon name="desktop-o" />
|
||||
<span class="alarm-card__device-code">{{ item.deviceCode }}</span>
|
||||
<span class="alarm-card__device-name">{{ item.deviceName }}</span>
|
||||
</div>
|
||||
<div class="alarm-card__footer">
|
||||
<van-tag plain :type="alarmStatusTagType(item.status)">{{ alarmStatusText(item.status) }}</van-tag>
|
||||
<span v-if="item.handler" class="alarm-card__handler">处理人:{{ item.handler }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alarm-card__content">{{ item.content }}</div>
|
||||
<div class="alarm-card__device">
|
||||
<van-icon name="desktop-o" />
|
||||
<span class="alarm-card__device-code">{{ item.deviceCode }}</span>
|
||||
<span class="alarm-card__device-name">{{ item.deviceName }}</span>
|
||||
</div>
|
||||
<div class="alarm-card__footer">
|
||||
<van-tag plain :type="statusTagType(item.status)">{{ statusText(item.status) }}</van-tag>
|
||||
<span v-if="item.handler" class="alarm-card__handler">处理人:{{ item.handler }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,13 +83,25 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAlarms } from './api'
|
||||
import type { AlarmItem, AlarmLevel, AlarmSource, AlarmStatus, AlarmListParams } from './types'
|
||||
import {
|
||||
ALARM_LEVEL_OPTS,
|
||||
ALARM_SOURCE_OPTS,
|
||||
ALARM_STATUS_OPTS,
|
||||
alarmLevelTagType,
|
||||
alarmLevelText,
|
||||
alarmSourceText,
|
||||
alarmStatusTagType,
|
||||
alarmStatusText,
|
||||
} from './constants'
|
||||
import type { AlarmItem, AlarmListParams } from './types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const list = ref<AlarmItem[]>([])
|
||||
const loading = ref(false)
|
||||
// finished 固定为 true:mock 数据一次性返回,无分页;保留 van-list 是为后续接入真接口预留
|
||||
const finished = ref(true)
|
||||
const refreshing = ref(false)
|
||||
|
||||
const filter = reactive<AlarmListParams>({
|
||||
level: '',
|
||||
@@ -90,31 +110,6 @@ const filter = reactive<AlarmListParams>({
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const levelOpts = [
|
||||
{ text: '全部级别', value: '' },
|
||||
{ text: '严重', value: 'critical' },
|
||||
{ text: '警告', value: 'warn' },
|
||||
{ text: '提示', value: 'info' },
|
||||
]
|
||||
|
||||
const sourceOpts = [
|
||||
{ text: '全部来源', value: '' },
|
||||
{ text: '运行日志', value: 'runLog' },
|
||||
{ text: '自检异常', value: 'selfCheck' },
|
||||
{ text: '巡检异常', value: 'inspection' },
|
||||
{ text: 'SLA 超时', value: 'sla' },
|
||||
{ text: 'OTA 失败', value: 'ota' },
|
||||
{ text: '数据分发失败', value: 'dataDistribute' },
|
||||
]
|
||||
|
||||
const statusOpts = [
|
||||
{ text: '全部状态', value: '' },
|
||||
{ text: '未处理', value: 'pending' },
|
||||
{ text: '处理中', value: 'processing' },
|
||||
{ text: '已忽略', value: 'ignored' },
|
||||
{ text: '已转工单', value: 'workorder' },
|
||||
]
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = list.value
|
||||
return {
|
||||
@@ -124,28 +119,6 @@ const stats = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const levelText = (lv: AlarmLevel): string => ({ critical: '严重', warn: '警告', info: '提示' })[lv]
|
||||
const sourceText = (s: AlarmSource): string => ({
|
||||
runLog: '运行日志',
|
||||
selfCheck: '自检',
|
||||
inspection: '巡检',
|
||||
sla: 'SLA',
|
||||
ota: 'OTA',
|
||||
dataDistribute: '分发',
|
||||
})[s]
|
||||
const statusText = (s: AlarmStatus): string => ({
|
||||
pending: '未处理',
|
||||
processing: '处理中',
|
||||
ignored: '已忽略',
|
||||
workorder: '已转工单',
|
||||
})[s]
|
||||
|
||||
const levelTagType = (lv: AlarmLevel): 'danger' | 'warning' | 'primary' =>
|
||||
lv === 'critical' ? 'danger' : lv === 'warn' ? 'warning' : 'primary'
|
||||
|
||||
const statusTagType = (s: AlarmStatus): 'danger' | 'warning' | 'default' | 'primary' =>
|
||||
s === 'pending' ? 'danger' : s === 'processing' ? 'primary' : s === 'workorder' ? 'warning' : 'default'
|
||||
|
||||
const goDetail = (id: string): void => {
|
||||
router.push(`/miniprogram/ops/alarm-detail/${id}`)
|
||||
}
|
||||
@@ -159,6 +132,7 @@ const loadList = (): void => {
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
finished.value = true
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +143,10 @@ const reload = (): void => {
|
||||
loadList()
|
||||
}
|
||||
|
||||
const onRefresh = (): void => {
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
@@ -192,6 +170,11 @@ onMounted(() => {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 32px 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-mini {
|
||||
@@ -205,9 +188,11 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 22px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
&--critical &__value {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 实施运维小程序 / 工作台 + 告警 字典常量
|
||||
*
|
||||
* 设计目的:把告警列表页和详情页里分散的级别 / 来源 / 状态映射集中管理,
|
||||
* 避免 .vue 文件之间复制粘贴相同的 { critical: '严重' } 字典。
|
||||
*
|
||||
* 用法:
|
||||
* import { ALARM_LEVEL_OPTS, alarmLevelText } from './constants'
|
||||
* <van-tag :type="alarmLevelTagType(item.level)">{{ alarmLevelText(item.level) }}</van-tag>
|
||||
*/
|
||||
import type { AlarmLevel, AlarmSource, AlarmStatus } from './types'
|
||||
|
||||
/** 告警级别下拉项(含"全部"占位) */
|
||||
export const ALARM_LEVEL_OPTS: Array<{ text: string; value: AlarmLevel | '' }> = [
|
||||
{ text: '全部级别', value: '' },
|
||||
{ text: '严重', value: 'critical' },
|
||||
{ text: '警告', value: 'warn' },
|
||||
{ text: '提示', value: 'info' },
|
||||
]
|
||||
|
||||
/** 告警来源下拉项 */
|
||||
export const ALARM_SOURCE_OPTS: Array<{ text: string; value: AlarmSource | '' }> = [
|
||||
{ text: '全部来源', value: '' },
|
||||
{ text: '运行日志', value: 'runLog' },
|
||||
{ text: '自检异常', value: 'selfCheck' },
|
||||
{ text: '巡检异常', value: 'inspection' },
|
||||
{ text: 'SLA 超时', value: 'sla' },
|
||||
{ text: 'OTA 失败', value: 'ota' },
|
||||
{ text: '数据分发失败', value: 'dataDistribute' },
|
||||
]
|
||||
|
||||
/** 告警状态下拉项 */
|
||||
export const ALARM_STATUS_OPTS: Array<{ text: string; value: AlarmStatus | '' }> = [
|
||||
{ text: '全部状态', value: '' },
|
||||
{ text: '未处理', value: 'pending' },
|
||||
{ text: '处理中', value: 'processing' },
|
||||
{ text: '已忽略', value: 'ignored' },
|
||||
{ text: '已转工单', value: 'workorder' },
|
||||
]
|
||||
|
||||
/** 告警级别 → 中文文本 */
|
||||
export const alarmLevelText = (lv: AlarmLevel): string =>
|
||||
({ critical: '严重', warn: '警告', info: '提示' })[lv]
|
||||
|
||||
/** 告警级别 → Vant Tag 类型 */
|
||||
export const alarmLevelTagType = (lv: AlarmLevel): 'danger' | 'warning' | 'primary' =>
|
||||
lv === 'critical' ? 'danger' : lv === 'warn' ? 'warning' : 'primary'
|
||||
|
||||
/** 告警来源 → 中文文本 */
|
||||
export const alarmSourceText = (s: AlarmSource): string =>
|
||||
({
|
||||
runLog: '运行日志',
|
||||
selfCheck: '自检',
|
||||
inspection: '巡检',
|
||||
sla: 'SLA',
|
||||
ota: 'OTA',
|
||||
dataDistribute: '分发',
|
||||
})[s]
|
||||
|
||||
/** 告警状态 → 中文文本 */
|
||||
export const alarmStatusText = (s: AlarmStatus): string =>
|
||||
({
|
||||
pending: '未处理',
|
||||
processing: '处理中',
|
||||
ignored: '已忽略',
|
||||
workorder: '已转工单',
|
||||
})[s]
|
||||
|
||||
/** 告警状态 → Vant Tag 类型 */
|
||||
export const alarmStatusTagType = (s: AlarmStatus): 'danger' | 'warning' | 'default' | 'primary' =>
|
||||
s === 'pending' ? 'danger' : s === 'processing' ? 'primary' : s === 'workorder' ? 'warning' : 'default'
|
||||
@@ -61,3 +61,42 @@ body {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局滚动条样式微调(Webkit / Chromium / 新版 Edge)
|
||||
* - 默认滚动条太粗(12-16px)+ 颜色太暗,统一调整为 8px 细条 + 浅灰半透明
|
||||
* - hover 时颜色略深,保留可见性
|
||||
* - Firefox 用 scrollbar-width: thin + scrollbar-color 兜底
|
||||
*/
|
||||
.ant-table-body,
|
||||
.ant-table-content,
|
||||
.ant-table-header,
|
||||
.ant-tabs-nav-wrap,
|
||||
.ant-layout-sider,
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.18) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 日期时间格式化工具
|
||||
*
|
||||
* 设计目的:统一运维小程序各表单页面的"操作时间"字段格式,避免在每个 .vue
|
||||
* 中重复写 `new Date()` + padStart 拼接逻辑。
|
||||
*
|
||||
* 与 platform-vue-tenant 的 dayjs 调用对齐:
|
||||
* - dayjs().format('YYYY-MM-DD HH:mm:ss') ≈ formatNow()
|
||||
* - dayjs(date).format('YYYY-MM-DD HH:mm:ss') ≈ formatDateTime(date)
|
||||
*
|
||||
* 用法:
|
||||
* import { formatNow, formatDateTime, formatDate } from '@utils/datetime'
|
||||
* form.operateTime = formatNow() // 当前时间,秒位固定 00
|
||||
* form.repairTime = formatNow(0) // 同上
|
||||
* form.repairTime = formatNow() // 默认秒位 00(运维上报习惯)
|
||||
* formatDateTime(new Date()) // 完整时间戳
|
||||
* formatDate(new Date()) // 仅日期
|
||||
*/
|
||||
|
||||
const pad2 = (n: number): string => String(n).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* 把任意 Date 转为 `YYYY-MM-DD HH:mm:ss` 字符串
|
||||
* @param date 日期对象,缺省取当前时间
|
||||
* @param fixedSeconds 是否固定秒位为 00(默认 true,匹配运维上报页面习惯)
|
||||
*/
|
||||
export const formatDateTime = (date: Date = new Date(), fixedSeconds = true): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = pad2(date.getMonth() + 1)
|
||||
const d = pad2(date.getDate())
|
||||
const h = pad2(date.getHours())
|
||||
const min = pad2(date.getMinutes())
|
||||
const s = fixedSeconds ? '00' : pad2(date.getSeconds())
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前时间的完整格式化字符串(秒位固定 00)
|
||||
* 用于表单"操作时间 / 维修时间 / 实施时间"等自动回填字段
|
||||
*/
|
||||
export const formatNow = (): string => formatDateTime(new Date(), true)
|
||||
|
||||
/**
|
||||
* 把任意 Date 转为 `YYYY-MM-DD` 字符串
|
||||
* @param date 日期对象,缺省取当前时间
|
||||
*/
|
||||
export const formatDate = (date: Date = new Date()): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = pad2(date.getMonth() + 1)
|
||||
const d = pad2(date.getDate())
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前日期的中文星期描述("周一" / "周日")
|
||||
* 用于工作台头部展示
|
||||
*/
|
||||
export const weekDayText = (date: Date = new Date()): string => {
|
||||
const names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return names[date.getDay()] ?? ''
|
||||
}
|
||||
Reference in New Issue
Block a user