pull/201/head
xingyu4j 2025-08-19 17:34:00 +08:00
commit 1d5bfaac5a
165 changed files with 15467 additions and 177 deletions

View File

@ -21,7 +21,8 @@
// CSS
"vunguyentuan.vscode-css-variables",
// package.json PNPM catalog
"antfu.pnpm-catalog-lens"
"antfu.pnpm-catalog-lens",
"augment.vscode-augment"
],
"unwantedRecommendations": [
// volar

View File

@ -4,11 +4,12 @@ export namespace ErpProductCategoryApi {
/** ERP 产品分类信息 */
export interface ProductCategory {
id?: number; // 分类编号
parentId: number; // 父分类编号
parentId?: number; // 父分类编号
name: string; // 分类名称
code: string; // 分类编码
sort: number; // 分类排序
status: number; // 开启状态
code?: string; // 分类编码
sort?: number; // 分类排序
status?: number; // 开启状态
children?: ProductCategory[]; // 子分类
}
}

View File

@ -2,7 +2,7 @@ import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
namespace ErpStockOutApi {
export namespace ErpStockOutApi {
/** 其它出库单信息 */
export interface StockOut {
id?: number; // 出库编号
@ -13,6 +13,24 @@ namespace ErpStockOutApi {
totalPrice: number; // 合计金额,单位:元
status: number; // 状态
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; // 备注
}
/** 其它出库单分页查询参数 */

View File

@ -16,6 +16,7 @@ export namespace InfraFileConfigApi {
accessKey?: string;
accessSecret?: string;
pathStyle?: boolean;
enablePublicAccess?: boolean;
domain: string;
}

View File

@ -1,2 +1,2 @@
export { default as Tinyflow } from './tinyflow.vue';
export { default as Tinyflow } from './Tinyflow.vue';
export * from './ui/typing';

View File

@ -10,7 +10,7 @@ import { isNumber } from '@vben/utils';
import { Button, Input, Select } from 'ant-design-vue';
import { testWorkflow } from '#/api/ai/workflow';
import { Tinyflow } from '#/components/tinyflow';
import { Tinyflow } from '#/components/Tinyflow';
defineProps<{
provider: any;

View 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' },
},
];
}

View File

@ -1,34 +1,166 @@
<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>
<template>
<Page>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【产品】产品信息、分类、单位"
url="https://doc.iocoder.cn/erp/product/"
/>
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/category/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/category/index
代码pull request 贡献给我们
</Button>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['产品分类']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['erp:product-category:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: toggleExpand,
},
]"
/>
</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>
</template>

View File

@ -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>

View 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' },
},
];
}

View File

@ -1,34 +1,152 @@
<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>
<template>
<Page>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【产品】产品信息、分类、单位"
url="https://doc.iocoder.cn/erp/product/"
/>
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/product/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/product/index
代码pull request 贡献给我们
</Button>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['产品']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['erp:product:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['erp:product:export'],
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>
</template>

View 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>

View 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' },
},
];
}

View File

@ -1,34 +1,152 @@
<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>
<template>
<Page>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【产品】产品信息、分类、单位"
url="https://doc.iocoder.cn/erp/product/"
/>
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/unit/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/product/unit/index
代码pull request 贡献给我们
</Button>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['产品单位']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['erp:product-unit:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['erp:product-unit:export'],
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>
</template>

View 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>

View File

@ -114,7 +114,11 @@ const [Modal, modalApi] = useVbenModal({
//
const data =
(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)) {
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';

View File

@ -88,7 +88,10 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
//
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)) {
data.fileUrl = data.fileUrl.length > 0 ? data.fileUrl[0] : '';

View 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' },
},
];
}

View File

