feat(@vben/web-antd): 新增巡检记录模块及路由配置

- 新增巡检记录 API(分页查询、详情、统计)
- 卡片式列表展示巡检记录,支持按状态 Tab 筛选
- 统计栏展示合格率/总数/合格数/不合格数
- 详情抽屉展示巡检明细项、照片、归因结果
- 注册巡检记录和巡检模板路由

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-22 14:55:45 +08:00
parent 9ee16e2db3
commit be40db2a9c
7 changed files with 1807 additions and 0 deletions

View 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 });
}

View File

@@ -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'),
},
],
},
];

View 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 },
];

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>