feat(@vben/web-antd): 新增巡检模板管理模块
- 新增巡检检查项模板 API(CRUD + 批量排序) - 左侧功能类型分类面板 + 右侧检查项表格 - 支持拖拽排序检查项顺序 - 支持启用/停用状态切换(带确认弹窗) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
79
apps/web-antd/src/api/ops/inspection-template/index.ts
Normal file
79
apps/web-antd/src/api/ops/inspection-template/index.ts
Normal 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);
|
||||
}
|
||||
111
apps/web-antd/src/views/ops/inspection-template/data.ts
Normal file
111
apps/web-antd/src/views/ops/inspection-template/data.ts
Normal 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
359
apps/web-antd/src/views/ops/inspection-template/index.vue
Normal file
359
apps/web-antd/src/views/ops/inspection-template/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user