一对多数据结构

455 阅读5分钟

20-一对多数据结构案例_哔哩哔哩_bilibili

说明

后端采用一对多的数据结构,前端采用主从表单的布局 以订单作为案例

  • 主表:订单表
  • 从表:明细表

实现的功能

  • 一对多数据结构的增删改查
  • 时间字段的筛选
  • 封装明细表组件
    • 数据代理
    • 实时保存
    • 实时计算
    • 表尾合计
    • 分页显示
    • 自定义配置项

后端

新建了一个apps

数据模型:models.py

from django.db import models

# Create your models here.
from dvadmin.utils.models import CoreModel

table_prefix = 'apps_'


class OrderModel(CoreModel):
    order_number = models.CharField(max_length=10, unique=True, verbose_name="订单编号")
    customer = models.CharField(max_length=10, verbose_name="购买客户")
    order_date = models.DateField(verbose_name="下单日期")
    payment_type = models.CharField(max_length=10, verbose_name="支付方式")
    is_invoice = models.BooleanField(default=False, verbose_name="是否需要发票")
    express = models.CharField(max_length=10, verbose_name="物流快递")
    address = models.CharField(max_length=255, verbose_name="收货地址")

    def __str__(self):
        return self.order_number

    class Meta:
        db_table = table_prefix + "order"
        verbose_name = "订单表"
        verbose_name_plural = verbose_name
        ordering = ("id",)


class OrderRecordModel(CoreModel):
    order = models.ForeignKey(
        to="OrderModel",
        verbose_name="订单",
        on_delete=models.PROTECT,
        db_constraint=False,
        null=True,
        blank=True,
        help_text="订单编号", )
    product = models.CharField(max_length=10, verbose_name="购买产品")
    specification = models.CharField(max_length=10, null=True, blank=True, verbose_name="型号/规格")
    price = models.FloatField(default=0, verbose_name="单价")
    quantity = models.FloatField(default=0, verbose_name="数量")
    amount = models.FloatField(default=0, verbose_name="金额")

    def __str__(self):
        return self.product

    class Meta:
        db_table = table_prefix + "order_record"
        verbose_name = "订单明细表"
        verbose_name_plural = verbose_name
        ordering = ("-id",)

序列化器:serializers.py

'''
  @author: MuziLi
  @contact: 1537080775@qq.com
  @file: serializers.py
  @time: 2023-05-13 22:37
  @SoftWare: PyCharm
 '''

from apps.order.models import OrderModel, OrderRecordModel
from dvadmin.utils.serializers import CustomModelSerializer


class OrderSerializer(CustomModelSerializer):
    """
    订单查询序列化
    """

    class Meta:
        model = OrderModel
        fields = "__all__"


class OrderCreateUpdateSerializer(CustomModelSerializer):
    """
    订单新增,修改序列化
    """

    class Meta:
        model = OrderModel
        fields = "__all__"


class OrderImportSerializer(CustomModelSerializer):
    """
    订单导入,导出序列化
    """

    class Meta:
        model = OrderModel
        fields = "__all__"


class OrderRecordSerializer(CustomModelSerializer):
    """
    订单明细查询序列化
    """
    order = OrderSerializer()  # 订单

    class Meta:
        model = OrderRecordModel
        fields = "__all__"


class OrderRecordCreateUpdateSerializer(CustomModelSerializer):
    """
    订单明细新增,修改序列化
    """

    class Meta:
        model = OrderRecordModel
        fields = "__all__"


class OrderRecordImportSerializer(CustomModelSerializer):
    """
    订单明细导入,导出序列化
    """

    class Meta:
        model = OrderRecordModel
        fields = "__all__"

筛选:filter.py

import django_filters

from apps.order.models import OrderModel


class OrderFilter(django_filters.rest_framework.FilterSet):
    customer = django_filters.CharFilter(field_name='customer', lookup_expr='contains', help_text="购买客户")
    order_date = django_filters.BaseRangeFilter(field_name="order_date")

    class Meta:
        model = OrderModel
        fields = "__all__"

