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:
commit
9c2d0daae7
2 files changed
+189
-6
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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-drawer-content :title="isEdit ? '编辑商品' : '新增商品'" closable>
|
||||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
|
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||||
<!-- 基础信息 -->
|
<!-- 基础信息 -->
|
||||||
@@ -110,6 +110,51 @@
|
|||||||
<n-form-item label="状态" path="status">
|
<n-form-item label="状态" path="status">
|
||||||
<n-select v-model:value="form.status" :options="statusOptions" />
|
<n-select v-model:value="form.status" :options="statusOptions" />
|
||||||
</n-form-item>
|
</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>
|
</n-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -139,6 +184,10 @@ const message = useMessage()
|
|||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const loadingTaxonomy = 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)
|
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
|
// Load product data when editing
|
||||||
watch(() => props.show, async (val) => {
|
watch(() => props.show, async (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
@@ -286,16 +451,17 @@ watch(() => props.show, async (val) => {
|
|||||||
support_refund: res.data.support_refund !== false,
|
support_refund: res.data.support_refund !== false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await loadVariants(props.productId)
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载商品失败')
|
message.error('加载商品失败')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset form for create
|
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
title: '', price: 0, stock: 0, excerpt: '', content: '', status: 'publish',
|
title: '', price: 0, stock: 0, excerpt: '', content: '', status: 'publish',
|
||||||
image: '', galleryText: '', categories: [], tags: [],
|
image: '', galleryText: '', categories: [], tags: [],
|
||||||
shipping_mode: 'digital', shipping_fee: 0, after_sale_policy: '', support_refund: true,
|
shipping_mode: 'digital', shipping_fee: 0, after_sale_policy: '', support_refund: true,
|
||||||
})
|
})
|
||||||
|
variants.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -321,14 +487,19 @@ async function handleSave() {
|
|||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await api.request(`/shop/products/${props.productId}`, {
|
await api.request(`/shop/products/${props.productId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
data: payload,
|
||||||
})
|
})
|
||||||
|
await persistVariants(props.productId)
|
||||||
message.success('商品已更新')
|
message.success('商品已更新')
|
||||||
} else {
|
} else {
|
||||||
await api.request('/shop/products', {
|
const res = await api.request('/shop/products', {
|
||||||
method: 'POST',
|
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('商品已创建')
|
message.success('商品已创建')
|
||||||
}
|
}
|
||||||
emit('update:show', false)
|
emit('update:show', false)
|
||||||
@@ -340,3 +511,13 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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);
|
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'] ?? [];
|
$variants = $params['variants'] ?? [];
|
||||||
if (!is_array($variants)) {
|
if (!is_array($variants)) {
|
||||||
return $this->error('变体数据格式错误');
|
return $this->error('变体数据格式错误');
|
||||||
|
|||||||
Reference in new issue
Block a user