feat(@vben/web-antd): 新增巡检记录模块及路由配置
- 新增巡检记录 API(分页查询、详情、统计) - 卡片式列表展示巡检记录,支持按状态 Tab 筛选 - 统计栏展示合格率/总数/合格数/不合格数 - 详情抽屉展示巡检明细项、照片、归因结果 - 注册巡检记录和巡检模板路由 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
90
apps/web-antd/src/api/ops/inspection-record/index.ts
Normal file
90
apps/web-antd/src/api/ops/inspection-record/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace InspectionRecordApi {
|
||||
/** 巡检记录(列表项,对应 InspectionRecordRespVO) */
|
||||
export interface InspectionRecord {
|
||||
id: number;
|
||||
areaId: number;
|
||||
areaFullName?: string;
|
||||
inspectorId: number;
|
||||
inspectorName?: string;
|
||||
isLocationException: number; // 0=正常, 1=异常
|
||||
resultStatus: number; // 0=不合格, 1=合格
|
||||
attributionResult?: number; // 1=个人责任, 2=突发状况, 3=正常
|
||||
generatedOrderId?: number;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
/** 巡检明细项(对应 InspectionRecordItemRespVO) */
|
||||
export interface RecordItem {
|
||||
id: number;
|
||||
templateId: number;
|
||||
itemTitle?: string;
|
||||
itemDescription?: string;
|
||||
isPassed: boolean;
|
||||
remark?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** 巡检记录详情(对应 InspectionRecordDetailRespVO,继承列表 VO) */
|
||||
export interface InspectionRecordDetail extends InspectionRecord {
|
||||
remark?: string;
|
||||
tags?: string[];
|
||||
photos?: string[];
|
||||
items?: RecordItem[];
|
||||
}
|
||||
|
||||
/** 分页查询参数(对应 InspectionRecordPageReqVO) */
|
||||
export interface PageQuery extends PageParam {
|
||||
areaId?: number;
|
||||
inspectorId?: number;
|
||||
resultStatus?: number;
|
||||
createTime?: string[];
|
||||
}
|
||||
|
||||
/** 统计查询参数(对应 InspectionStatsReqVO) */
|
||||
export interface StatsQuery {
|
||||
areaId?: number;
|
||||
createTime?: string[];
|
||||
}
|
||||
|
||||
/** 区域不合格统计 */
|
||||
export interface AreaFailStat {
|
||||
areaId: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
/** 统计数据(对应 InspectionStatsRespVO) */
|
||||
export interface InspectionStats {
|
||||
totalCount: number;
|
||||
passedCount: number;
|
||||
failedCount: number;
|
||||
passRate: number;
|
||||
hotSpotAreas?: AreaFailStat[];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 巡检记录 API ==========
|
||||
|
||||
/** 获取巡检记录分页列表 */
|
||||
export function getRecordPage(
|
||||
params: InspectionRecordApi.PageQuery,
|
||||
): Promise<PageResult<InspectionRecordApi.InspectionRecord>> {
|
||||
return requestClient.get('/ops/inspection/record/page', { params });
|
||||
}
|
||||
|
||||
/** 获取巡检记录详情 */
|
||||
export function getRecordDetail(
|
||||
id: number,
|
||||
): Promise<InspectionRecordApi.InspectionRecordDetail> {
|
||||
return requestClient.get('/ops/inspection/record/get', { params: { id } });
|
||||
}
|
||||
|
||||
/** 获取巡检统计数据 */
|
||||
export function getInspectionStats(
|
||||
params?: InspectionRecordApi.StatsQuery,
|
||||
): Promise<InspectionRecordApi.InspectionStats> {
|
||||
return requestClient.get('/ops/inspection/record/stats', { params });
|
||||
}
|
||||
@@ -93,6 +93,28 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('#/views/ops/work-order/dashboard/index.vue'),
|
||||
},
|
||||
// 巡检记录
|
||||
{
|
||||
path: 'inspection-record',
|
||||
name: 'OpsInspectionRecord',
|
||||
meta: {
|
||||
title: '巡检记录',
|
||||
activePath: '/ops/inspection-record',
|
||||
},
|
||||
component: () =>
|
||||
import('#/views/ops/inspection-record/index.vue'),
|
||||
},
|
||||
// 巡检模板管理
|
||||
{
|
||||
path: 'inspection-template',
|
||||
name: 'OpsInspectionTemplate',
|
||||
meta: {
|
||||
title: '巡检模板管理',
|
||||
activePath: '/ops/inspection-template',
|
||||
},
|
||||
component: () =>
|
||||
import('#/views/ops/inspection-template/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
75
apps/web-antd/src/views/ops/inspection-record/data.ts
Normal file
75
apps/web-antd/src/views/ops/inspection-record/data.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/** 巡检结果状态映射 */
|
||||
export const RESULT_STATUS_MAP: Record<number, string> = {
|
||||
0: '不合格',
|
||||
1: '合格',
|
||||
};
|
||||
|
||||
/** 巡检结果颜色映射 */
|
||||
export const RESULT_COLOR_MAP: Record<number, string> = {
|
||||
0: '#ff4d4f',
|
||||
1: '#52c41a',
|
||||
};
|
||||
|
||||
/** 巡检结果浅色映射(用于背景等) */
|
||||
export const RESULT_LIGHT_COLOR_MAP: Record<number, string> = {
|
||||
0: '#fff1f0',
|
||||
1: '#f6ffed',
|
||||
};
|
||||
|
||||
/** 巡检结果图标映射 */
|
||||
export const RESULT_ICON_MAP: Record<number, string> = {
|
||||
0: 'solar:close-circle-bold',
|
||||
1: 'solar:check-circle-bold',
|
||||
};
|
||||
|
||||
/** 卡片左上角渐变背景(亮色模式) */
|
||||
export const CARD_GRADIENT_MAP: Record<number, string> = {
|
||||
0: 'radial-gradient(ellipse at top left, rgb(255 77 79 / 12%) 0%, rgb(255 77 79 / 4%) 40%, transparent 70%)',
|
||||
1: 'radial-gradient(ellipse at top left, rgb(82 196 26 / 12%) 0%, rgb(82 196 26 / 4%) 40%, transparent 70%)',
|
||||
};
|
||||
|
||||
/** 卡片左上角渐变背景(暗色模式) */
|
||||
export const CARD_GRADIENT_DARK_MAP: Record<number, string> = {
|
||||
0: 'radial-gradient(ellipse at top left, rgb(255 77 79 / 15%) 0%, rgb(255 77 79 / 5%) 40%, transparent 70%)',
|
||||
1: 'radial-gradient(ellipse at top left, rgb(82 196 26 / 15%) 0%, rgb(82 196 26 / 5%) 40%, transparent 70%)',
|
||||
};
|
||||
|
||||
/** 详情页顶部状态横幅渐变 */
|
||||
export const DETAIL_BANNER_GRADIENT_MAP: Record<number, string> = {
|
||||
0: 'linear-gradient(135deg, #fff1f0 0%, #ffccc7 50%, #fff1f0 100%)',
|
||||
1: 'linear-gradient(135deg, #f6ffed 0%, #d9f7be 50%, #f6ffed 100%)',
|
||||
};
|
||||
|
||||
/** 详情页顶部状态横幅渐变(暗色) */
|
||||
export const DETAIL_BANNER_GRADIENT_DARK_MAP: Record<number, string> = {
|
||||
0: 'linear-gradient(135deg, #2a1215 0%, #431418 50%, #2a1215 100%)',
|
||||
1: 'linear-gradient(135deg, #162312 0%, #274916 50%, #162312 100%)',
|
||||
};
|
||||
|
||||
/** 归因结果映射 */
|
||||
export const ATTRIBUTION_MAP: Record<number, string> = {
|
||||
1: '个人责任',
|
||||
2: '突发状况',
|
||||
3: '正常',
|
||||
};
|
||||
|
||||
/** 归因结果颜色映射 */
|
||||
export const ATTRIBUTION_COLOR_MAP: Record<number, string> = {
|
||||
1: '#ff4d4f',
|
||||
2: '#faad14',
|
||||
3: '#52c41a',
|
||||
};
|
||||
|
||||
/** 归因结果 Tag 颜色映射 */
|
||||
export const ATTRIBUTION_TAG_COLOR_MAP: Record<number, string> = {
|
||||
1: 'error',
|
||||
2: 'warning',
|
||||
3: 'success',
|
||||
};
|
||||
|
||||
/** 状态 Tab 选项 */
|
||||
export const STATUS_TAB_OPTIONS = [
|
||||
{ key: 'ALL', label: '全部', resultStatus: undefined },
|
||||
{ key: 'PASSED', label: '合格', resultStatus: 1 },
|
||||
{ key: 'FAILED', label: '不合格', resultStatus: 0 },
|
||||
];
|
||||
353
apps/web-antd/src/views/ops/inspection-record/index.vue
Normal file
353
apps/web-antd/src/views/ops/inspection-record/index.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import type { InspectionRecordApi } from '#/api/ops/inspection-record';
|
||||
|
||||
import { onActivated, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Input,
|
||||
Tabs,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { STATUS_TAB_OPTIONS } from './data';
|
||||
import CardView from './modules/card-view.vue';
|
||||
import DetailDrawer from './modules/detail-drawer.vue';
|
||||
import StatsBar from './modules/stats-bar.vue';
|
||||
|
||||
defineOptions({ name: 'OpsInspectionRecord' });
|
||||
|
||||
const activeTab = ref('ALL');
|
||||
const showAdvancedFilter = ref(false);
|
||||
const cardViewRef = ref();
|
||||
const statsBarRef = ref();
|
||||
|
||||
// 详情抽屉
|
||||
const [DetailDrawerModal, detailDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: DetailDrawer,
|
||||
});
|
||||
|
||||
// 查询参数
|
||||
const queryParams = ref<Partial<InspectionRecordApi.PageQuery>>({
|
||||
resultStatus: undefined,
|
||||
areaId: undefined,
|
||||
inspectorId: undefined,
|
||||
createTime: undefined,
|
||||
});
|
||||
|
||||
// TODO: searchKeyword 尚未传入后端查询(API 暂不支持关键词搜索),待后端支持后接入
|
||||
const searchKeyword = ref('');
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
cardViewRef.value?.query();
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
function handleRefresh() {
|
||||
handleSearch();
|
||||
statsBarRef.value?.refresh();
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
resultStatus: undefined,
|
||||
areaId: undefined,
|
||||
inspectorId: undefined,
|
||||
createTime: undefined,
|
||||
};
|
||||
searchKeyword.value = '';
|
||||
activeTab.value = 'ALL';
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** Tab 切换 */
|
||||
function handleTabChange(key: number | string) {
|
||||
const keyStr = String(key);
|
||||
activeTab.value = keyStr;
|
||||
const tabConfig = STATUS_TAB_OPTIONS.find((t) => t.key === keyStr);
|
||||
if (tabConfig) {
|
||||
queryParams.value.resultStatus = tabConfig.resultStatus;
|
||||
}
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 查看详情 */
|
||||
function handleDetail(record: InspectionRecordApi.InspectionRecord) {
|
||||
detailDrawerApi.setData(record).open();
|
||||
}
|
||||
|
||||
/** 日期范围变化 */
|
||||
function handleDateChange(_dates: any, dateStrings: [string, string]) {
|
||||
if (dateStrings && dateStrings[0]) {
|
||||
queryParams.value.createTime = dateStrings;
|
||||
} else {
|
||||
queryParams.value.createTime = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let isFirstActivate = true;
|
||||
onMounted(() => {
|
||||
handleRefresh();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (isFirstActivate) {
|
||||
isFirstActivate = false;
|
||||
return;
|
||||
}
|
||||
handleRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 详情抽屉 -->
|
||||
<DetailDrawerModal />
|
||||
|
||||
<!-- 统计栏 -->
|
||||
<StatsBar ref="statsBarRef" />
|
||||
|
||||
<!-- 列表容器 -->
|
||||
<Card :body-style="{ padding: 0 }">
|
||||
<!-- Tab 行 -->
|
||||
<div class="tab-row">
|
||||
<!-- 左侧 Tab -->
|
||||
<Tabs
|
||||
v-model:active-key="activeTab"
|
||||
class="status-tabs"
|
||||
:tab-bar-gutter="24"
|
||||
@change="handleTabChange"
|
||||
>
|
||||
<Tabs.TabPane v-for="tab in STATUS_TAB_OPTIONS" :key="tab.key">
|
||||
<template #tab>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 右侧操作按钮 -->
|
||||
<div class="tab-actions">
|
||||
<!-- 筛选按钮 -->
|
||||
<Button
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn--active': showAdvancedFilter }"
|
||||
@click="showAdvancedFilter = !showAdvancedFilter"
|
||||
>
|
||||
<IconifyIcon icon="solar:filter-bold" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button class="action-btn" @click="handleRefresh">
|
||||
<IconifyIcon icon="solar:refresh-bold" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索面板 -->
|
||||
<div v-show="showAdvancedFilter" class="search-panel">
|
||||
<div class="search-items">
|
||||
<div class="search-item">
|
||||
<span class="search-label">区域名称</span>
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="输入区域名称"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">巡检时间</span>
|
||||
<DatePicker.RangePicker
|
||||
style="width: 240px"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="solar:restart-bold" class="btn-icon" />
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="solar:magnifer-bold" class="btn-icon" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片内容区 -->
|
||||
<div class="card-content">
|
||||
<CardView
|
||||
ref="cardViewRef"
|
||||
:search-params="queryParams"
|
||||
@detail="handleDetail"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* Tab 行 */
|
||||
.tab-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* Tab 样式 */
|
||||
.status-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 14px 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
color: #595959;
|
||||
background: transparent;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索面板 */
|
||||
.search-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.search-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-label {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.tab-row {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: #8c8c8c;
|
||||
border-color: #434343;
|
||||
|
||||
&:hover {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--ant-color-primary);
|
||||
background: rgb(22 119 255 / 15%);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
background: transparent;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.search-label {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,388 @@
|
||||
<script lang="ts" setup>
|
||||
import type { InspectionRecordApi } from '#/api/ops/inspection-record';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Col, Empty, Pagination, Row, Spin, Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getRecordPage } from '#/api/ops/inspection-record';
|
||||
|
||||
import {
|
||||
ATTRIBUTION_MAP,
|
||||
ATTRIBUTION_TAG_COLOR_MAP,
|
||||
CARD_GRADIENT_DARK_MAP,
|
||||
CARD_GRADIENT_MAP,
|
||||
RESULT_COLOR_MAP,
|
||||
RESULT_ICON_MAP,
|
||||
RESULT_STATUS_MAP,
|
||||
} from '../data';
|
||||
|
||||
defineOptions({ name: 'InspectionRecordCardView' });
|
||||
|
||||
const props = defineProps<{
|
||||
searchParams?: Partial<InspectionRecordApi.PageQuery>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
detail: [record: InspectionRecordApi.InspectionRecord];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<InspectionRecordApi.InspectionRecord[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
/** 获取列表 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getRecordPage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理页码变化 */
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(time: Date | string) {
|
||||
if (!time) return '-';
|
||||
try {
|
||||
const date = dayjs(time);
|
||||
return date.isValid() ? date.format('YYYY-MM-DD HH:mm') : '-';
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
query: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inspection-card-view">
|
||||
<Spin :spinning="loading" class="card-grid">
|
||||
<Row v-if="list.length > 0" :gutter="[12, 12]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
:xl="6"
|
||||
>
|
||||
<div
|
||||
class="record-card"
|
||||
:style="{
|
||||
'--card-gradient': CARD_GRADIENT_MAP[item.resultStatus],
|
||||
'--card-gradient-dark': CARD_GRADIENT_DARK_MAP[item.resultStatus],
|
||||
'--status-color': RESULT_COLOR_MAP[item.resultStatus],
|
||||
}"
|
||||
@click="emit('detail', item)"
|
||||
>
|
||||
<!-- 左上角渐变背景层 -->
|
||||
<div class="card-gradient-bg" />
|
||||
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-inner">
|
||||
<!-- 头部:状态图标 + 结果文字 -->
|
||||
<div class="card-header">
|
||||
<div class="status-badge">
|
||||
<IconifyIcon
|
||||
:icon="RESULT_ICON_MAP[item.resultStatus]"
|
||||
class="status-badge__icon"
|
||||
/>
|
||||
<span class="status-badge__text">
|
||||
{{ RESULT_STATUS_MAP[item.resultStatus] }}
|
||||
</span>
|
||||
</div>
|
||||
<Tag
|
||||
:color="item.isLocationException === 1 ? 'error' : 'success'"
|
||||
class="exception-tag"
|
||||
>
|
||||
{{ item.isLocationException === 1 ? '定位异常' : '定位正常' }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 区域名称 -->
|
||||
<h4 class="area-name">{{ item.areaFullName || '未知区域' }}</h4>
|
||||
|
||||
<!-- 信息行 -->
|
||||
<div class="info-rows">
|
||||
<div class="info-row">
|
||||
<IconifyIcon
|
||||
icon="solar:user-bold-duotone"
|
||||
class="info-icon"
|
||||
/>
|
||||
<span class="info-text">
|
||||
{{ item.inspectorName || '未知' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<IconifyIcon
|
||||
icon="solar:clock-circle-bold-duotone"
|
||||
class="info-icon"
|
||||
/>
|
||||
<span class="info-text">
|
||||
{{ formatTime(item.createTime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:归因 + 工单 -->
|
||||
<div class="card-footer">
|
||||
<Tag
|
||||
v-if="item.attributionResult"
|
||||
:color="ATTRIBUTION_TAG_COLOR_MAP[item.attributionResult]"
|
||||
class="footer-tag"
|
||||
>
|
||||
{{ ATTRIBUTION_MAP[item.attributionResult] }}
|
||||
</Tag>
|
||||
<span
|
||||
v-if="item.generatedOrderId"
|
||||
class="order-link"
|
||||
>
|
||||
<IconifyIcon icon="solar:document-bold-duotone" />
|
||||
已生成工单
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Empty v-else description="暂无巡检记录" class="my-16" />
|
||||
</Spin>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="pagination-wrapper">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(t: number) => `共 ${t} 条`"
|
||||
size="small"
|
||||
show-quick-jumper
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inspection-card-view {
|
||||
.card-grid {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
--status-color: #52c41a;
|
||||
--card-gradient: none;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 190px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
|
||||
}
|
||||
}
|
||||
|
||||
// 左上角渐变背景
|
||||
.card-gradient-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: var(--card-gradient);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
z-index: 1;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
// 头部
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
color: var(--status-color);
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--status-color);
|
||||
}
|
||||
}
|
||||
|
||||
.exception-tag {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
// 区域名
|
||||
.area-name {
|
||||
display: -webkit-box;
|
||||
margin: 0 0 10px;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #262626;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
// 信息行
|
||||
.info-rows {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 底部
|
||||
.card-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.footer-tag {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.order-link {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
// 分页
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
html.dark {
|
||||
.inspection-card-view {
|
||||
.record-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
|
||||
&:hover {
|
||||
border-color: #434343;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 40%);
|
||||
}
|
||||
}
|
||||
|
||||
.card-gradient-bg {
|
||||
background: var(--card-gradient-dark);
|
||||
}
|
||||
|
||||
.area-name {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
border-color: #303030;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,635 @@
|
||||
<script setup lang="ts">
|
||||
import type { InspectionRecordApi } from '#/api/ops/inspection-record';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Image,
|
||||
Row,
|
||||
Spin,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getRecordDetail } from '#/api/ops/inspection-record';
|
||||
|
||||
import {
|
||||
ATTRIBUTION_MAP,
|
||||
ATTRIBUTION_TAG_COLOR_MAP,
|
||||
RESULT_COLOR_MAP,
|
||||
RESULT_ICON_MAP,
|
||||
RESULT_STATUS_MAP,
|
||||
} from '../data';
|
||||
|
||||
const router = useRouter();
|
||||
const record = ref<InspectionRecordApi.InspectionRecordDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data =
|
||||
drawerApi.getData<InspectionRecordApi.InspectionRecord>();
|
||||
if (data?.id) {
|
||||
loadDetail(data.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function loadDetail(id: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
record.value = await getRecordDetail(id);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(time: Date | string) {
|
||||
if (!time) return '-';
|
||||
try {
|
||||
const date = dayjs(time);
|
||||
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-';
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
/** 巡检项统计 */
|
||||
const itemStats = computed(() => {
|
||||
if (!record.value?.items?.length) return null;
|
||||
const total = record.value.items.length;
|
||||
const passed = record.value.items.filter((i) => i.isPassed).length;
|
||||
return { total, passed, failed: total - passed, rate: Math.round((passed / total) * 100) };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer title="巡检记录详情" class="detail-drawer">
|
||||
<Spin :spinning="loading">
|
||||
<template v-if="record">
|
||||
<!-- ============ 1. 结果横幅 ============ -->
|
||||
<div
|
||||
class="result-banner"
|
||||
:class="record.resultStatus === 1 ? 'result-banner--pass' : 'result-banner--fail'"
|
||||
>
|
||||
<div class="result-banner__left">
|
||||
<IconifyIcon
|
||||
:icon="RESULT_ICON_MAP[record.resultStatus]"
|
||||
class="result-banner__icon"
|
||||
/>
|
||||
<div>
|
||||
<div class="result-banner__text">
|
||||
{{ RESULT_STATUS_MAP[record.resultStatus] }}
|
||||
</div>
|
||||
<div v-if="itemStats" class="result-banner__sub">
|
||||
{{ itemStats.passed }}/{{ itemStats.total }} 项通过
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="itemStats" class="result-banner__ring">
|
||||
<svg viewBox="0 0 36 36" class="ring-svg">
|
||||
<path
|
||||
class="ring-bg"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<path
|
||||
class="ring-fill"
|
||||
:stroke-dasharray="`${itemStats.rate}, 100`"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
</svg>
|
||||
<span class="ring-text">{{ itemStats.rate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 2. 基本信息 ============ -->
|
||||
<Card size="small" class="info-card">
|
||||
<Descriptions :column="2" :colon="false" size="small">
|
||||
<Descriptions.Item label="巡检区域" :span="2">
|
||||
<span class="info-card__area">
|
||||
{{ record.areaFullName || '未知区域' }}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="巡检人">
|
||||
{{ record.inspectorName || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="巡检时间">
|
||||
{{ formatTime(record.createTime) }}
|
||||
</Descriptions.Item>
|
||||
<!-- <Descriptions.Item label="归因结果">
|
||||
<Tag
|
||||
v-if="record.attributionResult"
|
||||
:color="ATTRIBUTION_TAG_COLOR_MAP[record.attributionResult]"
|
||||
class="desc-tag"
|
||||
>
|
||||
{{ ATTRIBUTION_MAP[record.attributionResult] }}
|
||||
</Tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</Descriptions.Item> -->
|
||||
<Descriptions.Item label="定位状态">
|
||||
<Tag
|
||||
:color="record.isLocationException === 1 ? 'error' : 'success'"
|
||||
class="desc-tag"
|
||||
>
|
||||
{{ record.isLocationException === 1 ? '异常' : '正常' }}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- ============ 3. 检查项(核心区域) ============ -->
|
||||
<Card
|
||||
v-if="record.items && record.items.length > 0"
|
||||
size="small"
|
||||
class="check-card"
|
||||
>
|
||||
<template #title>
|
||||
<div class="check-card__header">
|
||||
<span>检查项</span>
|
||||
<div v-if="itemStats" class="check-card__stats">
|
||||
<span v-if="itemStats.passed > 0" class="check-card__stat check-card__stat--pass">
|
||||
<IconifyIcon icon="solar:check-circle-bold" />
|
||||
{{ itemStats.passed }}
|
||||
</span>
|
||||
<span v-if="itemStats.failed > 0" class="check-card__stat check-card__stat--fail">
|
||||
<IconifyIcon icon="solar:close-circle-bold" />
|
||||
{{ itemStats.failed }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 记录级标签 -->
|
||||
<div
|
||||
v-if="record.tags && record.tags.length > 0"
|
||||
class="check-card__tags"
|
||||
>
|
||||
<Tag v-for="tag in record.tags" :key="tag">{{ tag }}</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 检查项列表 -->
|
||||
<div class="check-list">
|
||||
<div
|
||||
v-for="item in record.items"
|
||||
:key="item.id"
|
||||
class="check-item"
|
||||
:class="{ 'check-item--fail': !item.isPassed }"
|
||||
>
|
||||
<!-- 主行 -->
|
||||
<div class="check-item__main">
|
||||
<IconifyIcon
|
||||
:icon="item.isPassed ? 'solar:check-circle-bold' : 'solar:close-circle-bold'"
|
||||
class="check-item__icon"
|
||||
:style="{ color: item.isPassed ? '#52c41a' : '#ff4d4f' }"
|
||||
/>
|
||||
<span class="check-item__title">
|
||||
{{ item.itemTitle || `检查项 #${item.id}` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 不合格详情(仅不合格时展示) -->
|
||||
<div v-if="!item.isPassed" class="check-item__detail">
|
||||
<div v-if="item.remark" class="check-item__remark">
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.tags && item.tags.length > 0"
|
||||
class="check-item__tags"
|
||||
>
|
||||
<Tag
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
color="error"
|
||||
class="check-item__tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- ============ 4. 现场记录(备注 + 照片) ============ -->
|
||||
<Card
|
||||
v-if="(record.photos && record.photos.length > 0) || record.remark"
|
||||
size="small"
|
||||
title="现场记录"
|
||||
class="scene-card"
|
||||
>
|
||||
<p v-if="record.remark" class="scene-card__remark">
|
||||
{{ record.remark }}
|
||||
</p>
|
||||
<Image.PreviewGroup v-if="record.photos && record.photos.length > 0">
|
||||
<Row :gutter="[8, 8]">
|
||||
<Col v-for="(photo, idx) in record.photos" :key="idx">
|
||||
<Image
|
||||
:src="photo"
|
||||
:width="80"
|
||||
:height="80"
|
||||
class="scene-card__photo"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Image.PreviewGroup>
|
||||
</Card>
|
||||
|
||||
<!-- ============ 5. 关联工单 ============ -->
|
||||
<Card
|
||||
v-if="record.generatedOrderId"
|
||||
size="small"
|
||||
class="linked-card"
|
||||
>
|
||||
<div
|
||||
class="linked-card__row"
|
||||
@click="router.push({ name: 'WorkOrderDetail', params: { id: record.generatedOrderId } })"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="solar:document-bold-duotone"
|
||||
class="linked-card__icon"
|
||||
/>
|
||||
<span>已生成整改工单</span>
|
||||
<Tag color="processing">#{{ record.generatedOrderId }}</Tag>
|
||||
<IconifyIcon
|
||||
icon="solar:arrow-right-bold"
|
||||
class="linked-card__arrow"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<Empty v-if="!loading && !record" description="暂无数据" />
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ==================== 结果横幅 ==================== */
|
||||
.result-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 8px;
|
||||
|
||||
&--pass {
|
||||
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
|
||||
|
||||
.result-banner__icon,
|
||||
.result-banner__text {
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.ring-fill {
|
||||
stroke: #52c41a;
|
||||
}
|
||||
|
||||
.ring-text {
|
||||
color: #389e0d;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
background: linear-gradient(135deg, #fff1f0 0%, #ffccc7 100%);
|
||||
|
||||
.result-banner__icon,
|
||||
.result-banner__text {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.ring-fill {
|
||||
stroke: #ff4d4f;
|
||||
}
|
||||
|
||||
.ring-text {
|
||||
color: #cf1322;
|
||||
}
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
&__ring {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 环形进度 */
|
||||
.ring-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ring-bg {
|
||||
fill: none;
|
||||
stroke: rgb(0 0 0 / 6%);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.ring-fill {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.6s ease;
|
||||
}
|
||||
|
||||
.ring-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==================== 基本信息 ==================== */
|
||||
.info-card {
|
||||
margin-bottom: 14px;
|
||||
|
||||
.info-card__area {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.desc-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 检查项 ==================== */
|
||||
.check-card {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&--pass {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&--fail {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.check-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #fafafa;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--fail {
|
||||
.check-item__title {
|
||||
color: #cf1322;
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 不合格详情 */
|
||||
&__detail {
|
||||
padding: 6px 0 2px 24px;
|
||||
}
|
||||
|
||||
&__remark {
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #8c8c8c;
|
||||
background: #fafafa;
|
||||
border-left: 2px solid #ff4d4f;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 现场记录 ==================== */
|
||||
.scene-card {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__remark {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
&__photo {
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
:deep(.ant-image-img) {
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 关联工单 ==================== */
|
||||
.linked-card {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: #bfbfbf;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&__row:hover &__arrow {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 暗色模式 ==================== */
|
||||
html.dark {
|
||||
.result-banner {
|
||||
&--pass {
|
||||
background: linear-gradient(135deg, #162312 0%, #274916 100%);
|
||||
}
|
||||
|
||||
&--fail {
|
||||
background: linear-gradient(135deg, #2a1215 0%, #431418 100%);
|
||||
}
|
||||
|
||||
&__sub {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
.ring-bg {
|
||||
stroke: rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.info-card {
|
||||
.info-card__area {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-content) {
|
||||
color: rgb(255 255 255 / 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.check-card__tags {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
border-color: #262626;
|
||||
|
||||
&__title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
&--fail .check-item__title {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
&__remark {
|
||||
color: rgb(255 255 255 / 55%);
|
||||
background: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-card__remark {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.linked-card__row {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import type { InspectionRecordApi } from '#/api/ops/inspection-record';
|
||||
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Card, Col, Row, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getInspectionStats } from '#/api/ops/inspection-record';
|
||||
|
||||
defineOptions({ name: 'InspectionStatsBar' });
|
||||
|
||||
const loading = ref(false);
|
||||
const statsData = ref<InspectionRecordApi.InspectionStats>({
|
||||
totalCount: 0,
|
||||
passedCount: 0,
|
||||
failedCount: 0,
|
||||
passRate: 0,
|
||||
});
|
||||
|
||||
let refreshTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
loadStats();
|
||||
refreshTimer = setInterval(loadStats, 30_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getInspectionStats();
|
||||
statsData.value = data;
|
||||
} catch {
|
||||
// 使用默认数据
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(startPolling);
|
||||
onActivated(() => {
|
||||
if (!refreshTimer) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
onDeactivated(stopPolling);
|
||||
onUnmounted(stopPolling);
|
||||
|
||||
defineExpose({ refresh: loadStats });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-dashboard">
|
||||
<Spin :spinning="loading">
|
||||
<Row :gutter="12" class="mb-3">
|
||||
<!-- 巡检总数 -->
|
||||
<Col :xs="24" :sm="12" :md="6">
|
||||
<Card
|
||||
:body-style="{ padding: '12px 14px' }"
|
||||
class="stats-card"
|
||||
>
|
||||
<div class="stats-content">
|
||||
<div
|
||||
class="stats-icon"
|
||||
style="--icon-color: #1677ff; --icon-bg: #e6f4ff"
|
||||
>
|
||||
<IconifyIcon icon="solar:clipboard-list-bold-duotone" />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">巡检总数</div>
|
||||
<div class="stats-value">{{ statsData.totalCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<!-- 合格数 -->
|
||||
<Col :xs="24" :sm="12" :md="6">
|
||||
<Card
|
||||
:body-style="{ padding: '12px 14px' }"
|
||||
class="stats-card"
|
||||
>
|
||||
<div class="stats-content">
|
||||
<div
|
||||
class="stats-icon"
|
||||
style="--icon-color: #52c41a; --icon-bg: #f6ffed"
|
||||
>
|
||||
<IconifyIcon icon="solar:check-circle-bold-duotone" />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">合格</div>
|
||||
<div class="stats-value">{{ statsData.passedCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<!-- 不合格数 -->
|
||||
<Col :xs="24" :sm="12" :md="6">
|
||||
<Card
|
||||
:body-style="{ padding: '12px 14px' }"
|
||||
class="stats-card"
|
||||
>
|
||||
<div class="stats-content">
|
||||
<div
|
||||
class="stats-icon"
|
||||
style="--icon-color: #ff4d4f; --icon-bg: #fff1f0"
|
||||
>
|
||||
<IconifyIcon icon="solar:close-circle-bold-duotone" />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">不合格</div>
|
||||
<div class="stats-value">{{ statsData.failedCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<!-- 合格率 -->
|
||||
<Col :xs="24" :sm="12" :md="6">
|
||||
<Card
|
||||
:body-style="{ padding: '12px 14px' }"
|
||||
class="stats-card"
|
||||
>
|
||||
<div class="stats-content">
|
||||
<div
|
||||
class="stats-icon"
|
||||
style="--icon-color: #722ed1; --icon-bg: #f9f0ff"
|
||||
>
|
||||
<IconifyIcon icon="solar:chart-bold-duotone" />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">合格率</div>
|
||||
<div class="stats-value">
|
||||
{{ statsData.passRate ? `${statsData.passRate.toFixed(1)}%` : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stats-dashboard {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
--icon-color: #8c8c8c;
|
||||
--icon-bg: #f5f5f5;
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--icon-color);
|
||||
background: var(--icon-bg);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
:deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto,
|
||||
sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.stats-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
|
||||
&:hover {
|
||||
border-color: #434343;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user