视图:views.py

# Create your views here.
from apps.order.filter import OrderFilter
from apps.order.models import OrderModel, OrderRecordModel
from apps.order.serializers import OrderSerializer, OrderCreateUpdateSerializer, OrderRecordSerializer, \
    OrderRecordCreateUpdateSerializer
from dvadmin.utils.viewset import CustomModelViewSet


class OrderViewSet(CustomModelViewSet):
    """
    订单表视图类
    """
    queryset = OrderModel.objects.all()
    serializer_class = OrderSerializer
    create_serializer_class = OrderCreateUpdateSerializer
    update_serializer_class = OrderCreateUpdateSerializer
    filter_class = OrderFilter  # 筛选


class OrderRecordViewSet(CustomModelViewSet):
    """
    订单明细表视图类
    """
    queryset = OrderRecordModel.objects.all()
    serializer_class = OrderRecordSerializer
    create_serializer_class = OrderRecordCreateUpdateSerializer
    update_serializer_class = OrderRecordCreateUpdateSerializer

路由:urls.py


from rest_framework.routers import SimpleRouter

from apps.order.views import OrderRecordViewSet, OrderViewSet

router = SimpleRouter()
router.register("order", OrderViewSet)  # 订单
router.register("order_record", OrderRecordViewSet)  # 订单明细
urlpatterns = [
]
urlpatterns += router.urls

注册

APP注册

application/settings.py 文件中注册创建的app应用

INSTALLED_APPS=[
	...
    'apps.orders',  # 订单管理
]

路由注册

application/urls.py注册路由

urlpatterns = (
    [
        ...
        path("api/apps/", include("apps.order.urls")),  # 订单管理
        ...
    ]
)

后端运行

  1. 进入后端项目目录 cd backend
  2. 在项目根目录中,复制 ./conf/env.example.py 文件为一份新的到 ./conf/env.py 下,并重命名为env.py
  3. env.py 中配置数据库信息(默认数据库为sqlite3,测试演示可忽略此步骤)
  4. 安装依赖环境 pip3 install -r requirements.txt
  5. 执行迁移命令 python3 manage.py makemigrationspython3 manage.py migrate
  6. 初始化数据 python3 manage.py init
  7. 初始化省市县数据python3 manage.py init_area
  8. 启动项目 python3 manage.py runserver 0.0.0.0:8000

前端

封装了一个子表的组件,可以适用于其他项目

子表组件

路径src\components\sub-table\index.vue

<!--
 * @文件介绍: 子表组件-主页面
-->
<template>
	<el-card>
		<vxe-grid ref="xGrid" v-bind="gridOptions" @edit-closed="editClosedEvent">
			<!--自定义插槽 toolbar buttons 插槽-->
			<template #toolbar_buttons>
				<vxe-button
					:disabled="!gridOptions.editConfig.enabled"
					size="mini"
					@click="addStartRow"
					icon="fa fa-plus"
				>
					新增
				</vxe-button>
			</template>

			<!--自定义插槽 操作列 插槽-->
			<template #operate="{ row }">
				<el-button
					:disabled="!gridOptions.editConfig.enabled"
					size="mini"
					type="success"
					icon="el-icon-plus"
					@click="addEndRow(row)"
					circle
				></el-button>
				<el-button
					:disabled="!gridOptions.editConfig.enabled"
					size="mini"
					type="danger"
					icon="el-icon-delete"
					@click="delRow(row)"
					circle
				></el-button>
			</template>
		</vxe-grid>
	</el-card>
