feat(@vben/web-antd): 新增巡检模板管理模块

- 新增巡检检查项模板 API(CRUD + 批量排序)
- 左侧功能类型分类面板 + 右侧检查项表格
- 支持拖拽排序检查项顺序
- 支持启用/停用状态切换(带确认弹窗)

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

View File

@@ -0,0 +1,79 @@
import { requestClient } from '#/api/request';
import type { FunctionType } from '#/api/ops/area';
export namespace InspectionTemplateApi {
/** 巡检检查项模板 */
export interface Template {
id?: number;
/** 功能类型 */
functionType: FunctionType;
/** 检查项标题 */
itemTitle: string;
/** 检查项描述 */
itemDescription?: string;
/** 排序号 */
sortOrder?: number;
/** 启用状态 */
isActive?: boolean;
/** 创建时间 */
createTime?: string;
/** 更新时间 */
updateTime?: string;
}
/** 分页查询参数 */
export interface TemplatePageQuery {
functionType?: FunctionType;
itemTitle?: string;
enabled?: boolean;
pageNo?: number;
pageSize?: number;
}
/** 分页结果 */
export interface PageResult {
list: Template[];
total: number;
}
}
// ========== 巡检检查项模板 API ==========
/** 获取模板分页 */
export function getTemplatePage(params: InspectionTemplateApi.TemplatePageQuery) {
return requestClient.get<InspectionTemplateApi.PageResult>(
'/ops/inspection/template/page',
{ params },
);
}
/** 获取模板详情 */
export function getTemplate(id: number) {
return requestClient.get<InspectionTemplateApi.Template>(
'/ops/inspection/template/get',
{ params: { id } },
);
}
/** 新增模板 */
export function createTemplate(data: InspectionTemplateApi.Template) {
return requestClient.post<number>('/ops/inspection/template/create', data);
}
/** 更新模板 */
export function updateTemplate(data: InspectionTemplateApi.Template) {
return requestClient.put('/ops/inspection/template/update', data);
}
/** 删除模板 */
export function deleteTemplate(id: number) {
return requestClient.delete<boolean>('/ops/inspection/template/delete', {
params: { id },
});
}
/** 批量更新排序 */
export function updateTemplateSortBatch(ids: number[]) {
return requestClient.put('/ops/inspection/template/update-sort-batch', ids);
}

View File

@@ -0,0 +1,111 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InspectionTemplateApi } from '#/api/ops/inspection-template';
import { z } from '#/adapter/form';
import {
FUNCTION_TYPE_OPTIONS,
FUNCTION_TYPE_TAG_COLORS,
} from '../area/data';
export { FUNCTION_TYPE_OPTIONS, FUNCTION_TYPE_TAG_COLORS };
/** 检查项列表列 */
export function useGridColumns(): VxeTableGridOptions<InspectionTemplateApi.Template>['columns'] {
return [
{
field: 'drag',
title: '',
width: 40,
slots: { default: 'drag' },
},
{
field: 'sortOrder',
title: '排序号',
width: 100,
},
{
field: 'itemTitle',
title: '检查项标题',
minWidth: 200,
align: 'left',
},
{
field: 'itemDescription',
title: '检查项描述',
minWidth: 200,
align: 'left',
},
{
field: 'isActive',
title: '启用状态',
width: 100,
slots: { default: 'isActive' },
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 检查项表单 Schema */
export function useTemplateFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: { triggerFields: [''], show: () => false },
},
{
fieldName: 'itemTitle',
label: '检查项标题',
component: 'Input',
componentProps: { placeholder: '请输入检查项标题' },
rules: 'required',
},
{
fieldName: 'itemDescription',
label: '检查项描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入检查项描述',
rows: 3,
},
},
{
fieldName: 'sortOrder',
label: '排序号',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入排序号',
class: 'w-full',
},
rules: z.number().min(0).default(0),
},
{
fieldName: 'isActive',
label: '启用状态',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '启用', value: true },
{ label: '停用', value: false },
],
optionType: 'button',
buttonStyle: 'solid',
},
rules: z.boolean().default(true),
},
];
}

View File

