Merge pull request 'feat(shop): product editor SKU variants UI' (#58) from worktree-shop-variant-editor into main
Reviewed-on: CYL/wordpress-theme-aksrt#58
This commit is contained in:
@@ -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('变体数据格式错误');
|
||||
|
||||
Reference in New Issue
Block a user