588 lines
17 KiB
Vue
588 lines
17 KiB
Vue
<template>
|
||
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1000px" :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.pageNum"
|
||
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="图片" prop="imageUrl" required>
|
||
<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[];
|
||
dictType?: 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,
|
||
pageNum: 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' },
|
||
{ type: 'number', min: 0.01, message: '价格必须大于0', trigger: 'blur' }
|
||
],
|
||
stock: [
|
||
{ required: true, message: '请输入库存数量', trigger: 'blur' },
|
||
{
|
||
validator: (rule: any, value: any, callback: any) => {
|
||
if (skuForm.unlimitedStock) {
|
||
callback();
|
||
} else if (value === 0 || value === undefined || value === null) {
|
||
callback(new Error('库存数量必须大于0'));
|
||
} else {
|
||
callback();
|
||
}
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
],
|
||
imageUrl: [{ required: true, message: '请上传SKU图片', trigger: 'change' }],
|
||
|
||
};
|
||
|
||
// 打开弹窗
|
||
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) || [],
|
||
dictType: item.dictType || '',
|
||
}));
|
||
} 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.pageNum = 1;
|
||
};
|
||
|
||
const onResetQuery = () => {
|
||
resetQuery();
|
||
getSkuList();
|
||
};
|
||
|
||
// 分页
|
||
const onSizeChange = (size: number) => {
|
||
queryParams.pageSize = size;
|
||
queryParams.pageNum = 1;
|
||
getSkuList();
|
||
};
|
||
|
||
const onCurrentChange = (pageNum: number) => {
|
||
queryParams.pageNum = pageNum;
|
||
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);
|
||
skuFormRef.value?.validateField('imageUrl');
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('图片上传失败');
|
||
}
|
||
};
|
||
|
||
// 删除 SKU 图片
|
||
const removeSkuImage = () => {
|
||
skuForm.imageUrl = '';
|
||
skuImagePreview.value = '';
|
||
skuFormRef.value?.validateField('imageUrl');
|
||
};
|
||
|
||
// 获取属性的可选值
|
||
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;
|
||
|
||
// 校验规格属性必填
|
||
if (assetSpecAttrs.value.length > 0) {
|
||
for (const attr of assetSpecAttrs.value) {
|
||
if (!specValuesMap[attr.name] || specValuesMap[attr.name].trim() === '') {
|
||
ElMessage.warning(`请填写规格属性:${attr.name}`);
|
||
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>
|