feat(shop): product editor SKU variants UI

Wire ProductEditorDrawer to existing /shop/products/{id}/variants REST
for list/save. Support attribute text parsing, batch replace save, and
optional save-variants-only. Align save endpoint JSON body parsing with
controller helper.
This commit is contained in:
2026-07-11 18:27:52 +08:00
parent ee3e3f912f
commit 387486f49d
2 changed files with 189 additions and 6 deletions
@@ -1,5 +1,5 @@
<template>
<n-drawer :show="show" :width="560" @update:show="$emit('update:show', $event)">
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
<n-drawer-content :title="isEdit ? '编辑商品' : '新增商品'" closable>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<!-- 基础信息 -->
@@ -110,6 +110,51 @@
<n-form-item label="状态" path="status">
<n-select v-model:value="form.status" :options="statusOptions" />
</n-form-item>
<!-- SKU / 变体(编辑已有商品时可用;新建时先创建再编辑变体) -->
<n-divider title-placement="left">SKU 变体</n-divider>
<n-alert v-if="!isEdit" type="info" :bordered="false" style="margin-bottom:12px;">
新建商品请先保存,再编辑时配置 SKU 变体。
</n-alert>
<template v-else>
<n-space style="margin-bottom:12px;">
<n-button size="small" @click="addVariantRow">
<template #icon><i class="fas fa-plus" /></template>
添加变体
</n-button>
<n-button size="small" type="primary" ghost :loading="savingVariants" @click="saveVariantsOnly">
仅保存变体
</n-button>
<n-text depth="3" style="font-size:12px;">属性格式:名称:值,多项用分号分隔,如 颜色:红;尺寸:L</n-text>
</n-space>
<n-spin :show="loadingVariants">
<n-empty v-if="!variants.length" description="暂无变体,点击「添加变体」" style="margin:12px 0;" />
<div v-for="(row, index) in variants" :key="row._key || row.id || index" class="aksrt-variant-row">
<n-grid :cols="24" :x-gap="8" :y-gap="8">
<n-gi :span="6">
<n-input v-model:value="row.sku" placeholder="SKU" size="small" />
</n-gi>
<n-gi :span="8">
<n-input v-model:value="row.attrsText" placeholder="颜色:红;尺寸:L" size="small" />
</n-gi>
<n-gi :span="4">
<n-input-number v-model:value="row.price" :min="0" :precision="2" size="small" style="width:100%;" placeholder="价格" />
</n-gi>
<n-gi :span="4">
<n-input-number v-model:value="row.stock" :min="0" size="small" style="width:100%;" placeholder="库存" />
</n-gi>
<n-gi :span="2">
<n-button size="small" quaternary type="error" @click="removeVariantRow(index)">
<i class="fas fa-trash" />
</n-button>
</n-gi>
<n-gi :span="22">
<n-input v-model:value="row.image" placeholder="变体图片 URL(可选)" size="small" />
</n-gi>
</n-grid>
</div>
</n-spin>
</template>
</n-form>
<template #footer>
@@ -139,6 +184,10 @@ const message = useMessage()
const formRef = ref(null)
const saving = ref(false)
const loadingTaxonomy = ref(false)
const loadingVariants = ref(false)
const savingVariants = ref(false)
const variants = ref([])
let variantKeySeq = 1
const isEdit = computed(() => !!props.productId)
@@ -260,6 +309,122 @@ function onTagsUpdate(values) {
})
}
function attributesToText(attrs) {
if (!Array.isArray(attrs) || !attrs.length) return ''
return attrs
.map((item) => {
if (!item || typeof item !== 'object') return ''
const name = String(item.name ?? item.key ?? '').trim()
const value = String(item.value ?? '').trim()
if (!name && !value) return ''
return name ? `${name}:${value}` : value
})
.filter(Boolean)
.join(';')
}
function textToAttributes(text) {
const raw = String(text || '').trim()
if (!raw) return []
return raw
.split(/[;;\n]+/)
.map(part => part.trim())
.filter(Boolean)
.map((part) => {
const sep = part.includes(':') ? ':' : (part.includes(':') ? ':' : null)
if (!sep) return { name: '规格', value: part }
const [name, ...rest] = part.split(sep)
return { name: (name || '规格').trim(), value: rest.join(sep).trim() }
})
.filter(item => item.value !== '')
}
function mapVariantFromApi(row) {
return {
_key: `v_${variantKeySeq++}`,
id: row.id || 0,
sku: row.sku || '',
price: Number(row.price || 0),
stock: Number(row.stock || 0),
image: row.image || '',
attrsText: attributesToText(row.attributes),
}
}
function buildVariantsPayload() {
return variants.value.map((row, index) => ({
sku: String(row.sku || '').trim(),
price: Number(row.price || 0),
stock: Math.max(0, parseInt(row.stock, 10) || 0),
image: String(row.image || '').trim(),
attributes: textToAttributes(row.attrsText),
sort_order: index,
}))
}
function addVariantRow() {
variants.value = [
...variants.value,
{
_key: `v_${variantKeySeq++}`,
id: 0,
sku: '',
price: Number(form.price || 0),
stock: Number(form.stock || 0),
image: '',
attrsText: '',
},
]
}
function removeVariantRow(index) {
variants.value = variants.value.filter((_, i) => i !== index)
}
async function loadVariants(productId) {
if (!productId) {
variants.value = []
return
}
loadingVariants.value = true
try {
const res = await api.request(`/shop/products/${productId}/variants`)
const list = res?.data?.variants || res?.variants || []
variants.value = Array.isArray(list) ? list.map(mapVariantFromApi) : []
} catch {
variants.value = []
message.warning('变体加载失败')
} finally {
loadingVariants.value = false
}
}
async function persistVariants(productId) {
if (!productId) return false
await api.request(`/shop/products/${productId}/variants`, {
method: 'POST',
data: { variants: buildVariantsPayload() },
})
return true
}
async function saveVariantsOnly() {
if (!props.productId) {
message.warning('请先保存商品')
return
}
savingVariants.value = true
try {
await persistVariants(props.productId)
message.success('变体已保存')
await loadVariants(props.productId)
} catch (err) {
message.error(err?.message || '变体保存失败')
} finally {
savingVariants.value = false
}
}
// Load product data when editing
watch(() => props.show, async (val) => {
if (val) {
@@ -286,16 +451,17 @@ watch(() => props.show, async (val) => {
support_refund: res.data.support_refund !== false,
})
}
await loadVariants(props.productId)
} catch {
message.error('加载商品失败')
}
} else {
// Reset form for create
Object.assign(form, {
title: '', price: 0, stock: 0, excerpt: '', content: '', status: 'publish',
image: '', galleryText: '', categories: [], tags: [],
shipping_mode: 'digital', shipping_fee: 0, after_sale_policy: '', support_refund: true,
})
variants.value = []
}
}
})
@@ -321,14 +487,19 @@ async function handleSave() {
if (isEdit.value) {
await api.request(`/shop/products/${props.productId}`, {
method: 'POST',
body: JSON.stringify(payload),
data: payload,
})
await persistVariants(props.productId)
message.success('商品已更新')
} else {
await api.request('/shop/products', {
const res = await api.request('/shop/products', {
method: 'POST',
body: JSON.stringify(payload),
data: payload,
})
const productId = res?.data?.id || res?.id || null
if (productId && variants.value.length) {
await persistVariants(productId)
}
message.success('商品已创建')
}
emit('update:show', false)
@@ -340,3 +511,13 @@ async function handleSave() {
}
}
</script>
<style scoped>
.aksrt-variant-row {
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--n-border-color, #e5e7eb);
border-radius: 8px;
background: var(--n-color-modal, #fafafa);
}
</style>
@@ -31,7 +31,9 @@ trait AKSRT_Admin_Shop_Controller_Variant_Export_Operations {
return $this->error('商品不存在', 404);
}
$params = $request->get_json_params() ?? [];
$params = method_exists($this, 'get_json_params')
? $this->get_json_params($request)
: ($request->get_json_params() ?? []);
$variants = $params['variants'] ?? [];
if (!is_array($variants)) {
return $this->error('变体数据格式错误');