</template>
<script>
import XEUtils from 'xe-utils';
import { request } from '@/api/service';
export default {
	name: 'sub-table',
	props: {
		gridConfig: Object, // 配置文件
		parent: Object, // 父级配置
	},
	data() {
		return {
			gridOptions: {
				border: true,
				keepSource: true,
				align: 'center', // 所有的表头列的对齐方式
				size: 'mini',
				// 工具栏配置
				toolbarConfig: { refresh: true, slots: { buttons: 'toolbar_buttons' } },
				// 可编辑配置项
				editConfig: {
					enabled: true,
					trigger: 'click',
					mode: 'row',
					showStatus: true,
				},
				// 分页配置项
				pagerConfig: {
					pageSize: 10,
					pageSizes: [10, 100, 200, 500, 1000, 2000],
				},
				// 数据代理配置项
				proxyConfig: {
					props: {
						result: 'data.data', // 配置响应结果列表字段,需要配置分页属性
						total: 'data.total', // 配置响应结果总页数字段
					},
					// 只接收Promise,具体实现自由发挥
					ajax: {
						// 当点击工具栏查询按钮或者手动提交指令 query或reload 时会被触发
						query: ({ page, sorts, filters, form }) => {
							const queryParams = {
								limit: page.pageSize,
								page: page.currentPage,
							};
							queryParams[this.parent.name] = this.parent.id;
							if (this.parent.id) {
								return request({
									url: this.gridConfig.urlPrefix,
									method: 'get',
									params: { ...queryParams },
								});
							} else {
								return [];
							}
						},
					},
				},
			},
		};
	},
	created() {
		// 合并配置文件
		XEUtils.merge(this.gridOptions, this.gridConfig);
		// 判断是否可编辑
		this.gridOptions.editConfig.enabled = this.gridOptions.mode !== 'view';
	},
	methods: {
		// 即时保存
		async editClosedEvent({ row, column }) {
			row[this.parent.name] = this.parent.id;
			const $xGrid = this.$refs.xGrid;
			const errMap = await $xGrid.validate(row).catch((errMap) => errMap); // 校验规则
			if (errMap) {
				this.$message.error('数据校验未通过!');
			} else {
				if (!row.id) {
					// 新增数据
					this.CreateObj(row).then((res) => {
						$xGrid.commitProxy('query');
						this.$message.success(res.msg);
					});
				} else {
					// 修改数据
					this.UpdateObj(row).then((res) => {
						$xGrid.reloadRow(row);
						this.$message.success(res.msg);
					});
				}
			}
		},
		// 从当前行后面新增,实现复制行
		addEndRow(row) {
			const records = XEUtils.clone(row, true); // 深拷贝
			delete records.id; // 删除ID
			this.$refs.xGrid.insertAt(records, row);
		},
		// 从开头新增行
		addStartRow() {
			this.$refs.xGrid.insert();
		},
		// 删除行
		delRow(row) {
			if (row.id) {
				this.$confirm('确定要删除这条数据吗?', '提示', {
					confirmButtonText: '确定',
					cancelButtonText: '取消',
					type: 'warning',
				}).then(() => {
					this.DelObj(row.id).then((res) => {
						this.$refs.xGrid.commitProxy('query');
						this.$message.success(res.msg);
					});
				});
			} else {
				this.$refs.xGrid.remove(row);
			}
		},
		// 新增API
		CreateObj(row) {
			return request({
				url: this.gridConfig.urlPrefix,
				method: 'post',
				data: row,
			});
		},
		// 修改API
		UpdateObj(row) {
			return request({
				url: this.gridConfig.urlPrefix + row.id + '/',
				method: 'put',
				data: row,
			});
		},
		// 删除API
		DelObj(id) {
			return request({
				url: this.gridConfig.urlPrefix + id + '/',
				method: 'delete',
			});
		},
	},
};
</script>
<style>
.vxe-select--panel {
	z-index: 10080 !important;
}
.vxe-table--context-menu-wrapper {
	z-index: 10081 !important;
}
</style>

接口文件:api.js

/*
 * @文件介绍: 老师管理接口
 */
import { request, downloadFile } from '@/api/service';

export const urlPrefix = '/api/apps/order/';