@ -1,34 +1,219 @@
<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>
<template>
<Page>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【库存】其它入库、其它出库"
url="https://doc.iocoder.cn/erp/stock-in-out/"
/>
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/stock/out/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/erp/stock/out/index
代码pull request 贡献给我们
</Button>
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['其它出库']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['erp:stock-out:create'],
onClick: () => openForm('create'),
},
{
label: $t('ui.actionTitle.export'),
type: 'default',
icon: ACTION_ICON.DOWNLOAD,
auth: ['erp:stock-out:export'],
onClick: handleExport,
},
{
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>
</template>

View 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>

View File

@ -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>

View File

@ -5,7 +5,7 @@ import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { JsonViewer } from '@vben/common-ui';
import { formatDateTime, isObject } from '@vben/utils';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
@ -192,13 +192,13 @@ export function useDetailSchema(): DescriptionItemSchema[] {
field: 'requestParams',
label: '请求参数',
content: (data) => {
if (isObject(data.requestParams)) {
if (data.requestParams) {
return h(JsonViewer, {
value: data.requestParams,
value: JSON.parse(data.requestParams),
previewMode: true,
});
}
return data.requestParams;
return '';
},
},
{

View File

@ -5,7 +5,9 @@ import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
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 {
@ -177,13 +179,13 @@ export function useDetailSchema(): DescriptionItemSchema[] {
field: 'requestParams',
label: '请求参数',
content: (data) => {
if (isObject(data.requestParams)) {
if (data.requestParams) {
return h(JsonViewer, {
value: data.requestParams,
value: JSON.parse(data.requestParams),
previewMode: true,
});
}
return data.requestParams;
return '';
},
},
{
@ -201,13 +203,10 @@ export function useDetailSchema(): DescriptionItemSchema[] {
field: 'exceptionStackTrace',
label: '异常堆栈',
content: (data) => {
if (isObject(data.exceptionStackTrace)) {
return h(JsonViewer, {
value: data.exceptionStackTrace,
previewMode: true,
});
}
return data.exceptionStackTrace;
return h(Textarea, {
value: data.exceptionStackTrace,
rows: 20,
});
},
},
{

View File

@ -200,6 +200,25 @@ export function useFormSchema(): VbenFormSchema[] {
},
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',

View File

@ -19,3 +19,5 @@ VITE_INJECT_APP_LOADING=true
VITE_APP_DEFAULT_USERNAME=admin
# 默认登录密码
VITE_APP_DEFAULT_PASSWORD=admin123
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'

View File

@ -21,3 +21,5 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'

View File

@ -50,7 +50,9 @@
"highlight.js": "catalog:",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
"vue-dompurify-html": "catalog:",
"vue-router": "catalog:",
"vuedraggable": "catalog:"
},
"devDependencies": {
"unplugin-element-plus": "catalog:"

View File

@ -16,6 +16,7 @@ export namespace InfraFileConfigApi {
accessKey?: string;
accessSecret?: string;
pathStyle?: boolean;
enablePublicAccess?: boolean;
domain: string;
}

View File

@ -48,6 +48,8 @@ export namespace MallCombinationActivityApi {
combinationPrice?: number;
/** 商品列表 */
products: CombinationProduct[];
/** 图片 */
picUrl?: string;
}
/** 扩展 SKU 配置 */

View File

@ -52,7 +52,7 @@ export function deleteDiyPage(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}`);
}
/** 更新装修页面属性 */

View File

@ -55,6 +55,8 @@ export namespace MallSeckillActivityApi {
seckillPrice?: number;
/** 秒杀商品列表 */
products?: SeckillProduct[];
/** 图片 */
picUrl?: string;
}
/** 扩展 SKU 配置 */

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -1,4 +1,5 @@
import { createApp, watchEffect } from 'vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui';
@ -34,7 +35,7 @@ async function bootstrap(namespace: string) {
// zIndex: 2000,
// });
const app = createApp(App);
app.use(VueDOMPurifyHTML);
// 注册Element Plus提供的v-loading指令
app.directive('loading', ElLoading.directive);

View File

@ -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>

View 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[];

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,4 @@
// 导出所有优惠券相关组件
export { CouponDiscount } from './coupon-discount';
export { CouponDiscountDesc } from './coupon-discount-desc';
export { CouponValidTerm } from './coupon-validTerm';

View File

@ -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>;

View File

@ -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>
);
},
});

View File

@ -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>
);
},
});

View File

@ -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>;
},
});

View File

@ -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"
>
<!-- 布局11-->
<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>
<!-- 布局22-->
<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>
<!-- 布局33-->
<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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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
/**
*
*
* 使vueuseuseDraggable使
* @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);
};

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,69 @@
import { defineAsyncComponent } from 'vue';
/*
*
*
*
* 1.
* 2. config.ts
* 3. index.vue Page
* 4. property.vue
*
*
* IDconfig.tsidID
*/
// 导入组件界面模块
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 };

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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);
// 628
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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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