新增资产SKU管理功能,添加SKU等功能

This commit is contained in:
WUSIJIAN
2025-12-26 17:14:17 +08:00
parent d8c0fa11d3
commit 37710631df
3 changed files with 629 additions and 3 deletions

View File

@@ -84,3 +84,73 @@ export function uploadAssetImage(file: File) {
},
});
}
// SKU 列表查询参数
export interface SkuQueryParams {
assetId: string;
status?: number;
keyword?: string;
minPrice?: number;
maxPrice?: number;
page?: number;
pageSize?: number;
}
// SKU 创建参数
export interface CreateSkuParams {
assetId: string;
assetName: string;
skuName: string;
imageUrl?: string;
specValues?: Record<string, any>;
price: number;
unlimitedStock: boolean;
stock: number;
sort?: number;
status?: number;
}
// 获取 SKU 列表
export function listAssetSkus(params: SkuQueryParams) {
return newService({
url: '/assets/asset/sku/listAssetSkus',
method: 'get',
params,
});
}
// 创建 SKU
export function createAssetSku(data: CreateSkuParams) {
return newService({
url: '/assets/asset/sku/createAssetSku',
method: 'post',
data,
});
}
// 获取 SKU 详情
export function getAssetSku(id: string) {
return newService({
url: '/assets/asset/sku/getAssetSku',
method: 'get',
params: { id },
});
}
// 修改 SKU
export function updateAssetSku(data: CreateSkuParams & { id: string }) {
return newService({
url: '/assets/asset/sku/updateAssetSku',
method: 'put',
data,
});
}
// 删除 SKU
export function deleteAssetSku(id: string) {
return newService({
url: '/assets/asset/sku/deleteAssetSku',
method: 'delete',
params: { id },
});
}

View File

