This commit is contained in:
xingyu4j
2025-08-19 17:34:00 +08:00
165 changed files with 15467 additions and 177 deletions

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { handleTree } from '@vben/utils';
import * as ProductCategoryApi from '#/api/mall/product/category';
/** 商品分类选择组件 */
defineOptions({ name: 'ProductCategorySelect' });
const props = defineProps({
// 选中的ID
modelValue: {
type: [Number, Array<Number>],
default: undefined,
},
// 是否多选
multiple: {
type: Boolean,
default: false,
},
// 上级品类的编号
parentId: {
type: Number,
default: undefined,
},
});
/** 分类选择 */
const emit = defineEmits(['update:modelValue']);
/** 选中的分类 ID */
const selectCategoryId = computed({
get: () => {
return props.modelValue;
},
set: (val: number | number[]) => {
emit('update:modelValue', val);
},
});
/** 初始化 */
const categoryList = ref<any[]>([]); // 分类树
onMounted(async () => {
// 获得分类树
const data = await ProductCategoryApi.getCategoryList({
parentId: props.parentId,
});
categoryList.value = handleTree(data, 'id', 'parentId');
});
</script>
<template>
<el-tree-select
v-model="selectCategoryId"
:data="categoryList"
:props="{
children: 'children',
label: 'name',
value: 'id',
isLeaf: 'leaf',
emitPath: false,
}"
:multiple="multiple"
:show-checkbox="multiple"
class="w-1/1"
node-key="id"
placeholder="请选择商品分类"
/>
</template>

View File

@@ -0,0 +1,166 @@
<script lang="ts" setup>
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { ElImage, ElTooltip } from 'element-plus';
import * as ProductSpuApi from '#/api/mall/product/spu';
import SpuTableSelect from '#/views/mall/product/spu/components/spu-table-select.vue';
// 商品橱窗,一般用于与商品建立关系时使用
// 提供功能:展示商品列表、添加商品、移除商品
defineOptions({ name: 'SpuShowcase' });
const props = defineProps({
modelValue: {
type: [Number, Array],
required: true,
},
// 限制数量:默认不限制
limit: {
type: Number,
default: Number.MAX_VALUE,
},
disabled: Boolean,
});
const emit = defineEmits(['update:modelValue', 'change']);
// 计算是否可以添加
const canAdd = computed(() => {
// 情况一:禁用时不可以添加
if (props.disabled) return false;
// 情况二:未指定限制数量时,可以添加
if (!props.limit) return true;
// 情况三:检查已添加数量是否小于限制数量
return productSpus.value.length < props.limit;
});
// 商品列表
const productSpus = ref<MallSpuApi.Spu[]>([]);
watch(
() => props.modelValue,
async () => {
let ids: number[] = [];
if (Array.isArray(props.modelValue)) {
ids = props.modelValue as number[];
} else {
ids = props.modelValue ? [props.modelValue] : [];
}
// 不需要返显
if (ids.length === 0) {
productSpus.value = [];
return;
}
// 只有商品发生变化之后,才去查询商品
if (
productSpus.value.length === 0 ||
productSpus.value.some((spu) => !ids.includes(spu.id!))
) {
productSpus.value = await ProductSpuApi.getSpuDetailList(ids);
}
},
{ immediate: true },
);
/** 商品表格选择对话框 */
const spuTableSelectRef = ref();
// 打开对话框
const openSpuTableSelect = () => {
spuTableSelectRef.value.open(productSpus.value);
};
/**
* 选择商品后触发
*
* @param spus 选中的商品列表
*/
const handleSpuSelected = (spus: MallSpuApi.Spu | MallSpuApi.Spu[]) => {
productSpus.value = Array.isArray(spus) ? spus : [spus];
emitSpuChange();
};
/**
* 删除商品
*
* @param index 商品索引
*/
const handleRemoveSpu = (index: number) => {
productSpus.value.splice(index, 1);
emitSpuChange();
};
const emitSpuChange = () => {
if (props.limit === 1) {
const spu = productSpus.value.length > 0 ? productSpus.value[0] : null;
emit('update:modelValue', spu?.id || 0);
emit('change', spu);
} else {
emit(
'update:modelValue',
productSpus.value.map((spu) => spu.id),
);
emit('change', productSpus.value);
}
};
</script>
<template>
<div class="gap-8px flex flex-wrap items-center">
<div
v-for="(spu, index) in productSpus"
:key="spu.id"
class="select-box spu-pic"
>
<ElTooltip :content="spu.name">
<div class="relative h-full w-full">
<ElImage :src="spu.picUrl" class="h-full w-full" />
<IconifyIcon
v-show="!disabled"
class="del-icon"
icon="ep:circle-close-filled"
@click="handleRemoveSpu(index)"
/>
</div>
</ElTooltip>
</div>
<ElTooltip content="选择商品" v-if="canAdd">
<div class="select-box" @click="openSpuTableSelect">
<IconifyIcon icon="ep:plus" />
</div>
</ElTooltip>
</div>
<!-- 商品选择对话框表格形式 -->
<SpuTableSelect
ref="spuTableSelectRef"
:multiple="limit !== 1"
@change="handleSpuSelected"
/>
</template>
<style lang="scss" scoped>
.select-box {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
border: 1px dashed var(--el-border-color-darker);
border-radius: 8px;
}
.spu-pic {
position: relative;
}
.del-icon {
position: absolute;
top: -10px;
right: -10px;
z-index: 1;
width: 20px !important;
height: 20px !important;
}
</style>

