Files
admin-ui/src/views/assets/asset/component/skuDialog.vue

588 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>