/**
 * 列表查询
 * @param {*} query
 * @returns
 */
export function GetList(query) {
	return request({
		url: urlPrefix,
		method: 'get',
		params: { ...query },
	});
}

/**
 * 单例查询
 * @param {*} id
 * @returns
 */
export function GetObj(id) {
	return request({
		url: urlPrefix + id + '/',
		method: 'get',
	});
}

/**
 * 新增
 * @param {*} obj
 * @returns
 */
export function AddObj(obj) {
	return request({
		url: urlPrefix,
		method: 'post',
		data: obj,
	});
}

/**
 * 修改
 * @param {*} obj
 * @returns
 */
export function UpdateObj(obj) {
	return request({
		url: urlPrefix + obj.id + '/',
		method: 'put',
		data: obj,
	});
}

/**
 * 删除
 * @param {*} id
 * @returns
 */
export function DelObj(id) {
	return request({
		url: urlPrefix + id + '/',
		method: 'delete',
		data: { id },
	});
}

/**
 * 批量删除
 * @param {*} keys
 * @returns
 */
export function BatchDel(keys) {
	return request({
		url: urlPrefix + 'multiple_delete/',
		method: 'delete',
		data: { keys },
	});
}

/**
 * 导出
 * @param params
 */
export function ExportData(params) {
	return downloadFile({
		url: urlPrefix + 'export_data/',
		params: params,
		method: 'get',
	});
}

配置文件:crud.js

/*
 * @文件介绍: 老师管理配置文件
 */
import XEUtils from 'xe-utils';
export const crudOptions = (vm) => {
	return {
		pageOptions: {
			compact: true,
			export: { local: false }, // 服务器导出
		},
		options: {
			height: '100%',
			rowKey: 'id',
		},
		formOptions: {
			width: '80%',
			defaultSpan: 12, // 默认的表单 span
			size: 'mini',
		},
		viewOptions: {
			componentType: 'form', //form=使用表单组件,row=使用行展示组件
		},
		selectionRow: {
			align: 'center',
			width: 40,
		},
		columns: [
			{
				title: '订单编号',
				key: 'order_number',
				form: {
					rules: [
						// 表单校验规则
						{ required: true, message: '订单编号必填项' },
					],
					component: {
						clearable: true,
					},
				},
			},
			{
				title: '购买客户',
				key: 'customer',
				search: {
					disabled: false,
				},
				form: {
					rules: [
						// 表单校验规则
						{ required: true, message: '购买客户必填项' },
					],
					component: {
						clearable: true,
					},
				},
			},
			{
				title: '下单日期',
				key: 'order_date',
				type: 'date',
				width: 90,
				search: {
					disabled: false,
					width: 250,
					component: {
						props: {
							type: 'daterange',
						},
					},
				},
				form: {
					rules: [
						// 表单校验规则
						{ required: true, message: '下单日期必填项' },
					],
					component: {
						clearable: true,
						props: {
							valueFormat: 'yyyy-MM-dd',
							clearable: true,
							pickerOptions: {
								disabledDate: (time) => {
									// 不允许选择从今往后的日期
									return time.getTime() >= Date.now();
								},
							},
						},
					},
				},
				// 提交时,处理数据
				valueResolve(row, col) {
					if (row[col.key] instanceof Array) {
						row[col.key] = row[col.key].join(',');
					}
				},
			},
			{
				title: '支付方式',
				key: 'payment_type',
				search: {
					disabled: false,
				},
				type: 'select',
				dict: {
					data: [
						{ value: '微信', label: '微信' },
						{ value: '支付宝', label: '支付宝' },
						{ value: '其他', label: '其他' },
					],
				},
				form: {
					rules: [
						// 表单校验规则
						{ required: true, message: '支付方式必填项' },
					],
					component: {
						clearable: true,
					},
				},
			},
			{
				title: '是否需要发票',
				key: 'is_invoice',
				type: 'radio',
				dict: {
					data: [
						{ value: false, label: '不需要' },
						{ value: true, label: '需要' },
					],
				},
				form: {
					value: false,
				},
			},
			{
				title: '物流快递',
				key: 'express',
				search: {
					disabled: false,
				},
				type: 'select',
				dict: {
					data: [
						{ value: '中国邮政', label: '中国邮政' },
						{ value: '顺丰', label: '顺丰' },
						{ value: '申通', label: '申通' },
						{ value: '中通', label: '中通' },
						{ value: '韵达', label: '韵达' },
					],
				},
				form: {
					rules: [
						// 表单校验规则
						{ required: true, message: '物流快递必填项' },
					],
					component: {
						clearable: true,
					},
				},
			},
			{
				title: '收货地址',
				key: 'address',
				form: {
					rules: [
						// 表单校验规则
						{ required: true, message: '收货地址必填项' },
					],
					component: {
						span: 24,
						clearable: true,
					},
				},
			},
			{
				title: '备注',
				key: 'description',
				search: {
					disabled: true,
				},
				type: 'textarea',
				form: {
					component: {
						span: 24,
						placeholder: '请输入内容',
						showWordLimit: true,
						maxlength: '200',
						props: {
							type: 'textarea',
						},
					},
				},
			},
			// {
			// 	title: '子表临时数据',
			// 	key: 'inspection',
			// 	type: 'button',
			// 	form: {
			// 		valueChange(key, value, form, others) {
			// 			const orderRecord = vm.$refs.OrderRecordRef.$refs.xGrid;
			// 			const insertRecords = orderRecord.getInsertRecords();
			// 			console.log(insertRecords);
			// 		},
			// 	},
			// },
			{
				title: '订单明细',
				key: 'order_record',
				show: false,
				addForm: {
					component: {
						show: false,
					},
				},
				form: {
					title: '',
					slot: true,
					component: {
						span: 24, // 该字段占据多宽,24为占满一行
					},
					itemProps: {
						// el-form-item的配置
						//  https://element.eleme.cn/#/zh-CN/component/form#form-item-attributes
						labelWidth: '0px', // 可以隐藏表单项的label
					},
				},
			},
		],
	};
};

