Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vben into dev
This commit is contained in:
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -21,7 +21,8 @@
|
|||||||
// CSS 变量提示
|
// CSS 变量提示
|
||||||
"vunguyentuan.vscode-css-variables",
|
"vunguyentuan.vscode-css-variables",
|
||||||
// 在 package.json 中显示 PNPM catalog 的版本
|
// 在 package.json 中显示 PNPM catalog 的版本
|
||||||
"antfu.pnpm-catalog-lens"
|
"antfu.pnpm-catalog-lens",
|
||||||
|
"augment.vscode-augment"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": [
|
"unwantedRecommendations": [
|
||||||
// 和 volar 冲突
|
// 和 volar 冲突
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ export namespace ErpProductCategoryApi {
|
|||||||
/** ERP 产品分类信息 */
|
/** ERP 产品分类信息 */
|
||||||
export interface ProductCategory {
|
export interface ProductCategory {
|
||||||
id?: number; // 分类编号
|
id?: number; // 分类编号
|
||||||
parentId: number; // 父分类编号
|
parentId?: number; // 父分类编号
|
||||||
name: string; // 分类名称
|
name: string; // 分类名称
|
||||||
code: string; // 分类编码
|
code?: string; // 分类编码
|
||||||
sort: number; // 分类排序
|
sort?: number; // 分类排序
|
||||||
status: number; // 开启状态
|
status?: number; // 开启状态
|
||||||
|
children?: ProductCategory[]; // 子分类
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
namespace ErpStockOutApi {
|
export namespace ErpStockOutApi {
|
||||||
/** 其它出库单信息 */
|
/** 其它出库单信息 */
|
||||||
export interface StockOut {
|
export interface StockOut {
|
||||||
id?: number; // 出库编号
|
id?: number; // 出库编号
|
||||||
@@ -13,6 +13,24 @@ namespace ErpStockOutApi {
|
|||||||
totalPrice: number; // 合计金额,单位:元
|
totalPrice: number; // 合计金额,单位:元
|
||||||
status: number; // 状态
|
status: number; // 状态
|
||||||
remark: string; // 备注
|
remark: string; // 备注
|
||||||
|
fileUrl?: string; // 附件
|
||||||
|
items?: StockOutItem[]; // 出库产品清单
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 其它出库单产品信息 */
|
||||||
|
export interface StockOutItem {
|
||||||
|
id?: number; // 编号
|
||||||
|
warehouseId?: number; // 仓库编号
|
||||||
|
productId?: number; // 产品编号
|
||||||
|
productName?: string; // 产品名称
|
||||||
|
productUnitId?: number; // 产品单位编号
|
||||||
|
productUnitName?: string; // 产品单位名称
|
||||||
|
productBarCode?: string; // 产品条码
|
||||||
|
count: number; // 数量
|
||||||
|
productPrice: number; // 产品单价
|
||||||
|
totalPrice: number; // 总价
|
||||||
|
stockCount?: number; // 库存数量
|
||||||
|
remark?: string; // 备注
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 其它出库单分页查询参数 */
|
/** 其它出库单分页查询参数 */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export namespace InfraFileConfigApi {
|
|||||||
accessKey?: string;
|
accessKey?: string;
|
||||||
accessSecret?: string;
|
accessSecret?: string;
|
||||||
pathStyle?: boolean;
|
pathStyle?: boolean;
|
||||||
|
enablePublicAccess?: boolean;
|
||||||
domain: string;
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as Tinyflow } from './tinyflow.vue';
|
export { default as Tinyflow } from './Tinyflow.vue';
|
||||||
export * from './ui/typing';
|
export * from './ui/typing';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { isNumber } from '@vben/utils';
|
|||||||
import { Button, Input, Select } from 'ant-design-vue';
|
import { Button, Input, Select } from 'ant-design-vue';
|
||||||
|
|
||||||
import { testWorkflow } from '#/api/ai/workflow';
|
import { testWorkflow } from '#/api/ai/workflow';
|
||||||
import { Tinyflow } from '#/components/tinyflow';
|
import { Tinyflow } from '#/components/Tinyflow';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
provider: any;
|
provider: any;
|
||||||
|
|||||||
125
apps/web-antd/src/views/erp/product/category/data.ts
Normal file
125
apps/web-antd/src/views/erp/product/category/data.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { ErpProductCategoryApi } from '#/api/erp/product/category';
|
||||||
|
|
||||||
|
import { handleTree } from '@vben/utils';
|
||||||
|
|
||||||
|
import { z } from '#/adapter/form';
|
||||||
|
import { getProductCategoryList } from '#/api/erp/product/category';
|
||||||
|
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
|
||||||
|
|
||||||
|
/** 新增/修改的表单 */
|
||||||
|
export function useFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'id',
|
||||||
|
component: 'Input',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: [''],
|
||||||
|
show: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'parentId',
|
||||||
|
label: '上级分类',
|
||||||
|
component: 'ApiTreeSelect',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
api: async () => {
|
||||||
|
const data = await getProductCategoryList();
|
||||||
|
data.unshift({
|
||||||
|
id: 0,
|
||||||
|
name: '顶级分类',
|
||||||
|
});
|
||||||
|
return handleTree(data);
|
||||||
|
},
|
||||||
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
childrenField: 'children',
|
||||||
|
placeholder: '请选择上级分类',
|
||||||
|
treeDefaultExpandAll: true,
|
||||||
|
},
|
||||||
|
rules: 'selectRequired',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '分类名称',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入分类名称',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'code',
|
||||||
|
label: '分类编码',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入分类编码',
|
||||||
|
},
|
||||||
|
rules: z.string().regex(/^[A-Z]+$/, '分类编码必须为大写字母'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'sort',
|
||||||
|
label: '显示顺序',
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
min: 0,
|
||||||
|
controlsPosition: 'right',
|
||||||
|
placeholder: '请输入显示顺序',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||||
|
buttonStyle: 'solid',
|
||||||
|
optionType: 'button',
|
||||||
|
},
|
||||||
|
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(): VxeTableGridOptions<ErpProductCategoryApi.ProductCategory>['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '分类名称',
|
||||||
|
align: 'left',
|
||||||
|
treeNode: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'code',
|
||||||
|
title: '分类编码',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sort',
|
||||||
|
title: '显示顺序',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '分类状态',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '创建时间',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,34 +1,166 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DocAlert, Page } from '@vben/common-ui';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { ErpProductCategoryApi } from '#/api/erp/product/category';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import {
|
||||||
|
deleteProductCategory,
|
||||||
|
getProductCategoryList,
|
||||||
|
} from '#/api/erp/product/category';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useGridColumns } from './data';
|
||||||
|
import Form from './modules/form.vue';
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: Form,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function onRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换树形展开/收缩状态 */
|
||||||
|
const isExpanded = ref(true);
|
||||||
|
function toggleExpand() {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
gridApi.grid.setAllTreeExpand(isExpanded.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建分类 */
|
||||||
|
function handleCreate() {
|
||||||
|
formModalApi.setData(null).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加下级分类 */
|
||||||
|
function handleAppend(row: ErpProductCategoryApi.ProductCategory) {
|
||||||
|
formModalApi.setData({ parentId: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑分类 */
|
||||||
|
function handleEdit(row: ErpProductCategoryApi.ProductCategory) {
|
||||||
|
formModalApi.setData(row).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除分类 */
|
||||||
|
async function handleDelete(row: ErpProductCategoryApi.ProductCategory) {
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||||
|
key: 'action_key_msg',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteProductCategory(row.id as number);
|
||||||
|
message.success({
|
||||||
|
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||||
|
key: 'action_key_msg',
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async () => {
|
||||||
|
return await getProductCategoryList();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
treeConfig: {
|
||||||
|
transform: true,
|
||||||
|
rowField: 'id',
|
||||||
|
parentField: 'parentId',
|
||||||
|
expandAll: true,
|
||||||
|
accordion: false,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<ErpProductCategoryApi.ProductCategory>,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<template #doc>
|
<template #doc>
|
||||||
<DocAlert
|
<DocAlert
|
||||||
title="【产品】产品信息、分类、单位"
|
title="【产品】产品信息、分类、单位"
|
||||||
url="https://doc.iocoder.cn/erp/product/"
|
url="https://doc.iocoder.cn/erp/product/"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<FormModal @success="onRefresh" />
|
||||||
danger
|
<Grid table-title="产品分类列表">
|
||||||
type="link"
|
<template #toolbar-tools>
|
||||||
target="_blank"
|
<TableAction
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
:actions="[
|
||||||
>
|
{
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
label: $t('ui.actionTitle.create', ['产品分类']),
|
||||||
</Button>
|
type: 'primary',
|
||||||
<br />
|
icon: ACTION_ICON.ADD,
|
||||||
<Button
|
auth: ['erp:product-category:create'],
|
||||||
type="link"
|
onClick: handleCreate,
|
||||||
target="_blank"
|
},
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/category/index"
|
{
|
||||||
>
|
label: isExpanded ? '收缩' : '展开',
|
||||||
可参考
|
type: 'primary',
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/category/index
|
onClick: toggleExpand,
|
||||||
代码,pull request 贡献给我们!
|
},
|
||||||
</Button>
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增下级',
|
||||||
|
type: 'link',
|
||||||
|
icon: ACTION_ICON.ADD,
|
||||||
|
auth: ['erp:product-category:create'],
|
||||||
|
onClick: handleAppend.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.edit'),
|
||||||
|
type: 'link',
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
auth: ['erp:product-category:update'],
|
||||||
|
onClick: handleEdit.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'link',
|
||||||
|
danger: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['erp:product-category:delete'],
|
||||||
|
ifShow: !(row.children && row.children.length > 0),
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ErpProductCategoryApi } from '#/api/erp/product/category';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
createProductCategory,
|
||||||
|
getProductCategory,
|
||||||
|
updateProductCategory,
|
||||||
|
} from '#/api/erp/product/category';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<ErpProductCategoryApi.ProductCategory>();
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
return formData.value?.id
|
||||||
|
? $t('ui.actionTitle.edit', ['分类'])
|
||||||
|
: $t('ui.actionTitle.create', ['分类']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 80,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data =
|
||||||
|
(await formApi.getValues()) as ErpProductCategoryApi.ProductCategory;
|
||||||
|
try {
|
||||||
|
await (formData.value?.id
|
||||||
|
? updateProductCategory(data)
|
||||||
|
: createProductCategory(data));
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载数据
|
||||||
|
let data = modalApi.getData<ErpProductCategoryApi.ProductCategory>();
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.id) {
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
data = await getProductCategory(data.id);
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 设置到 values
|
||||||
|
formData.value = data;
|
||||||
|
await formApi.setValues(formData.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="getTitle">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
220
apps/web-antd/src/views/erp/product/product/data.ts
Normal file
220
apps/web-antd/src/views/erp/product/product/data.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { handleTree } from '@vben/utils';
|
||||||
|
|
||||||
|
import { z } from '#/adapter/form';
|
||||||
|
import { getProductCategorySimpleList } from '#/api/erp/product/category';
|
||||||
|
import { getProductUnitSimpleList } from '#/api/erp/product/unit';
|
||||||
|
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
|
||||||
|
|
||||||
|
/** 新增/修改的表单 */
|
||||||
|
export function useFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'id',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: [''],
|
||||||
|
show: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '名称',
|
||||||
|
rules: 'required',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入名称',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'barCode',
|
||||||
|
label: '条码',
|
||||||
|
component: 'Input',
|
||||||
|
rules: 'required',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入条码',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'categoryId',
|
||||||
|
label: '分类',
|
||||||
|
component: 'ApiTreeSelect',
|
||||||
|
componentProps: {
|
||||||
|
api: async () => {
|
||||||
|
const data = await getProductCategorySimpleList();
|
||||||
|
return handleTree(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
childrenField: 'children',
|
||||||
|
placeholder: '请选择分类',
|
||||||
|
treeDefaultExpandAll: true,
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'unitId',
|
||||||
|
label: '单位',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
api: getProductUnitSimpleList,
|
||||||
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
placeholder: '请选择单位',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||||
|
buttonStyle: 'solid',
|
||||||
|
optionType: 'button',
|
||||||
|
},
|
||||||
|
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'standard',
|
||||||
|
label: '规格',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入规格',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'expiryDay',
|
||||||
|
label: '保质期天数',
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入保质期天数',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'weight',
|
||||||
|
label: '重量(kg)',
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入重量(kg)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'purchasePrice',
|
||||||
|
label: '采购价格',
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入采购价格,单位:元',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'salePrice',
|
||||||
|
label: '销售价格',
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入销售价格,单位:元',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'minPrice',
|
||||||
|
label: '最低价格',
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入最低价格,单位:元',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
component: 'Textarea',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '名称',
|
||||||
|
component: 'Input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'categoryId',
|
||||||
|
label: '分类',
|
||||||
|
component: 'ApiTreeSelect',
|
||||||
|
componentProps: {
|
||||||
|
api: async () => {
|
||||||
|
const data = await getProductCategorySimpleList();
|
||||||
|
return handleTree(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
childrenField: 'children',
|
||||||
|
placeholder: '请选择分类',
|
||||||
|
treeDefaultExpandAll: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
field: 'barCode',
|
||||||
|
title: '条码',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '名称',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'standard',
|
||||||
|
title: '规格',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'categoryName',
|
||||||
|
title: '分类',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'unitName',
|
||||||
|
title: '单位',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'purchasePrice',
|
||||||
|
title: '采购价格',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'salePrice',
|
||||||
|
title: '销售价格',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'minPrice',
|
||||||
|
title: '最低价格',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '创建时间',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 130,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,34 +1,152 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DocAlert, Page } from '@vben/common-ui';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { ErpProductApi } from '#/api/erp/product/product';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import {
|
||||||
|
deleteProduct,
|
||||||
|
exportProduct,
|
||||||
|
getProductPage,
|
||||||
|
} from '#/api/erp/product/product';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import Form from './modules/form.vue';
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: Form,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function onRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出表格 */
|
||||||
|
async function handleExport() {
|
||||||
|
const data = await exportProduct(await gridApi.formApi.getValues());
|
||||||
|
downloadFileFromBlobPart({ fileName: '产品信息.xls', source: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建产品 */
|
||||||
|
function handleCreate() {
|
||||||
|
formModalApi.setData(null).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑产品 */
|
||||||
|
function handleEdit(row: ErpProductApi.Product) {
|
||||||
|
formModalApi.setData(row).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除产品 */
|
||||||
|
async function handleDelete(row: ErpProductApi.Product) {
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||||
|
key: 'action_key_msg',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteProduct(row.id as number);
|
||||||
|
message.success({
|
||||||
|
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||||
|
key: 'action_key_msg',
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getProductPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<ErpProductApi.Product>,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<template #doc>
|
<template #doc>
|
||||||
<DocAlert
|
<DocAlert
|
||||||
title="【产品】产品信息、分类、单位"
|
title="【产品】产品信息、分类、单位"
|
||||||
url="https://doc.iocoder.cn/erp/product/"
|
url="https://doc.iocoder.cn/erp/product/"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<FormModal @success="onRefresh" />
|
||||||
danger
|
<Grid table-title="产品列表">
|
||||||
type="link"
|
<template #toolbar-tools>
|
||||||
target="_blank"
|
<TableAction
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
:actions="[
|
||||||
>
|
{
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
label: $t('ui.actionTitle.create', ['产品']),
|
||||||
</Button>
|
type: 'primary',
|
||||||
<br />
|
icon: ACTION_ICON.ADD,
|
||||||
<Button
|
auth: ['erp:product:create'],
|
||||||
type="link"
|
onClick: handleCreate,
|
||||||
target="_blank"
|
},
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/product/index"
|
{
|
||||||
>
|
label: $t('ui.actionTitle.export'),
|
||||||
可参考
|
type: 'primary',
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/product/index
|
icon: ACTION_ICON.DOWNLOAD,
|
||||||
代码,pull request 贡献给我们!
|
auth: ['erp:product:export'],
|
||||||
</Button>
|
onClick: handleExport,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: $t('common.edit'),
|
||||||
|
type: 'link',
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
auth: ['erp:product:update'],
|
||||||
|
onClick: handleEdit.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'link',
|
||||||
|
danger: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['erp:product:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
86
apps/web-antd/src/views/erp/product/product/modules/form.vue
Normal file
86
apps/web-antd/src/views/erp/product/product/modules/form.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ErpProductApi } from '#/api/erp/product/product';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
createProduct,
|
||||||
|
getProduct,
|
||||||
|
updateProduct,
|
||||||
|
} from '#/api/erp/product/product';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<ErpProductApi.Product>();
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
return formData.value?.id
|
||||||
|
? $t('ui.actionTitle.edit', ['产品'])
|
||||||
|
: $t('ui.actionTitle.create', ['产品']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 一共2列
|
||||||
|
wrapperClass: 'grid-cols-2',
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data = (await formApi.getValues()) as ErpProductApi.Product;
|
||||||
|
try {
|
||||||
|
await (formData.value?.id ? updateProduct(data) : createProduct(data));
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<ErpProductApi.Product>();
|
||||||
|
if (!data || !data.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
formData.value = await getProduct(data.id as number);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(formData.value);
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal class="w-2/5" :title="getTitle">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
89
apps/web-antd/src/views/erp/product/unit/data.ts
Normal file
89
apps/web-antd/src/views/erp/product/unit/data.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { z } from '#/adapter/form';
|
||||||
|
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
|
||||||
|
|
||||||
|
/** 新增/修改的表单 */
|
||||||
|
export function useFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'id',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: [''],
|
||||||
|
show: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '单位名称',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '单位状态',
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||||
|
buttonStyle: 'solid',
|
||||||
|
optionType: 'button',
|
||||||
|
},
|
||||||
|
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '单位名称',
|
||||||
|
component: 'Input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '单位状态',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
field: 'id',
|
||||||
|
title: '单位编号',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '单位名称',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '单位状态',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '创建时间',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 130,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,34 +1,152 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DocAlert, Page } from '@vben/common-ui';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { ErpProductUnitApi } from '#/api/erp/product/unit';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import {
|
||||||
|
deleteProductUnit,
|
||||||
|
exportProductUnit,
|
||||||
|
getProductUnitPage,
|
||||||
|
} from '#/api/erp/product/unit';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import Form from './modules/form.vue';
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: Form,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function onRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出表格 */
|
||||||
|
async function handleExport() {
|
||||||
|
const data = await exportProductUnit(await gridApi.formApi.getValues());
|
||||||
|
downloadFileFromBlobPart({ fileName: '产品单位.xls', source: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建产品单位 */
|
||||||
|
function handleCreate() {
|
||||||
|
formModalApi.setData(null).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑产品单位 */
|
||||||
|
function handleEdit(row: ErpProductUnitApi.ProductUnit) {
|
||||||
|
formModalApi.setData(row).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除产品单位 */
|
||||||
|
async function handleDelete(row: ErpProductUnitApi.ProductUnit) {
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||||
|
key: 'action_key_msg',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteProductUnit(row.id as number);
|
||||||
|
message.success({
|
||||||
|
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||||
|
key: 'action_key_msg',
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getProductUnitPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<ErpProductUnitApi.ProductUnit>,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<template #doc>
|
<template #doc>
|
||||||
<DocAlert
|
<DocAlert
|
||||||
title="【产品】产品信息、分类、单位"
|
title="【产品】产品信息、分类、单位"
|
||||||
url="https://doc.iocoder.cn/erp/product/"
|
url="https://doc.iocoder.cn/erp/product/"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<FormModal @success="onRefresh" />
|
||||||
danger
|
<Grid table-title="产品单位列表">
|
||||||
type="link"
|
<template #toolbar-tools>
|
||||||
target="_blank"
|
<TableAction
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
:actions="[
|
||||||
>
|
{
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
label: $t('ui.actionTitle.create', ['产品单位']),
|
||||||
</Button>
|
type: 'primary',
|
||||||
<br />
|
icon: ACTION_ICON.ADD,
|
||||||
<Button
|
auth: ['erp:product-unit:create'],
|
||||||
type="link"
|
onClick: handleCreate,
|
||||||
target="_blank"
|
},
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/unit/index"
|
{
|
||||||
>
|
label: $t('ui.actionTitle.export'),
|
||||||
可参考
|
type: 'primary',
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/unit/index
|
icon: ACTION_ICON.DOWNLOAD,
|
||||||
代码,pull request 贡献给我们!
|
auth: ['erp:product-unit:export'],
|
||||||
</Button>
|
onClick: handleExport,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: $t('common.edit'),
|
||||||
|
type: 'link',
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
auth: ['erp:product-unit:update'],
|
||||||
|
onClick: handleEdit.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'link',
|
||||||
|
danger: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['erp:product-unit:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
88
apps/web-antd/src/views/erp/product/unit/modules/form.vue
Normal file
88
apps/web-antd/src/views/erp/product/unit/modules/form.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ErpProductUnitApi } from '#/api/erp/product/unit';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
createProductUnit,
|
||||||
|
getProductUnit,
|
||||||
|
updateProductUnit,
|
||||||
|
} from '#/api/erp/product/unit';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<ErpProductUnitApi.ProductUnit>();
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
return formData.value?.id
|
||||||
|
? $t('ui.actionTitle.edit', ['产品单位'])
|
||||||
|
: $t('ui.actionTitle.create', ['产品单位']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 80,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data = (await formApi.getValues()) as ErpProductUnitApi.ProductUnit;
|
||||||
|
try {
|
||||||
|
await (formData.value?.id
|
||||||
|
? updateProductUnit(data)
|
||||||
|
: createProductUnit(data));
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<ErpProductUnitApi.ProductUnit>();
|
||||||
|
if (!data || !data.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
formData.value = await getProductUnit(data.id as number);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(formData.value);
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal class="w-2/5" :title="getTitle">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -114,7 +114,11 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
// 提交表单
|
// 提交表单
|
||||||
const data =
|
const data =
|
||||||
(await formApi.getValues()) as ErpPurchaseOrderApi.PurchaseOrder;
|
(await formApi.getValues()) as ErpPurchaseOrderApi.PurchaseOrder;
|
||||||
data.items = formData.value?.items;
|
data.items = formData.value?.items?.map((item) => ({
|
||||||
|
...item,
|
||||||
|
// 解决新增采购订单报错
|
||||||
|
id: undefined,
|
||||||
|
}));
|
||||||
// 将文件数组转换为字符串
|
// 将文件数组转换为字符串
|
||||||
if (data.fileUrl && Array.isArray(data.fileUrl)) {
|
if (data.fileUrl && Array.isArray(data.fileUrl)) {
|
||||||
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';
|
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const data = (await formApi.getValues()) as ErpStockInApi.StockIn;
|
const data = (await formApi.getValues()) as ErpStockInApi.StockIn;
|
||||||
data.items = formData.value?.items;
|
data.items = formData.value?.items?.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: undefined,
|
||||||
|
}));
|
||||||
// 将文件数组转换为字符串
|
// 将文件数组转换为字符串
|
||||||
if (data.fileUrl && Array.isArray(data.fileUrl)) {
|
if (data.fileUrl && Array.isArray(data.fileUrl)) {
|
||||||
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';
|
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';
|
||||||
|
|||||||
329
apps/web-antd/src/views/erp/stock/out/data.ts
Normal file
329
apps/web-antd/src/views/erp/stock/out/data.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { createRequiredValidation } from '#/adapter/vxe-table';
|
||||||
|
import { getProductSimpleList } from '#/api/erp/product/product';
|
||||||
|
import { getCustomerSimpleList } from '#/api/erp/sale/customer';
|
||||||
|
import { getSimpleUserList } from '#/api/system/user';
|
||||||
|
import { DICT_TYPE, getDictOptions } from '#/utils';
|
||||||
|
|
||||||
|
/** 表单的配置项 */
|
||||||
|
export function useFormSchema(formType: string): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
style: { display: 'none' },
|
||||||
|
},
|
||||||
|
fieldName: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
hideLabel: true,
|
||||||
|
formItemClass: 'hidden',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '系统自动生成',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
fieldName: 'no',
|
||||||
|
label: '出库单号',
|
||||||
|
disabled: formType === 'detail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'DatePicker',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '选择出库时间',
|
||||||
|
showTime: true,
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
valueFormat: 'x',
|
||||||
|
style: { width: '100%' },
|
||||||
|
},
|
||||||
|
disabled: formType === 'detail',
|
||||||
|
fieldName: 'outTime',
|
||||||
|
label: '出库时间',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择客户',
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
api: getCustomerSimpleList,
|
||||||
|
fieldNames: {
|
||||||
|
label: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disabled: formType === 'detail',
|
||||||
|
fieldName: 'customerId',
|
||||||
|
label: '客户',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入备注',
|
||||||
|
autoSize: { minRows: 2, maxRows: 4 },
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
disabled: formType === 'detail',
|
||||||
|
fieldName: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
formItemClass: 'col-span-3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'FileUpload',
|
||||||
|
disabled: formType === 'detail',
|
||||||
|
componentProps: {
|
||||||
|
maxNumber: 1,
|
||||||
|
maxSize: 10,
|
||||||
|
accept: [
|
||||||
|
'pdf',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'txt',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
],
|
||||||
|
showDescription: true,
|
||||||
|
},
|
||||||
|
fieldName: 'fileUrl',
|
||||||
|
label: '附件',
|
||||||
|
formItemClass: 'col-span-3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'product',
|
||||||
|
disabled: formType === 'detail',
|
||||||
|
label: '产品清单',
|
||||||
|
component: 'Input',
|
||||||
|
formItemClass: 'col-span-3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 出库产品清单表格列定义 */
|
||||||
|
export function useStockInItemTableColumns(
|
||||||
|
isValidating?: any,
|
||||||
|
): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{ type: 'seq', title: '序号', minWidth: 50, fixed: 'left' },
|
||||||
|
{
|
||||||
|
field: 'warehouseId',
|
||||||
|
title: '仓库名称',
|
||||||
|
minWidth: 150,
|
||||||
|
slots: { default: 'warehouseId' },
|
||||||
|
className: createRequiredValidation(isValidating, 'warehouseId'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'productId',
|
||||||
|
title: '产品名称',
|
||||||
|
minWidth: 200,
|
||||||
|
slots: { default: 'productId' },
|
||||||
|
className: createRequiredValidation(isValidating, 'productId'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'stockCount',
|
||||||
|
title: '库存',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'productBarCode',
|
||||||
|
title: '条码',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'productUnitName',
|
||||||
|
title: '单位',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'count',
|
||||||
|
title: '数量',
|
||||||
|
minWidth: 120,
|
||||||
|
slots: { default: 'count' },
|
||||||
|
className: createRequiredValidation(isValidating, 'count'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'productPrice',
|
||||||
|
title: '产品单价',
|
||||||
|
minWidth: 120,
|
||||||
|
slots: { default: 'productPrice' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'totalPrice',
|
||||||
|
title: '金额',
|
||||||
|
minWidth: 120,
|
||||||
|
formatter: 'formatAmount2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'remark',
|
||||||
|
title: '备注',
|
||||||
|
minWidth: 150,
|
||||||
|
slots: { default: 'remark' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 50,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'no',
|
||||||
|
label: '出库单号',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入出库单号',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'productId',
|
||||||
|
label: '产品',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择产品',
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
api: getProductSimpleList,
|
||||||
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
filterOption: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'outTime',
|
||||||
|
label: '出库时间',
|
||||||
|
component: 'RangePicker',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: ['开始日期', '结束日期'],
|
||||||
|
showTime: true,
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'customerId',
|
||||||
|
label: '客户',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择客户',
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
api: getCustomerSimpleList,
|
||||||
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
filterOption: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择状态',
|
||||||
|
allowClear: true,
|
||||||
|
options: getDictOptions(DICT_TYPE.ERP_AUDIT_STATUS, 'number'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入备注',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'creator',
|
||||||
|
label: '创建人',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择创建人',
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
api: getSimpleUserList,
|
||||||
|
labelField: 'nickname',
|
||||||
|
valueField: 'id',
|
||||||
|
filterOption: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'checkbox',
|
||||||
|
width: 50,
|
||||||
|
fixed: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'no',
|
||||||
|
title: '出库单号',
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'productNames',
|
||||||
|
title: '产品信息',
|
||||||
|
minWidth: 200,
|
||||||
|
showOverflow: 'tooltip',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'customerName',
|
||||||
|
title: '客户',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'outTime',
|
||||||
|
title: '出库时间',
|
||||||
|
minWidth: 180,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDateTime',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'creatorName',
|
||||||
|
title: '创建人',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'totalCount',
|
||||||
|
title: '数量',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'totalPrice',
|
||||||
|
title: '价格',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
minWidth: 90,
|
||||||
|
fixed: 'right',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.ERP_AUDIT_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 300,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,34 +1,219 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DocAlert, Page } from '@vben/common-ui';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { ErpStockOutApi } from '#/api/erp/stock/out';
|
||||||
|
|
||||||
import { Button } from 'ant-design-vue';
|
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import {
|
||||||
|
deleteStockOut,
|
||||||
|
exportStockOut,
|
||||||
|
getStockOutPage,
|
||||||
|
updateStockOutStatus,
|
||||||
|
} from '#/api/erp/stock/out';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import StockInForm from './modules/form.vue';
|
||||||
|
|
||||||
|
/** 其它出库单管理 */
|
||||||
|
defineOptions({ name: 'ErpStockOut' });
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: StockInForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function onRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出出库单 */
|
||||||
|
async function handleExport() {
|
||||||
|
const data = await exportStockOut(await gridApi.formApi.getValues());
|
||||||
|
downloadFileFromBlobPart({ fileName: '其它出库单.xls', source: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑/详情 */
|
||||||
|
function openForm(type: string, id?: number) {
|
||||||
|
formModalApi.setData({ type, id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 */
|
||||||
|
async function handleDelete(ids: any[]) {
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: '删除中...',
|
||||||
|
duration: 0,
|
||||||
|
key: 'action_process_msg',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteStockOut(ids);
|
||||||
|
message.success({
|
||||||
|
content: '删除成功',
|
||||||
|
key: 'action_process_msg',
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审核/反审核 */
|
||||||
|
async function handleUpdateStatus(id: any, status: number) {
|
||||||
|
const statusText = status === 20 ? '审核' : '反审核';
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: `${statusText}中...`,
|
||||||
|
duration: 0,
|
||||||
|
key: 'action_process_msg',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await updateStockOutStatus({ id, status });
|
||||||
|
message.success({
|
||||||
|
content: `${statusText}成功`,
|
||||||
|
key: 'action_process_msg',
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getStockOutPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
checkboxConfig: {
|
||||||
|
reserve: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<ErpStockOutApi.StockOut>,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<template #doc>
|
<template #doc>
|
||||||
<DocAlert
|
<DocAlert
|
||||||
title="【库存】其它入库、其它出库"
|
title="【库存】其它入库、其它出库"
|
||||||
url="https://doc.iocoder.cn/erp/stock-in-out/"
|
url="https://doc.iocoder.cn/erp/stock-in-out/"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<Grid table-title="其它出库单列表">
|
||||||
danger
|
<template #toolbar-tools>
|
||||||
type="link"
|
<TableAction
|
||||||
target="_blank"
|
:actions="[
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
{
|
||||||
>
|
label: $t('ui.actionTitle.create', ['其它出库']),
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
type: 'primary',
|
||||||
</Button>
|
icon: ACTION_ICON.ADD,
|
||||||
<br />
|
auth: ['erp:stock-out:create'],
|
||||||
<Button
|
onClick: () => openForm('create'),
|
||||||
type="link"
|
},
|
||||||
target="_blank"
|
{
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/stock/out/index"
|
label: $t('ui.actionTitle.export'),
|
||||||
>
|
type: 'default',
|
||||||
可参考
|
icon: ACTION_ICON.DOWNLOAD,
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/stock/out/index
|
auth: ['erp:stock-out:export'],
|
||||||
代码,pull request 贡献给我们!
|
onClick: handleExport,
|
||||||
</Button>
|
},
|
||||||
|
{
|
||||||
|
label: '批量删除',
|
||||||
|
type: 'default',
|
||||||
|
danger: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['erp:stock-out:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: '是否删除所选中数据?',
|
||||||
|
confirm: () => {
|
||||||
|
const checkboxRecords = gridApi.grid.getCheckboxRecords();
|
||||||
|
if (checkboxRecords.length === 0) {
|
||||||
|
message.warning('请选择要删除的数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleDelete(checkboxRecords.map((item) => item.id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '详情',
|
||||||
|
icon: ACTION_ICON.VIEW,
|
||||||
|
auth: ['erp:stock-out:query'],
|
||||||
|
onClick: () => openForm('detail', row.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
auth: ['erp:stock-out:update'],
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
disabled: row.status !== 10,
|
||||||
|
onClick: () => openForm('update', row.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '审核',
|
||||||
|
auth: ['erp:stock-out:update'],
|
||||||
|
ifShow: row.status === 10,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确认要审核该入库单吗?',
|
||||||
|
confirm: () => handleUpdateStatus(row.id, 20),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '反审核',
|
||||||
|
danger: true,
|
||||||
|
auth: ['erp:stock-out:update'],
|
||||||
|
ifShow: row.status === 20,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确认要反审核该入库单吗?',
|
||||||
|
confirm: () => handleUpdateStatus(row.id, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
danger: true,
|
||||||
|
auth: ['erp:stock-out:delete'],
|
||||||
|
disabled: row.status !== 10,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确认要删除该出库单吗?',
|
||||||
|
confirm: () => handleDelete([row.id]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 表单弹窗 -->
|
||||||
|
<FormModal @success="onRefresh" />
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
196
apps/web-antd/src/views/erp/stock/out/modules/form.vue
Normal file
196
apps/web-antd/src/views/erp/stock/out/modules/form.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ErpStockOutApi } from '#/api/erp/stock/out';
|
||||||
|
|
||||||
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
createStockOut,
|
||||||
|
getStockOut,
|
||||||
|
updateStockOut,
|
||||||
|
updateStockOutStatus,
|
||||||
|
} from '#/api/erp/stock/out';
|
||||||
|
|
||||||
|
import { useFormSchema } from '../data';
|
||||||
|
import StockInItemForm from './stock-out-item-form.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<ErpStockOutApi.StockOut>();
|
||||||
|
const formType = ref('');
|
||||||
|
const itemFormRef = ref();
|
||||||
|
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
if (formType.value === 'create') return '添加其它出库单';
|
||||||
|
if (formType.value === 'update') return '编辑其它出库单';
|
||||||
|
return '其它出库单详情';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
wrapperClass: 'grid-cols-3',
|
||||||
|
layout: 'vertical',
|
||||||
|
schema: useFormSchema(formType.value),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpdateItems = (items: ErpStockOutApi.StockOutItem[]) => {
|
||||||
|
formData.value = modalApi.getData<ErpStockOutApi.StockOut>();
|
||||||
|
if (formData.value) {
|
||||||
|
formData.value.items = items;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或更新其它出库单
|
||||||
|
*/
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const itemFormInstance = Array.isArray(itemFormRef.value)
|
||||||
|
? itemFormRef.value[0]
|
||||||
|
: itemFormRef.value;
|
||||||
|
if (itemFormInstance && typeof itemFormInstance.validate === 'function') {
|
||||||
|
try {
|
||||||
|
const isValid = await itemFormInstance.validate();
|
||||||
|
if (!isValid) {
|
||||||
|
message.error('产品清单验证失败,请检查必填项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '产品清单验证失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error('产品清单验证方法不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证产品清单不能为空
|
||||||
|
if (!formData.value?.items || formData.value.items.length === 0) {
|
||||||
|
message.error('产品清单不能为空,请至少添加一个产品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data = (await formApi.getValues()) as ErpStockOutApi.StockOut;
|
||||||
|
data.items = formData.value?.items?.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: undefined,
|
||||||
|
}));
|
||||||
|
// 将文件数组转换为字符串
|
||||||
|
if (data.fileUrl && Array.isArray(data.fileUrl)) {
|
||||||
|
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await (formType.value === 'create'
|
||||||
|
? createStockOut(data)
|
||||||
|
: updateStockOut(data));
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success(formType.value === 'create' ? '新增成功' : '更新成功');
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<{ id?: number; type: string }>();
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formType.value = data.type;
|
||||||
|
formApi.updateSchema(useFormSchema(formType.value));
|
||||||
|
if (!data.id) {
|
||||||
|
// 初始化空的表单数据
|
||||||
|
formData.value = { items: [] } as unknown as ErpStockOutApi.StockOut;
|
||||||
|
await nextTick();
|
||||||
|
const itemFormInstance = Array.isArray(itemFormRef.value)
|
||||||
|
? itemFormRef.value[0]
|
||||||
|
: itemFormRef.value;
|
||||||
|
if (itemFormInstance && typeof itemFormInstance.init === 'function') {
|
||||||
|
itemFormInstance.init([]);
|
||||||
|
}
|
||||||
|
// 如果是新增,自动添加一行
|
||||||
|
if (formType.value === 'create' && itemFormInstance) {
|
||||||
|
itemFormInstance.handleAdd();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
formData.value = await getStockOut(data.id);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(formData.value);
|
||||||
|
// 初始化子表单
|
||||||
|
await nextTick();
|
||||||
|
const itemFormInstance = Array.isArray(itemFormRef.value)
|
||||||
|
? itemFormRef.value[0]
|
||||||
|
: itemFormRef.value;
|
||||||
|
if (itemFormInstance && typeof itemFormInstance.init === 'function') {
|
||||||
|
itemFormInstance.init(formData.value.items || []);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 审核/反审核 */
|
||||||
|
async function handleUpdateStatus(id: number, status: number) {
|
||||||
|
try {
|
||||||
|
await updateStockOutStatus({ id, status });
|
||||||
|
message.success(status === 20 ? '审核成功' : '反审核成功');
|
||||||
|
emit('success');
|
||||||
|
await modalApi.close();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '操作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ modalApi, handleUpdateStatus });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
v-bind="$attrs"
|
||||||
|
:title="getTitle"
|
||||||
|
class="w-4/5"
|
||||||
|
:closable="true"
|
||||||
|
:mask-closable="true"
|
||||||
|
:show-confirm-button="formType !== 'detail'"
|
||||||
|
>
|
||||||
|
<Form class="mx-3">
|
||||||
|
<template #product="slotProps">
|
||||||
|
<StockInItemForm
|
||||||
|
v-bind="slotProps"
|
||||||
|
ref="itemFormRef"
|
||||||
|
class="w-full"
|
||||||
|
:items="formData?.items ?? []"
|
||||||
|
:disabled="formType === 'detail'"
|
||||||
|
@update:items="handleUpdateItems"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { ErpStockOutApi } from '#/api/erp/stock/out';
|
||||||
|
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { erpPriceMultiply } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Input, InputNumber, Select } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getProductSimpleList } from '#/api/erp/product/product';
|
||||||
|
import { getStockCount } from '#/api/erp/stock/stock';
|
||||||
|
import { getWarehouseSimpleList } from '#/api/erp/stock/warehouse';
|
||||||
|
|
||||||
|
import { useStockInItemTableColumns } from '../data';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
items: () => [],
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:items']);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items?: ErpStockOutApi.StockOutItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData = ref<ErpStockOutApi.StockOutItem[]>([]);
|
||||||
|
const productOptions = ref<any[]>([]);
|
||||||
|
const warehouseOptions = ref<any[]>([]);
|
||||||
|
const isValidating = ref(false);
|
||||||
|
|
||||||
|
/** 表格配置 */
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
gridOptions: {
|
||||||
|
editConfig: {
|
||||||
|
trigger: 'click',
|
||||||
|
mode: 'cell',
|
||||||
|
},
|
||||||
|
columns: useStockInItemTableColumns(isValidating),
|
||||||
|
data: tableData.value,
|
||||||
|
border: true,
|
||||||
|
showOverflow: true,
|
||||||
|
autoResize: true,
|
||||||
|
minHeight: 250,
|
||||||
|
keepSource: true,
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
showFooter: true,
|
||||||
|
footerCellClassName: 'stock-in-footer-cell',
|
||||||
|
footerMethod: ({
|
||||||
|
columns,
|
||||||
|
}: {
|
||||||
|
columns: VxeTableGridOptions['columns'];
|
||||||
|
}) => {
|
||||||
|
const footers: any[][] = [];
|
||||||
|
const sums = getSummaries();
|
||||||
|
const footerData: any[] = [];
|
||||||
|
columns!.forEach((column, columnIndex: number) => {
|
||||||
|
if (columnIndex === 0) {
|
||||||
|
footerData.push('合计');
|
||||||
|
} else if (column.field === 'count') {
|
||||||
|
footerData.push(sums.count);
|
||||||
|
} else if (column.field === 'totalPrice') {
|
||||||
|
footerData.push(sums.totalPrice);
|
||||||
|
} else {
|
||||||
|
footerData.push('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
footers.push(footerData);
|
||||||
|
return footers;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 监听外部传入的列数据 */
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
async (items) => {
|
||||||
|
if (!items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
tableData.value = [...items];
|
||||||
|
await nextTick();
|
||||||
|
gridApi.grid.reloadData(tableData.value);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(async () => {
|
||||||
|
productOptions.value = await getProductSimpleList();
|
||||||
|
warehouseOptions.value = await getWarehouseSimpleList();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const newRow = {
|
||||||
|
warehouseId: undefined,
|
||||||
|
productId: undefined,
|
||||||
|
productName: '',
|
||||||
|
productUnitId: undefined,
|
||||||
|
productUnitName: '',
|
||||||
|
productBarCode: '',
|
||||||
|
count: 1,
|
||||||
|
productPrice: 0,
|
||||||
|
totalPrice: 0,
|
||||||
|
stockCount: 0,
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
tableData.value.push(newRow);
|
||||||
|
gridApi.grid.insertAt(newRow, -1);
|
||||||
|
emit('update:items', [...tableData.value]);
|
||||||
|
// 触发表格重新渲染以更新cellClassName
|
||||||
|
nextTick(() => {
|
||||||
|
gridApi.grid.refreshColumn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(row: ErpStockOutApi.StockOutItem) {
|
||||||
|
gridApi.grid.remove(row);
|
||||||
|
const index = tableData.value.findIndex((item) => item.id === row.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
tableData.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
emit('update:items', [...tableData.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWarehouseChange(warehouseId: any, row: any) {
|
||||||
|
const warehouse = warehouseOptions.value.find((w) => w.id === warehouseId);
|
||||||
|
if (!warehouse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.warehouseId = warehouseId;
|
||||||
|
|
||||||
|
// 如果已选择产品,重新获取库存
|
||||||
|
if (row.productId) {
|
||||||
|
const stockCount = await getStockCount(row.productId, warehouseId);
|
||||||
|
row.stockCount = stockCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateValue(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProductChange(productId: any, row: any) {
|
||||||
|
const product = productOptions.value.find((p) => p.id === productId);
|
||||||
|
if (!product) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取库存数量
|
||||||
|
const stockCount = row.warehouseId
|
||||||
|
? await getStockCount(productId, row.warehouseId)
|
||||||
|
: await getStockCount(productId);
|
||||||
|
|
||||||
|
row.productId = productId;
|
||||||
|
row.productUnitId = product.unitId;
|
||||||
|
row.productBarCode = product.barCode;
|
||||||
|
row.productUnitName = product.unitName;
|
||||||
|
row.productName = product.name;
|
||||||
|
row.stockCount = stockCount || 0;
|
||||||
|
row.productPrice = product.purchasePrice || 0;
|
||||||
|
row.count = row.count || 1;
|
||||||
|
|
||||||
|
handlePriceChange(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePriceChange(row: any) {
|
||||||
|
if (row.productPrice && row.count) {
|
||||||
|
row.totalPrice = erpPriceMultiply(row.productPrice, row.count) ?? 0;
|
||||||
|
}
|
||||||
|
handleUpdateValue(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateValue(row: any) {
|
||||||
|
const index = tableData.value.findIndex((item) => item.id === row.id);
|
||||||
|
if (index === -1) {
|
||||||
|
tableData.value.push(row);
|
||||||
|
} else {
|
||||||
|
tableData.value[index] = row;
|
||||||
|
}
|
||||||
|
emit('update:items', [...tableData.value]);
|
||||||
|
// 触发表格重新渲染以更新cellClassName
|
||||||
|
nextTick(() => {
|
||||||
|
gridApi.grid.refreshColumn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSummaries = (): {
|
||||||
|
count: number;
|
||||||
|
totalPrice: number;
|
||||||
|
} => {
|
||||||
|
return {
|
||||||
|
count: tableData.value.reduce((sum, item) => sum + (item.count || 0), 0),
|
||||||
|
totalPrice: tableData.value.reduce(
|
||||||
|
(sum, item) => sum + (item.totalPrice || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 验证表单 */
|
||||||
|
function validate(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
isValidating.value = true;
|
||||||
|
|
||||||
|
// 触发表格重新渲染以显示验证错误
|
||||||
|
nextTick(() => {
|
||||||
|
gridApi.grid.refreshColumn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证是否有产品清单
|
||||||
|
if (!tableData.value || tableData.value.length === 0) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每一行的必填字段
|
||||||
|
for (const item of tableData.value) {
|
||||||
|
if (
|
||||||
|
!item.warehouseId ||
|
||||||
|
!item.productId ||
|
||||||
|
!item.count ||
|
||||||
|
item.count <= 0
|
||||||
|
) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证通过,清除验证状态
|
||||||
|
isValidating.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
gridApi.grid.refreshColumn();
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化表格数据 */
|
||||||
|
function init(items: ErpStockOutApi.StockOutItem[]) {
|
||||||
|
tableData.value = items || [];
|
||||||
|
gridApi.grid.reloadData(tableData.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
validate,
|
||||||
|
init,
|
||||||
|
handleAdd,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="mb-4 flex justify-between">
|
||||||
|
<span class="text-lg font-medium"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<template #warehouseId="{ row }">
|
||||||
|
<Select
|
||||||
|
v-model:value="row.warehouseId"
|
||||||
|
:options="warehouseOptions"
|
||||||
|
:field-names="{ label: 'name', value: 'id' }"
|
||||||
|
placeholder="请选择仓库"
|
||||||
|
:disabled="disabled"
|
||||||
|
show-search
|
||||||
|
@change="(value) => handleWarehouseChange(value, row)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #productId="{ row }">
|
||||||
|
<Select
|
||||||
|
v-model:value="row.productId"
|
||||||
|
:options="productOptions"
|
||||||
|
:field-names="{ label: 'name', value: 'id' }"
|
||||||
|
placeholder="请选择产品"
|
||||||
|
:disabled="disabled"
|
||||||
|
show-search
|
||||||
|
@change="(value) => handleProductChange(value, row)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #count="{ row }">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="row.count"
|
||||||
|
:disabled="disabled"
|
||||||
|
:min="0.001"
|
||||||
|
:precision="3"
|
||||||
|
@change="() => handlePriceChange(row)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #productPrice="{ row }">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="row.productPrice"
|
||||||
|
:disabled="disabled"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
@change="() => handlePriceChange(row)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #remark="{ row }">
|
||||||
|
<Input
|
||||||
|
v-model:value="row.remark"
|
||||||
|
:disabled="disabled"
|
||||||
|
placeholder="请输入备注"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
v-if="!disabled"
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
type: 'link',
|
||||||
|
danger: true,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确认删除该产品吗?',
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #bottom>
|
||||||
|
<TableAction
|
||||||
|
v-if="!disabled"
|
||||||
|
class="mt-4 flex justify-center"
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '添加产品',
|
||||||
|
type: 'default',
|
||||||
|
onClick: handleAdd,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.vxe-table .vxe-footer--column.stock-in-footer-cell .vxe-cell) {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@ import type { DescriptionItemSchema } from '#/components/description';
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
import { JsonViewer } from '@vben/common-ui';
|
import { JsonViewer } from '@vben/common-ui';
|
||||||
import { formatDateTime, isObject } from '@vben/utils';
|
import { formatDateTime } from '@vben/utils';
|
||||||
|
|
||||||
import { DictTag } from '#/components/dict-tag';
|
import { DictTag } from '#/components/dict-tag';
|
||||||
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
|
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
|
||||||
@@ -192,13 +192,13 @@ export function useDetailSchema(): DescriptionItemSchema[] {
|
|||||||
field: 'requestParams',
|
field: 'requestParams',
|
||||||
label: '请求参数',
|
label: '请求参数',
|
||||||
content: (data) => {
|
content: (data) => {
|
||||||
if (isObject(data.requestParams)) {
|
if (data.requestParams) {
|
||||||
return h(JsonViewer, {
|
return h(JsonViewer, {
|
||||||
value: data.requestParams,
|
value: JSON.parse(data.requestParams),
|
||||||
previewMode: true,
|
previewMode: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return data.requestParams;
|
return '';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type { DescriptionItemSchema } from '#/components/description';
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
import { JsonViewer } from '@vben/common-ui';
|
import { JsonViewer } from '@vben/common-ui';
|
||||||
import { formatDateTime, isObject } from '@vben/utils';
|
import { formatDateTime } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Textarea } from 'ant-design-vue';
|
||||||
|
|
||||||
import { DictTag } from '#/components/dict-tag';
|
import { DictTag } from '#/components/dict-tag';
|
||||||
import {
|
import {
|
||||||
@@ -177,13 +179,13 @@ export function useDetailSchema(): DescriptionItemSchema[] {
|
|||||||
field: 'requestParams',
|
field: 'requestParams',
|
||||||
label: '请求参数',
|
label: '请求参数',
|
||||||
content: (data) => {
|
content: (data) => {
|
||||||
if (isObject(data.requestParams)) {
|
if (data.requestParams) {
|
||||||
return h(JsonViewer, {
|
return h(JsonViewer, {
|
||||||
value: data.requestParams,
|
value: JSON.parse(data.requestParams),
|
||||||
previewMode: true,
|
previewMode: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return data.requestParams;
|
return '';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -201,13 +203,10 @@ export function useDetailSchema(): DescriptionItemSchema[] {
|
|||||||
field: 'exceptionStackTrace',
|
field: 'exceptionStackTrace',
|
||||||
label: '异常堆栈',
|
label: '异常堆栈',
|
||||||
content: (data) => {
|
content: (data) => {
|
||||||
if (isObject(data.exceptionStackTrace)) {
|
return h(Textarea, {
|
||||||
return h(JsonViewer, {
|
value: data.exceptionStackTrace,
|
||||||
value: data.exceptionStackTrace,
|
rows: 20,
|
||||||
previewMode: true,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return data.exceptionStackTrace;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -200,6 +200,25 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
},
|
},
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'config.enablePublicAccess',
|
||||||
|
label: '公开访问',
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '公开', value: true },
|
||||||
|
{ label: '私有', value: false },
|
||||||
|
],
|
||||||
|
buttonStyle: 'solid',
|
||||||
|
optionType: 'button',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['storage'],
|
||||||
|
show: (formValues) => formValues.storage === 20,
|
||||||
|
},
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
// 通用
|
// 通用
|
||||||
{
|
{
|
||||||
fieldName: 'config.domain',
|
fieldName: 'config.domain',
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ VITE_INJECT_APP_LOADING=true
|
|||||||
VITE_APP_DEFAULT_USERNAME=admin
|
VITE_APP_DEFAULT_USERNAME=admin
|
||||||
# 默认登录密码
|
# 默认登录密码
|
||||||
VITE_APP_DEFAULT_PASSWORD=admin123
|
VITE_APP_DEFAULT_PASSWORD=admin123
|
||||||
|
|
||||||
|
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
|
||||||
|
|||||||
@@ -21,3 +21,5 @@ VITE_INJECT_APP_LOADING=true
|
|||||||
|
|
||||||
# 打包后是否生成dist.zip
|
# 打包后是否生成dist.zip
|
||||||
VITE_ARCHIVER=true
|
VITE_ARCHIVER=true
|
||||||
|
|
||||||
|
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
"highlight.js": "catalog:",
|
"highlight.js": "catalog:",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-router": "catalog:"
|
"vue-dompurify-html": "catalog:",
|
||||||
|
"vue-router": "catalog:",
|
||||||
|
"vuedraggable": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"unplugin-element-plus": "catalog:"
|
"unplugin-element-plus": "catalog:"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export namespace InfraFileConfigApi {
|
|||||||
accessKey?: string;
|
accessKey?: string;
|
||||||
accessSecret?: string;
|
accessSecret?: string;
|
||||||
pathStyle?: boolean;
|
pathStyle?: boolean;
|
||||||
|
enablePublicAccess?: boolean;
|
||||||
domain: string;
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export namespace MallCombinationActivityApi {
|
|||||||
combinationPrice?: number;
|
combinationPrice?: number;
|
||||||
/** 商品列表 */
|
/** 商品列表 */
|
||||||
products: CombinationProduct[];
|
products: CombinationProduct[];
|
||||||
|
/** 图片 */
|
||||||
|
picUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扩展 SKU 配置 */
|
/** 扩展 SKU 配置 */
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function deleteDiyPage(id: number) {
|
|||||||
|
|
||||||
/** 获得装修页面属性 */
|
/** 获得装修页面属性 */
|
||||||
export function getDiyPageProperty(id: number) {
|
export function getDiyPageProperty(id: number) {
|
||||||
return requestClient.get<string>(`/promotion/diy-page/get-property?id=${id}`);
|
return requestClient.get(`/promotion/diy-page/get-property?id=${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新装修页面属性 */
|
/** 更新装修页面属性 */
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export namespace MallSeckillActivityApi {
|
|||||||
seckillPrice?: number;
|
seckillPrice?: number;
|
||||||
/** 秒杀商品列表 */
|
/** 秒杀商品列表 */
|
||||||
products?: SeckillProduct[];
|
products?: SeckillProduct[];
|
||||||
|
/** 图片 */
|
||||||
|
picUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扩展 SKU 配置 */
|
/** 扩展 SKU 配置 */
|
||||||
|
|||||||
BIN
apps/web-ele/src/assets/imgs/diy/app-nav-bar-mp.png
Normal file
BIN
apps/web-ele/src/assets/imgs/diy/app-nav-bar-mp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
apps/web-ele/src/assets/imgs/diy/statusBar.png
Normal file
BIN
apps/web-ele/src/assets/imgs/diy/statusBar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { createApp, watchEffect } from 'vue';
|
import { createApp, watchEffect } from 'vue';
|
||||||
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
|
|
||||||
import { registerAccessDirective } from '@vben/access';
|
import { registerAccessDirective } from '@vben/access';
|
||||||
import { registerLoadingDirective } from '@vben/common-ui';
|
import { registerLoadingDirective } from '@vben/common-ui';
|
||||||
@@ -34,7 +35,7 @@ async function bootstrap(namespace: string) {
|
|||||||
// zIndex: 2000,
|
// zIndex: 2000,
|
||||||
// });
|
// });
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
app.use(VueDOMPurifyHTML);
|
||||||
// 注册Element Plus提供的v-loading指令
|
// 注册Element Plus提供的v-loading指令
|
||||||
app.directive('loading', ElLoading.directive);
|
app.directive('loading', ElLoading.directive);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ButtonInstance, ScrollbarInstance } from 'element-plus';
|
||||||
|
|
||||||
|
import type { AppLink } from './data';
|
||||||
|
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { getUrlNumberValue } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
|
import ProductCategorySelect from '#/views/mall/product/category/components/product-category-select.vue';
|
||||||
|
|
||||||
|
import { APP_LINK_GROUP_LIST, APP_LINK_TYPE_ENUM } from './data';
|
||||||
|
|
||||||
|
// APP 链接选择弹框
|
||||||
|
defineOptions({ name: 'AppLinkSelectDialog' });
|
||||||
|
// 处理绑定值更新
|
||||||
|
const emit = defineEmits<{
|
||||||
|
appLinkChange: [appLink: AppLink];
|
||||||
|
change: [link: string];
|
||||||
|
}>();
|
||||||
|
// 选中的分组,默认选中第一个
|
||||||
|
const activeGroup = ref(APP_LINK_GROUP_LIST[0]?.name);
|
||||||
|
// 选中的 APP 链接
|
||||||
|
const activeAppLink = ref({} as AppLink);
|
||||||
|
|
||||||
|
/** 打开弹窗 */
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const open = (link: string) => {
|
||||||
|
activeAppLink.value.path = link;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
// 滚动到当前的链接
|
||||||
|
const group = APP_LINK_GROUP_LIST.find((group) =>
|
||||||
|
group.links.some((linkItem) => {
|
||||||
|
const sameLink = isSameLink(linkItem.path, link);
|
||||||
|
if (sameLink) {
|
||||||
|
activeAppLink.value = { ...linkItem, path: link };
|
||||||
|
}
|
||||||
|
return sameLink;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (group) {
|
||||||
|
// 使用 nextTick 的原因:可能 Dom 还没生成,导致滚动失败
|
||||||
|
nextTick(() => handleGroupSelected(group.name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
defineExpose({ open });
|
||||||
|
|
||||||
|
// 处理 APP 链接选中
|
||||||
|
const handleAppLinkSelected = (appLink: AppLink) => {
|
||||||
|
if (!isSameLink(appLink.path, activeAppLink.value.path)) {
|
||||||
|
activeAppLink.value = appLink;
|
||||||
|
}
|
||||||
|
switch (appLink.type) {
|
||||||
|
case APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST: {
|
||||||
|
detailSelectDialog.value.visible = true;
|
||||||
|
detailSelectDialog.value.type = appLink.type;
|
||||||
|
// 返显
|
||||||
|
detailSelectDialog.value.id =
|
||||||
|
getUrlNumberValue(
|
||||||
|
'id',
|
||||||
|
`http://127.0.0.1${activeAppLink.value.path}`,
|
||||||
|
) || undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
emit('change', activeAppLink.value.path);
|
||||||
|
emit('appLinkChange', activeAppLink.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分组标题引用列表
|
||||||
|
const groupTitleRefs = ref<HTMLInputElement[]>([]);
|
||||||
|
/**
|
||||||
|
* 处理右侧链接列表滚动
|
||||||
|
* @param {object} param0 滚动事件参数
|
||||||
|
* @param {number} param0.scrollTop 滚动条的位置
|
||||||
|
*/
|
||||||
|
const handleScroll = ({ scrollTop }: { scrollTop: number }) => {
|
||||||
|
const titleEl = groupTitleRefs.value.find((titleEl: HTMLInputElement) => {
|
||||||
|
// 获取标题的位置信息
|
||||||
|
const { offsetHeight, offsetTop } = titleEl;
|
||||||
|
// 判断标题是否在可视范围内
|
||||||
|
return scrollTop >= offsetTop && scrollTop < offsetTop + offsetHeight;
|
||||||
|
});
|
||||||
|
// 只需处理一次
|
||||||
|
if (titleEl && activeGroup.value !== titleEl.textContent) {
|
||||||
|
activeGroup.value = titleEl.textContent || '';
|
||||||
|
// 同步左侧的滚动条位置
|
||||||
|
scrollToGroupBtn(activeGroup.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 右侧滚动条
|
||||||
|
const linkScrollbar = ref<ScrollbarInstance>();
|
||||||
|
// 处理分组选中
|
||||||
|
const handleGroupSelected = (group: string) => {
|
||||||
|
activeGroup.value = group;
|
||||||
|
const titleRef = groupTitleRefs.value.find(
|
||||||
|
(item: HTMLInputElement) => item.textContent === group,
|
||||||
|
);
|
||||||
|
if (titleRef) {
|
||||||
|
// 滚动分组标题
|
||||||
|
linkScrollbar.value?.setScrollTop(titleRef.offsetTop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分组滚动条
|
||||||
|
const groupScrollbar = ref<ScrollbarInstance>();
|
||||||
|
// 分组引用列表
|
||||||
|
const groupBtnRefs = ref<ButtonInstance[]>([]);
|
||||||
|
// 自动滚动分组按钮,确保分组按钮保持在可视区域内
|
||||||
|
const scrollToGroupBtn = (group: string) => {
|
||||||
|
const groupBtn = groupBtnRefs.value
|
||||||
|
.map((btn: ButtonInstance) => btn.ref)
|
||||||
|
.find((ref: HTMLButtonElement | undefined) => ref?.textContent === group);
|
||||||
|
if (groupBtn) {
|
||||||
|
groupScrollbar.value?.setScrollTop(groupBtn.offsetTop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 是否为相同的链接(不比较参数,只比较链接)
|
||||||
|
const isSameLink = (link1: string, link2: string) => {
|
||||||
|
return link2 ? link1.split('?')[0] === link2.split('?')[0] : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 详情选择对话框
|
||||||
|
const detailSelectDialog = ref<{
|
||||||
|
id?: number;
|
||||||
|
type?: APP_LINK_TYPE_ENUM;
|
||||||
|
visible: boolean;
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
id: undefined,
|
||||||
|
type: undefined,
|
||||||
|
});
|
||||||
|
// 处理详情选择
|
||||||
|
const handleProductCategorySelected = (id: number) => {
|
||||||
|
const url = new URL(activeAppLink.value.path, 'http://127.0.0.1');
|
||||||
|
// 修改 id 参数
|
||||||
|
url.searchParams.set('id', `${id}`);
|
||||||
|
// 排除域名
|
||||||
|
activeAppLink.value.path = `${url.pathname}${url.search}`;
|
||||||
|
// 关闭对话框
|
||||||
|
detailSelectDialog.value.visible = false;
|
||||||
|
// 重置 id
|
||||||
|
detailSelectDialog.value.id = undefined;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="dialogVisible" title="选择链接" width="65%">
|
||||||
|
<div class="flex h-[500px] gap-2">
|
||||||
|
<!-- 左侧分组列表 -->
|
||||||
|
<ElScrollbar
|
||||||
|
wrap-class="h-full"
|
||||||
|
ref="groupScrollbar"
|
||||||
|
view-class="flex flex-col"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
|
||||||
|
:key="groupIndex"
|
||||||
|
class="ml-0 mr-4 w-[90px] justify-start"
|
||||||
|
:class="[{ active: activeGroup === group.name }]"
|
||||||
|
ref="groupBtnRefs"
|
||||||
|
:text="activeGroup !== group.name"
|
||||||
|
:type="activeGroup === group.name ? 'primary' : 'default'"
|
||||||
|
@click="handleGroupSelected(group.name)"
|
||||||
|
>
|
||||||
|
{{ group.name }}
|
||||||
|
</el-button>
|
||||||
|
</ElScrollbar>
|
||||||
|
<!-- 右侧链接列表 -->
|
||||||
|
<ElScrollbar
|
||||||
|
class="h-full flex-1"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
ref="linkScrollbar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
|
||||||
|
:key="groupIndex"
|
||||||
|
>
|
||||||
|
<!-- 分组标题 -->
|
||||||
|
<div class="font-bold" ref="groupTitleRefs">{{ group.name }}</div>
|
||||||
|
<!-- 链接列表 -->
|
||||||
|
<el-tooltip
|
||||||
|
v-for="(appLink, appLinkIndex) in group.links"
|
||||||
|
:key="appLinkIndex"
|
||||||
|
:content="appLink.path"
|
||||||
|
placement="bottom"
|
||||||
|
:show-after="300"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
class="mb-2 ml-0 mr-2"
|
||||||
|
:type="
|
||||||
|
isSameLink(appLink.path, activeAppLink.path)
|
||||||
|
? 'primary'
|
||||||
|
: 'default'
|
||||||
|
"
|
||||||
|
@click="handleAppLinkSelected(appLink)"
|
||||||
|
>
|
||||||
|
{{ appLink.name }}
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
<!-- 底部对话框操作按钮 -->
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||||
|
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
<el-dialog v-model="detailSelectDialog.visible" title="" width="50%">
|
||||||
|
<el-form class="min-h-[200px]">
|
||||||
|
<el-form-item
|
||||||
|
label="选择分类"
|
||||||
|
v-if="
|
||||||
|
detailSelectDialog.type === APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ProductCategorySelect
|
||||||
|
v-model="detailSelectDialog.id"
|
||||||
|
:parent-id="0"
|
||||||
|
@update:model-value="handleProductCategorySelected"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
:deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
apps/web-ele/src/components/app-link-input/data.ts
Normal file
236
apps/web-ele/src/components/app-link-input/data.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// APP 链接分组
|
||||||
|
export interface AppLinkGroup {
|
||||||
|
// 分组名称
|
||||||
|
name: string;
|
||||||
|
// 链接列表
|
||||||
|
links: AppLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// APP 链接
|
||||||
|
export interface AppLink {
|
||||||
|
// 链接名称
|
||||||
|
name: string;
|
||||||
|
// 链接地址
|
||||||
|
path: string;
|
||||||
|
// 链接的类型
|
||||||
|
type?: APP_LINK_TYPE_ENUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
// APP 链接类型(需要特殊处理,例如商品详情)
|
||||||
|
export enum APP_LINK_TYPE_ENUM {
|
||||||
|
// 拼团活动
|
||||||
|
ACTIVITY_COMBINATION,
|
||||||
|
// 积分商城活动
|
||||||
|
ACTIVITY_POINT,
|
||||||
|
// 秒杀活动
|
||||||
|
ACTIVITY_SECKILL,
|
||||||
|
// 文章详情
|
||||||
|
ARTICLE_DETAIL,
|
||||||
|
// 优惠券详情
|
||||||
|
COUPON_DETAIL,
|
||||||
|
// 自定义页面详情
|
||||||
|
DIY_PAGE_DETAIL,
|
||||||
|
// 品类列表
|
||||||
|
PRODUCT_CATEGORY_LIST,
|
||||||
|
// 拼团商品详情
|
||||||
|
PRODUCT_DETAIL_COMBINATION,
|
||||||
|
// 商品详情
|
||||||
|
PRODUCT_DETAIL_NORMAL,
|
||||||
|
// 秒杀商品详情
|
||||||
|
PRODUCT_DETAIL_SECKILL,
|
||||||
|
// 商品列表
|
||||||
|
PRODUCT_LIST,
|
||||||
|
}
|
||||||
|
|
||||||
|
// APP 链接列表(做一下持久化?)
|
||||||
|
export const APP_LINK_GROUP_LIST = [
|
||||||
|
{
|
||||||
|
name: '商城',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: '首页',
|
||||||
|
path: '/pages/index/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '商品分类',
|
||||||
|
path: '/pages/index/category',
|
||||||
|
type: APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '购物车',
|
||||||
|
path: '/pages/index/cart',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '个人中心',
|
||||||
|
path: '/pages/index/user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '商品搜索',
|
||||||
|
path: '/pages/index/search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '自定义页面',
|
||||||
|
path: '/pages/index/page',
|
||||||
|
type: APP_LINK_TYPE_ENUM.DIY_PAGE_DETAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '客服',
|
||||||
|
path: '/pages/chat/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '系统设置',
|
||||||
|
path: '/pages/public/setting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '常见问题',
|
||||||
|
path: '/pages/public/faq',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '商品',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: '商品列表',
|
||||||
|
path: '/pages/goods/list',
|
||||||
|
type: APP_LINK_TYPE_ENUM.PRODUCT_LIST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '商品详情',
|
||||||
|
path: '/pages/goods/index',
|
||||||
|
type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_NORMAL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '拼团商品详情',
|
||||||
|
path: '/pages/goods/groupon',
|
||||||
|
type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_COMBINATION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '秒杀商品详情',
|
||||||
|
path: '/pages/goods/seckill',
|
||||||
|
type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_SECKILL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '营销活动',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: '拼团订单',
|
||||||
|
path: '/pages/activity/groupon/order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '营销商品',
|
||||||
|
path: '/pages/activity/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '拼团活动',
|
||||||
|
path: '/pages/activity/groupon/list',
|
||||||
|
type: APP_LINK_TYPE_ENUM.ACTIVITY_COMBINATION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '秒杀活动',
|
||||||
|
path: '/pages/activity/seckill/list',
|
||||||
|
type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '积分商城活动',
|
||||||
|
path: '/pages/activity/point/list',
|
||||||
|
type: APP_LINK_TYPE_ENUM.ACTIVITY_POINT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '签到中心',
|
||||||
|
path: '/pages/app/sign',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '优惠券中心',
|
||||||
|
path: '/pages/coupon/list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '优惠券详情',
|
||||||
|
path: '/pages/coupon/detail',
|
||||||
|
type: APP_LINK_TYPE_ENUM.COUPON_DETAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '文章详情',
|
||||||
|
path: '/pages/public/richtext',
|
||||||
|
type: APP_LINK_TYPE_ENUM.ARTICLE_DETAIL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '分销商城',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: '分销中心',
|
||||||
|
path: '/pages/commission/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '推广商品',
|
||||||
|
path: '/pages/commission/goods',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '分销订单',
|
||||||
|
path: '/pages/commission/order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '我的团队',
|
||||||
|
path: '/pages/commission/team',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '支付',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: '充值余额',
|
||||||
|
path: '/pages/pay/recharge',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '充值记录',
|
||||||
|
path: '/pages/pay/recharge-log',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '用户中心',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: '用户信息',
|
||||||
|
path: '/pages/user/info',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '用户订单',
|
||||||
|
path: '/pages/order/list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '售后订单',
|
||||||
|
path: '/pages/order/aftersale/list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '商品收藏',
|
||||||
|
path: '/pages/user/goods-collect',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '浏览记录',
|
||||||
|
path: '/pages/user/goods-log',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '地址管理',
|
||||||
|
path: '/pages/user/address/list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '用户佣金',
|
||||||
|
path: '/pages/user/wallet/commission',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '用户余额',
|
||||||
|
path: '/pages/user/wallet/money',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '用户积分',
|
||||||
|
path: '/pages/user/wallet/score',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as AppLinkGroup[];
|
||||||
48
apps/web-ele/src/components/app-link-input/index.vue
Normal file
48
apps/web-ele/src/components/app-link-input/index.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import AppLinkSelectDialog from './app-link-select-dialog.vue';
|
||||||
|
|
||||||
|
// APP 链接输入框
|
||||||
|
defineOptions({ name: 'AppLinkInput' });
|
||||||
|
// 定义属性
|
||||||
|
const props = defineProps({
|
||||||
|
// 当前选中的链接
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// setter
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [link: string];
|
||||||
|
}>();
|
||||||
|
// 当前的链接
|
||||||
|
const appLink = ref('');
|
||||||
|
// 选择对话框
|
||||||
|
const dialogRef = ref();
|
||||||
|
// 处理打开对话框
|
||||||
|
const handleOpenDialog = () => dialogRef.value?.open(appLink.value);
|
||||||
|
// 处理 APP 链接选中
|
||||||
|
const handleLinkSelected = (link: string) => (appLink.value = link);
|
||||||
|
|
||||||
|
// getter
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => (appLink.value = props.modelValue),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => appLink.value,
|
||||||
|
() => emit('update:modelValue', appLink.value),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<el-input v-model="appLink" placeholder="输入或选择链接">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="handleOpenDialog">选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<AppLinkSelectDialog ref="dialogRef" @change="handleLinkSelected" />
|
||||||
|
</template>
|
||||||
38
apps/web-ele/src/components/color-input/index.vue
Normal file
38
apps/web-ele/src/components/color-input/index.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { PREDEFINE_COLORS } from '#/utils/constants';
|
||||||
|
|
||||||
|
// 颜色输入框
|
||||||
|
defineOptions({ name: 'ColorInput' });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const color = computed({
|
||||||
|
get: () => {
|
||||||
|
return props.modelValue;
|
||||||
|
},
|
||||||
|
set: (val: string) => {
|
||||||
|
emit('update:modelValue', val);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-input v-model="color">
|
||||||
|
<template #prepend>
|
||||||
|
<el-color-picker v-model="color" :predefine="PREDEFINE_COLORS" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.el-input-group__prepend) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComponentStyle } from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadio,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
ElTabPane,
|
||||||
|
ElTabs,
|
||||||
|
ElTree,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import ColorInput from '#/components/color-input/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件容器属性:目前右边部分
|
||||||
|
* 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'ComponentContainer' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: ComponentStyle }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
|
||||||
|
const treeData = [
|
||||||
|
{
|
||||||
|
label: '外部边距',
|
||||||
|
prop: 'margin',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: '上',
|
||||||
|
prop: 'marginTop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '右',
|
||||||
|
prop: 'marginRight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '下',
|
||||||
|
prop: 'marginBottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '左',
|
||||||
|
prop: 'marginLeft',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '内部边距',
|
||||||
|
prop: 'padding',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: '上',
|
||||||
|
prop: 'paddingTop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '右',
|
||||||
|
prop: 'paddingRight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '下',
|
||||||
|
prop: 'paddingBottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '左',
|
||||||
|
prop: 'paddingLeft',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '边框圆角',
|
||||||
|
prop: 'borderRadius',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: '上左',
|
||||||
|
prop: 'borderTopLeftRadius',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '上右',
|
||||||
|
prop: 'borderTopRightRadius',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '下右',
|
||||||
|
prop: 'borderBottomRightRadius',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '下左',
|
||||||
|
prop: 'borderBottomLeftRadius',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSliderChange = (prop: string) => {
|
||||||
|
switch (prop) {
|
||||||
|
case 'borderRadius': {
|
||||||
|
formData.value.borderTopLeftRadius = formData.value.borderRadius;
|
||||||
|
formData.value.borderTopRightRadius = formData.value.borderRadius;
|
||||||
|
formData.value.borderBottomRightRadius = formData.value.borderRadius;
|
||||||
|
formData.value.borderBottomLeftRadius = formData.value.borderRadius;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'margin': {
|
||||||
|
formData.value.marginTop = formData.value.margin;
|
||||||
|
formData.value.marginRight = formData.value.margin;
|
||||||
|
formData.value.marginBottom = formData.value.margin;
|
||||||
|
formData.value.marginLeft = formData.value.margin;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'padding': {
|
||||||
|
formData.value.paddingTop = formData.value.padding;
|
||||||
|
formData.value.paddingRight = formData.value.padding;
|
||||||
|
formData.value.paddingBottom = formData.value.padding;
|
||||||
|
formData.value.paddingLeft = formData.value.padding;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElTabs stretch>
|
||||||
|
<!-- 每个组件的自定义内容 -->
|
||||||
|
<ElTabPane label="内容" v-if="$slots.default">
|
||||||
|
<slot></slot>
|
||||||
|
</ElTabPane>
|
||||||
|
|
||||||
|
<!-- 每个组件的通用内容 -->
|
||||||
|
<ElTabPane label="样式" lazy>
|
||||||
|
<ElCard header="组件样式" class="property-group">
|
||||||
|
<ElForm :model="formData" label-width="80px">
|
||||||
|
<ElFormItem label="组件背景" prop="bgType">
|
||||||
|
<ElRadioGroup v-model="formData.bgType">
|
||||||
|
<ElRadio value="color">纯色</ElRadio>
|
||||||
|
<ElRadio value="img">图片</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem
|
||||||
|
label="选择颜色"
|
||||||
|
prop="bgColor"
|
||||||
|
v-if="formData.bgType === 'color'"
|
||||||
|
>
|
||||||
|
<ColorInput v-model="formData.bgColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="上传图片" prop="bgImg" v-else>
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.bgImg"
|
||||||
|
:limit="1"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip>建议宽度 750px</template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElTree
|
||||||
|
:data="treeData"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
default-expand-all
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<ElFormItem
|
||||||
|
:label="data.label"
|
||||||
|
:prop="data.prop"
|
||||||
|
:label-width="node.level === 1 ? '80px' : '62px'"
|
||||||
|
class="mb-0 w-full"
|
||||||
|
>
|
||||||
|
<ElSlider
|
||||||
|
v-model="
|
||||||
|
formData[data.prop as keyof ComponentStyle] as number
|
||||||
|
"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
@input="handleSliderChange(data.prop)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</ElTree>
|
||||||
|
<slot name="style" :style="formData"></slot>
|
||||||
|
</ElForm>
|
||||||
|
</ElCard>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.el-slider__runway) {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input-number) {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElButton, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
|
import { components } from '#/components/diy-editor/components/mobile';
|
||||||
|
import VerticalButtonGroup from '#/components/vertical-button-group/index.vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件容器:目前在中间部分
|
||||||
|
* 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'ComponentContainer', components });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
component: {
|
||||||
|
type: Object as () => DiyComponentWithStyle,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
canMoveUp: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
canMoveDown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showToolbar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'move', direction: number): void;
|
||||||
|
(e: 'copy'): void;
|
||||||
|
(e: 'delete'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type DiyComponentWithStyle = DiyComponent<any> & {
|
||||||
|
property: { style?: ComponentStyle };
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 组件样式
|
||||||
|
*/
|
||||||
|
const style = computed(() => {
|
||||||
|
const componentStyle = props.component.property.style;
|
||||||
|
if (!componentStyle) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
marginTop: `${componentStyle.marginTop || 0}px`,
|
||||||
|
marginBottom: `${componentStyle.marginBottom || 0}px`,
|
||||||
|
marginLeft: `${componentStyle.marginLeft || 0}px`,
|
||||||
|
marginRight: `${componentStyle.marginRight || 0}px`,
|
||||||
|
paddingTop: `${componentStyle.paddingTop || 0}px`,
|
||||||
|
paddingRight: `${componentStyle.paddingRight || 0}px`,
|
||||||
|
paddingBottom: `${componentStyle.paddingBottom || 0}px`,
|
||||||
|
paddingLeft: `${componentStyle.paddingLeft || 0}px`,
|
||||||
|
borderTopLeftRadius: `${componentStyle.borderTopLeftRadius || 0}px`,
|
||||||
|
borderTopRightRadius: `${componentStyle.borderTopRightRadius || 0}px`,
|
||||||
|
borderBottomRightRadius: `${componentStyle.borderBottomRightRadius || 0}px`,
|
||||||
|
borderBottomLeftRadius: `${componentStyle.borderBottomLeftRadius || 0}px`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background:
|
||||||
|
componentStyle.bgType === 'color'
|
||||||
|
? componentStyle.bgColor
|
||||||
|
: `url(${componentStyle.bgImg})`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动组件
|
||||||
|
* @param direction 移动方向
|
||||||
|
*/
|
||||||
|
const handleMoveComponent = (direction: number) => {
|
||||||
|
emits('move', direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制组件
|
||||||
|
*/
|
||||||
|
const handleCopyComponent = () => {
|
||||||
|
emits('copy');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除组件
|
||||||
|
*/
|
||||||
|
const handleDeleteComponent = () => {
|
||||||
|
emits('delete');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="component" :class="[{ active }]">
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
...style,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component :is="component.id" :property="component.property" />
|
||||||
|
</div>
|
||||||
|
<div class="component-wrap">
|
||||||
|
<!-- 左侧:组件名(悬浮的小贴条) -->
|
||||||
|
<div class="component-name" v-if="component.name">
|
||||||
|
{{ component.name }}
|
||||||
|
</div>
|
||||||
|
<!-- 右侧:组件操作工具栏 -->
|
||||||
|
<div
|
||||||
|
class="component-toolbar"
|
||||||
|
v-if="showToolbar && component.name && active"
|
||||||
|
>
|
||||||
|
<VerticalButtonGroup type="primary">
|
||||||
|
<ElTooltip content="上移" placement="right">
|
||||||
|
<ElButton
|
||||||
|
:disabled="!canMoveUp"
|
||||||
|
@click.stop="handleMoveComponent(-1)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="ep:arrow-up" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="下移" placement="right">
|
||||||
|
<ElButton
|
||||||
|
:disabled="!canMoveDown"
|
||||||
|
@click.stop="handleMoveComponent(1)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="ep:arrow-down" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="复制" placement="right">
|
||||||
|
<ElButton @click.stop="handleCopyComponent()">
|
||||||
|
<IconifyIcon icon="ep:copy-document" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="删除" placement="right">
|
||||||
|
<ElButton @click.stop="handleDeleteComponent()">
|
||||||
|
<IconifyIcon icon="ep:delete" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</VerticalButtonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$active-border-width: 2px;
|
||||||
|
$hover-border-width: 1px;
|
||||||
|
$name-position: -85px;
|
||||||
|
$toolbar-position: -55px;
|
||||||
|
|
||||||
|
/* 组件 */
|
||||||
|
.component {
|
||||||
|
position: relative;
|
||||||
|
cursor: move;
|
||||||
|
|
||||||
|
.component-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -$active-border-width;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
/* 鼠标放到组件上时 */
|
||||||
|
&:hover {
|
||||||
|
border: $hover-border-width dashed var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 5px 0 rgb(24 144 255 / 30%);
|
||||||
|
|
||||||
|
.component-name {
|
||||||
|
top: $hover-border-width;
|
||||||
|
|
||||||
|
/* 防止加了边框之后,位置移动 */
|
||||||
|
left: $name-position - $hover-border-width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧:组件名称 */
|
||||||
|
.component-name {
|
||||||
|
position: absolute;
|
||||||
|
top: $active-border-width;
|
||||||
|
left: $name-position;
|
||||||
|
display: block;
|
||||||
|
width: 80px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 25px;
|
||||||
|
color: #6a6a6a;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 4px #00000014,
|
||||||
|
0 2px 6px #0000000f,
|
||||||
|
0 4px 8px 2px #0000000a;
|
||||||
|
|
||||||
|
/* 右侧小三角 */
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 7.5px;
|
||||||
|
right: -10px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
content: ' ';
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧:组件操作工具栏 */
|
||||||
|
.component-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: $toolbar-position;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
/* 左侧小三角 */
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: -10px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
content: ' ';
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-right-color: #2d8cf0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组件选中时 */
|
||||||
|
&.active {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.component-wrap {
|
||||||
|
margin-bottom: $active-border-width + $active-border-width;
|
||||||
|
border: $active-border-width solid var(--el-color-primary) !important;
|
||||||
|
box-shadow: 0 0 10px 0 rgb(24 144 255 / 30%);
|
||||||
|
|
||||||
|
.component-name {
|
||||||
|
top: 0 !important;
|
||||||
|
|
||||||
|
/* 防止加了边框之后,位置移动 */
|
||||||
|
left: $name-position - $active-border-width !important;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-left-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-toolbar {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DiyComponent,
|
||||||
|
DiyComponentLibrary,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { cloneDeep } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElAside, ElCollapse, ElCollapseItem, ElScrollbar } from 'element-plus';
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
|
|
||||||
|
import { componentConfigs } from './mobile/index';
|
||||||
|
|
||||||
|
/** 组件库:目前左侧的【基础组件】、【图文组件】部分 */
|
||||||
|
defineOptions({ name: 'ComponentLibrary' });
|
||||||
|
|
||||||
|
// 组件列表
|
||||||
|
const props = defineProps<{
|
||||||
|
list: DiyComponentLibrary[];
|
||||||
|
}>();
|
||||||
|
// 组件分组
|
||||||
|
const groups = reactive<any[]>([]);
|
||||||
|
// 展开的折叠面板
|
||||||
|
const extendGroups = reactive<string[]>([]);
|
||||||
|
|
||||||
|
// 监听 list 属性,按照 DiyComponentLibrary 的 name 分组
|
||||||
|
watch(
|
||||||
|
() => props.list,
|
||||||
|
() => {
|
||||||
|
// 清除旧数据
|
||||||
|
extendGroups.length = 0;
|
||||||
|
groups.length = 0;
|
||||||
|
// 重新生成数据
|
||||||
|
props.list.forEach((group) => {
|
||||||
|
// 是否展开分组
|
||||||
|
if (group.extended) {
|
||||||
|
extendGroups.push(group.name);
|
||||||
|
}
|
||||||
|
// 查找组件
|
||||||
|
const components = group.components
|
||||||
|
.map((name) => componentConfigs[name] as DiyComponent<any>)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (components.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
name: group.name,
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 克隆组件
|
||||||
|
const handleCloneComponent = (component: DiyComponent<any>) => {
|
||||||
|
const instance = cloneDeep(component);
|
||||||
|
instance.uid = Date.now();
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElAside class="editor-left" width="261px">
|
||||||
|
<ElScrollbar>
|
||||||
|
<ElCollapse v-model="extendGroups">
|
||||||
|
<ElCollapseItem
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.name"
|
||||||
|
:name="group.name"
|
||||||
|
:title="group.name"
|
||||||
|
>
|
||||||
|
<draggable
|
||||||
|
class="component-container"
|
||||||
|
ghost-class="draggable-ghost"
|
||||||
|
item-key="index"
|
||||||
|
:list="group.components"
|
||||||
|
:sort="false"
|
||||||
|
:group="{ name: 'component', pull: 'clone', put: false }"
|
||||||
|
:clone="handleCloneComponent"
|
||||||
|
:animation="200"
|
||||||
|
:force-fallback="true"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div>
|
||||||
|
<div class="drag-placement">组件放置区域</div>
|
||||||
|
<div class="component">
|
||||||
|
<IconifyIcon :icon="element.icon" :size="32" />
|
||||||
|
<span class="mt-1 text-xs">{{ element.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</ElCollapseItem>
|
||||||
|
</ElCollapse>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElAside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.editor-left {
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
|
||||||
|
|
||||||
|
:deep(.el-collapse) {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__wrap) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__content) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__header) {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 24px;
|
||||||
|
line-height: 32px;
|
||||||
|
background-color: var(--el-bg-color-page);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 86px;
|
||||||
|
height: 86px;
|
||||||
|
cursor: move;
|
||||||
|
border-right: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.component.active,
|
||||||
|
.component:hover {
|
||||||
|
color: var(--el-color-white);
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: var(--el-color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.component:nth-of-type(3n) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽占位提示,默认不显示 */
|
||||||
|
.drag-placement {
|
||||||
|
display: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-area {
|
||||||
|
/* 拖拽到手机区域时的样式 */
|
||||||
|
.draggable-ghost {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
/* 条纹背景 */
|
||||||
|
background: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#91a8d5 0,
|
||||||
|
#91a8d5 10%,
|
||||||
|
#94b4eb 10%,
|
||||||
|
#94b4eb 50%,
|
||||||
|
#91a8d5 50%,
|
||||||
|
#91a8d5 60%,
|
||||||
|
#94b4eb 60%,
|
||||||
|
#94b4eb
|
||||||
|
);
|
||||||
|
background-size: 1rem 1rem;
|
||||||
|
transition: all 0.5s;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 140px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 25px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
background: #5487df;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽时隐藏组件 */
|
||||||
|
.component {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽时显示占位提示 */
|
||||||
|
.drag-placement {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 轮播图属性 */
|
||||||
|
export interface CarouselProperty {
|
||||||
|
// 类型:默认 | 卡片
|
||||||
|
type: 'card' | 'default';
|
||||||
|
// 指示器样式:点 | 数字
|
||||||
|
indicator: 'dot' | 'number';
|
||||||
|
// 是否自动播放
|
||||||
|
autoplay: boolean;
|
||||||
|
// 播放间隔
|
||||||
|
interval: number;
|
||||||
|
// 轮播内容
|
||||||
|
items: CarouselItemProperty[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
// 轮播内容属性
|
||||||
|
export interface CarouselItemProperty {
|
||||||
|
// 类型:图片 | 视频
|
||||||
|
type: 'img' | 'video';
|
||||||
|
// 图片链接
|
||||||
|
imgUrl: string;
|
||||||
|
// 视频链接
|
||||||
|
videoUrl: string;
|
||||||
|
// 跳转链接
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'Carousel',
|
||||||
|
name: '轮播图',
|
||||||
|
icon: 'system-uicons:carousel',
|
||||||
|
property: {
|
||||||
|
type: 'default',
|
||||||
|
indicator: 'dot',
|
||||||
|
autoplay: false,
|
||||||
|
interval: 3,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'img',
|
||||||
|
imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg',
|
||||||
|
videoUrl: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'img',
|
||||||
|
imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg',
|
||||||
|
videoUrl: '',
|
||||||
|
},
|
||||||
|
] as CarouselItemProperty[],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<CarouselProperty>;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CarouselProperty } from './config';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElCarousel, ElCarouselItem, ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 轮播图 */
|
||||||
|
defineOptions({ name: 'Carousel' });
|
||||||
|
|
||||||
|
defineProps<{ property: CarouselProperty }>();
|
||||||
|
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const handleIndexChange = (index: number) => {
|
||||||
|
currentIndex.value = index + 1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<!-- 无图片 -->
|
||||||
|
<div
|
||||||
|
class="flex h-[250px] items-center justify-center bg-gray-300"
|
||||||
|
v-if="property.items.length === 0"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="tdesign:image" class="text-[120px] text-gray-800" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="relative">
|
||||||
|
<ElCarousel
|
||||||
|
height="174px"
|
||||||
|
:type="property.type === 'card' ? 'card' : ''"
|
||||||
|
:autoplay="property.autoplay"
|
||||||
|
:interval="property.interval * 1000"
|
||||||
|
:indicator-position="property.indicator === 'number' ? 'none' : undefined"
|
||||||
|
@change="handleIndexChange"
|
||||||
|
>
|
||||||
|
<ElCarouselItem v-for="(item, index) in property.items" :key="index">
|
||||||
|
<ElImage class="h-full w-full" :src="item.imgUrl" />
|
||||||
|
</ElCarouselItem>
|
||||||
|
</ElCarousel>
|
||||||
|
<div
|
||||||
|
v-if="property.indicator === 'number'"
|
||||||
|
class="absolute bottom-[10px] right-[10px] rounded-xl bg-black px-[8px] py-[2px] text-[10px] text-white opacity-40"
|
||||||
|
>
|
||||||
|
{{ currentIndex }} / {{ property.items.length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CarouselProperty } from './config';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadioButton,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
ElSwitch,
|
||||||
|
ElText,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import UploadFile from '#/components/upload/file-upload.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 轮播图属性面板
|
||||||
|
defineOptions({ name: 'CarouselProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: CarouselProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElCard header="样式设置" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="样式" prop="type">
|
||||||
|
<ElRadioGroup v-model="formData.type">
|
||||||
|
<ElTooltip class="item" content="默认" placement="bottom">
|
||||||
|
<ElRadioButton value="default">
|
||||||
|
<IconifyIcon icon="system-uicons:carousel" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="卡片" placement="bottom">
|
||||||
|
<ElRadioButton value="card">
|
||||||
|
<IconifyIcon icon="ic:round-view-carousel" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="指示器" prop="indicator">
|
||||||
|
<ElRadioGroup v-model="formData.indicator">
|
||||||
|
<ElRadio value="dot">小圆点</ElRadio>
|
||||||
|
<ElRadio value="number">数字</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="是否轮播" prop="autoplay">
|
||||||
|
<ElSwitch v-model="formData.autoplay" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="播放间隔" prop="interval" v-if="formData.autoplay">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.interval"
|
||||||
|
:max="10"
|
||||||
|
:min="0.5"
|
||||||
|
:step="0.5"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
<ElText type="info">单位:秒</ElText>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="内容设置" class="property-group" shadow="never">
|
||||||
|
<Draggable v-model="formData.items" :empty-item="{ type: 'img' }">
|
||||||
|
<template #default="{ element }">
|
||||||
|
<ElFormItem
|
||||||
|
label="类型"
|
||||||
|
prop="type"
|
||||||
|
class="mb-2"
|
||||||
|
label-width="40px"
|
||||||
|
>
|
||||||
|
<ElRadioGroup v-model="element.type">
|
||||||
|
<ElRadio value="img">图片</ElRadio>
|
||||||
|
<ElRadio value="video">视频</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem
|
||||||
|
label="图片"
|
||||||
|
class="mb-2"
|
||||||
|
label-width="40px"
|
||||||
|
v-if="element.type === 'img'"
|
||||||
|
>
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.imgUrl"
|
||||||
|
draggable="false"
|
||||||
|
height="80px"
|
||||||
|
width="100%"
|
||||||
|
class="min-w-[80px]"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-else>
|
||||||
|
<ElFormItem label="封面" class="mb-2" label-width="40px">
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.imgUrl"
|
||||||
|
draggable="false"
|
||||||
|
:show-description="false"
|
||||||
|
height="80px"
|
||||||
|
width="100%"
|
||||||
|
class="min-w-[80px]"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="视频" class="mb-2" label-width="40px">
|
||||||
|
<UploadFile
|
||||||
|
v-model="element.videoUrl"
|
||||||
|
:file-type="['mp4']"
|
||||||
|
:limit="1"
|
||||||
|
:file-size="100"
|
||||||
|
class="min-w-[80px]"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<ElFormItem label="链接" class="mb-2" label-width="40px">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { DiyComponent } from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 分割线属性 */
|
||||||
|
export interface DividerProperty {
|
||||||
|
// 高度
|
||||||
|
height: number;
|
||||||
|
// 线宽
|
||||||
|
lineWidth: number;
|
||||||
|
// 边距类型
|
||||||
|
paddingType: 'horizontal' | 'none';
|
||||||
|
// 颜色
|
||||||
|
lineColor: string;
|
||||||
|
// 类型
|
||||||
|
borderType: 'dashed' | 'dotted' | 'none' | 'solid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'Divider',
|
||||||
|
name: '分割线',
|
||||||
|
icon: 'tdesign:component-divider-vertical',
|
||||||
|
property: {
|
||||||
|
height: 30,
|
||||||
|
lineWidth: 1,
|
||||||
|
paddingType: 'none',
|
||||||
|
lineColor: '#dcdfe6',
|
||||||
|
borderType: 'solid',
|
||||||
|
},
|
||||||
|
} as DiyComponent<DividerProperty>;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DividerProperty } from './config';
|
||||||
|
|
||||||
|
/** 页面顶部导航栏 */
|
||||||
|
defineOptions({ name: 'Divider' });
|
||||||
|
|
||||||
|
defineProps<{ property: DividerProperty }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center"
|
||||||
|
:style="{
|
||||||
|
height: `${property.height}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{
|
||||||
|
borderTopStyle: property.borderType,
|
||||||
|
borderTopColor: property.lineColor,
|
||||||
|
borderTopWidth: `${property.lineWidth}px`,
|
||||||
|
margin: property.paddingType === 'none' ? '0' : '0px 16px',
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DividerProperty } from './config';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadioButton,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import ColorInput from '#/components/input-with-color/index.vue';
|
||||||
|
|
||||||
|
// 导航栏属性面板
|
||||||
|
defineOptions({ name: 'DividerProperty' });
|
||||||
|
const props = defineProps<{ modelValue: DividerProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
|
||||||
|
// 线类型
|
||||||
|
const BORDER_TYPES = [
|
||||||
|
{
|
||||||
|
icon: 'vaadin:line-h',
|
||||||
|
text: '实线',
|
||||||
|
type: 'solid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'tabler:line-dashed',
|
||||||
|
text: '虚线',
|
||||||
|
type: 'dashed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'tabler:line-dotted',
|
||||||
|
text: '点线',
|
||||||
|
type: 'dotted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'entypo:progress-empty',
|
||||||
|
text: '无',
|
||||||
|
type: 'none',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElFormItem label="高度" prop="height">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.height"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="选择样式" prop="borderType">
|
||||||
|
<ElRadioGroup v-model="formData!.borderType">
|
||||||
|
<ElTooltip
|
||||||
|
placement="top"
|
||||||
|
v-for="(item, index) in BORDER_TYPES"
|
||||||
|
:key="index"
|
||||||
|
:content="item.text"
|
||||||
|
>
|
||||||
|
<ElRadioButton :value="item.type">
|
||||||
|
<IconifyIcon :icon="item.icon" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-if="formData.borderType !== 'none'">
|
||||||
|
<ElFormItem label="线宽" prop="lineWidth">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.lineWidth"
|
||||||
|
:min="1"
|
||||||
|
:max="30"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="左右边距" prop="paddingType">
|
||||||
|
<ElRadioGroup v-model="formData!.paddingType">
|
||||||
|
<ElTooltip content="无边距" placement="top">
|
||||||
|
<ElRadioButton value="none">
|
||||||
|
<IconifyIcon icon="tabler:box-padding" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="左右留边" placement="top">
|
||||||
|
<ElRadioButton value="horizontal">
|
||||||
|
<IconifyIcon icon="vaadin:padding" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="颜色">
|
||||||
|
<!-- 分割线颜色 -->
|
||||||
|
<ColorInput v-model="formData.lineColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</ElForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DiyComponent } from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 弹窗广告属性 */
|
||||||
|
export interface PopoverProperty {
|
||||||
|
list: PopoverItemProperty[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopoverItemProperty {
|
||||||
|
// 图片地址
|
||||||
|
imgUrl: string;
|
||||||
|
// 跳转连接
|
||||||
|
url: string;
|
||||||
|
// 显示类型:仅显示一次、每次启动都会显示
|
||||||
|
showType: 'always' | 'once';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'Popover',
|
||||||
|
name: '弹窗广告',
|
||||||
|
icon: 'carbon:popup',
|
||||||
|
position: 'fixed',
|
||||||
|
property: {
|
||||||
|
list: [{ showType: 'once' }],
|
||||||
|
},
|
||||||
|
} as DiyComponent<PopoverProperty>;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverProperty } from './config';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 弹窗广告 */
|
||||||
|
defineOptions({ name: 'Popover' });
|
||||||
|
// 定义属性
|
||||||
|
defineProps<{ property: PopoverProperty }>();
|
||||||
|
|
||||||
|
// 处理选中
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const handleActive = (index: number) => {
|
||||||
|
activeIndex.value = index;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in property.list"
|
||||||
|
:key="index"
|
||||||
|
class="absolute bottom-1/2 right-1/2 h-[454px] w-[292px] rounded border border-gray-300 bg-white p-0.5"
|
||||||
|
:style="{
|
||||||
|
zIndex: 100 + index + (activeIndex === index ? 100 : 0),
|
||||||
|
marginRight: `${-146 - index * 20}px`,
|
||||||
|
marginBottom: `${-227 - index * 20}px`,
|
||||||
|
}"
|
||||||
|
@click="handleActive(index)"
|
||||||
|
>
|
||||||
|
<ElImage :src="item.imgUrl" fit="contain" class="h-full w-full">
|
||||||
|
<template #error>
|
||||||
|
<div class="flex h-full w-full items-center justify-center">
|
||||||
|
<IconifyIcon icon="ep:picture" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElImage>
|
||||||
|
<div class="absolute right-1 top-1 text-xs">{{ index + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadio,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 弹窗广告属性面板
|
||||||
|
defineOptions({ name: 'PopoverProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: PopoverProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<Draggable v-model="formData.list" :empty-item="{ showType: 'once' }">
|
||||||
|
<template #default="{ element, index }">
|
||||||
|
<ElFormItem label="图片" :prop="`list[${index}].imgUrl`">
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.imgUrl"
|
||||||
|
height="56px"
|
||||||
|
width="56px"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="跳转链接" :prop="`list[${index}].url`">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="显示次数" :prop="`list[${index}].showType`">
|
||||||
|
<ElRadioGroup v-model="element.showType">
|
||||||
|
<ElTooltip
|
||||||
|
content="只显示一次,下次打开时不显示"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<ElRadio value="once">一次</ElRadio>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="每次打开时都会显示" placement="bottom">
|
||||||
|
<ElRadio value="always">不限</ElRadio>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 导出所有优惠券相关组件
|
||||||
|
export { CouponDiscount } from './coupon-discount';
|
||||||
|
export { CouponDiscountDesc } from './coupon-discount-desc';
|
||||||
|
export { CouponValidTerm } from './coupon-validTerm';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 商品卡片属性 */
|
||||||
|
export interface CouponCardProperty {
|
||||||
|
// 列数
|
||||||
|
columns: number;
|
||||||
|
// 背景图
|
||||||
|
bgImg: string;
|
||||||
|
// 文字颜色
|
||||||
|
textColor: string;
|
||||||
|
// 按钮样式
|
||||||
|
button: {
|
||||||
|
// 背景颜色
|
||||||
|
bgColor: string;
|
||||||
|
// 颜色
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
// 间距
|
||||||
|
space: number;
|
||||||
|
// 优惠券编号列表
|
||||||
|
couponIds: number[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'CouponCard',
|
||||||
|
name: '优惠券',
|
||||||
|
icon: 'ep:ticket',
|
||||||
|
property: {
|
||||||
|
columns: 1,
|
||||||
|
bgImg: '',
|
||||||
|
textColor: '#E9B461',
|
||||||
|
button: {
|
||||||
|
color: '#434343',
|
||||||
|
bgColor: '',
|
||||||
|
},
|
||||||
|
space: 0,
|
||||||
|
couponIds: [],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<CouponCardProperty>;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
import { floatToFixed2 } from '@vben/utils';
|
||||||
|
|
||||||
|
import { PromotionDiscountTypeEnum } from '#/utils/constants';
|
||||||
|
|
||||||
|
// 优惠描述
|
||||||
|
export const CouponDiscountDesc = defineComponent({
|
||||||
|
name: 'CouponDiscountDesc',
|
||||||
|
props: {
|
||||||
|
coupon: {
|
||||||
|
type: Object as () => MallCouponTemplateApi.CouponTemplate,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const coupon = props.coupon as MallCouponTemplateApi.CouponTemplate;
|
||||||
|
// 使用条件
|
||||||
|
const useCondition =
|
||||||
|
coupon.usePrice > 0 ? `满${floatToFixed2(coupon.usePrice)}元,` : '';
|
||||||
|
// 优惠描述
|
||||||
|
const discountDesc =
|
||||||
|
coupon.discountType === PromotionDiscountTypeEnum.PRICE.type
|
||||||
|
? `减${floatToFixed2(coupon.discountPrice)}元`
|
||||||
|
: `打${coupon.discountPercent / 10}折`;
|
||||||
|
return () => (
|
||||||
|
<div>
|
||||||
|
<span>{useCondition}</span>
|
||||||
|
<span>{discountDesc}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
import { floatToFixed2 } from '@vben/utils';
|
||||||
|
|
||||||
|
import { PromotionDiscountTypeEnum } from '#/utils/constants';
|
||||||
|
|
||||||
|
// 优惠值
|
||||||
|
export const CouponDiscount = defineComponent({
|
||||||
|
name: 'CouponDiscount',
|
||||||
|
props: {
|
||||||
|
coupon: {
|
||||||
|
type: Object as () => MallCouponTemplateApi.CouponTemplate,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const coupon = props.coupon as MallCouponTemplateApi.CouponTemplate;
|
||||||
|
// 折扣
|
||||||
|
let value = `${coupon.discountPercent / 10}`;
|
||||||
|
let suffix = ' 折';
|
||||||
|
// 满减
|
||||||
|
if (coupon.discountType === PromotionDiscountTypeEnum.PRICE.type) {
|
||||||
|
value = floatToFixed2(coupon.discountPrice);
|
||||||
|
suffix = ' 元';
|
||||||
|
}
|
||||||
|
return () => (
|
||||||
|
<div>
|
||||||
|
<span class={'text-20px font-bold'}>{value}</span>
|
||||||
|
<span>{suffix}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
import { formatDate } from '@vben/utils';
|
||||||
|
|
||||||
|
import { CouponTemplateValidityTypeEnum } from '#/utils/constants';
|
||||||
|
|
||||||
|
// 有效期
|
||||||
|
export const CouponValidTerm = defineComponent({
|
||||||
|
name: 'CouponValidTerm',
|
||||||
|
props: {
|
||||||
|
coupon: {
|
||||||
|
type: Object as () => MallCouponTemplateApi.CouponTemplate,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const coupon = props.coupon as MallCouponTemplateApi.CouponTemplate;
|
||||||
|
const text =
|
||||||
|
coupon.validityType === CouponTemplateValidityTypeEnum.DATE.type
|
||||||
|
? `有效期:${formatDate(coupon.validStartTime, 'YYYY-MM-DD')} 至 ${formatDate(
|
||||||
|
coupon.validEndTime,
|
||||||
|
'YYYY-MM-DD',
|
||||||
|
)}`
|
||||||
|
: `领取后第 ${coupon.fixedStartTerm} - ${coupon.fixedEndTerm} 天内可用`;
|
||||||
|
return () => <div>{text}</div>;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CouponCardProperty } from './config';
|
||||||
|
|
||||||
|
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
|
import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CouponDiscount,
|
||||||
|
CouponDiscountDesc,
|
||||||
|
CouponValidTerm,
|
||||||
|
} from './component';
|
||||||
|
|
||||||
|
/** 商品卡片 */
|
||||||
|
defineOptions({ name: 'CouponCard' });
|
||||||
|
// 定义属性
|
||||||
|
const props = defineProps<{ property: CouponCardProperty }>();
|
||||||
|
// 商品列表
|
||||||
|
const couponList = ref<MallCouponTemplateApi.CouponTemplate[]>([]);
|
||||||
|
watch(
|
||||||
|
() => props.property.couponIds,
|
||||||
|
async () => {
|
||||||
|
if (props.property.couponIds?.length > 0) {
|
||||||
|
couponList.value = await CouponTemplateApi.getCouponTemplateList(
|
||||||
|
props.property.couponIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 手机宽度
|
||||||
|
const phoneWidth = ref(375);
|
||||||
|
// 容器
|
||||||
|
const containerRef = ref();
|
||||||
|
// 滚动条宽度
|
||||||
|
const scrollbarWidth = ref('100%');
|
||||||
|
// 优惠券的宽度
|
||||||
|
const couponWidth = ref(375);
|
||||||
|
// 计算布局参数
|
||||||
|
watch(
|
||||||
|
() => [props.property, phoneWidth, couponList.value.length],
|
||||||
|
() => {
|
||||||
|
// 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数
|
||||||
|
couponWidth.value =
|
||||||
|
(phoneWidth.value - props.property.space * (props.property.columns - 1)) /
|
||||||
|
props.property.columns;
|
||||||
|
// 显示滚动条
|
||||||
|
scrollbarWidth.value = `${
|
||||||
|
couponWidth.value * couponList.value.length +
|
||||||
|
props.property.space * (couponList.value.length - 1)
|
||||||
|
}px`;
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
onMounted(() => {
|
||||||
|
// 提取手机宽度
|
||||||
|
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ElScrollbar class="z-10 min-h-[30px]" wrap-class="w-full" ref="containerRef">
|
||||||
|
<div
|
||||||
|
class="flex flex-row text-xs"
|
||||||
|
:style="{
|
||||||
|
gap: `${property.space}px`,
|
||||||
|
width: scrollbarWidth,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="box-content"
|
||||||
|
:style="{
|
||||||
|
background: property.bgImg
|
||||||
|
? `url(${property.bgImg}) 100% center / 100% 100% no-repeat`
|
||||||
|
: '#fff',
|
||||||
|
width: `${couponWidth}px`,
|
||||||
|
color: property.textColor,
|
||||||
|
}"
|
||||||
|
v-for="(coupon, index) in couponList"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<!-- 布局1:1列-->
|
||||||
|
<div
|
||||||
|
v-if="property.columns === 1"
|
||||||
|
class="ml-4 flex flex-row justify-between p-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-evenly gap-1">
|
||||||
|
<!-- 优惠值 -->
|
||||||
|
<CouponDiscount :coupon="coupon" />
|
||||||
|
<!-- 优惠描述 -->
|
||||||
|
<CouponDiscountDesc :coupon="coupon" />
|
||||||
|
<!-- 有效期 -->
|
||||||
|
<CouponValidTerm :coupon="coupon" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-evenly">
|
||||||
|
<div
|
||||||
|
class="rounded-full px-2 py-0.5"
|
||||||
|
:style="{
|
||||||
|
color: property.button.color,
|
||||||
|
background: property.button.bgColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
立即领取
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 布局2:2列-->
|
||||||
|
<div
|
||||||
|
v-else-if="property.columns === 2"
|
||||||
|
class="ml-4 flex flex-row justify-between p-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-evenly gap-1">
|
||||||
|
<!-- 优惠值 -->
|
||||||
|
<CouponDiscount :coupon="coupon" />
|
||||||
|
<!-- 优惠描述 -->
|
||||||
|
<CouponDiscountDesc :coupon="coupon" />
|
||||||
|
<!-- 领取说明 -->
|
||||||
|
<div v-if="coupon.totalCount >= 0">
|
||||||
|
仅剩:{{ coupon.totalCount - coupon.takeCount }}张
|
||||||
|
</div>
|
||||||
|
<div v-else-if="coupon.totalCount === -1">仅剩:不限制</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div
|
||||||
|
class="h-full w-5 rounded-full px-0.5 py-2 text-center"
|
||||||
|
:style="{
|
||||||
|
color: property.button.color,
|
||||||
|
background: property.button.bgColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
立即领取
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 布局3:3列-->
|
||||||
|
<div v-else class="flex flex-col items-center justify-around gap-1 p-1">
|
||||||
|
<!-- 优惠值 -->
|
||||||
|
<CouponDiscount :coupon="coupon" />
|
||||||
|
<!-- 优惠描述 -->
|
||||||
|
<CouponDiscountDesc :coupon="coupon" />
|
||||||
|
<div
|
||||||
|
class="rounded-full px-2 py-0.5"
|
||||||
|
:style="{
|
||||||
|
color: property.button.color,
|
||||||
|
background: property.button.bgColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
立即领取
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</template>
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CouponCardProperty } from './config';
|
||||||
|
|
||||||
|
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { floatToFixed2 } from '@vben/utils';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadioButton,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate';
|
||||||
|
import ColorInput from '#/components/color-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
import {
|
||||||
|
CouponTemplateTakeTypeEnum,
|
||||||
|
PromotionDiscountTypeEnum,
|
||||||
|
} from '#/utils/constants';
|
||||||
|
import CouponSelect from '#/views/mall/promotion/coupon/components/coupon-select.vue';
|
||||||
|
|
||||||
|
// 优惠券卡片属性面板
|
||||||
|
defineOptions({ name: 'CouponCardProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: CouponCardProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
|
||||||
|
// 优惠券列表
|
||||||
|
const couponList = ref<MallCouponTemplateApi.CouponTemplate[]>([]);
|
||||||
|
const couponSelectDialog = ref();
|
||||||
|
// 添加优惠券
|
||||||
|
const handleAddCoupon = () => {
|
||||||
|
couponSelectDialog.value.open();
|
||||||
|
};
|
||||||
|
const handleCouponSelect = () => {
|
||||||
|
formData.value.couponIds = couponList.value.map((coupon) => coupon.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => formData.value.couponIds,
|
||||||
|
async () => {
|
||||||
|
if (formData.value.couponIds?.length > 0) {
|
||||||
|
couponList.value = await CouponTemplateApi.getCouponTemplateList(
|
||||||
|
formData.value.couponIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElCard header="优惠券列表" class="property-group" shadow="never">
|
||||||
|
<div
|
||||||
|
v-for="(coupon, index) in couponList"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<ElText size="large" truncated>{{ coupon.name }}</ElText>
|
||||||
|
<ElText type="info" truncated>
|
||||||
|
<span v-if="coupon.usePrice > 0">
|
||||||
|
满{{ floatToFixed2(coupon.usePrice) }}元,
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
coupon.discountType === PromotionDiscountTypeEnum.PRICE.type
|
||||||
|
"
|
||||||
|
>
|
||||||
|
减{{ floatToFixed2(coupon.discountPrice) }}元
|
||||||
|
</span>
|
||||||
|
<span v-else> 打{{ coupon.discountPercent }}折 </span>
|
||||||
|
</ElText>
|
||||||
|
</div>
|
||||||
|
<ElFormItem label-width="0">
|
||||||
|
<ElButton
|
||||||
|
@click="handleAddCoupon"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
class="mt-2 w-full"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="ep:plus" class="mr-1" /> 添加
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="优惠券样式" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="列数" prop="type">
|
||||||
|
<ElRadioGroup v-model="formData.columns">
|
||||||
|
<ElTooltip class="item" content="一列" placement="bottom">
|
||||||
|
<ElRadioButton :value="1">
|
||||||
|
<IconifyIcon icon="fluent:text-column-one-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="二列" placement="bottom">
|
||||||
|
<ElRadioButton :value="2">
|
||||||
|
<IconifyIcon icon="fluent:text-column-two-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="三列" placement="bottom">
|
||||||
|
<ElRadioButton :value="3">
|
||||||
|
<IconifyIcon icon="fluent:text-column-three-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="背景图片" prop="bgImg">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.bgImg"
|
||||||
|
height="80px"
|
||||||
|
width="100%"
|
||||||
|
class="min-w-[160px]"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="文字颜色" prop="textColor">
|
||||||
|
<ColorInput v-model="formData.textColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="按钮背景" prop="button.bgColor">
|
||||||
|
<ColorInput v-model="formData.button.bgColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="按钮文字" prop="button.color">
|
||||||
|
<ColorInput v-model="formData.button.color" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="间隔" prop="space">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.space"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
<!-- 优惠券选择 -->
|
||||||
|
<CouponSelect
|
||||||
|
ref="couponSelectDialog"
|
||||||
|
v-model:multiple-selection="couponList"
|
||||||
|
:take-type="CouponTemplateTakeTypeEnum.USER.type"
|
||||||
|
@change="handleCouponSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { DiyComponent } from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
// 悬浮按钮属性
|
||||||
|
export interface FloatingActionButtonProperty {
|
||||||
|
// 展开方向
|
||||||
|
direction: 'horizontal' | 'vertical';
|
||||||
|
// 是否显示文字
|
||||||
|
showText: boolean;
|
||||||
|
// 按钮列表
|
||||||
|
list: FloatingActionButtonItemProperty[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 悬浮按钮项属性
|
||||||
|
export interface FloatingActionButtonItemProperty {
|
||||||
|
// 图片地址
|
||||||
|
imgUrl: string;
|
||||||
|
// 跳转连接
|
||||||
|
url: string;
|
||||||
|
// 文字
|
||||||
|
text: string;
|
||||||
|
// 文字颜色
|
||||||
|
textColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'FloatingActionButton',
|
||||||
|
name: '悬浮按钮',
|
||||||
|
icon: 'tabler:float-right',
|
||||||
|
position: 'fixed',
|
||||||
|
property: {
|
||||||
|
direction: 'vertical',
|
||||||
|
showText: true,
|
||||||
|
list: [{ textColor: '#fff' }],
|
||||||
|
},
|
||||||
|
} as DiyComponent<FloatingActionButtonProperty>;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FloatingActionButtonProperty } from './config';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElImage, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 悬浮按钮 */
|
||||||
|
defineOptions({ name: 'FloatingActionButton' });
|
||||||
|
// 定义属性
|
||||||
|
defineProps<{ property: FloatingActionButtonProperty }>();
|
||||||
|
|
||||||
|
// 是否展开
|
||||||
|
const expanded = ref(false);
|
||||||
|
// 处理展开/折叠
|
||||||
|
const handleToggleFab = () => {
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActive = (index: number) => {
|
||||||
|
ElMessage.success(`点击了${index}`);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-8 right-[calc(50%-375px/2+32px)] z-20 flex items-center gap-3"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'flex-row': property.direction === 'horizontal',
|
||||||
|
'flex-col': property.direction === 'vertical',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-if="expanded">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in property.list"
|
||||||
|
:key="index"
|
||||||
|
class="flex flex-col items-center"
|
||||||
|
@click="handleActive(index)"
|
||||||
|
>
|
||||||
|
<ElImage :src="item.imgUrl" fit="contain" class="h-7 w-7">
|
||||||
|
<template #error>
|
||||||
|
<div class="flex h-full w-full items-center justify-center">
|
||||||
|
<IconifyIcon icon="ep:picture" :color="item.textColor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElImage>
|
||||||
|
<span
|
||||||
|
v-if="property.showText"
|
||||||
|
class="mt-1 text-xs"
|
||||||
|
:style="{ color: item.textColor }"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- todo: @owen 使用APP主题色 -->
|
||||||
|
<el-button type="primary" size="large" circle @click="handleToggleFab">
|
||||||
|
<IconifyIcon
|
||||||
|
icon="ep:plus"
|
||||||
|
class="fab-icon"
|
||||||
|
:class="[{ active: expanded }]"
|
||||||
|
/>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<!-- 模态背景:展开时显示,点击后折叠 -->
|
||||||
|
<div v-if="expanded" class="modal-bg" @click="handleToggleFab"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 模态背景 */
|
||||||
|
.modal-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(50% - 375px / 2);
|
||||||
|
z-index: 11;
|
||||||
|
width: 375px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgb(0 0 0 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
transform: rotate(135deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FloatingActionButtonProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadio,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import InputWithColor from '#/components/input-with-color/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 悬浮按钮属性面板
|
||||||
|
defineOptions({ name: 'FloatingActionButtonProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: FloatingActionButtonProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElCard header="按钮配置" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="展开方向" prop="direction">
|
||||||
|
<ElRadioGroup v-model="formData.direction">
|
||||||
|
<ElRadio value="vertical">垂直</ElRadio>
|
||||||
|
<ElRadio value="horizontal">水平</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="显示文字" prop="showText">
|
||||||
|
<ElSwitch v-model="formData.showText" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="按钮列表" class="property-group" shadow="never">
|
||||||
|
<Draggable v-model="formData.list" :empty-item="{ textColor: '#fff' }">
|
||||||
|
<template #default="{ element, index }">
|
||||||
|
<ElFormItem label="图标" :prop="`list[${index}].imgUrl`">
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.imgUrl"
|
||||||
|
height="56px"
|
||||||
|
width="56px"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="文字" :prop="`list[${index}].text`">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.text"
|
||||||
|
v-model:color="element.textColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="跳转链接" :prop="`list[${index}].url`">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { StyleValue } from 'vue';
|
||||||
|
|
||||||
|
import type { HotZoneItemProperty } from '#/components/diy-editor/components/mobile/HotZone/config';
|
||||||
|
|
||||||
|
// 热区的最小宽高
|
||||||
|
export const HOT_ZONE_MIN_SIZE = 100;
|
||||||
|
|
||||||
|
// 控制的类型
|
||||||
|
export enum CONTROL_TYPE_ENUM {
|
||||||
|
LEFT,
|
||||||
|
TOP,
|
||||||
|
WIDTH,
|
||||||
|
HEIGHT,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义热区的控制点
|
||||||
|
export interface ControlDot {
|
||||||
|
position: string;
|
||||||
|
types: CONTROL_TYPE_ENUM[];
|
||||||
|
style: StyleValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 热区的8个控制点
|
||||||
|
export const CONTROL_DOT_LIST = [
|
||||||
|
{
|
||||||
|
position: '左上角',
|
||||||
|
types: [
|
||||||
|
CONTROL_TYPE_ENUM.LEFT,
|
||||||
|
CONTROL_TYPE_ENUM.TOP,
|
||||||
|
CONTROL_TYPE_ENUM.WIDTH,
|
||||||
|
CONTROL_TYPE_ENUM.HEIGHT,
|
||||||
|
],
|
||||||
|
style: { left: '-5px', top: '-5px', cursor: 'nwse-resize' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '上方中间',
|
||||||
|
types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.HEIGHT],
|
||||||
|
style: {
|
||||||
|
left: '50%',
|
||||||
|
top: '-5px',
|
||||||
|
cursor: 'n-resize',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '右上角',
|
||||||
|
types: [
|
||||||
|
CONTROL_TYPE_ENUM.TOP,
|
||||||
|
CONTROL_TYPE_ENUM.WIDTH,
|
||||||
|
CONTROL_TYPE_ENUM.HEIGHT,
|
||||||
|
],
|
||||||
|
style: { right: '-5px', top: '-5px', cursor: 'nesw-resize' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '右侧中间',
|
||||||
|
types: [CONTROL_TYPE_ENUM.WIDTH],
|
||||||
|
style: {
|
||||||
|
right: '-5px',
|
||||||
|
top: '50%',
|
||||||
|
cursor: 'e-resize',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '右下角',
|
||||||
|
types: [CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
|
||||||
|
style: { right: '-5px', bottom: '-5px', cursor: 'nwse-resize' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '下方中间',
|
||||||
|
types: [CONTROL_TYPE_ENUM.HEIGHT],
|
||||||
|
style: {
|
||||||
|
left: '50%',
|
||||||
|
bottom: '-5px',
|
||||||
|
cursor: 's-resize',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '左下角',
|
||||||
|
types: [
|
||||||
|
CONTROL_TYPE_ENUM.LEFT,
|
||||||
|
CONTROL_TYPE_ENUM.WIDTH,
|
||||||
|
CONTROL_TYPE_ENUM.HEIGHT,
|
||||||
|
],
|
||||||
|
style: { left: '-5px', bottom: '-5px', cursor: 'nesw-resize' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: '左侧中间',
|
||||||
|
types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH],
|
||||||
|
style: {
|
||||||
|
left: '-5px',
|
||||||
|
top: '50%',
|
||||||
|
cursor: 'w-resize',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as ControlDot[];
|
||||||
|
|
||||||
|
// region 热区的缩放
|
||||||
|
// 热区的缩放比例
|
||||||
|
export const HOT_ZONE_SCALE_RATE = 2;
|
||||||
|
// 缩小:缩回适合手机屏幕的大小
|
||||||
|
export const zoomOut = (list?: HotZoneItemProperty[]) => {
|
||||||
|
return (
|
||||||
|
list?.map((hotZone) => ({
|
||||||
|
...hotZone,
|
||||||
|
left: (hotZone.left /= HOT_ZONE_SCALE_RATE),
|
||||||
|
top: (hotZone.top /= HOT_ZONE_SCALE_RATE),
|
||||||
|
width: (hotZone.width /= HOT_ZONE_SCALE_RATE),
|
||||||
|
height: (hotZone.height /= HOT_ZONE_SCALE_RATE),
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// 放大:作用是为了方便在电脑屏幕上编辑
|
||||||
|
export const zoomIn = (list?: HotZoneItemProperty[]) => {
|
||||||
|
return (
|
||||||
|
list?.map((hotZone) => ({
|
||||||
|
...hotZone,
|
||||||
|
left: (hotZone.left *= HOT_ZONE_SCALE_RATE),
|
||||||
|
top: (hotZone.top *= HOT_ZONE_SCALE_RATE),
|
||||||
|
width: (hotZone.width *= HOT_ZONE_SCALE_RATE),
|
||||||
|
height: (hotZone.height *= HOT_ZONE_SCALE_RATE),
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封装热区拖拽
|
||||||
|
*
|
||||||
|
* 注:为什么不使用vueuse的useDraggable。在本场景下,其使用方式比较复杂
|
||||||
|
* @param hotZone 热区
|
||||||
|
* @param downEvent 鼠标按下事件
|
||||||
|
* @param callback 回调函数
|
||||||
|
*/
|
||||||
|
export const useDraggable = (
|
||||||
|
hotZone: HotZoneItemProperty,
|
||||||
|
downEvent: MouseEvent,
|
||||||
|
callback: (
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
moveWidth: number,
|
||||||
|
moveHeight: number,
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
|
// 阻止事件冒泡
|
||||||
|
downEvent.stopPropagation();
|
||||||
|
|
||||||
|
// 移动前的鼠标坐标
|
||||||
|
const { clientX: startX, clientY: startY } = downEvent;
|
||||||
|
// 移动前的热区坐标、大小
|
||||||
|
const { left, top, width, height } = hotZone;
|
||||||
|
|
||||||
|
// 监听鼠标移动
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
// 移动宽度
|
||||||
|
const moveWidth = e.clientX - startX;
|
||||||
|
// 移动高度
|
||||||
|
const moveHeight = e.clientY - startY;
|
||||||
|
// 移动回调
|
||||||
|
callback(left, top, width, height, moveWidth, moveHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 松开鼠标后,结束拖拽
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ControlDot } from './controller';
|
||||||
|
|
||||||
|
import type { AppLink } from '#/components/app-link-input/data';
|
||||||
|
import type { HotZoneItemProperty } from '#/components/diy-editor/components/mobile/HotZone/config';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElButton, ElDialog, ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONTROL_DOT_LIST,
|
||||||
|
CONTROL_TYPE_ENUM,
|
||||||
|
HOT_ZONE_MIN_SIZE,
|
||||||
|
useDraggable,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
} from './controller';
|
||||||
|
|
||||||
|
/** 热区编辑对话框 */
|
||||||
|
defineOptions({ name: 'HotZoneEditDialog' });
|
||||||
|
|
||||||
|
// 定义属性
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Array<HotZoneItemProperty>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
imgUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = ref<HotZoneItemProperty[]>([]);
|
||||||
|
|
||||||
|
// 弹窗的是否显示
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
// 打开弹窗
|
||||||
|
const open = () => {
|
||||||
|
// 放大
|
||||||
|
formData.value = zoomIn(props.modelValue);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
// 提供 open 方法,用于打开弹窗
|
||||||
|
defineExpose({ open });
|
||||||
|
|
||||||
|
// 热区容器
|
||||||
|
const container = ref<HTMLDivElement>();
|
||||||
|
|
||||||
|
// 增加热区
|
||||||
|
const handleAdd = () => {
|
||||||
|
formData.value.push({
|
||||||
|
width: HOT_ZONE_MIN_SIZE,
|
||||||
|
height: HOT_ZONE_MIN_SIZE,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
} as HotZoneItemProperty);
|
||||||
|
};
|
||||||
|
// 删除热区
|
||||||
|
const handleRemove = (hotZone: HotZoneItemProperty) => {
|
||||||
|
formData.value = formData.value.filter((item) => item !== hotZone);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移动热区
|
||||||
|
const handleMove = (item: HotZoneItemProperty, e: MouseEvent) => {
|
||||||
|
useDraggable(item, e, (left, top, _, __, moveWidth, moveHeight) => {
|
||||||
|
setLeft(item, left + moveWidth);
|
||||||
|
setTop(item, top + moveHeight);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调整热区大小、位置
|
||||||
|
const handleResize = (
|
||||||
|
item: HotZoneItemProperty,
|
||||||
|
ctrlDot: ControlDot,
|
||||||
|
e: MouseEvent,
|
||||||
|
) => {
|
||||||
|
useDraggable(item, e, (left, top, width, height, moveWidth, moveHeight) => {
|
||||||
|
ctrlDot.types.forEach((type) => {
|
||||||
|
switch (type) {
|
||||||
|
case CONTROL_TYPE_ENUM.HEIGHT: {
|
||||||
|
{
|
||||||
|
// 左移时,宽度为减少
|
||||||
|
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.TOP)
|
||||||
|
? -1
|
||||||
|
: 1;
|
||||||
|
setHeight(item, height + moveHeight * direction);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CONTROL_TYPE_ENUM.LEFT: {
|
||||||
|
setLeft(item, left + moveWidth);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CONTROL_TYPE_ENUM.TOP: {
|
||||||
|
setTop(item, top + moveHeight);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CONTROL_TYPE_ENUM.WIDTH: {
|
||||||
|
{
|
||||||
|
// 上移时,高度为减少
|
||||||
|
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.LEFT)
|
||||||
|
? -1
|
||||||
|
: 1;
|
||||||
|
setWidth(item, width + moveWidth * direction);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置X轴坐标
|
||||||
|
const setLeft = (item: HotZoneItemProperty, left: number) => {
|
||||||
|
// 不能超出容器
|
||||||
|
if (left >= 0 && left <= container.value!.offsetWidth - item.width) {
|
||||||
|
item.left = left;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 设置Y轴坐标
|
||||||
|
const setTop = (item: HotZoneItemProperty, top: number) => {
|
||||||
|
// 不能超出容器
|
||||||
|
if (top >= 0 && top <= container.value!.offsetHeight - item.height) {
|
||||||
|
item.top = top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 设置宽度
|
||||||
|
const setWidth = (item: HotZoneItemProperty, width: number) => {
|
||||||
|
// 不能小于最小宽度 && 不能超出容器右边
|
||||||
|
if (
|
||||||
|
width >= HOT_ZONE_MIN_SIZE &&
|
||||||
|
item.left + width <= container.value!.offsetWidth
|
||||||
|
) {
|
||||||
|
item.width = width;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 设置高度
|
||||||
|
const setHeight = (item: HotZoneItemProperty, height: number) => {
|
||||||
|
// 不能小于最小高度 && 不能超出容器底部
|
||||||
|
if (
|
||||||
|
height >= HOT_ZONE_MIN_SIZE &&
|
||||||
|
item.top + height <= container.value!.offsetHeight
|
||||||
|
) {
|
||||||
|
item.height = height;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理对话框关闭
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// 会自动触发handleClose
|
||||||
|
dialogVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理对话框关闭
|
||||||
|
const handleClose = () => {
|
||||||
|
// 缩小
|
||||||
|
const list = zoomOut(formData.value);
|
||||||
|
emit('update:modelValue', list);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeHotZone = ref<HotZoneItemProperty>();
|
||||||
|
const appLinkDialogRef = ref();
|
||||||
|
const handleShowAppLinkDialog = (hotZone: HotZoneItemProperty) => {
|
||||||
|
activeHotZone.value = hotZone;
|
||||||
|
appLinkDialogRef.value.open(hotZone.url);
|
||||||
|
};
|
||||||
|
const handleAppLinkChange = (appLink: AppLink) => {
|
||||||
|
if (!appLink || !activeHotZone.value) return;
|
||||||
|
activeHotZone.value.name = appLink.name;
|
||||||
|
activeHotZone.value.url = appLink.path;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
title="设置热区"
|
||||||
|
width="780"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div ref="container" class="w-750px relative h-full">
|
||||||
|
<ElImage
|
||||||
|
:src="imgUrl"
|
||||||
|
class="w-750px pointer-events-none h-full select-none"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="(item, hotZoneIndex) in formData"
|
||||||
|
:key="hotZoneIndex"
|
||||||
|
class="hot-zone"
|
||||||
|
:style="{
|
||||||
|
width: `${item.width}px`,
|
||||||
|
height: `${item.height}px`,
|
||||||
|
top: `${item.top}px`,
|
||||||
|
left: `${item.left}px`,
|
||||||
|
}"
|
||||||
|
@mousedown="handleMove(item, $event)"
|
||||||
|
@dblclick="handleShowAppLinkDialog(item)"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none select-none">{{
|
||||||
|
item.name || '双击选择链接'
|
||||||
|
}}</span>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="ep:close"
|
||||||
|
class="delete"
|
||||||
|
:size="14"
|
||||||
|
@click="handleRemove(item)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 8个控制点 -->
|
||||||
|
<span
|
||||||
|
class="ctrl-dot"
|
||||||
|
v-for="(dot, dotIndex) in CONTROL_DOT_LIST"
|
||||||
|
:key="dotIndex"
|
||||||
|
:style="dot.style"
|
||||||
|
@mousedown="handleResize(item, dot, $event)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="handleAdd" type="primary" plain>
|
||||||
|
<IconifyIcon icon="ep:plus" class="mr-5px" />
|
||||||
|
添加热区
|
||||||
|
</ElButton>
|
||||||
|
<ElButton @click="handleSubmit" type="primary" plain>
|
||||||
|
<IconifyIcon icon="ep:check" class="mr-5px" />
|
||||||
|
确定
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
<AppLinkSelectDialog
|
||||||
|
ref="appLinkDialogRef"
|
||||||
|
@app-link-change="handleAppLinkChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.hot-zone {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: move;
|
||||||
|
background: var(--el-color-primary-light-7);
|
||||||
|
border: 1px solid var(--el-color-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
/* 控制点 */
|
||||||
|
.ctrl-dot {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 11;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: inherit;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: none;
|
||||||
|
padding: 2px 2px 6px 6px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
border-radius: 0 0 0 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.delete {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 热区属性 */
|
||||||
|
export interface HotZoneProperty {
|
||||||
|
// 图片地址
|
||||||
|
imgUrl: string;
|
||||||
|
// 导航菜单列表
|
||||||
|
list: HotZoneItemProperty[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 热区项目属性 */
|
||||||
|
export interface HotZoneItemProperty {
|
||||||
|
// 链接的名称
|
||||||
|
name: string;
|
||||||
|
// 链接
|
||||||
|
url: string;
|
||||||
|
// 宽
|
||||||
|
width: number;
|
||||||
|
// 高
|
||||||
|
height: number;
|
||||||
|
// 上
|
||||||
|
top: number;
|
||||||
|
// 左
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'HotZone',
|
||||||
|
name: '热区',
|
||||||
|
icon: 'tabler:hand-click',
|
||||||
|
property: {
|
||||||
|
imgUrl: '',
|
||||||
|
list: [] as HotZoneItemProperty[],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<HotZoneProperty>;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HotZoneProperty } from './config';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 热区 */
|
||||||
|
defineOptions({ name: 'HotZone' });
|
||||||
|
const props = defineProps<{ property: HotZoneProperty }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-30px relative h-full w-full">
|
||||||
|
<ElImage
|
||||||
|
:src="props.property.imgUrl"
|
||||||
|
class="pointer-events-none h-full w-full select-none"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in props.property.list"
|
||||||
|
:key="index"
|
||||||
|
class="hot-zone"
|
||||||
|
:style="{
|
||||||
|
width: `${item.width}px`,
|
||||||
|
height: `${item.height}px`,
|
||||||
|
top: `${item.top}px`,
|
||||||
|
left: `${item.left}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.hot-zone {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: move;
|
||||||
|
background: var(--el-color-primary-light-7);
|
||||||
|
border: 1px solid var(--el-color-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HotZoneProperty } from './config';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElButton, ElForm, ElFormItem, ElText } from 'element-plus';
|
||||||
|
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
import HotZoneEditDialog from './components/hot-zone-edit-dialog/index.vue';
|
||||||
|
|
||||||
|
/** 热区属性面板 */
|
||||||
|
defineOptions({ name: 'HotZoneProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: HotZoneProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
|
||||||
|
// 热区编辑对话框
|
||||||
|
const editDialogRef = ref();
|
||||||
|
// 打开热区编辑对话框
|
||||||
|
const handleOpenEditDialog = () => {
|
||||||
|
editDialogRef.value.open();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<!-- 表单 -->
|
||||||
|
<ElForm label-width="80px" :model="formData" class="mt-2">
|
||||||
|
<ElFormItem label="上传图片" prop="imgUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.imgUrl"
|
||||||
|
height="50px"
|
||||||
|
width="auto"
|
||||||
|
class="min-w-[80px]"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip>
|
||||||
|
<ElText type="info" size="small"> 推荐宽度 750</ElText>
|
||||||
|
</template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<ElButton type="primary" plain class="w-full" @click="handleOpenEditDialog">
|
||||||
|
设置热区
|
||||||
|
</ElButton>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
<!-- 热区编辑对话框 -->
|
||||||
|
<HotZoneEditDialog
|
||||||
|
ref="editDialogRef"
|
||||||
|
v-model="formData.list"
|
||||||
|
:img-url="formData.imgUrl"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.hot-zone {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: move;
|
||||||
|
background: #409effbf;
|
||||||
|
border: 1px solid var(--el-color-primary);
|
||||||
|
|
||||||
|
/* 控制点 */
|
||||||
|
.ctrl-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 图片展示属性 */
|
||||||
|
export interface ImageBarProperty {
|
||||||
|
// 图片链接
|
||||||
|
imgUrl: string;
|
||||||
|
// 跳转链接
|
||||||
|
url: string;
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'ImageBar',
|
||||||
|
name: '图片展示',
|
||||||
|
icon: 'ep:picture',
|
||||||
|
property: {
|
||||||
|
imgUrl: '',
|
||||||
|
url: '',
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<ImageBarProperty>;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ImageBarProperty } from './config';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 图片展示 */
|
||||||
|
defineOptions({ name: 'ImageBar' });
|
||||||
|
|
||||||
|
defineProps<{ property: ImageBarProperty }>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<!-- 无图片 -->
|
||||||
|
<div
|
||||||
|
class="flex h-12 items-center justify-center bg-gray-300"
|
||||||
|
v-if="!property.imgUrl"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="ep:picture" class="text-3xl text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<ElImage class="min-h-8" v-else :src="property.imgUrl" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 图片 */
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ImageBarProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElForm, ElFormItem } from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 图片展示属性面板
|
||||||
|
defineOptions({ name: 'ImageBarProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: ImageBarProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElFormItem label="上传图片" prop="imgUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.imgUrl"
|
||||||
|
draggable="false"
|
||||||
|
height="80px"
|
||||||
|
width="100%"
|
||||||
|
class="min-w-[80px]"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议宽度750 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="链接" prop="url">
|
||||||
|
<AppLinkInput v-model="formData.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 组件注册
|
||||||
|
*
|
||||||
|
* 组件规范:
|
||||||
|
* 1. 每个子目录就是一个独立的组件,每个目录包括以下三个文件:
|
||||||
|
* 2. config.ts:组件配置,必选,用于定义组件、组件默认的属性、定义属性的类型
|
||||||
|
* 3. index.vue:组件展示,用于展示组件的渲染效果。可以不提供,如 Page(页面设置),只需要属性配置表单即可
|
||||||
|
* 4. property.vue:组件属性表单,用于配置组件,必选,
|
||||||
|
*
|
||||||
|
* 注:
|
||||||
|
* 组件ID以config.ts中配置的id为准,与组件目录的名称无关,但还是建议组件目录的名称与组件ID保持一致
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 导入组件界面模块
|
||||||
|
const viewModules: Record<string, any> = import.meta.glob('./*/*.vue');
|
||||||
|
// 导入配置模块
|
||||||
|
const configModules: Record<string, any> = import.meta.glob('./*/config.ts', {
|
||||||
|
eager: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 界面模块
|
||||||
|
const components: Record<string, any> = {};
|
||||||
|
// 组件配置模块
|
||||||
|
const componentConfigs: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 组件界面的类型
|
||||||
|
type ViewType = 'index' | 'property';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册组件的界面模块
|
||||||
|
*
|
||||||
|
* @param componentId 组件ID
|
||||||
|
* @param configPath 配置模块的文件路径
|
||||||
|
* @param viewType 组件界面的类型
|
||||||
|
*/
|
||||||
|
const registerComponentViewModule = (
|
||||||
|
componentId: string,
|
||||||
|
configPath: string,
|
||||||
|
viewType: ViewType,
|
||||||
|
) => {
|
||||||
|
const viewPath = configPath.replace('config.ts', `${viewType}.vue`);
|
||||||
|
const viewModule = viewModules[viewPath];
|
||||||
|
if (viewModule) {
|
||||||
|
// 定义异步组件
|
||||||
|
components[componentId] = defineAsyncComponent(viewModule);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
Object.keys(configModules).forEach((modulePath: string) => {
|
||||||
|
const component = configModules[modulePath].component;
|
||||||
|
const componentId = component?.id;
|
||||||
|
if (componentId) {
|
||||||
|
// 注册组件
|
||||||
|
componentConfigs[componentId] = component;
|
||||||
|
// 注册预览界面
|
||||||
|
registerComponentViewModule(componentId, modulePath, 'index');
|
||||||
|
// 注册属性配置表单
|
||||||
|
registerComponentViewModule(
|
||||||
|
`${componentId}Property`,
|
||||||
|
modulePath,
|
||||||
|
'property',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { componentConfigs, components };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 广告魔方属性 */
|
||||||
|
export interface MagicCubeProperty {
|
||||||
|
// 上圆角
|
||||||
|
borderRadiusTop: number;
|
||||||
|
// 下圆角
|
||||||
|
borderRadiusBottom: number;
|
||||||
|
// 间隔
|
||||||
|
space: number;
|
||||||
|
// 导航菜单列表
|
||||||
|
list: MagicCubeItemProperty[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 广告魔方项目属性 */
|
||||||
|
export interface MagicCubeItemProperty {
|
||||||
|
// 图标链接
|
||||||
|
imgUrl: string;
|
||||||
|
// 链接
|
||||||
|
url: string;
|
||||||
|
// 宽
|
||||||
|
width: number;
|
||||||
|
// 高
|
||||||
|
height: number;
|
||||||
|
// 上
|
||||||
|
top: number;
|
||||||
|
// 左
|
||||||
|
left: number;
|
||||||
|
// 右
|
||||||
|
right: number;
|
||||||
|
// 下
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'MagicCube',
|
||||||
|
name: '广告魔方',
|
||||||
|
icon: 'bi:columns',
|
||||||
|
property: {
|
||||||
|
borderRadiusTop: 0,
|
||||||
|
borderRadiusBottom: 0,
|
||||||
|
space: 0,
|
||||||
|
list: [],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<MagicCubeProperty>;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MagicCubeProperty } from './config';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 广告魔方 */
|
||||||
|
defineOptions({ name: 'MagicCube' });
|
||||||
|
const props = defineProps<{ property: MagicCubeProperty }>();
|
||||||
|
// 一个方块的大小
|
||||||
|
const CUBE_SIZE = 93.75;
|
||||||
|
/**
|
||||||
|
* 计算方块的行数
|
||||||
|
* 行数用于计算魔方的总体高度,存在以下情况:
|
||||||
|
* 1. 没有数据时,默认就只显示一行的高度
|
||||||
|
* 2. 底部的空白不算高度,例如只有第一行有数据,那么就只显示一行的高度
|
||||||
|
* 3. 顶部及中间的空白算高度,例如一共有四行,只有最后一行有数据,那么也显示四行的高度
|
||||||
|
*/
|
||||||
|
const rowCount = computed(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (props.property.list.length > 0) {
|
||||||
|
// 最大行号
|
||||||
|
count = Math.max(
|
||||||
|
...props.property.list.map((item) => item.top + item.height),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 保证至少有一行
|
||||||
|
return count === 0 ? 1 : count;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
:style="{
|
||||||
|
height: `${rowCount * CUBE_SIZE}px`,
|
||||||
|
width: `${4 * CUBE_SIZE}px`,
|
||||||
|
padding: `${property.space}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in property.list"
|
||||||
|
:key="index"
|
||||||
|
class="absolute"
|
||||||
|
:style="{
|
||||||
|
width: `${item.width * CUBE_SIZE - property.space}px`,
|
||||||
|
height: `${item.height * CUBE_SIZE - property.space}px`,
|
||||||
|
top: `${item.top * CUBE_SIZE}px`,
|
||||||
|
left: `${item.left * CUBE_SIZE}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ElImage
|
||||||
|
class="h-full w-full"
|
||||||
|
fit="cover"
|
||||||
|
:src="item.imgUrl"
|
||||||
|
:style="{
|
||||||
|
borderTopLeftRadius: `${property.borderRadiusTop}px`,
|
||||||
|
borderTopRightRadius: `${property.borderRadiusTop}px`,
|
||||||
|
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
|
||||||
|
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #error>
|
||||||
|
<div class="image-slot">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
:style="{
|
||||||
|
width: `${item.width * CUBE_SIZE}px`,
|
||||||
|
height: `${item.height * CUBE_SIZE}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElImage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MagicCubeProperty } from './config';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElForm, ElFormItem, ElSlider, ElText } from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import MagicCubeEditor from '#/components/magic-cube-editor/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
/** 广告魔方属性面板 */
|
||||||
|
defineOptions({ name: 'MagicCubeProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: MagicCubeProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
|
||||||
|
// 选中的热区
|
||||||
|
const selectedHotAreaIndex = ref(-1);
|
||||||
|
const handleHotAreaSelected = (_: any, index: number) => {
|
||||||
|
selectedHotAreaIndex.value = index;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<!-- 表单 -->
|
||||||
|
<ElForm label-width="80px" :model="formData" class="mt-2">
|
||||||
|
<ElText tag="p"> 魔方设置 </ElText>
|
||||||
|
<ElText type="info" size="small"> 每格尺寸187 * 187 </ElText>
|
||||||
|
<MagicCubeEditor
|
||||||
|
class="my-4"
|
||||||
|
v-model="formData.list"
|
||||||
|
:rows="4"
|
||||||
|
:cols="4"
|
||||||
|
@hot-area-selected="handleHotAreaSelected"
|
||||||
|
/>
|
||||||
|
<template v-for="(hotArea, index) in formData.list" :key="index">
|
||||||
|
<template v-if="selectedHotAreaIndex === index">
|
||||||
|
<ElFormItem label="上传图片" :prop="`list[${index}].imgUrl`">
|
||||||
|
<UploadImg
|
||||||
|
v-model="hotArea.imgUrl"
|
||||||
|
height="80px"
|
||||||
|
width="80px"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="链接" :prop="`list[${index}].url`">
|
||||||
|
<AppLinkInput v-model="hotArea.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<ElFormItem label="上圆角" prop="borderRadiusTop">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.borderRadiusTop"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="下圆角" prop="borderRadiusBottom">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.borderRadiusBottom"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="间隔" prop="space">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.space"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
import { cloneDeep } from '@vben/utils';
|
||||||
|
|
||||||
|
/** 宫格导航属性 */
|
||||||
|
export interface MenuGridProperty {
|
||||||
|
// 列数
|
||||||
|
column: number;
|
||||||
|
// 导航菜单列表
|
||||||
|
list: MenuGridItemProperty[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宫格导航项目属性 */
|
||||||
|
export interface MenuGridItemProperty {
|
||||||
|
// 图标链接
|
||||||
|
iconUrl: string;
|
||||||
|
// 标题
|
||||||
|
title: string;
|
||||||
|
// 标题颜色
|
||||||
|
titleColor: string;
|
||||||
|
// 副标题
|
||||||
|
subtitle: string;
|
||||||
|
// 副标题颜色
|
||||||
|
subtitleColor: string;
|
||||||
|
// 链接
|
||||||
|
url: string;
|
||||||
|
// 角标
|
||||||
|
badge: {
|
||||||
|
// 角标背景颜色
|
||||||
|
bgColor: string;
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
// 角标文字
|
||||||
|
text: string;
|
||||||
|
// 角标文字颜色
|
||||||
|
textColor: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_MENU_GRID_ITEM_PROPERTY = {
|
||||||
|
title: '标题',
|
||||||
|
titleColor: '#333',
|
||||||
|
subtitle: '副标题',
|
||||||
|
subtitleColor: '#bbb',
|
||||||
|
badge: {
|
||||||
|
show: false,
|
||||||
|
textColor: '#fff',
|
||||||
|
bgColor: '#FF6000',
|
||||||
|
},
|
||||||
|
} as MenuGridItemProperty;
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'MenuGrid',
|
||||||
|
name: '宫格导航',
|
||||||
|
icon: 'bi:grid-3x3-gap',
|
||||||
|
property: {
|
||||||
|
column: 3,
|
||||||
|
list: [cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY)],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
padding: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingRight: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingLeft: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderTopLeftRadius: 8,
|
||||||
|
borderTopRightRadius: 8,
|
||||||
|
borderBottomRightRadius: 8,
|
||||||
|
borderBottomLeftRadius: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<MenuGridProperty>;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuGridProperty } from './config';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 宫格导航 */
|
||||||
|
defineOptions({ name: 'MenuGrid' });
|
||||||
|
defineProps<{ property: MenuGridProperty }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in property.list"
|
||||||
|
:key="index"
|
||||||
|
class="relative flex flex-col items-center pb-3.5 pt-5"
|
||||||
|
:style="{ width: `${100 * (1 / property.column)}%` }"
|
||||||
|
>
|
||||||
|
<!-- 右上角角标 -->
|
||||||
|
<span
|
||||||
|
v-if="item.badge?.show"
|
||||||
|
class="absolute left-1/2 top-2.5 z-10 h-5 rounded-full px-1.5 text-center text-xs leading-5"
|
||||||
|
:style="{
|
||||||
|
color: item.badge.textColor,
|
||||||
|
backgroundColor: item.badge.bgColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.badge.text }}
|
||||||
|
</span>
|
||||||
|
<ElImage v-if="item.iconUrl" class="h-7 w-7" :src="item.iconUrl" />
|
||||||
|
<span
|
||||||
|
class="mt-2 h-4 text-xs leading-4"
|
||||||
|
:style="{ color: item.titleColor }"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="mt-1.5 h-3 text-xs leading-3"
|
||||||
|
:style="{ color: item.subtitleColor }"
|
||||||
|
>
|
||||||
|
{{ item.subtitle }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuGridProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadio,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
import { EMPTY_MENU_GRID_ITEM_PROPERTY } from './config';
|
||||||
|
|
||||||
|
/** 宫格导航属性面板 */
|
||||||
|
defineOptions({ name: 'MenuGridProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: MenuGridProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<!-- 表单 -->
|
||||||
|
<ElForm label-width="80px" :model="formData" class="mt-2">
|
||||||
|
<ElFormItem label="每行数量" prop="column">
|
||||||
|
<ElRadioGroup v-model="formData.column">
|
||||||
|
<ElRadio :value="3">3个</ElRadio>
|
||||||
|
<ElRadio :value="4">4个</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElCard header="菜单设置" class="property-group" shadow="never">
|
||||||
|
<Draggable
|
||||||
|
v-model="formData.list"
|
||||||
|
:empty-item="EMPTY_MENU_GRID_ITEM_PROPERTY"
|
||||||
|
>
|
||||||
|
<template #default="{ element }">
|
||||||
|
<ElFormItem label="图标" prop="iconUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.iconUrl"
|
||||||
|
height="80px"
|
||||||
|
width="80px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议尺寸:44 * 44 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="标题" prop="title">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.title"
|
||||||
|
v-model:color="element.titleColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="副标题" prop="subtitle">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.subtitle"
|
||||||
|
v-model:color="element.subtitleColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="链接" prop="url">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="显示角标" prop="badge.show">
|
||||||
|
<ElSwitch v-model="element.badge.show" />
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-if="element.badge.show">
|
||||||
|
<ElFormItem label="角标内容" prop="badge.text">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.badge.text"
|
||||||
|
v-model:color="element.badge.textColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="背景颜色" prop="badge.bgColor">
|
||||||
|
<ColorInput v-model="element.badge.bgColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
import { cloneDeep } from '@vben/utils';
|
||||||
|
|
||||||
|
/** 列表导航属性 */
|
||||||
|
export interface MenuListProperty {
|
||||||
|
// 导航菜单列表
|
||||||
|
list: MenuListItemProperty[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表导航项目属性 */
|
||||||
|
export interface MenuListItemProperty {
|
||||||
|
// 图标链接
|
||||||
|
iconUrl: string;
|
||||||
|
// 标题
|
||||||
|
title: string;
|
||||||
|
// 标题颜色
|
||||||
|
titleColor: string;
|
||||||
|
// 副标题
|
||||||
|
subtitle: string;
|
||||||
|
// 副标题颜色
|
||||||
|
subtitleColor: string;
|
||||||
|
// 链接
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_MENU_LIST_ITEM_PROPERTY = {
|
||||||
|
title: '标题',
|
||||||
|
titleColor: '#333',
|
||||||
|
subtitle: '副标题',
|
||||||
|
subtitleColor: '#bbb',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'MenuList',
|
||||||
|
name: '列表导航',
|
||||||
|
icon: 'fa-solid:list',
|
||||||
|
property: {
|
||||||
|
list: [cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY)],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<MenuListProperty>;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuListProperty } from './config';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 列表导航 */
|
||||||
|
defineOptions({ name: 'MenuList' });
|
||||||
|
defineProps<{ property: MenuListProperty }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-[42px] flex-col">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in property.list"
|
||||||
|
:key="index"
|
||||||
|
class="item flex h-[42px] flex-row items-center justify-between gap-1 px-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 flex-row items-center gap-2">
|
||||||
|
<ElImage v-if="item.iconUrl" class="h-4 w-4" :src="item.iconUrl" />
|
||||||
|
<span class="text-base" :style="{ color: item.titleColor }">{{
|
||||||
|
item.title
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-center flex flex-row justify-center gap-1">
|
||||||
|
<span class="text-xs" :style="{ color: item.subtitleColor }">{{
|
||||||
|
item.subtitle
|
||||||
|
}}</span>
|
||||||
|
<IconifyIcon icon="ep:arrow-right" color="#000" :size="16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.item + .item {
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuListProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElForm, ElFormItem, ElText } from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import InputWithColor from '#/components/input-with-color/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
import { EMPTY_MENU_LIST_ITEM_PROPERTY } from './config';
|
||||||
|
|
||||||
|
/** 列表导航属性面板 */
|
||||||
|
defineOptions({ name: 'MenuListProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: MenuListProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElText tag="p"> 菜单设置 </ElText>
|
||||||
|
<ElText type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </ElText>
|
||||||
|
|
||||||
|
<!-- 表单 -->
|
||||||
|
<ElForm label-width="60px" :model="formData" class="mt-2">
|
||||||
|
<Draggable
|
||||||
|
v-model="formData.list"
|
||||||
|
:empty-item="EMPTY_MENU_LIST_ITEM_PROPERTY"
|
||||||
|
>
|
||||||
|
<template #default="{ element }">
|
||||||
|
<ElFormItem label="图标" prop="iconUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.iconUrl"
|
||||||
|
height="80px"
|
||||||
|
width="80px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议尺寸:44 * 44 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="标题" prop="title">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.title"
|
||||||
|
v-model:color="element.titleColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="副标题" prop="subtitle">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.subtitle"
|
||||||
|
v-model:color="element.subtitleColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="链接" prop="url">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
import { cloneDeep } from '@vben/utils';
|
||||||
|
|
||||||
|
/** 菜单导航属性 */
|
||||||
|
export interface MenuSwiperProperty {
|
||||||
|
// 布局: 图标+文字 | 图标
|
||||||
|
layout: 'icon' | 'iconText';
|
||||||
|
// 行数
|
||||||
|
row: number;
|
||||||
|
// 列数
|
||||||
|
column: number;
|
||||||
|
// 导航菜单列表
|
||||||
|
list: MenuSwiperItemProperty[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
/** 菜单导航项目属性 */
|
||||||
|
export interface MenuSwiperItemProperty {
|
||||||
|
// 图标链接
|
||||||
|
iconUrl: string;
|
||||||
|
// 标题
|
||||||
|
title: string;
|
||||||
|
// 标题颜色
|
||||||
|
titleColor: string;
|
||||||
|
// 链接
|
||||||
|
url: string;
|
||||||
|
// 角标
|
||||||
|
badge: {
|
||||||
|
// 角标背景颜色
|
||||||
|
bgColor: string;
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
// 角标文字
|
||||||
|
text: string;
|
||||||
|
// 角标文字颜色
|
||||||
|
textColor: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_MENU_SWIPER_ITEM_PROPERTY = {
|
||||||
|
title: '标题',
|
||||||
|
titleColor: '#333',
|
||||||
|
badge: {
|
||||||
|
show: false,
|
||||||
|
textColor: '#fff',
|
||||||
|
bgColor: '#FF6000',
|
||||||
|
},
|
||||||
|
} as MenuSwiperItemProperty;
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'MenuSwiper',
|
||||||
|
name: '菜单导航',
|
||||||
|
icon: 'bi:grid-3x2-gap',
|
||||||
|
property: {
|
||||||
|
layout: 'iconText',
|
||||||
|
row: 1,
|
||||||
|
column: 3,
|
||||||
|
list: [cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<MenuSwiperProperty>;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuSwiperItemProperty, MenuSwiperProperty } from './config';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ElCarousel, ElCarouselItem, ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 菜单导航 */
|
||||||
|
defineOptions({ name: 'MenuSwiper' });
|
||||||
|
const props = defineProps<{ property: MenuSwiperProperty }>();
|
||||||
|
// 标题的高度
|
||||||
|
const TITLE_HEIGHT = 20;
|
||||||
|
// 图标的高度
|
||||||
|
const ICON_SIZE = 32;
|
||||||
|
// 垂直间距:一行上下的间距
|
||||||
|
const SPACE_Y = 16;
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pages = ref<MenuSwiperItemProperty[][]>([]);
|
||||||
|
// 轮播图高度
|
||||||
|
const carouselHeight = ref(0);
|
||||||
|
// 行高
|
||||||
|
const rowHeight = ref(0);
|
||||||
|
// 列宽
|
||||||
|
const columnWidth = ref('');
|
||||||
|
watch(
|
||||||
|
() => props.property,
|
||||||
|
() => {
|
||||||
|
// 计算列宽:每一列的百分比
|
||||||
|
columnWidth.value = `${100 * (1 / props.property.column)}%`;
|
||||||
|
// 计算行高:图标 + 文字(仅显示图片时为0) + 垂直间距 * 2
|
||||||
|
rowHeight.value =
|
||||||
|
(props.property.layout === 'iconText'
|
||||||
|
? ICON_SIZE + TITLE_HEIGHT
|
||||||
|
: ICON_SIZE) +
|
||||||
|
SPACE_Y * 2;
|
||||||
|
// 计算轮播的高度:行数 * 行高
|
||||||
|
carouselHeight.value = props.property.row * rowHeight.value;
|
||||||
|
|
||||||
|
// 每页数量:行数 * 列数
|
||||||
|
const pageSize = props.property.row * props.property.column;
|
||||||
|
// 清空分页
|
||||||
|
pages.value = [];
|
||||||
|
// 每一页的菜单
|
||||||
|
let pageItems: MenuSwiperItemProperty[] = [];
|
||||||
|
for (const item of props.property.list) {
|
||||||
|
// 本页满员,新建下一页
|
||||||
|
if (pageItems.length === pageSize) {
|
||||||
|
pageItems = [];
|
||||||
|
}
|
||||||
|
// 增加一页
|
||||||
|
if (pageItems.length === 0) {
|
||||||
|
pages.value.push(pageItems);
|
||||||
|
}
|
||||||
|
// 本页增加一个
|
||||||
|
pageItems.push(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCarousel
|
||||||
|
:height="`${carouselHeight}px`"
|
||||||
|
:autoplay="false"
|
||||||
|
arrow="hover"
|
||||||
|
indicator-position="outside"
|
||||||
|
>
|
||||||
|
<ElCarouselItem v-for="(page, pageIndex) in pages" :key="pageIndex">
|
||||||
|
<div class="flex flex-row flex-wrap">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in page"
|
||||||
|
:key="index"
|
||||||
|
class="relative flex flex-col items-center justify-center"
|
||||||
|
:style="{ width: columnWidth, height: `${rowHeight}px` }"
|
||||||
|
>
|
||||||
|
<!-- 图标 + 角标 -->
|
||||||
|
<div class="relative" :class="`h-${ICON_SIZE}px w-${ICON_SIZE}px`">
|
||||||
|
<!-- 右上角角标 -->
|
||||||
|
<span
|
||||||
|
v-if="item.badge?.show"
|
||||||
|
class="absolute -right-2.5 -top-2.5 z-10 h-5 rounded-[10px] px-1.5 text-center text-xs leading-5"
|
||||||
|
:style="{
|
||||||
|
color: item.badge.textColor,
|
||||||
|
backgroundColor: item.badge.bgColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.badge.text }}
|
||||||
|
</span>
|
||||||
|
<ElImage
|
||||||
|
v-if="item.iconUrl"
|
||||||
|
:src="item.iconUrl"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 标题 -->
|
||||||
|
<span
|
||||||
|
v-if="property.layout === 'iconText'"
|
||||||
|
class="text-xs"
|
||||||
|
:style="{
|
||||||
|
color: item.titleColor,
|
||||||
|
height: `${TITLE_HEIGHT}px`,
|
||||||
|
lineHeight: `${TITLE_HEIGHT}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCarouselItem>
|
||||||
|
</ElCarousel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// 重写指示器样式,与 APP 保持一致
|
||||||
|
:root {
|
||||||
|
.el-carousel__indicator {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
|
.el-carousel__button {
|
||||||
|
--el-carousel-indicator-height: 6px;
|
||||||
|
--el-carousel-indicator-width: 6px;
|
||||||
|
--el-carousel-indicator-out-color: #ff6000;
|
||||||
|
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-carousel__indicator.is-active {
|
||||||
|
.el-carousel__button {
|
||||||
|
--el-carousel-indicator-width: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuSwiperProperty } from './config';
|
||||||
|
|
||||||
|
import { cloneDeep } from '@vben/utils';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadio,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ColorInput from '#/components/color-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import InputWithColor from '#/components/input-with-color/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
import { EMPTY_MENU_SWIPER_ITEM_PROPERTY } from './config';
|
||||||
|
|
||||||
|
/** 菜单导航属性面板 */
|
||||||
|
defineOptions({ name: 'MenuSwiperProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: MenuSwiperProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<!-- 表单 -->
|
||||||
|
<ElForm label-width="80px" :model="formData" class="mt-2">
|
||||||
|
<ElFormItem label="布局" prop="layout">
|
||||||
|
<ElRadioGroup v-model="formData.layout">
|
||||||
|
<ElRadio value="iconText">图标+文字</ElRadio>
|
||||||
|
<ElRadio value="icon">仅图标</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="行数" prop="row">
|
||||||
|
<ElRadioGroup v-model="formData.row">
|
||||||
|
<ElRadio :value="1">1行</ElRadio>
|
||||||
|
<ElRadio :value="2">2行</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="列数" prop="column">
|
||||||
|
<ElRadioGroup v-model="formData.column">
|
||||||
|
<ElRadio :value="3">3列</ElRadio>
|
||||||
|
<ElRadio :value="4">4列</ElRadio>
|
||||||
|
<ElRadio :value="5">5列</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElCard header="菜单设置" class="property-group" shadow="never">
|
||||||
|
<Draggable
|
||||||
|
v-model="formData.list"
|
||||||
|
:empty-item="cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)"
|
||||||
|
>
|
||||||
|
<template #default="{ element }">
|
||||||
|
<ElFormItem label="图标" prop="iconUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="element.iconUrl"
|
||||||
|
height="80px"
|
||||||
|
width="80px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议尺寸:98 * 98 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="标题" prop="title">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.title"
|
||||||
|
v-model:color="element.titleColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="链接" prop="url">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="显示角标" prop="badge.show">
|
||||||
|
<ElSwitch v-model="element.badge.show" />
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-if="element.badge.show">
|
||||||
|
<ElFormItem label="角标内容" prop="badge.text">
|
||||||
|
<InputWithColor
|
||||||
|
v-model="element.badge.text"
|
||||||
|
v-model:color="element.badge.textColor"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="背景颜色" prop="badge.bgColor">
|
||||||
|
<ColorInput v-model="element.badge.bgColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { NavigationBarCellProperty } from '../config';
|
||||||
|
|
||||||
|
import type { Rect } from '#/components/magic-cube-editor/util';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElRadio,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import appNavBarMp from '#/assets/imgs/diy/app-nav-bar-mp.png';
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ColorInput from '#/components/color-input/index.vue';
|
||||||
|
import MagicCubeEditor from '#/components/magic-cube-editor/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 导航栏属性面板
|
||||||
|
defineOptions({ name: 'NavigationBarCellProperty' });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isMp: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Array as () => NavigationBarCellProperty[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const cellList = useVModel(props, 'modelValue', emit);
|
||||||
|
|
||||||
|
// 单元格数量:小程序6个(右侧胶囊按钮占了2个),其它平台8个
|
||||||
|
const cellCount = computed(() => (props.isMp ? 6 : 8));
|
||||||
|
|
||||||
|
// 转换为Rect格式的数据
|
||||||
|
const rectList = computed<Rect[]>(() => {
|
||||||
|
return cellList.value.map((cell) => ({
|
||||||
|
left: cell.left,
|
||||||
|
top: cell.top,
|
||||||
|
width: cell.width,
|
||||||
|
height: cell.height,
|
||||||
|
right: cell.left + cell.width,
|
||||||
|
bottom: cell.top + cell.height,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 选中的热区
|
||||||
|
const selectedHotAreaIndex = ref(0);
|
||||||
|
const handleHotAreaSelected = (
|
||||||
|
cellValue: NavigationBarCellProperty,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
selectedHotAreaIndex.value = index;
|
||||||
|
if (!cellValue.type) {
|
||||||
|
cellValue.type = 'text';
|
||||||
|
cellValue.textColor = '#111111';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-40px flex items-center justify-center">
|
||||||
|
<MagicCubeEditor
|
||||||
|
v-model="rectList"
|
||||||
|
:cols="cellCount"
|
||||||
|
:cube-size="38"
|
||||||
|
:rows="1"
|
||||||
|
class="m-b-16px"
|
||||||
|
@hot-area-selected="handleHotAreaSelected"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="isMp"
|
||||||
|
alt=""
|
||||||
|
style="width: 76px; height: 30px"
|
||||||
|
:src="appNavBarMp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template v-for="(cell, cellIndex) in cellList" :key="cellIndex">
|
||||||
|
<template v-if="selectedHotAreaIndex === Number(cellIndex)">
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].type`" label="类型">
|
||||||
|
<ElRadioGroup v-model="cell.type">
|
||||||
|
<ElRadio value="text">文字</ElRadio>
|
||||||
|
<ElRadio value="image">图片</ElRadio>
|
||||||
|
<ElRadio value="search">搜索框</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<!-- 1. 文字 -->
|
||||||
|
<template v-if="cell.type === 'text'">
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].text`" label="内容">
|
||||||
|
<ElInput v-model="cell!.text" maxlength="10" show-word-limit />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].text`" label="颜色">
|
||||||
|
<ColorInput v-model="cell!.textColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].url`" label="链接">
|
||||||
|
<AppLinkInput v-model="cell.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<!-- 2. 图片 -->
|
||||||
|
<template v-else-if="cell.type === 'image'">
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].imgUrl`" label="图片">
|
||||||
|
<UploadImg
|
||||||
|
v-model="cell.imgUrl"
|
||||||
|
:limit="1"
|
||||||
|
height="56px"
|
||||||
|
width="56px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip>建议尺寸 56*56</template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].url`" label="链接">
|
||||||
|
<AppLinkInput v-model="cell.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<!-- 3. 搜索框 -->
|
||||||
|
<template v-else>
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].placeholder`" label="提示文字">
|
||||||
|
<ElInput v-model="cell.placeholder" maxlength="10" show-word-limit />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :prop="`cell[${cellIndex}].borderRadius`" label="圆角">
|
||||||
|
<ElSlider
|
||||||
|
v-model="cell.borderRadius"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
:show-input-controls="false"
|
||||||
|
input-size="small"
|
||||||
|
show-input
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { DiyComponent } from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 顶部导航栏属性 */
|
||||||
|
export interface NavigationBarProperty {
|
||||||
|
// 背景类型
|
||||||
|
bgType: 'color' | 'img';
|
||||||
|
// 背景颜色
|
||||||
|
bgColor: string;
|
||||||
|
// 图片链接
|
||||||
|
bgImg: string;
|
||||||
|
// 样式类型:默认 | 沉浸式
|
||||||
|
styleType: 'inner' | 'normal';
|
||||||
|
// 常驻显示
|
||||||
|
alwaysShow: boolean;
|
||||||
|
// 小程序单元格列表
|
||||||
|
mpCells: NavigationBarCellProperty[];
|
||||||
|
// 其它平台单元格列表
|
||||||
|
otherCells: NavigationBarCellProperty[];
|
||||||
|
// 本地变量
|
||||||
|
_local: {
|
||||||
|
// 预览顶部导航(小程序)
|
||||||
|
previewMp: boolean;
|
||||||
|
// 预览顶部导航(非小程序)
|
||||||
|
previewOther: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部导航栏 - 单元格 属性 */
|
||||||
|
export interface NavigationBarCellProperty {
|
||||||
|
// 类型:文字 | 图片 | 搜索框
|
||||||
|
type: 'image' | 'search' | 'text';
|
||||||
|
// 宽度
|
||||||
|
width: number;
|
||||||
|
// 高度
|
||||||
|
height: number;
|
||||||
|
// 顶部位置
|
||||||
|
top: number;
|
||||||
|
// 左侧位置
|
||||||
|
left: number;
|
||||||
|
// 文字内容
|
||||||
|
text: string;
|
||||||
|
// 文字颜色
|
||||||
|
textColor: string;
|
||||||
|
// 图片地址
|
||||||
|
imgUrl: string;
|
||||||
|
// 图片链接
|
||||||
|
url: string;
|
||||||
|
// 搜索框:提示文字
|
||||||
|
placeholder: string;
|
||||||
|
// 搜索框:边框圆角半径
|
||||||
|
borderRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'NavigationBar',
|
||||||
|
name: '顶部导航栏',
|
||||||
|
icon: 'tabler:layout-navbar',
|
||||||
|
property: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
bgImg: '',
|
||||||
|
styleType: 'normal',
|
||||||
|
alwaysShow: true,
|
||||||
|
mpCells: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
textColor: '#111111',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
otherCells: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
textColor: '#111111',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_local: {
|
||||||
|
previewMp: true,
|
||||||
|
previewOther: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as DiyComponent<NavigationBarProperty>;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { StyleValue } from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
NavigationBarCellProperty,
|
||||||
|
NavigationBarProperty,
|
||||||
|
} from './config';
|
||||||
|
|
||||||
|
import type { SearchProperty } from '#/components/diy-editor/components/mobile/search-bar/config';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import appNavbarMp from '#/assets/imgs/diy/app-nav-bar-mp.png';
|
||||||
|
import SearchBar from '#/components/diy-editor/components/mobile/search-bar/index.vue';
|
||||||
|
|
||||||
|
/** 页面顶部导航栏 */
|
||||||
|
defineOptions({ name: 'NavigationBar' });
|
||||||
|
|
||||||
|
const props = defineProps<{ property: NavigationBarProperty }>();
|
||||||
|
|
||||||
|
// 背景
|
||||||
|
const bgStyle = computed(() => {
|
||||||
|
const background =
|
||||||
|
props.property.bgType === 'img' && props.property.bgImg
|
||||||
|
? `url(${props.property.bgImg}) no-repeat top center / 100% 100%`
|
||||||
|
: props.property.bgColor;
|
||||||
|
return { background };
|
||||||
|
});
|
||||||
|
// 单元格列表
|
||||||
|
const cellList = computed(() =>
|
||||||
|
props.property._local?.previewMp
|
||||||
|
? props.property.mpCells
|
||||||
|
: props.property.otherCells,
|
||||||
|
);
|
||||||
|
// 单元格宽度
|
||||||
|
const cellWidth = computed(() => {
|
||||||
|
return props.property._local?.previewMp
|
||||||
|
? (375 - 80 - 86) / 6
|
||||||
|
: (375 - 90) / 8;
|
||||||
|
});
|
||||||
|
// 获得单元格样式
|
||||||
|
const getCellStyle = (cell: NavigationBarCellProperty) => {
|
||||||
|
return {
|
||||||
|
width: `${cell.width * cellWidth.value + (cell.width - 1) * 10}px`,
|
||||||
|
left: `${cell.left * cellWidth.value + (cell.left + 1) * 10}px`,
|
||||||
|
position: 'absolute',
|
||||||
|
} as StyleValue;
|
||||||
|
};
|
||||||
|
// 获得搜索框属性
|
||||||
|
const getSearchProp = computed(() => (cell: NavigationBarCellProperty) => {
|
||||||
|
return {
|
||||||
|
height: 30,
|
||||||
|
showScan: false,
|
||||||
|
placeholder: cell.placeholder,
|
||||||
|
borderRadius: cell.borderRadius,
|
||||||
|
} as SearchProperty;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="navigation-bar" :style="bgStyle">
|
||||||
|
<div class="flex h-full w-full items-center">
|
||||||
|
<div
|
||||||
|
v-for="(cell, cellIndex) in cellList"
|
||||||
|
:key="cellIndex"
|
||||||
|
:style="getCellStyle(cell)"
|
||||||
|
>
|
||||||
|
<span v-if="cell.type === 'text'">{{ cell.text }}</span>
|
||||||
|
<img
|
||||||
|
v-else-if="cell.type === 'image'"
|
||||||
|
:src="cell.imgUrl"
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
|
<SearchBar v-else :property="getSearchProp(cell)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
v-if="property._local?.previewMp"
|
||||||
|
:src="appNavbarMp"
|
||||||
|
alt=""
|
||||||
|
style="width: 86px; height: 30px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.navigation-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
/* 左边 */
|
||||||
|
.left {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 35px;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右边 */
|
||||||
|
.right {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NavigationBarProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
|
||||||
|
import NavigationBarCellProperty from './components/cell-property.vue';
|
||||||
|
|
||||||
|
// 导航栏属性面板
|
||||||
|
defineOptions({ name: 'NavigationBarProperty' });
|
||||||
|
const props = defineProps<{ modelValue: NavigationBarProperty }>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
// 表单校验
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
if (!formData.value._local) {
|
||||||
|
formData.value._local = { previewMp: true, previewOther: false };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-form label-width="80px" :model="formData" :rules="rules">
|
||||||
|
<el-form-item label="样式" prop="styleType">
|
||||||
|
<el-radio-group v-model="formData!.styleType">
|
||||||
|
<el-radio value="normal">标准</el-radio>
|
||||||
|
<el-tooltip
|
||||||
|
content="沉侵式头部仅支持微信小程序、APP,建议页面第一个组件为图片展示类组件"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-radio value="inner">沉浸式</el-radio>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="常驻显示"
|
||||||
|
prop="alwaysShow"
|
||||||
|
v-if="formData.styleType === 'inner'"
|
||||||
|
>
|
||||||
|
<el-radio-group v-model="formData!.alwaysShow">
|
||||||
|
<el-radio :value="false">关闭</el-radio>
|
||||||
|
<el-tooltip
|
||||||
|
content="常驻显示关闭后,头部小组件将在页面滑动时淡入"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-radio :value="true">开启</el-radio>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="背景类型" prop="bgType">
|
||||||
|
<el-radio-group v-model="formData.bgType">
|
||||||
|
<el-radio value="color">纯色</el-radio>
|
||||||
|
<el-radio value="img">图片</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="背景颜色"
|
||||||
|
prop="bgColor"
|
||||||
|
v-if="formData.bgType === 'color'"
|
||||||
|
>
|
||||||
|
<ColorInput v-model="formData.bgColor" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="背景图片" prop="bgImg" v-else>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.bgImg"
|
||||||
|
:limit="1"
|
||||||
|
width="56px"
|
||||||
|
height="56px"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
<span class="mb-2 ml-2 text-xs text-gray-400">建议宽度:750</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-card class="property-group" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>内容(小程序)</span>
|
||||||
|
<el-form-item prop="_local.previewMp" class="mb-0">
|
||||||
|
<el-checkbox
|
||||||
|
v-model="formData._local.previewMp"
|
||||||
|
@change="
|
||||||
|
formData._local.previewOther = !formData._local.previewMp
|
||||||
|
"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<NavigationBarCellProperty v-model="formData.mpCells" is-mp />
|
||||||
|
</el-card>
|
||||||
|
<el-card class="property-group" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>内容(非小程序)</span>
|
||||||
|
<el-form-item prop="_local.previewOther" class="mb-0">
|
||||||
|
<el-checkbox
|
||||||
|
v-model="formData._local.previewOther"
|
||||||
|
@change="
|
||||||
|
formData._local.previewMp = !formData._local.previewOther
|
||||||
|
"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<NavigationBarCellProperty v-model="formData.otherCells" :is-mp="false" />
|
||||||
|
</el-card>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 公告栏属性 */
|
||||||
|
export interface NoticeBarProperty {
|
||||||
|
// 图标地址
|
||||||
|
iconUrl: string;
|
||||||
|
// 公告内容列表
|
||||||
|
contents: NoticeContentProperty[];
|
||||||
|
// 背景颜色
|
||||||
|
backgroundColor: string;
|
||||||
|
// 文字颜色
|
||||||
|
textColor: string;
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容属性 */
|
||||||
|
export interface NoticeContentProperty {
|
||||||
|
// 内容文字
|
||||||
|
text: string;
|
||||||
|
// 链接地址
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'NoticeBar',
|
||||||
|
name: '公告栏',
|
||||||
|
icon: 'ep:bell',
|
||||||
|
property: {
|
||||||
|
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/xinjian.png',
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
text: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
textColor: '#333',
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '#fff',
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<NoticeBarProperty>;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NoticeBarProperty } from './config';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElCarousel, ElCarouselItem, ElDivider, ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
/** 公告栏 */
|
||||||
|
defineOptions({ name: 'NoticeBar' });
|
||||||
|
|
||||||
|
defineProps<{ property: NoticeBarProperty }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center py-1 text-xs"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: property.backgroundColor,
|
||||||
|
color: property.textColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ElImage :src="property.iconUrl" class="h-[18px]" />
|
||||||
|
<ElDivider direction="vertical" />
|
||||||
|
<ElCarousel
|
||||||
|
height="24px"
|
||||||
|
direction="vertical"
|
||||||
|
:autoplay="true"
|
||||||
|
class="flex-1 pr-2"
|
||||||
|
>
|
||||||
|
<ElCarouselItem v-for="(item, index) in property.contents" :key="index">
|
||||||
|
<div class="h-6 truncate leading-6">{{ item.text }}</div>
|
||||||
|
</ElCarouselItem>
|
||||||
|
</ElCarousel>
|
||||||
|
<IconifyIcon icon="ep:arrow-right" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NoticeBarProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElCard, ElForm, ElFormItem, ElInput } from 'element-plus';
|
||||||
|
|
||||||
|
import AppLinkInput from '#/components/app-link-input/index.vue';
|
||||||
|
import ColorInput from '#/components/color-input/index.vue';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import Draggable from '#/components/draggable/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 通知栏属性面板
|
||||||
|
defineOptions({ name: 'NoticeBarProperty' });
|
||||||
|
const props = defineProps<{ modelValue: NoticeBarProperty }>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
// 表单校验
|
||||||
|
const rules = {
|
||||||
|
content: [{ required: true, message: '请输入公告', trigger: 'blur' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="80px" :model="formData" :rules="rules">
|
||||||
|
<ElFormItem label="公告图标" prop="iconUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.iconUrl"
|
||||||
|
height="48px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip>建议尺寸:24 * 24</template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="背景颜色" prop="backgroundColor">
|
||||||
|
<ColorInput v-model="formData.backgroundColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="文字颜色" prop="文字颜色">
|
||||||
|
<ColorInput v-model="formData.textColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElCard header="公告内容" class="property-group" shadow="never">
|
||||||
|
<Draggable v-model="formData.contents">
|
||||||
|
<template #default="{ element }">
|
||||||
|
<ElFormItem label="公告" prop="text" label-width="40px">
|
||||||
|
<ElInput v-model="element.text" placeholder="请输入公告" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="链接" prop="url" label-width="40px">
|
||||||
|
<AppLinkInput v-model="element.url" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { DiyComponent } from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 页面设置属性 */
|
||||||
|
export interface PageConfigProperty {
|
||||||
|
// 页面描述
|
||||||
|
description: string;
|
||||||
|
// 页面背景颜色
|
||||||
|
backgroundColor: string;
|
||||||
|
// 页面背景图片
|
||||||
|
backgroundImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义页面组件
|
||||||
|
export const component = {
|
||||||
|
id: 'PageConfig',
|
||||||
|
name: '页面设置',
|
||||||
|
icon: 'ep:document',
|
||||||
|
property: {
|
||||||
|
description: '',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
backgroundImage: '',
|
||||||
|
},
|
||||||
|
} as DiyComponent<PageConfigProperty>;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PageConfigProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElForm, ElFormItem, ElInput } from 'element-plus';
|
||||||
|
|
||||||
|
import ColorInput from '#/components/input-with-color/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
|
||||||
|
// 导航栏属性面板
|
||||||
|
defineOptions({ name: 'PageConfigProperty' });
|
||||||
|
const props = defineProps<{ modelValue: PageConfigProperty }>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
// 表单校验
|
||||||
|
const rules = {};
|
||||||
|
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElForm label-width="80px" :model="formData" :rules="rules">
|
||||||
|
<ElFormItem label="页面描述" prop="description">
|
||||||
|
<ElInput
|
||||||
|
type="textarea"
|
||||||
|
v-model="formData!.description"
|
||||||
|
placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="背景颜色" prop="backgroundColor">
|
||||||
|
<ColorInput v-model="formData!.backgroundColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="背景图片" prop="backgroundImage">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData!.backgroundImage"
|
||||||
|
:limit="1"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip>建议宽度 750px</template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 商品卡片属性 */
|
||||||
|
export interface ProductCardProperty {
|
||||||
|
// 布局类型:单列大图 | 单列小图 | 双列
|
||||||
|
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol';
|
||||||
|
// 商品字段
|
||||||
|
fields: {
|
||||||
|
// 商品简介
|
||||||
|
introduction: ProductCardFieldProperty;
|
||||||
|
// 商品市场价
|
||||||
|
marketPrice: ProductCardFieldProperty;
|
||||||
|
// 商品名称
|
||||||
|
name: ProductCardFieldProperty;
|
||||||
|
// 商品价格
|
||||||
|
price: ProductCardFieldProperty;
|
||||||
|
// 商品销量
|
||||||
|
salesCount: ProductCardFieldProperty;
|
||||||
|
// 商品库存
|
||||||
|
stock: ProductCardFieldProperty;
|
||||||
|
};
|
||||||
|
// 角标
|
||||||
|
badge: {
|
||||||
|
// 角标图片
|
||||||
|
imgUrl: string;
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
// 按钮
|
||||||
|
btnBuy: {
|
||||||
|
// 文字按钮:背景渐变起始颜色
|
||||||
|
bgBeginColor: string;
|
||||||
|
// 文字按钮:背景渐变结束颜色
|
||||||
|
bgEndColor: string;
|
||||||
|
// 图片按钮:图片地址
|
||||||
|
imgUrl: string;
|
||||||
|
// 文字
|
||||||
|
text: string;
|
||||||
|
// 类型:文字 | 图片
|
||||||
|
type: 'img' | 'text';
|
||||||
|
};
|
||||||
|
// 上圆角
|
||||||
|
borderRadiusTop: number;
|
||||||
|
// 下圆角
|
||||||
|
borderRadiusBottom: number;
|
||||||
|
// 间距
|
||||||
|
space: number;
|
||||||
|
// 商品编号列表
|
||||||
|
spuIds: number[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
// 商品字段
|
||||||
|
export interface ProductCardFieldProperty {
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
// 颜色
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'ProductCard',
|
||||||
|
name: '商品卡片',
|
||||||
|
icon: 'fluent:text-column-two-left-24-filled',
|
||||||
|
property: {
|
||||||
|
layoutType: 'oneColBigImg',
|
||||||
|
fields: {
|
||||||
|
name: { show: true, color: '#000' },
|
||||||
|
introduction: { show: true, color: '#999' },
|
||||||
|
price: { show: true, color: '#ff3000' },
|
||||||
|
marketPrice: { show: true, color: '#c4c4c4' },
|
||||||
|
salesCount: { show: true, color: '#c4c4c4' },
|
||||||
|
stock: { show: false, color: '#c4c4c4' },
|
||||||
|
},
|
||||||
|
badge: { show: false, imgUrl: '' },
|
||||||
|
btnBuy: {
|
||||||
|
type: 'text',
|
||||||
|
text: '立即购买',
|
||||||
|
// todo: @owen 根据主题色配置
|
||||||
|
bgBeginColor: '#FF6000',
|
||||||
|
bgEndColor: '#FE832A',
|
||||||
|
imgUrl: '',
|
||||||
|
},
|
||||||
|
borderRadiusTop: 6,
|
||||||
|
borderRadiusBottom: 6,
|
||||||
|
space: 8,
|
||||||
|
spuIds: [],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '',
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<ProductCardProperty>;
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProductCardProperty } from './config';
|
||||||
|
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { fenToYuan } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
import * as ProductSpuApi from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
/** 商品卡片 */
|
||||||
|
defineOptions({ name: 'ProductCard' });
|
||||||
|
// 定义属性
|
||||||
|
const props = defineProps<{ property: ProductCardProperty }>();
|
||||||
|
// 商品列表
|
||||||
|
const spuList = ref<MallSpuApi.Spu[]>([]);
|
||||||
|
watch(
|
||||||
|
() => props.property.spuIds,
|
||||||
|
async () => {
|
||||||
|
spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算商品的间距
|
||||||
|
* @param index 商品索引
|
||||||
|
*/
|
||||||
|
const calculateSpace = (index: number) => {
|
||||||
|
// 商品的列数
|
||||||
|
const columns = props.property.layoutType === 'twoCol' ? 2 : 1;
|
||||||
|
// 第一列没有左边距
|
||||||
|
const marginLeft = index % columns === 0 ? '0' : `${props.property.space}px`;
|
||||||
|
// 第一行没有上边距
|
||||||
|
const marginTop = index < columns ? '0' : `${props.property.space}px`;
|
||||||
|
|
||||||
|
return { marginLeft, marginTop };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 容器
|
||||||
|
const containerRef = ref();
|
||||||
|
// 计算商品的宽度
|
||||||
|
const calculateWidth = () => {
|
||||||
|
let width = '100%';
|
||||||
|
// 双列时每列的宽度为:(总宽度 - 间距)/ 2
|
||||||
|
if (props.property.layoutType === 'twoCol') {
|
||||||
|
width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`;
|
||||||
|
}
|
||||||
|
return { width };
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="box-content flex min-h-[30px] w-full flex-row flex-wrap"
|
||||||
|
ref="containerRef"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
|
||||||
|
:style="{
|
||||||
|
...calculateSpace(index),
|
||||||
|
...calculateWidth(),
|
||||||
|
borderTopLeftRadius: `${property.borderRadiusTop}px`,
|
||||||
|
borderTopRightRadius: `${property.borderRadiusTop}px`,
|
||||||
|
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
|
||||||
|
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
|
||||||
|
}"
|
||||||
|
v-for="(spu, index) in spuList"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<!-- 角标 -->
|
||||||
|
<div
|
||||||
|
v-if="property.badge.show && property.badge.imgUrl"
|
||||||
|
class="absolute left-0 top-0 z-[1] items-center justify-center"
|
||||||
|
>
|
||||||
|
<ElImage
|
||||||
|
fit="cover"
|
||||||
|
:src="property.badge.imgUrl"
|
||||||
|
class="h-[26px] w-[38px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 商品封面图 -->
|
||||||
|
<div
|
||||||
|
class="h-[140px]"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'w-full': property.layoutType !== 'oneColSmallImg',
|
||||||
|
'w-[140px]': property.layoutType === 'oneColSmallImg',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<ElImage fit="cover" class="h-full w-full" :src="spu.picUrl" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="box-border flex flex-col gap-[8px] p-[8px]"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'w-full': property.layoutType !== 'oneColSmallImg',
|
||||||
|
'w-[calc(100%-140px-16px)]':
|
||||||
|
property.layoutType === 'oneColSmallImg',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 商品名称 -->
|
||||||
|
<div
|
||||||
|
v-if="property.fields.name.show"
|
||||||
|
class="text-[14px]"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
truncate: property.layoutType !== 'oneColSmallImg',
|
||||||
|
'line-clamp-2 overflow-ellipsis':
|
||||||
|
property.layoutType === 'oneColSmallImg',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{ color: property.fields.name.color }"
|
||||||
|
>
|
||||||
|
{{ spu.name }}
|
||||||
|
</div>
|
||||||
|
<!-- 商品简介 -->
|
||||||
|
<div
|
||||||
|
v-if="property.fields.introduction.show"
|
||||||
|
class="truncate text-[12px]"
|
||||||
|
:style="{ color: property.fields.introduction.color }"
|
||||||
|
>
|
||||||
|
{{ spu.introduction }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- 价格 -->
|
||||||
|
<span
|
||||||
|
v-if="property.fields.price.show"
|
||||||
|
class="text-[16px]"
|
||||||
|
:style="{ color: property.fields.price.color }"
|
||||||
|
>
|
||||||
|
¥{{ fenToYuan(spu.price as any) }}
|
||||||
|
</span>
|
||||||
|
<!-- 市场价 -->
|
||||||
|
<span
|
||||||
|
v-if="property.fields.marketPrice.show && spu.marketPrice"
|
||||||
|
class="ml-[4px] text-[10px] line-through"
|
||||||
|
:style="{ color: property.fields.marketPrice.color }"
|
||||||
|
>¥{{ fenToYuan(spu.marketPrice) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[12px]">
|
||||||
|
<!-- 销量 -->
|
||||||
|
<span
|
||||||
|
v-if="property.fields.salesCount.show"
|
||||||
|
:style="{ color: property.fields.salesCount.color }"
|
||||||
|
>
|
||||||
|
已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}件
|
||||||
|
</span>
|
||||||
|
<!-- 库存 -->
|
||||||
|
<span
|
||||||
|
v-if="property.fields.stock.show"
|
||||||
|
:style="{ color: property.fields.stock.color }"
|
||||||
|
>
|
||||||
|
库存{{ spu.stock || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 购买按钮 -->
|
||||||
|
<div class="absolute bottom-[8px] right-[8px]">
|
||||||
|
<!-- 文字按钮 -->
|
||||||
|
<span
|
||||||
|
v-if="property.btnBuy.type === 'text'"
|
||||||
|
class="rounded-full px-[12px] py-[4px] text-[12px] text-white"
|
||||||
|
:style="{
|
||||||
|
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ property.btnBuy.text }}
|
||||||
|
</span>
|
||||||
|
<!-- 图片按钮 -->
|
||||||
|
<ElImage
|
||||||
|
v-else
|
||||||
|
class="h-[28px] w-[28px] rounded-full"
|
||||||
|
fit="cover"
|
||||||
|
:src="property.btnBuy.imgUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProductCardProperty } from './config';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElCheckbox,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElRadioButton,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
ElSwitch,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import ColorInput from '#/components/color-input/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
import SpuShowcase from '#/views/mall/product/spu/components/spu-showcase.vue';
|
||||||
|
|
||||||
|
// 商品卡片属性面板
|
||||||
|
defineOptions({ name: 'ProductCardProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: ProductCardProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElCard header="商品列表" class="property-group" shadow="never">
|
||||||
|
<SpuShowcase v-model="formData.spuIds" />
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="商品样式" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="布局" prop="type">
|
||||||
|
<ElRadioGroup v-model="formData.layoutType">
|
||||||
|
<ElTooltip class="item" content="单列大图" placement="bottom">
|
||||||
|
<ElRadioButton value="oneColBigImg">
|
||||||
|
<IconifyIcon icon="fluent:text-column-one-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="单列小图" placement="bottom">
|
||||||
|
<ElRadioButton value="oneColSmallImg">
|
||||||
|
<IconifyIcon icon="fluent:text-column-two-left-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="双列" placement="bottom">
|
||||||
|
<ElRadioButton value="twoCol">
|
||||||
|
<IconifyIcon icon="fluent:text-column-two-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品名称" prop="fields.name.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.name.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.name.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品简介" prop="fields.introduction.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.introduction.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.introduction.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品价格" prop="fields.price.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.price.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.price.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="市场价" prop="fields.marketPrice.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.marketPrice.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.marketPrice.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品销量" prop="fields.salesCount.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.salesCount.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.salesCount.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品库存" prop="fields.stock.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.stock.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.stock.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="角标" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="角标" prop="badge.show">
|
||||||
|
<ElSwitch v-model="formData.badge.show" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.badge.imgUrl"
|
||||||
|
height="44px"
|
||||||
|
width="72px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议尺寸:36 * 22 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="按钮" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="按钮类型" prop="btnBuy.type">
|
||||||
|
<ElRadioGroup v-model="formData.btnBuy.type">
|
||||||
|
<ElRadioButton value="text">文字</ElRadioButton>
|
||||||
|
<ElRadioButton value="img">图片</ElRadioButton>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-if="formData.btnBuy.type === 'text'">
|
||||||
|
<ElFormItem label="按钮文字" prop="btnBuy.text">
|
||||||
|
<ElInput v-model="formData.btnBuy.text" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="左侧背景" prop="btnBuy.bgBeginColor">
|
||||||
|
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="右侧背景" prop="btnBuy.bgEndColor">
|
||||||
|
<ColorInput v-model="formData.btnBuy.bgEndColor" />
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ElFormItem label="图片" prop="btnBuy.imgUrl">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.btnBuy.imgUrl"
|
||||||
|
height="56px"
|
||||||
|
width="56px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议尺寸:56 * 56 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="商品样式" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="上圆角" prop="borderRadiusTop">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.borderRadiusTop"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="下圆角" prop="borderRadiusBottom">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.borderRadiusBottom"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="间隔" prop="space">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.space"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 商品栏属性 */
|
||||||
|
export interface ProductListProperty {
|
||||||
|
// 布局类型:双列 | 三列 | 水平滑动
|
||||||
|
layoutType: 'horizSwiper' | 'threeCol' | 'twoCol';
|
||||||
|
// 商品字段
|
||||||
|
fields: {
|
||||||
|
// 商品名称
|
||||||
|
name: ProductListFieldProperty;
|
||||||
|
// 商品价格
|
||||||
|
price: ProductListFieldProperty;
|
||||||
|
};
|
||||||
|
// 角标
|
||||||
|
badge: {
|
||||||
|
// 角标图片
|
||||||
|
imgUrl: string;
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
// 上圆角
|
||||||
|
borderRadiusTop: number;
|
||||||
|
// 下圆角
|
||||||
|
borderRadiusBottom: number;
|
||||||
|
// 间距
|
||||||
|
space: number;
|
||||||
|
// 商品编号列表
|
||||||
|
spuIds: number[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
// 商品字段
|
||||||
|
export interface ProductListFieldProperty {
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
// 颜色
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'ProductList',
|
||||||
|
name: '商品栏',
|
||||||
|
icon: 'fluent:text-column-two-24-filled',
|
||||||
|
property: {
|
||||||
|
layoutType: 'twoCol',
|
||||||
|
fields: {
|
||||||
|
name: { show: true, color: '#000' },
|
||||||
|
price: { show: true, color: '#ff3000' },
|
||||||
|
},
|
||||||
|
badge: { show: false, imgUrl: '' },
|
||||||
|
borderRadiusTop: 8,
|
||||||
|
borderRadiusBottom: 8,
|
||||||
|
space: 8,
|
||||||
|
spuIds: [],
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '',
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<ProductListProperty>;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProductListProperty } from './config';
|
||||||
|
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { fenToYuan } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElImage, ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
|
import * as ProductSpuApi from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
/** 商品栏 */
|
||||||
|
defineOptions({ name: 'ProductList' });
|
||||||
|
// 定义属性
|
||||||
|
const props = defineProps<{ property: ProductListProperty }>();
|
||||||
|
// 商品列表
|
||||||
|
const spuList = ref<MallSpuApi.Spu[]>([]);
|
||||||
|
watch(
|
||||||
|
() => props.property.spuIds,
|
||||||
|
async () => {
|
||||||
|
spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 手机宽度
|
||||||
|
const phoneWidth = ref(375);
|
||||||
|
// 容器
|
||||||
|
const containerRef = ref();
|
||||||
|
// 商品的列数
|
||||||
|
const columns = ref(2);
|
||||||
|
// 滚动条宽度
|
||||||
|
const scrollbarWidth = ref('100%');
|
||||||
|
// 商品图大小
|
||||||
|
const imageSize = ref('0');
|
||||||
|
// 商品网络列数
|
||||||
|
const gridTemplateColumns = ref('');
|
||||||
|
// 计算布局参数
|
||||||
|
watch(
|
||||||
|
() => [props.property, phoneWidth, spuList.value.length],
|
||||||
|
() => {
|
||||||
|
// 计算列数
|
||||||
|
columns.value = props.property.layoutType === 'twoCol' ? 2 : 3;
|
||||||
|
// 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数
|
||||||
|
const productWidth =
|
||||||
|
(phoneWidth.value - props.property.space * (columns.value - 1)) /
|
||||||
|
columns.value;
|
||||||
|
// 商品图布局:2列时,左右布局 3列时,上下布局
|
||||||
|
imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px`;
|
||||||
|
// 根据布局类型,计算行数、列数
|
||||||
|
if (props.property.layoutType === 'horizSwiper') {
|
||||||
|
// 单行显示
|
||||||
|
gridTemplateColumns.value = `repeat(auto-fill, ${productWidth}px)`;
|
||||||
|
// 显示滚动条
|
||||||
|
scrollbarWidth.value = `${
|
||||||
|
productWidth * spuList.value.length +
|
||||||
|
props.property.space * (spuList.value.length - 1)
|
||||||
|
}px`;
|
||||||
|
} else {
|
||||||
|
// 指定列数
|
||||||
|
gridTemplateColumns.value = `repeat(${columns.value}, auto)`;
|
||||||
|
// 不滚动
|
||||||
|
scrollbarWidth.value = '100%';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
onMounted(() => {
|
||||||
|
// 提取手机宽度
|
||||||
|
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ElScrollbar class="z-10 min-h-[30px]" wrap-class="w-full" ref="containerRef">
|
||||||
|
<!-- 商品网格 -->
|
||||||
|
<div
|
||||||
|
class="grid overflow-x-auto"
|
||||||
|
:style="{
|
||||||
|
gridGap: `${property.space}px`,
|
||||||
|
gridTemplateColumns,
|
||||||
|
width: scrollbarWidth,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- 商品 -->
|
||||||
|
<div
|
||||||
|
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
|
||||||
|
:style="{
|
||||||
|
borderTopLeftRadius: `${property.borderRadiusTop}px`,
|
||||||
|
borderTopRightRadius: `${property.borderRadiusTop}px`,
|
||||||
|
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
|
||||||
|
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
|
||||||
|
}"
|
||||||
|
v-for="(spu, index) in spuList"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<!-- 角标 -->
|
||||||
|
<div
|
||||||
|
v-if="property.badge.show"
|
||||||
|
class="absolute left-0 top-0 z-10 items-center justify-center"
|
||||||
|
>
|
||||||
|
<ElImage
|
||||||
|
fit="cover"
|
||||||
|
:src="property.badge.imgUrl"
|
||||||
|
class="h-[26px] w-[38px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 商品封面图 -->
|
||||||
|
<ElImage
|
||||||
|
fit="cover"
|
||||||
|
:src="spu.picUrl"
|
||||||
|
:style="{ width: imageSize, height: imageSize }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="box-border flex flex-col gap-2 p-2"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'w-[calc(100%-64px)]': columns === 2,
|
||||||
|
'w-full': columns === 3,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 商品名称 -->
|
||||||
|
<div
|
||||||
|
v-if="property.fields.name.show"
|
||||||
|
class="truncate text-xs"
|
||||||
|
:style="{ color: property.fields.name.color }"
|
||||||
|
>
|
||||||
|
{{ spu.name }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- 商品价格 -->
|
||||||
|
<span
|
||||||
|
v-if="property.fields.price.show"
|
||||||
|
class="text-xs"
|
||||||
|
:style="{ color: property.fields.price.color }"
|
||||||
|
>
|
||||||
|
¥{{ fenToYuan(spu.price || 0) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProductListProperty } from './config';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElRadioButton,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElSlider,
|
||||||
|
ElSwitch,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
import ColorInput from '#/components/input-with-color/index.vue';
|
||||||
|
import UploadImg from '#/components/upload/image-upload.vue';
|
||||||
|
import SpuShowcase from '#/views/mall/product/spu/components/spu-showcase.vue';
|
||||||
|
|
||||||
|
// 商品栏属性面板
|
||||||
|
defineOptions({ name: 'ProductListProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: ProductListProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="80px" :model="formData">
|
||||||
|
<ElCard header="商品列表" class="property-group" shadow="never">
|
||||||
|
<SpuShowcase v-model="formData.spuIds" />
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="商品样式" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="布局" prop="type">
|
||||||
|
<ElRadioGroup v-model="formData.layoutType">
|
||||||
|
<ElTooltip class="item" content="双列" placement="bottom">
|
||||||
|
<ElRadioButton value="twoCol">
|
||||||
|
<IconifyIcon icon="fluent:text-column-two-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="三列" placement="bottom">
|
||||||
|
<ElRadioButton value="threeCol">
|
||||||
|
<IconifyIcon icon="fluent:text-column-three-24-filled" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip class="item" content="水平滑动" placement="bottom">
|
||||||
|
<ElRadioButton value="horizSwiper">
|
||||||
|
<IconifyIcon icon="system-uicons:carousel" />
|
||||||
|
</ElRadioButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品名称" prop="fields.name.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.name.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.name.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商品价格" prop="fields.price.show">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorInput v-model="formData.fields.price.color" />
|
||||||
|
<ElCheckbox v-model="formData.fields.price.show" />
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="角标" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="角标" prop="badge.show">
|
||||||
|
<ElSwitch v-model="formData.badge.show" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
|
||||||
|
<UploadImg
|
||||||
|
v-model="formData.badge.imgUrl"
|
||||||
|
height="44px"
|
||||||
|
width="72px"
|
||||||
|
:show-description="false"
|
||||||
|
>
|
||||||
|
<template #tip> 建议尺寸:36 * 22 </template>
|
||||||
|
</UploadImg>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard header="商品样式" class="property-group" shadow="never">
|
||||||
|
<ElFormItem label="上圆角" prop="borderRadiusTop">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.borderRadiusTop"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="下圆角" prop="borderRadiusBottom">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.borderRadiusBottom"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="间隔" prop="space">
|
||||||
|
<ElSlider
|
||||||
|
v-model="formData.space"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
show-input
|
||||||
|
input-size="small"
|
||||||
|
:show-input-controls="false"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCard>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 营销文章属性 */
|
||||||
|
export interface PromotionArticleProperty {
|
||||||
|
// 文章编号
|
||||||
|
id: number;
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'PromotionArticle',
|
||||||
|
name: '营销文章',
|
||||||
|
icon: 'ph:article-medium',
|
||||||
|
property: {
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '',
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<PromotionArticleProperty>;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PromotionArticleProperty } from './config';
|
||||||
|
|
||||||
|
import type { MallArticleApi } from '#/api/mall/promotion/article';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import * as ArticleApi from '#/api/mall/promotion/article/index';
|
||||||
|
|
||||||
|
/** 营销文章 */
|
||||||
|
defineOptions({ name: 'PromotionArticle' });
|
||||||
|
// 定义属性
|
||||||
|
const props = defineProps<{ property: PromotionArticleProperty }>();
|
||||||
|
// 商品列表
|
||||||
|
const article = ref<MallArticleApi.Article>();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.property.id,
|
||||||
|
async () => {
|
||||||
|
if (props.property.id) {
|
||||||
|
article.value = await ArticleApi.getArticle(props.property.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[30px]" v-dompurify-html="article?.content"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PromotionArticleProperty } from './config';
|
||||||
|
|
||||||
|
import type { MallArticleApi } from '#/api/mall/promotion/article';
|
||||||
|
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { ElForm, ElFormItem, ElOption, ElSelect } from 'element-plus';
|
||||||
|
|
||||||
|
import * as ArticleApi from '#/api/mall/promotion/article/index';
|
||||||
|
import ComponentContainerProperty from '#/components/diy-editor/components/component-container-property.vue';
|
||||||
|
|
||||||
|
// 营销文章属性面板
|
||||||
|
defineOptions({ name: 'PromotionArticleProperty' });
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: PromotionArticleProperty }>();
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const formData = useVModel(props, 'modelValue', emit);
|
||||||
|
// 文章列表
|
||||||
|
const articles = ref<MallArticleApi.Article[]>([]);
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
const loading = ref(false);
|
||||||
|
// 查询文章列表
|
||||||
|
const queryArticleList = async (title?: string) => {
|
||||||
|
loading.value = true;
|
||||||
|
const { list } = await ArticleApi.getArticlePage({
|
||||||
|
title,
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
articles.value = list;
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
queryArticleList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComponentContainerProperty v-model="formData.style">
|
||||||
|
<ElForm label-width="40px" :model="formData">
|
||||||
|
<ElFormItem label="文章" prop="id">
|
||||||
|
<ElSelect
|
||||||
|
v-model="formData.id"
|
||||||
|
placeholder="请选择文章"
|
||||||
|
class="w-full"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
:remote-method="queryArticleList"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
:label="article.title"
|
||||||
|
:value="article.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</ComponentContainerProperty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
ComponentStyle,
|
||||||
|
DiyComponent,
|
||||||
|
} from '#/components/diy-editor/util';
|
||||||
|
|
||||||
|
/** 拼团属性 */
|
||||||
|
export interface PromotionCombinationProperty {
|
||||||
|
// 布局类型:单列 | 三列
|
||||||
|
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol';
|
||||||
|
// 商品字段
|
||||||
|
fields: {
|
||||||
|
// 商品简介
|
||||||
|
introduction: PromotionCombinationFieldProperty;
|
||||||
|
// 市场价
|
||||||
|
marketPrice: PromotionCombinationFieldProperty;
|
||||||
|
// 商品名称
|
||||||
|
name: PromotionCombinationFieldProperty;
|
||||||
|
// 商品价格
|
||||||
|
price: PromotionCombinationFieldProperty;
|
||||||
|
// 商品销量
|
||||||
|
salesCount: PromotionCombinationFieldProperty;
|
||||||
|
// 商品库存
|
||||||
|
stock: PromotionCombinationFieldProperty;
|
||||||
|
};
|
||||||
|
// 角标
|
||||||
|
badge: {
|
||||||
|
// 角标图片
|
||||||
|
imgUrl: string;
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
// 按钮
|
||||||
|
btnBuy: {
|
||||||
|
// 文字按钮:背景渐变起始颜色
|
||||||
|
bgBeginColor: string;
|
||||||
|
// 文字按钮:背景渐变结束颜色
|
||||||
|
bgEndColor: string;
|
||||||
|
// 图片按钮:图片地址
|
||||||
|
imgUrl: string;
|
||||||
|
// 文字
|
||||||
|
text: string;
|
||||||
|
// 类型:文字 | 图片
|
||||||
|
type: 'img' | 'text';
|
||||||
|
};
|
||||||
|
// 上圆角
|
||||||
|
borderRadiusTop: number;
|
||||||
|
// 下圆角
|
||||||
|
borderRadiusBottom: number;
|
||||||
|
// 间距
|
||||||
|
space: number;
|
||||||
|
// 拼团活动编号
|
||||||
|
activityIds: number[];
|
||||||
|
// 组件样式
|
||||||
|
style: ComponentStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商品字段
|
||||||
|
export interface PromotionCombinationFieldProperty {
|
||||||
|
// 是否显示
|
||||||
|
show: boolean;
|
||||||
|
// 颜色
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义组件
|
||||||
|
export const component = {
|
||||||
|
id: 'PromotionCombination',
|
||||||
|
name: '拼团',
|
||||||
|
icon: 'mdi:account-group',
|
||||||
|
property: {
|
||||||
|
layoutType: 'oneColBigImg',
|
||||||
|
fields: {
|
||||||
|
name: { show: true, color: '#000' },
|
||||||
|
introduction: { show: true, color: '#999' },
|
||||||
|
price: { show: true, color: '#ff3000' },
|
||||||
|
marketPrice: { show: true, color: '#c4c4c4' },
|
||||||
|
salesCount: { show: true, color: '#c4c4c4' },
|
||||||
|
stock: { show: false, color: '#c4c4c4' },
|
||||||
|
},
|
||||||
|
badge: { show: false, imgUrl: '' },
|
||||||
|
btnBuy: {
|
||||||
|
type: 'text',
|
||||||
|
text: '去拼团',
|
||||||
|
bgBeginColor: '#FF6000',
|
||||||
|
bgEndColor: '#FE832A',
|
||||||
|
imgUrl: '',
|
||||||
|
},
|
||||||
|
borderRadiusTop: 8,
|
||||||
|
borderRadiusBottom: 8,
|
||||||
|
space: 8,
|
||||||
|
style: {
|
||||||
|
bgType: 'color',
|
||||||
|
bgColor: '',
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
} as ComponentStyle,
|
||||||
|
},
|
||||||
|
} as DiyComponent<PromotionCombinationProperty>;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user