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:
fengpu
2026-07-06 14:07:23 +08:00
co-authored by Claude Opus 4.7
parent af0dc7013f
commit 011111655f
33 changed files with 1467 additions and 703 deletions
+44 -1
View File
@@ -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;
}
}
}
+2 -2
View File
@@ -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)
},
},
+38
View File
@@ -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>
+96 -20
View File
@@ -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>
+40
View File
@@ -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 值(会回到旧行为)
## 八、数据展示规范
| 数据类型 | 展示格式 |
+9
View File
@@ -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 {
+40 -1
View File
@@ -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
View File
@@ -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
}
/** 顶部统计聚合数据 */
+52 -29
View File
@@ -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 {
+88 -63
View File
@@ -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 {
+4 -3
View File
@@ -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 => {
+39 -16
View File
@@ -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>
+2 -2
View File
@@ -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>
+129 -92
View File
@@ -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 {
+28 -2
View File
@@ -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 {
+2 -2
View File
@@ -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>
+63 -39
View File
@@ -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;
+60 -60
View File
@@ -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: '其他' },
]
+132 -90
View File
@@ -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>
+142 -67
View File
@@ -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'
+39
View File
@@ -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;
}
}
+61
View File
@@ -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()] ?? ''
}