// 订单明细表配置
export const gridConfig = (vm) => {
	return {
		urlPrefix: '/api/apps/order_record/', // 订单明细api地址
		id: 'order_record',
		size: 'small',
		editConfig: { trigger: 'dblclick', mode: 'row', showStatus: true },
		showFooter: true,
		footerMethod({ columns, data }) {
			const footerData = [
				columns.map((column, _columnIndex) => {
					if (_columnIndex === 0) {
						return '平均';
					}
					if (['price'].includes(column.property)) {
						return XEUtils.round(XEUtils.mean(data, 'price'), 2);
					}
					if (['amount'].includes(column.property)) {
						return XEUtils.round(XEUtils.mean(data, 'amount'), 2);
					}
					return null;
				}),
				columns.map((column, _columnIndex) => {
					if (_columnIndex === 0) {
						return '和值';
					}
					if (['quantity'].includes(column.property)) {
						return XEUtils.sum(data, 'quantity');
					}
					if (['amount'].includes(column.property)) {
						return XEUtils.sum(data, 'amount');
					}
					return null;
				}),
			];
			return footerData;
		},
		// 列配置
		columns: [
			{
				title: '操作',
				fixed: 'left',
				width: 100,
				slots: {
					default: 'operate',
				},
				editRender: {
					enabled: false,
				},
			},
			// 审核显示字段
			{
				field: 'product',
				title: '购买产品',
				editRender: {
					name: '$select',
					options: [
						{ value: '苹果', label: '苹果' },
						{ value: '菠萝', label: '菠萝' },
						{ value: '荔枝', label: '荔枝' },
					],
				},
			},
			{
				field: 'specification',
				title: '型号/规格',
				editRender: {
					name: '$input',
				},
			},
			{
				field: 'price',
				title: '单价',
				editRender: {
					name: '$input',
					defaultValue: 0,
					immediate: true, // 实时更新
					props: {
						type: 'float',
						precision: 2,
						min: 0,
						step: 1,
					},
					events: {
						change({ row }) {
							// 计算金额
							row.amount = row.price * row.quantity;
						},
					},
				},
			},
			{
				field: 'quantity',
				title: '数量',
				editRender: {
					name: '$input',
					defaultValue: 0,
					immediate: true,
					props: {
						type: 'float',
						precision: 2,
						min: 0,
						step: 1,
					},
					events: {
						change({ row }) {
							// 计算金额
							row.amount = row.price * row.quantity;
						},
					},
				},
			},
			{
				field: 'amount',
				title: '金额',
				editRender: {
					enabled: false,
					defaultValue: 0,
				},
			},
			{
				field: 'description',
				title: '备注',
				editRender: {
					name: 'input',
				},
			},
		],
		// 校验规则配置项
		editRules: {
			product: [{ required: true, message: '购买产品' }],
		},
	};
};