@@ -0,0 +1,554 @@
<template>
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="900px" :close-on-click-modal="false" @close="onClose">
<!-- 搜索区域 -->
<div class="sku-search mb15">
<el-button type="primary" @click="onOpenAddSku">
<el-icon><ele-Plus /></el-icon>
添加SKU
</el-button>
<el-input v-model="queryParams.keyword" placeholder="请输入SKU名称" clearable style="width: 180px; margin-left: 10px" />
<el-select v-model="queryParams.status" placeholder="状态" clearable style="width: 100px; margin-left: 10px">
<el-option label="激活" :value="1" />
<el-option label="未激活" :value="0" />
</el-select>
<el-button type="primary" style="margin-left: 10px" @click="getSkuList">
<el-icon><ele-Search /></el-icon>
搜索
</el-button>
<el-button @click="onResetQuery">重置</el-button>
</div>
<!-- SKU 列表 -->
<el-table :data="tableData" v-loading="loading" border style="width: 100%">
<el-table-column prop="skuName" label="SKU名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="specValues" label="规格属性" min-width="150">
<template #default="scope">
<span v-if="scope.row.specValues && Object.keys(scope.row.specValues).length > 0">
<el-tag v-for="(value, key) in scope.row.specValues" :key="key" size="small" style="margin-right: 4px">
{{ key }}: {{ value }}
</el-tag>
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="price" label="价格" width="100" align="center">
<template #default="scope">
¥{{ (scope.row.price / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="stock" label="库存数量" width="100" align="center">
<template #default="scope">
{{ scope.row.unlimitedStock ? '无限' : scope.row.stock }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
{{ scope.row.status === 1 ? '激活' : '未激活' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="createdAt" label="创建时间" width="160" align="center" />
<el-table-column prop="updatedAt" label="修改时间" width="160" align="center" />
<el-table-column label="操作" width="120" align="center">
<template #default="scope">
<el-button size="small" text type="primary" @click="onEditSku(scope.row)">编辑</el-button>
<el-button size="small" text type="danger" @click="onDeleteSku(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="mt15" style="text-align: right">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="onSizeChange"
@current-change="onCurrentChange"
/>
</div>
<!-- 添加/编辑 SKU 弹窗 -->
<el-dialog v-model="skuFormVisible" :title="isEditSku ? '编辑SKU' : '添加SKU'" width="500px" :close-on-click-modal="false" append-to-body>
<el-form ref="skuFormRef" :model="skuForm" :rules="skuRules" label-width="80px" v-loading="editLoading">
<el-form-item label="资产名称">
<el-input v-model="assetName" disabled />
</el-form-item>
<el-form-item label="SKU名称" prop="skuName">
<el-input v-model="skuForm.skuName" placeholder="请输入SKU名称" />
</el-form-item>
<el-form-item label="规格属性" v-if="assetSpecAttrs.length > 0">
<div class="spec-values-container">
<div v-for="attr in assetSpecAttrs" :key="attr.name" class="spec-item">
<span class="spec-label">{{ attr.name }}</span>
<el-select v-if="attr.options && attr.options.length > 0" v-model="specValuesMap[attr.name]" placeholder="请选择" style="width: 120px" filterable allow-create clearable>
<el-option v-for="opt in attr.options" :key="opt" :label="opt" :value="opt" />
</el-select>
<el-input v-else v-model="specValuesMap[attr.name]" placeholder="请输入" style="width: 120px" />
</div>
</div>
</el-form-item>
<el-form-item label="价格" prop="price">
<el-input-number v-model="skuForm.price" :min="0" :precision="2" :step="0.01" controls-position="right" />
<span style="margin-left: 8px"></span>
</el-form-item>
<el-form-item label="库存数量" prop="stock">
<el-input-number v-model="skuForm.stock" :min="0" :disabled="skuForm.unlimitedStock" controls-position="right" />
<el-checkbox v-model="skuForm.unlimitedStock" style="margin-left: 10px">无限库存</el-checkbox>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="skuForm.status">
<el-radio :value="1">激活</el-radio>
<el-radio :value="0">未激活</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="skuForm.sort" :min="0" controls-position="right" />
<span style="margin-left: 8px; color: #909399; font-size: 12px">数值越小越靠前</span>
</el-form-item>
<el-form-item label="图片">
<el-upload
class="sku-image-uploader"
:show-file-list="false"
:http-request="handleSkuImageUpload"
accept="image/*"
>
<img v-if="skuImagePreview" :src="skuImagePreview" class="sku-image" />
<el-icon v-else class="sku-image-uploader-icon"><ele-Plus /></el-icon>
</el-upload>
<el-button v-if="skuImagePreview" type="danger" text size="small" style="margin-left: 10px" @click="removeSkuImage">删除</el-button>
</el-form-item>
<!-- <el-form-item label="描述">
<el-input v-model="skuForm.description" type="textarea" :rows="3" placeholder="请输入SKU描述" />
</el-form-item> -->
</el-form>
<template #footer>
<el-button @click="skuFormVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="onSubmitSku">确认</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { ElMessageBox } from 'element-plus';
import { listAssetSkus, createAssetSku, updateAssetSku, deleteAssetSku, getAssetSku, getAsset, uploadAssetImage } from '/@/api/assets/asset';
import type { UploadRequestOptions, UploadUserFile } from 'element-plus';
interface SpecValueItem {
key: string;
value: string;
}
interface AssetSpecAttr {
name: string;
options?: string[];
}
const dialogVisible = ref(false);
const loading = ref(false);
const submitLoading = ref(false);
const editLoading = ref(false);
const skuFormVisible = ref(false);
const isEditSku = ref(false);
const editSkuId = ref('');
const assetId = ref('');
const assetName = ref('');
const assetSpecAttrs = ref<AssetSpecAttr[]>([]);
const fileAddressPrefix = ref('');
const skuImagePreview = ref('');
const tableData = ref<any[]>([]);
const total = ref(0);
const queryParams = reactive({
keyword: '',
status: undefined as number | undefined,
page: 1,
pageSize: 10,
});
const skuFormRef = ref<FormInstance>();
const skuForm = reactive({
skuName: '',
price: 0,
stock: 0,
unlimitedStock: false,
status: 1,
sort: 0,
imageUrl: '',
description: '',
});
const specValuesList = ref<SpecValueItem[]>([{ key: '', value: '' }]);
const specValuesMap = reactive<Record<string, string>>({});
const skuRules: FormRules = {
skuName: [{ required: true, message: '请输入SKU名称', trigger: 'blur' }],
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
stock: [{ required: true, message: '请输入库存数量', trigger: 'blur' }],
};
// 打开弹窗
const openDialog = (row: { id: string; name: string }) => {
assetId.value = row.id;
assetName.value = row.name;
dialogVisible.value = true;
resetQuery();
getSkuList();
fetchAssetSpecAttrs();
};
// 获取资产规格属性(只获取多选类型)
const fetchAssetSpecAttrs = () => {
getAsset(assetId.value)
.then((res: any) => {
const data = res.data || {};
// 设置图片前缀
fileAddressPrefix.value = data.fileAddressPrefix || data.imgAddressPrefix || '';
// 从资产详情中获取规格属性列表
if (data.specAttrs && Array.isArray(data.specAttrs)) {
assetSpecAttrs.value = data.specAttrs;
} else if (data.metadata && Array.isArray(data.metadata)) {
// 只获取 multi_select 类型的属性
assetSpecAttrs.value = data.metadata
.filter((item: any) => item.type === 'multi_select')
.map((item: any) => ({
name: item.name,
options: item.options?.map((opt: any) => opt.label || opt.value) || [],
}));
} else {
assetSpecAttrs.value = [];
}
})
.catch(() => {
assetSpecAttrs.value = [];
});
};
// 获取 SKU 列表
const getSkuList = () => {
loading.value = true;
listAssetSkus({
assetId: assetId.value,
...queryParams,
})
.then((res: any) => {
tableData.value = res.data?.list ?? [];
total.value = res.data?.total ?? 0;
})
.catch(() => {
tableData.value = [];
total.value = 0;
})
.finally(() => {
loading.value = false;
});
};
// 重置查询
const resetQuery = () => {
queryParams.keyword = '';
queryParams.status = undefined;
queryParams.page = 1;
};
const onResetQuery = () => {
resetQuery();
getSkuList();
};
// 分页
const onSizeChange = (size: number) => {
queryParams.pageSize = size;
queryParams.page = 1;
getSkuList();
};
const onCurrentChange = (page: number) => {
queryParams.page = page;
getSkuList();
};
// 打开添加 SKU 弹窗
const onOpenAddSku = () => {
isEditSku.value = false;
resetSkuForm();
skuFormVisible.value = true;
};
// 编辑 SKU
const onEditSku = async (row: any) => {
isEditSku.value = true;
editSkuId.value = row.id || '';
resetSkuForm();
skuFormVisible.value = true;
editLoading.value = true;
try {
const res: any = await getAssetSku(row.id);
const data = res.data || {};
skuForm.skuName = data.skuName || '';
skuForm.price = (data.price || 0) / 100;
skuForm.stock = data.stock || 0;
skuForm.unlimitedStock = data.unlimitedStock || false;
skuForm.status = data.status ?? 1;
skuForm.sort = data.sort || 0;
skuForm.imageUrl = data.imageUrl || '';
skuForm.description = data.description || '';
// 图片预览回显
if (data.imageUrl) {
skuImagePreview.value = formatImageUrl(data.imageUrl);
}
// 处理规格属性
if (data.specValues && Object.keys(data.specValues).length > 0) {
specValuesList.value = Object.entries(data.specValues).map(([key, value]) => ({
key,
value: String(value),
}));
// 回显到 specValuesMap
Object.entries(data.specValues).forEach(([key, value]) => {
specValuesMap[key] = String(value);
});
} else {
specValuesList.value = [{ key: '', value: '' }];
}
} catch (error) {
skuFormVisible.value = false;
} finally {
editLoading.value = false;
}
};
// 删除 SKU
const onDeleteSku = (row: any) => {
ElMessageBox.confirm(`确定要删除SKU "${row.skuName}" 吗?`, '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
deleteAssetSku(row.id).then(() => {
ElMessage.success('删除成功');
getSkuList();
});
})
.catch(() => {});
};
// 重置 SKU 表单
const resetSkuForm = () => {
skuForm.skuName = '';
skuForm.price = 0;
skuForm.stock = 0;
skuForm.unlimitedStock = false;
skuForm.status = 1;
skuForm.sort = 0;
skuForm.imageUrl = '';
skuForm.description = '';
specValuesList.value = [{ key: '', value: '' }];
// 清空 specValuesMap
Object.keys(specValuesMap).forEach((key) => delete specValuesMap[key]);
skuImagePreview.value = '';
skuFormRef.value?.clearValidate();
};
// 格式化图片 URL
const formatImageUrl = (url?: string) => {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
if (/^blob:/i.test(url)) return url;
return `${fileAddressPrefix.value || ''}${url}`;
};
// 上传图片
const uploadImage = async (file: File): Promise<string> => {
const res: any = await uploadAssetImage(file);
if (res.fileAddressPrefix) {
fileAddressPrefix.value = res.fileAddressPrefix;
} else if (res.data && typeof res.data === 'object' && res.data.fileAddressPrefix) {
fileAddressPrefix.value = res.data.fileAddressPrefix;
}
if (res.fileURL) return res.fileURL;
if (res.data && typeof res.data === 'object') {
if (res.data.fileURL) return res.data.fileURL;
if (res.data.url) return res.data.url;
}
if (typeof res.data === 'string') return res.data;
return '';
};
// 处理 SKU 图片上传
const handleSkuImageUpload = async (options: UploadRequestOptions) => {
try {
const url = await uploadImage(options.file);
if (url) {
skuForm.imageUrl = url;
skuImagePreview.value = formatImageUrl(url);
}
} catch (error) {
ElMessage.error('图片上传失败');
}
};
// 删除 SKU 图片
const removeSkuImage = () => {
skuForm.imageUrl = '';
skuImagePreview.value = '';
};
// 获取属性的可选值
const getAttrOptions = (key: string): string[] => {
if (!key) return [];
const attr = assetSpecAttrs.value.find((a) => a.name === key);
return attr?.options || [];
};
// 属性名称变更时清空属性值
const onSpecKeyChange = (index: number) => {
specValuesList.value[index].value = '';
};
// 添加规格属性
const addSpecValue = () => {
specValuesList.value.push({ key: '', value: '' });
};
// 删除规格属性
const removeSpecValue = (index: number) => {
specValuesList.value.splice(index, 1);
};
// 提交 SKU
const onSubmitSku = async () => {
const form = skuFormRef.value;
if (!form) return;
await form.validate(async (valid) => {
if (!valid) return;
submitLoading.value = true;
// 构建规格属性对象(优先使用 specValuesMap
const specValues: Record<string, string> = {};
// 从 specValuesMap 获取(直接展示的属性)
Object.entries(specValuesMap).forEach(([key, value]) => {
if (key && value) {
specValues[key] = value;
}
});
// 兼容旧的 specValuesList
specValuesList.value.forEach((item) => {
if (item.key.trim() && !specValues[item.key.trim()]) {
specValues[item.key.trim()] = item.value.trim();
}
});
const data = {
assetId: assetId.value,
assetName: assetName.value,
skuName: skuForm.skuName,
imageUrl: skuForm.imageUrl || undefined,
specValues: Object.keys(specValues).length > 0 ? specValues : undefined,
price: Math.round(skuForm.price * 100),
unlimitedStock: skuForm.unlimitedStock,
stock: skuForm.stock,
sort: skuForm.sort,
status: skuForm.status,
description:skuForm.description,
};
try {
if (isEditSku.value) {
await updateAssetSku({ ...data, id: editSkuId.value });
} else {
await createAssetSku(data);
}
ElMessage.success(isEditSku.value ? '编辑成功' : '添加成功');
skuFormVisible.value = false;
getSkuList();
} catch (error) {
// 错误已由拦截器处理
} finally {
submitLoading.value = false;
}
});
};
// 关闭弹窗
const onClose = () => {
tableData.value = [];
total.value = 0;
resetQuery();
};
defineExpose({
openDialog,
});
</script>
<style scoped lang="scss">
.sku-search {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.spec-values-container {
.spec-item {
display: flex;
align-items: center;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.spec-label {
min-width: 60px;
color: #606266;
font-size: 14px;
}
}
.sku-image-uploader {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: var(--el-color-primary);
}
}
}
.sku-image-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.sku-image {
width: 100px;
height: 100px;
object-fit: cover;
}
</style>

View File

@@ -84,6 +84,7 @@
</el-card>
</div>
<EditAsset ref="editAssetRef" @getAssetList="getAssetList" />
<SkuDialog ref="skuDialogRef" />
</div>
</template>
@@ -98,6 +99,7 @@ import { ref, reactive, onMounted } from 'vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import { listAssets, updateAssetStatus, deleteAsset } from '/@/api/assets/asset';
import EditAsset from './component/editAsset.vue';
import SkuDialog from './component/skuDialog.vue';
interface AssetRow {
id: string;
@@ -115,6 +117,7 @@ interface AssetRow {
}
const editAssetRef = ref();
const skuDialogRef = ref();
const tableData = reactive({
data: [] as AssetRow[],
@@ -217,10 +220,9 @@ const onEdit = (row: AssetRow) => {
editAssetRef.value.openDialog(row, true);
};
// 添加SKU(待定)
// 管理SKU
const onAddSku = (row: AssetRow) => {
ElMessage.info('添加SKU功能待开发');
console.log('添加SKU:', row);
skuDialogRef.value.openDialog(row);
};
// 分页大小改变