feat: 新增属性分组功能

- 在产品详情页面添加属性分组标签页
- 实现属性分组的创建、编辑和删除功能
- 添加属性分组的排序和描述信息
- 优化属性选择界面,支持搜索和过滤功能
This commit is contained in:
fhysy 2025-08-15 17:43:51 +08:00
parent f8619047c5
commit 0c0a1994b2
8 changed files with 584 additions and 1 deletions

View File

@ -1,3 +1,5 @@
启动项目pnpm run dev:antd
<div align="center">
<a href="https://github.com/anncwb/vue-vben-admin">
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">

View File

@ -118,6 +118,7 @@ const formRules = {
},
],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
// 'valueParams.dataType': [
// { required: true, message: '', trigger: 'blur' },
// ],

View File

@ -155,6 +155,7 @@ const formRules = {
},
],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
};
//

View File

@ -38,6 +38,7 @@ const currentMetadata = ref<any>({
properties: [],
functions: [],
events: [],
propertyGroups: [], //
});
//
@ -97,6 +98,7 @@ const loadMetadata = async () => {
properties: [],
functions: [],
events: [],
propertyGroups: [], //
};
try {
currentMetadata.value = props.productInfo.metadata
@ -212,6 +214,7 @@ const handleSave = async () => {
metadata.properties.sort((a: any, b: any) => a.sort - b.sort);
metadata.functions.sort((a: any, b: any) => a.sort - b.sort);
metadata.events.sort((a: any, b: any) => a.sort - b.sort);
metadata.propertyGroups.sort((a: any, b: any) => a.sort - b.sort); //
await productUpdateById(props.productInfo.id, {
id: props.productInfo.id,
metadata: JSON.stringify(metadata),
@ -244,6 +247,11 @@ const handleMetadataChange = (data: any) => {
currentMetadata.value.properties = data;
break;
}
case 'propertyGroups': {
//
currentMetadata.value.propertyGroups = data;
break;
}
// No default
}
};
@ -328,6 +336,16 @@ loadMetadata();
@refresh="loadMetadata"
/>
</TabPane>
<TabPane key="propertyGroups" tab="属性分组">
<MetadataTable
type="propertyGroups"
:product-id="productInfo.id"
:metadata="currentMetadata.propertyGroups"
:available-properties="currentMetadata.properties"
@change="handleMetadataChange"
@refresh="loadMetadata"
/>
</TabPane>
<TabPane key="functions" tab="功能定义">
<MetadataTable
type="functions"

View File

@ -9,11 +9,13 @@ import { dataTypeOptions, readWriteTypeOptions } from '#/constants/dicts';
import EventDrawer from './EventDrawer.vue';
import FunctionDrawer from './FunctionDrawer.vue';
import PropertyDrawer from './PropertyDrawer.vue';
import PropertyGroupDrawer from './PropertyGroupDrawer.vue';
const props = defineProps<{
availableProperties?: any[]; //
metadata: any[];
productId: string;
type: 'events' | 'functions' | 'properties' | 'tags';
type: 'events' | 'functions' | 'properties' | 'propertyGroups' | 'tags';
}>();
const emit = defineEmits<{
@ -67,6 +69,18 @@ const columns = computed(() => {
fixed: 'right',
},
];
//
if (props.type === 'propertyGroups') {
baseColumns.splice(3, 0, {
title: '属性数',
dataIndex: 'properties',
key: 'count',
width: 100,
customRender: ({ record }: any) => record.properties?.length || 0,
});
}
//
if (props.type === 'properties') {
baseColumns.splice(
@ -129,6 +143,7 @@ const getTypeLabel = () => {
functions: '功能',
events: '事件',
tags: '标签',
propertyGroups: '分组',
};
return labels[props.type];
};
@ -319,6 +334,16 @@ onMounted(() => {
:data="formData"
@save="handleSave"
/>
<!-- 属性分组抽屉 -->
<PropertyGroupDrawer
v-if="type === 'propertyGroups'"
v-model:open="drawerVisible"
:is-edit="isEdit"
:data="formData"
:available-properties="props.availableProperties || []"
@save="handleSave"
/>
</div>
</template>

View File

@ -116,6 +116,7 @@ const formRules = {
},
],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
// 'valueParams.dataType': [
// { required: true, message: '', trigger: 'blur' },
// ],

View File

@ -0,0 +1,304 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import {
Button,
Col,
Drawer,
Form,
FormItem,
Input,
InputNumber,
message,
Row,
Space,
Table,
Textarea,
} from 'ant-design-vue';
import PropertySelectionModal from './PropertySelectionModal.vue';
interface PropertyGroupData {
id: string;
name: string;
id: string;
sort: number;
description: string;
properties: any[];
}
interface Props {
open: boolean;
isEdit: boolean;
data?: PropertyGroupData;
availableProperties: any[]; //
}
interface Emits {
(e: 'update:open', value: boolean): void;
(e: 'save', data: PropertyGroupData): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
});
const formRef = ref();
const saveLoading = ref(false);
//
const propertySelectionVisible = ref(false);
//
const defaultPropertyGroupData: PropertyGroupData = {
id: '',
name: '',
sort: 1,
description: '',
properties: [],
};
const formData = ref<PropertyGroupData>({ ...defaultPropertyGroupData });
//
const formRules = {
id: [
{ required: true, message: '请输入标识', trigger: 'blur' },
{
pattern: /^[a-z]\w*$/i,
message: '请输入以字母开头,只包含字母、数字和下划线的标识',
trigger: 'blur',
},
],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
};
//
const propertyColumns = [
{
title: '标识符',
dataIndex: 'id',
key: 'id',
width: 150,
align: 'center',
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150,
align: 'center',
},
{
title: '操作',
key: 'action',
width: 80,
align: 'center',
fixed: 'right',
},
];
//
const resetForm = () => {
formData.value = { ...defaultPropertyGroupData };
formRef.value?.resetFields();
};
//
watch(
() => props.data,
(newData) => {
if (newData) {
formData.value = JSON.parse(JSON.stringify(newData));
} else {
resetForm();
}
},
{ immediate: true },
);
//
const handleAddProperty = () => {
propertySelectionVisible.value = true;
};
//
const handleDeleteProperty = (propertyId: string) => {
const index = formData.value.properties.findIndex((p) => p.id === propertyId);
if (index !== -1) {
formData.value.properties.splice(index, 1);
message.success('删除属性成功');
}
};
//
const handlePropertySelectionConfirm = (selectedProperties: any[]) => {
//
const newProperties = selectedProperties.filter(
(prop) => !formData.value.properties.some((p) => p.id === prop.id),
);
if (newProperties.length > 0) {
formData.value.properties.push(...newProperties);
message.success(`成功添加 ${newProperties.length} 个属性`);
} else {
message.warning('没有可添加的新属性');
}
propertySelectionVisible.value = false;
};
//
const handleSave = async () => {
try {
await formRef.value.validate();
saveLoading.value = true;
emit('save', JSON.parse(JSON.stringify(formData.value)));
} catch (error) {
console.error('表单验证失败:', error);
} finally {
saveLoading.value = false;
}
};
//
const handleClose = () => {
visible.value = false;
resetForm();
};
</script>
<template>
<Drawer
v-model:open="visible"
:title="isEdit ? '编辑分组' : '添加'"
width="800px"
@close="handleClose"
>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="16">
<Col :span="12">
<FormItem label="标识" name="id">
<Input v-model:value="formData.id" placeholder="请输入" />
</FormItem>
</Col>
<Col :span="12">
<FormItem label="名称" name="name">
<Input v-model:value="formData.name" placeholder="请输入" />
</FormItem>
</Col>
</Row>
<Col :span="24">
<FormItem label="属性列表" name="properties">
<Table
:columns="propertyColumns"
:data-source="formData.properties"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button
type="link"
size="small"
danger
@click="handleDeleteProperty(record.id)"
>
删除
</Button>
</template>
</template>
</Table>
<div class="add-button-container">
<a-button
@click="handleAddProperty"
type="primary"
class="add-button"
>
<template #icon>
<PlusOutlined />
</template>
添加属性
</a-button>
</div>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber
v-model:value="formData.sort"
:min="1"
style="width: 100%"
placeholder="请输入"
/>
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea
v-model:value="formData.description"
placeholder="请输入"
:rows="3"
/>
</FormItem>
</Col>
</Form>
<template #footer>
<Space>
<Button @click="handleClose">取消</Button>
<Button type="primary" :loading="saveLoading" @click="handleSave">
确定
</Button>
</Space>
</template>
<!-- 属性选择弹窗 -->
<PropertySelectionModal
v-model:open="propertySelectionVisible"
:available-properties="props.availableProperties"
:selected-properties="formData.properties"
@confirm="handlePropertySelectionConfirm"
/>
</Drawer>
</template>
<style lang="scss" scoped>
.property-section {
padding-top: 16px;
margin: 24px 0;
border-top: 1px solid #f0f0f0;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
}
.add-button-container {
padding-top: 12px;
text-align: center;
.add-button {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Button, Input, Modal, Space, Table, Tag } from 'ant-design-vue';
import { dataTypeOptions, readWriteTypeOptions } from '#/constants/dicts';
interface Props {
open: boolean;
availableProperties: any[]; //
selectedProperties: any[]; //
}
interface Emits {
(e: 'update:open', value: boolean): void;
(e: 'confirm', value: any[]): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
});
const searchName = ref('');
//
const columns = [
{
title: '',
dataIndex: 'selection',
key: 'selection',
width: 40,
},
{
title: '标识',
dataIndex: 'id',
key: 'id',
width: 150,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '数据类型',
dataIndex: 'dataType',
key: 'dataType',
width: 120,
align: 'center',
},
{
title: '读写类型',
dataIndex: 'readWriteType',
key: 'readWriteType',
width: 100,
align: 'center',
},
{
title: '说明',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
];
//
const filteredProperties = computed(() => {
let result = [...props.availableProperties];
//
result = result.filter(
(prop) => !props.selectedProperties.some((p) => p.id === prop.id),
);
//
if (searchName.value) {
result = result.filter(
(prop) =>
prop.name.toLowerCase().includes(searchName.value.toLowerCase()) ||
prop.id.toLowerCase().includes(searchName.value.toLowerCase()),
);
}
return result;
});
//
const selectedPropertyIds = ref<string[]>([]);
//
const handleReset = () => {
searchName.value = '';
};
//
const handleSearch = () => {
// computed
};
//
const handleConfirm = () => {
const selectedProperties = filteredProperties.value.filter((prop) =>
selectedPropertyIds.value.includes(prop.id),
);
emit('confirm', selectedProperties);
selectedPropertyIds.value = [];
};
//
const handleCancel = () => {
selectedPropertyIds.value = [];
visible.value = false;
};
//
watch(
() => visible.value,
(newVal) => {
if (newVal) {
selectedPropertyIds.value = [];
}
},
);
//
const getDataTypeLabel = (dataType: string) => {
const option = dataTypeOptions.find((item) => item.value === dataType);
return option ? option.label : dataType;
};
//
const getReadWriteTypeLabel = (type: string) => {
const option = readWriteTypeOptions.find((item) => item.value === type);
return option ? option.label : type;
};
</script>
<template>
<Modal
v-model:open="visible"
title="选择属性"
width="1000px"
@cancel="handleCancel"
@ok="handleConfirm"
ok-text="确定"
cancel-text="取消"
>
<!-- 搜索区域 -->
<div class="search-section">
<Space>
标识/名称:
<Input
v-model:value="searchName"
placeholder="请输入标识和名称搜索"
style="width: 200px"
allow-clear
/>
<Button @click="handleReset">重置</Button>
<Button type="primary" @click="handleSearch">查询</Button>
</Space>
</div>
<!-- 属性列表 -->
<Table
:columns="columns"
:data-source="filteredProperties"
:pagination="false"
row-key="id"
size="small"
:row-selection="{
selectedRowKeys: selectedPropertyIds,
onChange: (selectedRowKeys: string[]) => {
selectedPropertyIds = selectedRowKeys;
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
<Tag color="processing">
{{
getDataTypeLabel(record.valueParams?.dataType || record.dataType)
}}
</Tag>
</template>
<template v-else-if="column.key === 'readWriteType'">
<Tag color="processing">
{{
getReadWriteTypeLabel(
record.expands?.type || record.readWriteType,
)
}}
</Tag>
</template>
<template v-else-if="column.key === 'description'">
{{ record.description || '-' }}
</template>
</template>
</Table>
<!-- 分页信息 -->
<div class="pagination-info">
<span>
1-{{ filteredProperties.length }} /总共{{
filteredProperties.length
}}
</span>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.search-section {
padding: 16px;
margin-bottom: 16px;
background-color: #fafafa;
border-radius: 6px;
}
.pagination-info {
margin-top: 16px;
font-size: 14px;
color: #666;
text-align: right;
}
</style>