View File

@@ -0,0 +1,359 @@
<script lang="ts" setup>
import type { MallCategoryApi } from '#/api/mall/product/category';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { onMounted, ref } from 'vue';
import { handleTree } from '@vben/utils';
import {
CHANGE_EVENT,
ElButton,
ElCheckbox,
ElDatePicker,
ElDialog,
ElForm,
ElFormItem,
ElImage,
ElInput,
ElRadio,
ElTable,
ElTableColumn,
ElTreeSelect,
} from 'element-plus';
import * as ProductCategoryApi from '#/api/mall/product/category';
import * as ProductSpuApi from '#/api/mall/product/spu';
/**
* 商品表格选择对话框
* 1. 单选模式:
* 1.1 点击表格左侧的单选框时,结束选择,并关闭对话框
* 1.2 再次打开时,保持选中状态
* 2. 多选模式:
* 2.1 点击表格左侧的多选框时,记录选中的商品
* 2.2 切换分页时,保持商品的选中的状态
* 2.3 点击右下角的确定按钮时,结束选择,关闭对话框
* 2.4 再次打开时,保持选中状态
*/
defineOptions({ name: 'SpuTableSelect' });
defineProps({
// 多选模式
multiple: {
type: Boolean,
default: false,
},
});
/** 确认选择时的触发事件 */
const emits = defineEmits<{
change: [spu: any | MallSpuApi.Spu | MallSpuApi.Spu[]];
}>();
// 列表的总页数
const total = ref(0);
// 列表的数据
const list = ref<MallSpuApi.Spu[]>([]);
// 列表的加载中
const loading = ref(false);
// 弹窗的是否展示
const dialogVisible = ref(false);
// 查询参数
const queryParams = ref({
pageNo: 1,
pageSize: 10,
// 默认获取上架的商品
tabType: 0,
name: '',
categoryId: null,
createTime: [],
});
/** 打开弹窗 */
const open = (spuList?: MallSpuApi.Spu[]) => {
// 重置
checkedSpus.value = [];
checkedStatus.value = {};
isCheckAll.value = false;
isIndeterminate.value = false;
// 处理已选中
if (spuList && spuList.length > 0) {
checkedSpus.value = [...spuList];
checkedStatus.value = Object.fromEntries(
spuList.map((spu) => [spu.id, true]),
);
}
dialogVisible.value = true;
resetQuery();
};
// 提供 open 方法,用于打开弹窗
defineExpose({ open });
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const data = await ProductSpuApi.getSpuPage(queryParams.value);
list.value = data.list;
total.value = data.total;
// checkbox绑定undefined会有问题需要给一个bool值
list.value.forEach(
(spu) =>
(checkedStatus.value[spu.id || 0] =
checkedStatus.value[spu.id || 0] || false),
);
// 计算全选框状态
calculateIsCheckAll();
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryParams.value = {
pageNo: 1,
pageSize: 10,
// 默认获取上架的商品
tabType: 0,
name: '',
categoryId: null,
createTime: [],
};
getList();
};
// 是否全选
const isCheckAll = ref(false);
// 全选框是否处于中间状态:不是全部选中 && 任意一个选中
const isIndeterminate = ref(false);
// 选中的商品
const checkedSpus = ref<MallSpuApi.Spu[]>([]);
// 选中状态key为商品IDvalue为是否选中
const checkedStatus = ref<Record<string, boolean>>({});
// 选中的商品 spuId
const selectedSpuId = ref();
/** 单选中时触发 */
const handleSingleSelected = (spu: MallSpuApi.Spu) => {
emits(CHANGE_EVENT, spu);
// 关闭弹窗
dialogVisible.value = false;
// 记住上次选择的ID
selectedSpuId.value = spu.id;
};
/** 多选完成 */
const handleEmitChange = () => {
// 关闭弹窗
dialogVisible.value = false;
emits(CHANGE_EVENT, [...checkedSpus.value]);
};
/** 全选/全不选 */
const handleCheckAll = (checked: boolean) => {
isCheckAll.value = checked;
isIndeterminate.value = false;
list.value.forEach((spu) => handleCheckOne(checked, spu, false));
};
/**
* 选中一行
* @param checked 是否选中
* @param spu 商品
* @param isCalcCheckAll 是否计算全选
*/
const handleCheckOne = (
checked: boolean,
spu: MallSpuApi.Spu,
isCalcCheckAll: boolean,
) => {
if (checked) {
checkedSpus.value.push(spu);
checkedStatus.value[spu.id || 0] = true;
} else {
const index = findCheckedIndex(spu);
if (index > -1) {
checkedSpus.value.splice(index, 1);
checkedStatus.value[spu.id || 0] = false;
isCheckAll.value = false;
}
}
// 计算全选框状态
if (isCalcCheckAll) {
calculateIsCheckAll();
}
};
// 查找商品在已选中商品列表中的索引
const findCheckedIndex = (spu: MallSpuApi.Spu) =>
checkedSpus.value.findIndex((item) => item.id === spu.id);
// 计算全选框状态
const calculateIsCheckAll = () => {
isCheckAll.value = list.value.every(
(spu) => checkedStatus.value[spu.id || 0],
);
// 计算中间状态:不是全部选中 && 任意一个选中
isIndeterminate.value =
!isCheckAll.value &&
list.value.some((spu) => checkedStatus.value[spu.id || 0]);
};
// 分类列表
const categoryList = ref<MallCategoryApi.Category[]>();
// 分类树
const categoryTreeList = ref();
/** 初始化 */
onMounted(async () => {
await getList();
// 获得分类树
categoryList.value = await ProductCategoryApi.getCategoryList({});
categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId');
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
:append-to-body="true"
title="选择商品"
width="70%"
>
<ContentWrap>
<ElForm
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<ElFormItem label="商品名称" prop="name">
<ElInput
v-model="queryParams.name"
class="!w-240px"
clearable
placeholder="请输入商品名称"
@keyup.enter="handleQuery"
/>
</ElFormItem>
<ElFormItem label="商品分类" prop="categoryId">
<ElTreeSelect
v-model="queryParams.categoryId"
:data="categoryTreeList"
:props="{
children: 'children',
label: 'name',
value: 'id',
isLeaf: 'leaf',
emitPath: false,
}"
check-strictly
class="!w-240px"
node-key="id"
placeholder="请选择商品分类"
/>
</ElFormItem>
<ElFormItem label="创建时间" prop="createTime">
<ElDatePicker
v-model="queryParams.createTime"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
end-placeholder="结束日期"
start-placeholder="开始日期"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</ElFormItem>
<ElFormItem>
<ElButton @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</ElButton>
<ElButton @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</ElButton>
</ElFormItem>
</ElForm>
<ElTable v-loading="loading" :data="list" show-overflow-tooltip>
<!-- 1. 多选模式不能使用type="selection"Element会忽略Header插槽 -->
<ElTableColumn width="55" v-if="multiple">
<template #header>
<ElCheckbox
v-model="isCheckAll"
:indeterminate="isIndeterminate"
@change="handleCheckAll"
/>
</template>
<template #default="{ row }">
<ElCheckbox
v-model="checkedStatus[row.id]"
@change="(checked: boolean) => handleCheckOne(checked, row, true)"
/>
</template>
</ElTableColumn>
<!-- 2. 单选模式 -->
<ElTableColumn label="#" width="55" v-else>
<template #default="{ row }">
<ElRadio
:value="row.id"
v-model="selectedSpuId"
@change="handleSingleSelected(row)"
>
<!-- 空格不能省略是为了让单选框不显示label如果不指定label不会有选中的效果 -->
&nbsp;
</ElRadio>
</template>
</ElTableColumn>
<ElTableColumn
key="id"
align="center"
label="商品编号"
prop="id"
min-width="60"
/>
<ElTableColumn label="商品图" min-width="80">
<template #default="{ row }">
<ElImage
:src="row.picUrl"
class="h-30px w-30px"
:preview-src-list="[row.picUrl]"
preview-teleported
/>
</template>
</ElTableColumn>
<ElTableColumn label="商品名称" min-width="200" prop="name" />
<ElTableColumn label="商品分类" min-width="100" prop="categoryId">
<template #default="{ row }">
<span>{{
categoryList?.find(
(c: MallCategoryApi.Category) => c.id === row.categoryId,
)?.name
}}</span>
</template>
</ElTableColumn>
</ElTable>
<!-- 分页 -->
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</ContentWrap>
<template #footer v-if="multiple">
<ElButton type="primary" @click="handleEmitChange"> </ElButton>
<ElButton @click="dialogVisible = false"> </ElButton>
</template>
</ElDialog>
</template>