@@ -0,0 +1,359 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FunctionType } from '#/api/ops/area';
import type { InspectionTemplateApi } from '#/api/ops/inspection-template';
import { nextTick, onMounted, ref, watch } from 'vue';
import { confirm as confirmModal, Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import { useSortable } from '@vueuse/integrations/useSortable';
import { Badge, Button, Card, message, Switch, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteTemplate,
getTemplatePage,
updateTemplate,
updateTemplateSortBatch,
} from '#/api/ops/inspection-template';
import { $t } from '#/locales';
import { FUNCTION_TYPE_OPTIONS, useGridColumns } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'OpsInspectionTemplate' });
// ========== 左侧功能类型 ==========
const selectedType = ref<FunctionType>('MALE_TOILET');
const typeCounts = ref<Record<string, number>>({});
// ========== 右侧模板列表 ==========
const templateList = ref<InspectionTemplateApi.Template[]>([]);
const loading = ref(false);
// ========== 拖拽排序 ==========
const isSorting = ref(false);
const originalList = ref<InspectionTemplateApi.Template[]>([]);
const sortableInstance = ref<any>(null);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
data: [],
keepSource: true,
pagerConfig: { enabled: false },
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: { enabled: false },
} as VxeTableGridOptions<InspectionTemplateApi.Template>,
});
/** 加载模板列表 */
async function loadTemplates() {
loading.value = true;
try {
const res = await getTemplatePage({
functionType: selectedType.value,
pageNo: 1,
pageSize: 100,
});
const list = res?.list ?? [];
templateList.value = list;
gridApi.setGridOptions({ data: list });
// 更新当前类型计数
typeCounts.value[selectedType.value] = res?.total ?? list.length;
} catch {
// 接口不可用时静默处理,避免全局拦截器重复弹错误消息
templateList.value = [];
gridApi.setGridOptions({ data: [] });
} finally {
loading.value = false;
}
}
/** 选择功能类型 */
function handleSelectType(type: FunctionType) {
if (isSorting.value) return;
selectedType.value = type;
}
/** 新增检查项 */
function handleCreate() {
formModalApi.setData({ functionType: selectedType.value }).open();
}
/** 编辑检查项 */
function handleEdit(row: InspectionTemplateApi.Template) {
formModalApi.setData(row).open();
}
/** 删除检查项 */
async function handleDelete(row: InspectionTemplateApi.Template) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.itemTitle]),
duration: 0,
});
try {
await deleteTemplate(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.itemTitle]));
await handleRefresh();
} catch (error: any) {
const msg = error?.message || error?.data?.msg || '删除失败';
message.error(msg);
} finally {
hideLoading();
}
}
/** 切换启用状态 */
async function handleToggleActive(row: InspectionTemplateApi.Template) {
const next = !row.isActive;
try {
await confirmModal(
next
? `确认启用检查项【${row.itemTitle}】吗?`
: `确认停用检查项【${row.itemTitle}】吗?`,
);
} catch {
return;
}
const hideLoading = message.loading({
content: next ? '启用中...' : '停用中...',
duration: 0,
});
try {
await updateTemplate({ ...row, isActive: next });
message.success(next ? '已启用' : '已停用');
await handleRefresh();
} catch (error: any) {
const msg = error?.message || error?.data?.msg || '操作失败';
message.error(msg);
} finally {
hideLoading();
}
}
/** 刷新列表 */
async function handleRefresh() {
await loadTemplates();
}
// ========== 拖拽排序 ==========
/** 开始排序 */
function handleStartSort() {
originalList.value = cloneDeep(templateList.value);
isSorting.value = true;
// 初始化拖拽
nextTick(() => {
if (sortableInstance.value) {
sortableInstance.value.option('disabled', false);
} else {
// NOTE: 选择器依赖 vxe-table 内部 DOM 结构,升级 vxe-table 时需验证
sortableInstance.value = useSortable(
'.inspection-template-table .vxe-table .vxe-table--body-wrapper:not(.fixed-right--wrapper) .vxe-table--body tbody',
templateList.value,
{
draggable: '.vxe-body--row',
animation: 150,
handle: '.drag-handle',
disabled: false,
onEnd: ({ newDraggableIndex, oldDraggableIndex }) => {
if (oldDraggableIndex !== newDraggableIndex) {
templateList.value.splice(
newDraggableIndex ?? 0,
0,
templateList.value.splice(oldDraggableIndex ?? 0, 1)[0]!,
);
}
},
},
);
}
});
}
/** 保存排序 */
async function handleSaveSort() {
const hideLoading = message.loading({
content: '正在保存排序...',
duration: 0,
});
try {
const ids = templateList.value.map((item) => item.id!);
await updateTemplateSortBatch(ids);
isSorting.value = false;
if (sortableInstance.value) {
sortableInstance.value.option('disabled', true);
}
message.success('排序保存成功');
await handleRefresh();
} catch (error) {
console.error('排序保存失败', error);
message.error('排序保存失败');
} finally {
hideLoading();
}
}
/** 取消排序 */
function handleCancelSort() {
templateList.value = cloneDeep(originalList.value);
gridApi.setGridOptions({ data: templateList.value });
if (sortableInstance.value) {
sortableInstance.value.option('disabled', true);
}
isSorting.value = false;
}
// ========== 初始化 ==========
watch(selectedType, () => {
// 重置排序状态
if (isSorting.value) {
handleCancelSort();
}
sortableInstance.value = null;
loadTemplates();
});
onMounted(() => {
loadTemplates();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<div class="flex h-full gap-4">
<!-- 左侧功能类型列表 -->
<Card class="w-[220px] flex-shrink-0 overflow-hidden" :body-style="{ padding: '8px' }">
<div class="mb-2 px-2 text-sm font-medium text-gray-500">功能类型</div>
<div
v-for="opt in FUNCTION_TYPE_OPTIONS"
:key="opt.value"
class="flex cursor-pointer items-center justify-between rounded-md px-3 py-2.5 text-sm transition-colors"
:class="
selectedType === opt.value
? 'bg-blue-50 font-medium text-blue-600'
: 'text-gray-700 hover:bg-gray-50'
"
@click="handleSelectType(opt.value)"
>
<span>{{ opt.label }}</span>
<Badge
:count="typeCounts[opt.value] ?? 0"
:number-style="{
backgroundColor: selectedType === opt.value ? '#1677ff' : '#f0f0f0',
color: selectedType === opt.value ? '#fff' : '#999',
fontSize: '12px',
boxShadow: 'none',
}"
/>
</div>
</Card>
<!-- 右侧检查项列表 -->
<Card
class="min-w-0 flex-1 overflow-hidden"
:body-style="{ padding: '16px', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
>
<!-- 工具栏 -->
<div class="mb-4 flex flex-shrink-0 items-center justify-between">
<div class="text-base font-medium">
{{
FUNCTION_TYPE_OPTIONS.find((o) => o.value === selectedType)
?.label ?? selectedType
}}
- 检查项列表
</div>
<div class="flex items-center gap-2">
<template v-if="!isSorting">
<Button
v-if="templateList.length > 1"
@click="handleStartSort"
>
<template #icon>
<IconifyIcon icon="lucide:align-start-vertical" />
</template>
排序
</Button>
<Button type="primary" @click="handleCreate">
<template #icon>
<IconifyIcon icon="lucide:plus" />
</template>
新增
</Button>
</template>
<template v-else>
<Button @click="handleCancelSort">取消</Button>
<Button type="primary" @click="handleSaveSort">保存排序</Button>
</template>
</div>
</div>
<!-- 表格 -->
<div class="inspection-template-table min-h-0 flex-1 overflow-auto">
<Grid>
<!-- 拖拽列 -->
<template #drag>
<Tooltip v-if="isSorting" title="拖动排序">
<IconifyIcon
icon="ic:round-drag-indicator"
class="drag-handle cursor-move text-xl text-gray-400"
/>
</Tooltip>
</template>
<!-- 启用状态 -->
<template #isActive="{ row }">
<Switch
:checked="row.isActive"
checked-children="启用"
un-checked-children="停用"
size="small"
@click="handleToggleActive(row)"
/>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
onClick: () => handleEdit(row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
popConfirm: {
title: `确认删除检查项【${row.itemTitle}】吗?`,
confirm: () => handleDelete(row),
},
},
]"
/>
</template>
</Grid>
</div>
</Card>
</div>
</Page>
</template>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { InspectionTemplateApi } from '#/api/ops/inspection-template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createTemplate,
getTemplate,
updateTemplate,
} from '#/api/ops/inspection-template';
import { $t } from '#/locales';
import { useTemplateFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<null | Partial<InspectionTemplateApi.Template>>();
const getTitle = computed(() => {
if (formData.value?.id) {
return '编辑检查项';
}
return '新增检查项';
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useTemplateFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const valid = await formApi.validate();
if (!valid) return;
modalApi.lock();
const values = (await formApi.getValues()) as InspectionTemplateApi.Template;
try {
// 自动填入功能类型
const data: InspectionTemplateApi.Template = {
...values,
functionType: formData.value?.functionType ?? values.functionType,
};
await (formData.value?.id ? updateTemplate(data) : createTemplate(data));
message.success($t('ui.actionMessage.operationSuccess'));
emit('success');
await modalApi.close();
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
const data = modalApi.getData<Partial<InspectionTemplateApi.Template>>();
formData.value = data ?? null;
if (!data) return;
if (data.id) {
modalApi.lock();
try {
const detail = await getTemplate(data.id);
await formApi.setValues(detail);
} finally {
modalApi.unlock();
}
} else {
await formApi.setValues({
isActive: true,
sortOrder: 0,
});
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>