页面文件:index.vue

<!--
 * @文件介绍:订单管理
-->
<template>
	<d2-container :class="{ 'page-compact': crud.pageOptions.compact }">
		<template slot="header">订单管理</template>
		<d2-crud-x ref="d2Crud" v-bind="_crudProps" v-on="_crudListeners">
			<div slot="header">
				<crud-search
					ref="search"
					:options="crud.searchOptions"
					@submit="handleSearch"
				/>
				<el-button-group>
					<el-button
						size="small"
						v-permission="'Create'"
						type="primary"
						@click="addRow"
					>
						<i class="el-icon-plus" /> 新增
					</el-button>
					<importExcel :api="api" v-permission="'Import'">导入 </importExcel>
				</el-button-group>
				<crud-toolbar v-bind="_crudToolbarProps" v-on="_crudToolbarListeners" />
			</div>
			<span slot="PaginationPrefixSlot" class="prefix">
				<el-button
					class="square"
					size="mini"
					title="批量删除"
					@click="batchDelete"
					icon="el-icon-delete"
					:disabled="!multipleSelection || multipleSelection.length == 0"
				/>
			</span>

			<!-- 明细插槽 -->
			<template slot="order_recordFormSlot">
				<OrderRecord
					ref="OrderRecordRef"
					:gridConfig="orderCecord.gridConfig"
					:parent="orderCecord.parent"
				/>
			</template>
		</d2-crud-x>
	</d2-container>
</template>

<script>
import * as api from './api';
import OrderRecord from '@/components/sub-table';
import { crudOptions, gridConfig } from './crud';
import { d2CrudPlus } from 'd2-crud-plus';
export default {
	name: 'teacher',
	mixins: [d2CrudPlus.crud],
	components: { OrderRecord },
	data() {
		return {
			api: api.urlPrefix,
			orderCecord: {
				gridConfig: gridConfig(this),
				parent: {
					name: 'order',
					id: undefined,
				},
			},
		};
	},
	methods: {
		// 对话框打开前被调用
		doDialogOpened({ row, mode }) {
			this.orderCecord.gridConfig.mode = mode;
			this.orderCecord.parent.id = row.id;
		},
		getCrudOptions() {
			return crudOptions(this);
		},
		pageRequest(query) {
			return api.GetList(query);
		},
		addRequest(row) {
			return api.AddObj(row);
		},
		updateRequest(row) {
			return api.UpdateObj(row);
		},
		delRequest(row) {
			return api.DelObj(row.id);
		},
		batchDelRequest(ids) {
			return api.BatchDel(ids);
		},
		doServerExport(context) {
			return api.ExportData({ ...context.search });
		},
	},
};
</script>

前端运行

  1. 进入前端项目目录 cd web
  2. 安装依赖 npm install --registry=https://registry.npm.taobao.org
  3. 启动服务 npm run dev