merge: feature/goods-dialog-split-dev into develop (local only, not pushed)

This commit is contained in:
yeuimu
2026-08-28 16:40:48 +08:00
14 changed files with 1719 additions and 1441 deletions
-1
View File
@@ -42,7 +42,6 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
+28
View File
@@ -0,0 +1,28 @@
import type { CategoryTree } from '@/types'
/** 在分类树中找目标分类的祖先路径(含自身) */
export function findCategoryPath(nodes: CategoryTree[], targetId: string): string[] {
for (const n of nodes) {
if (n.id === targetId) return [n.id]
if (n.children?.length) {
const sub = findCategoryPath(n.children, targetId)
if (sub.length) return [n.id, ...sub]
}
}
return []
}
export interface CascadeNode {
value: string
label: string
children?: CascadeNode[]
}
/** 分类树 → el-cascader 选项 */
export function buildCascader(tree: CategoryTree[]): CascadeNode[] {
return tree.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
+3 -3
View File
@@ -89,10 +89,10 @@ describe('deriveLinkTagNames', () => {
]); ]);
}); });
it('不打印 + 光板 + 不包邮 → 两工艺标签 + 不包邮', () => { it('不打印 + 光板 + 不包邮 → 归并为单个不打印(光板即不打印)', () => {
expect( expect(
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toEqual(['不打印', '光板', '不包邮']); ).toEqual(['不打印', '不包邮']);
}); });
it('双面印花 + 不包邮,且不误命中包邮', () => { it('双面印花 + 不包邮,且不误命中包邮', () => {
@@ -117,7 +117,7 @@ describe('linkDims', () => {
}); });
expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({ expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({
logistics: '不包邮光板', logistics: '不包邮光板',
crafts: ['不打印', '光板'], crafts: ['不打印'],
printCount: null, printCount: null,
}); });
}); });
+8 -4
View File
@@ -50,7 +50,11 @@ export function cleanLinkName(name: string | null | undefined): string {
return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName; return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName;
} }
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const; const CRAFT_KEYWORD_MAP: Record<string, string> = {
: '直喷',
: '不打印',
: '不打印', // 光板即为不打印
};
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */ /** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
export interface LinkDims { export interface LinkDims {
@@ -68,7 +72,7 @@ export function linkDims(name: string | null | undefined): LinkDims {
const closeIdx = Math.max(head.lastIndexOf(''), head.lastIndexOf(')')); const closeIdx = Math.max(head.lastIndexOf(''), head.lastIndexOf(')'));
const logistics = const logistics =
openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null; openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null;
const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k)); const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
const printCount = name.includes('双面印花') const printCount = name.includes('双面印花')
? '双面印花' ? '双面印花'
: name.includes('单面印花') : name.includes('单面印花')
@@ -83,7 +87,7 @@ export function linkDims(name: string | null | undefined): LinkDims {
/** /**
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示): * 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画; * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
* 物流:不包邮 优先于 包邮。 * 物流:不包邮 优先于 包邮。
*/ */
export function deriveLinkTagNames(name: string | null | undefined): string[] { export function deriveLinkTagNames(name: string | null | undefined): string[] {
@@ -91,7 +95,7 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
const names: string[] = []; const names: string[] = [];
if (name.includes('双面印花')) names.push('双面印花'); if (name.includes('双面印花')) names.push('双面印花');
else if (name.includes('单面印花')) names.push('单面印花'); else if (name.includes('单面印花')) names.push('单面印花');
const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k)); const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
if (craftHits.length > 0) names.push(...craftHits); if (craftHits.length > 0) names.push(...craftHits);
else names.push('烫画'); else names.push('烫画');
if (name.includes('不包邮')) names.push('不包邮'); if (name.includes('不包邮')) names.push('不包邮');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { countriesApi } from '@/api/countries'
import { buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'created', good: any): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const loading = ref(false)
const form = ref({
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
goodPriority: 0,
})
watch(() => props.visible, (v) => {
if (v) {
form.value = {
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
}
}
})
const categoryCascader = computed(() => buildCascader(props.categories))
function onCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
form.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) form.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
async function handleSubmit() {
if (!form.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
if (!form.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!form.value.categoryId) { ElMessage.warning('请选择分类'); return }
loading.value = true
try {
const created = await goodsApi.createCustomGood({
goodName: form.value.goodName.trim(),
goodImage: form.value.goodImage || undefined,
goodPrice: form.value.goodPrice || null,
countryId: Number(form.value.countryId),
categoryId: Number(form.value.categoryId),
tagIds: form.value.tagIds.map(Number),
goodPriority: form.value.goodPriority,
detail: {},
})
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
emit('update:visible', false)
emit('created', created)
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
} finally { loading.value = false }
}
</script>
<template>
<el-dialog :model-value="visible" title="新增自定义商品" width="620px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-alert
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
type="info"
:closable="false"
style="margin-bottom:16px"
/>
<el-form label-width="90px">
<el-form-item label="商品名称" required><el-input v-model="form.goodName" /></el-form-item>
<el-form-item label="商品图片"><ImageUpload v-model="form.goodImage" label="上传图片" /></el-form-item>
<el-form-item label="基础价格"><el-input v-model="form.goodPrice" placeholder="例如 28.00" /></el-form-item>
<el-form-item label="国家" required>
<div class="select-inline">
<el-select v-model="form.countryId" filterable placeholder="请选择国家" style="width:100%">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类" required>
<el-cascader v-model="form.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="form.goodPriority" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">创建并完善详情</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { CategoryTree, Country } from '@/types'
import { goodsApi } from '@/api/goods'
import { productFamiliesApi } from '@/api/product-families'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
/** 拖拽/配置的链接节点(含 rawId/goodName/goodImage/goodPrice/sdsGoodId/familyId */
og: any | null
/** 同分类下的可合并兄弟链接 */
siblings: any[]
/** 默认勾选的兄弟 rawId */
defaultCheckedIds: string[]
/** 左树拖放目标(国家/分类节点),为空时表单手动选 */
dropTarget: any | null
mode: 'category' | 'country' | 'global'
countries: Country[]
categories: CategoryTree[]
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'configured'): void
}>()
const loading = ref(false)
const form = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '', goodImage: '',
})
watch(() => props.visible, (v) => {
if (!v) return
const og = props.og
form.value = { countryId: '', cascaderCategory: [], categoryId: '', goodImage: og?.goodImage || '' }
if (props.dropTarget) {
if (props.mode === 'category') {
form.value.categoryId = props.dropTarget.id
form.value.cascaderCategory = findCategoryPath(props.categories, props.dropTarget.id)
} else {
form.value.countryId = props.dropTarget.id
}
}
})
const categoryCascader = computed(() => buildCascader(props.categories))
const checkedIds = ref<string[]>([])
watch(() => props.visible, (v) => {
if (v) checkedIds.value = [...props.defaultCheckedIds]
})
function onCascaderChange(val: any) {
form.value.categoryId = val.length ? val[val.length - 1] : ''
}
/** 勾选的兄弟链接与当前链接静默归入同一族(无族则自动建族);失败不阻断原配置流程 */
async function ensureFamilyMembership() {
const og = props.og
const primaryId = String(og.rawId)
const checked = checkedIds.value.filter((id) => id !== primaryId)
if (!checked.length) return
try {
if (og.familyId) {
await productFamiliesApi.updateMembers(og.familyId, {
addOriginGoodIds: [...new Set([primaryId, ...checked])],
})
} else {
await productFamiliesApi.create({
familyName: og.goodName ?? '',
originGoodIds: [primaryId, ...checked],
primaryOriginGoodId: primaryId,
})
}
} catch (e: any) {
ElMessage.warning(e?.response?.data?.message || '挂族失败,商品仍按原方式配置')
}
}
async function handleSubmit() {
const og = props.og
if (!form.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!form.value.categoryId) { ElMessage.warning('请选择分类'); return }
loading.value = true
try {
await ensureFamilyMembership()
await goodsApi.createGood({
goodName: og.goodName,
goodImage: form.value.goodImage || undefined,
originGoodId: Number(og.rawId),
countryId: Number(form.value.countryId),
categoryId: Number(form.value.categoryId),
positionId: undefined,
} as any)
ElMessage.success('配置成功')
emit('update:visible', false)
emit('configured')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '配置失败')
} finally { loading.value = false }
}
</script>
<template>
<el-dialog :model-value="visible" title="配置原产品" width="520px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="og" class="config-og-info">
<div>
<div class="config-og-name">{{ og.goodName }}</div>
<div class="config-og-meta">SDS ID: {{ og.sdsGoodId }}<template v-if="og.goodPrice"> · 价格: ¥{{ og.goodPrice }}</template></div>
</div>
</div>
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家">
<div v-if="mode === 'category' || !dropTarget" class="select-inline">
<el-select v-model="form.countryId" placeholder="请选择国家" filterable>
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
</div>
<el-tag v-else>{{ countries.find(c => c.id === form.countryId)?.countryName }}</el-tag>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-if="mode === 'country' || !dropTarget" v-model="form.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onCascaderChange" style="width:100%" />
<el-tag v-else>{{ dropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item v-if="siblings.length" label="合并同名">
<div class="config-merge-box">
<div class="config-merge-tip">勾选同分类下同名不同工厂/仓库原产品与当前链接归入同一族尺码/包装并集 + 价格矩阵</div>
<el-checkbox-group v-model="checkedIds">
<el-checkbox v-for="s in siblings" :key="s.rawId" :value="String(s.rawId)">
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
</el-checkbox>
</el-checkbox-group>
</div>
</el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="form.goodImage" label="上传图片" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">确认配置</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
.config-og-name { font-weight: 600; font-size: 14px; }
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
.config-merge-box { width: 100%; }
.config-merge-tip { color: #909399; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 160px; overflow-y: auto; }
</style>
@@ -0,0 +1,806 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowDown, QuestionFilled } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Good, GoodDetail, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { originGoodsApi } from '@/api/origin-goods'
import { productFamiliesApi } from '@/api/product-families'
import { countriesApi } from '@/api/countries'
import { syncApi } from '@/api/sync'
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
goodId: string
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
/** 商品数据变化(保存/同步/族变更),父级刷新左右树 */
(e: 'changed'): void
(e: 'deleted'): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const editLoading = ref(false)
const editDetailLoading = ref(false)
const detailSyncing = ref(false)
const editGood = ref<Good | GoodDetail | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', positionId: '',
})
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
const editVariants = computed(() => (editGood.value as GoodDetail | null)?.variants ?? [])
const editSizeColumns = computed(() => editOriginDetail.value?.sizeChart?.columns ?? [])
const editSizeRows = computed(() => {
const rows = editOriginDetail.value?.sizeChart?.rows ?? []
return rows.map((row: any) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
}))
})
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
/** 商品自身链接的定价维度(SKU 表列) */
const editLinkDims = computed(() => linkDims(editGood.value?.originGood?.goodName ?? null))
const categoryCascader = computed(() => buildCascader(props.categories))
// ─── 自定义商品内容编辑 ───
const customContentForm = ref({
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
minWeightG: '', productionProcess: '', materialDescription: '',
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
designExplanation: '', designArea: '', pictureRequest: '',
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
packageSpecsJson: '{\n "rows": []\n}',
optionsJson: '{}',
mediaJson: '{}',
variants: [] as Array<{
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
}>,
})
function fillCustomContent(g: GoodDetail) {
const detail = g.originDetail ?? {}
customContentForm.value = {
goodPrice: g.originGood?.goodPrice ?? '',
productCode: String(detail.productCode ?? ''),
englishName: String(detail.englishName ?? ''),
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
minWeightG: String(detail.minWeightG ?? ''),
productionProcess: String(detail.productionProcess ?? ''),
materialDescription: String(detail.materialDescription ?? ''),
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
textureName: String(detail.textureName ?? ''),
reminder: String(detail.reminder ?? ''),
productPerformance: String(detail.productPerformance ?? ''),
applicableScenarios: String(detail.applicableScenarios ?? ''),
washingInstructions: String(detail.washingInstructions ?? ''),
specialDescription: String(detail.specialDescription ?? ''),
designExplanation: String(detail.designExplanation ?? ''),
designArea: String(detail.designArea ?? ''),
pictureRequest: String(detail.pictureRequest ?? ''),
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
variants: g.variants.map((variant) => ({
sku: variant.sku,
sizeId: String(variant.sizeId ?? ''),
sizeName: String(variant.sizeName ?? ''),
colorId: String(variant.colorId ?? ''),
colorName: String(variant.colorName ?? ''),
colorHex: String(variant.colorHex ?? ''),
imageUrl: String(variant.imageUrl ?? ''),
price: String(variant.price ?? ''),
originalPrice: String(variant.originalPrice ?? ''),
weightG: String(variant.weightG ?? ''),
boxLengthCm: String(variant.boxLengthCm ?? ''),
boxWidthCm: String(variant.boxWidthCm ?? ''),
boxHeightCm: String(variant.boxHeightCm ?? ''),
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
enabled: variant.enabled,
})),
}
}
function addCustomVariant() {
customContentForm.value.variants.push({
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
})
}
// ─── 关联原产品(族成员,可展开查看详情 / 配标签 / 改价格) ───
interface FamilyMemberRow {
id: string
goodName: string
goodImage: string | null
source: string
delisted: boolean
sdsGoodId: string
goodPrice: string | null
variantCount: number
logisticsLabel: string | null
craftLabel: string | null
warehouseLabel: string | null
tagsManual: boolean
tags: Array<{ id: string; tagName: string; tagColor: string | null; manual: boolean }>
}
interface MemberPriceCell {
sizeId: string
colorId: string
sizeName: string | null
colorName: string | null
craft: string
logistics: string
price: string
manual: boolean
editPrice: string
}
const editFamilyId = ref<string>('')
const editFamilyCode = ref<string | null>(null)
const editFamilyMembers = ref<FamilyMemberRow[]>([])
const familyMemberCount = ref<number | null>(null)
const editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('')
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
const expandedMemberKeys = ref<Set<string>>(new Set())
const memberTagEdits = ref<Record<string, string[]>>({})
const memberTagSaving = ref<string>('')
/** 成员展开面板的 尺码×颜色→价格 编辑态(key = og id */
const memberPrices = ref<Record<string, MemberPriceCell[]>>({})
const memberPriceSaving = ref<string>('')
/** 族原始详情(含 priceMatrix */
const familyDetailRaw = ref<any>(null)
function toggleMemberExpand(key: string) {
const next = new Set(expandedMemberKeys.value)
if (next.has(key)) next.delete(key)
else {
next.add(key)
loadMemberPrices(key)
}
expandedMemberKeys.value = next
}
/** 该链接在族价格矩阵中的格子(craft/logistics 即该链接的标签维度) */
function loadMemberPrices(ogId: string) {
const row = editFamilyMembers.value.find((m) => m.id === ogId)
if (!row?.craftLabel || !row.logisticsLabel) {
memberPrices.value[ogId] = []
return
}
const matrixRows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
memberPrices.value[ogId] = matrixRows
.filter((r: any) => r.craft === row.craftLabel && r.logistics === row.logisticsLabel)
.map((r: any) => ({
sizeId: String(r.sizeId),
colorId: String(r.colorId),
sizeName: r.sizeName ?? null,
colorName: r.colorName ?? null,
craft: r.craft,
logistics: r.logistics,
price: String(r.price ?? ''),
manual: Boolean(r.manual),
editPrice: String(r.price ?? ''),
}))
}
async function saveMemberPrices(row: { id: string }) {
const cells = (memberPrices.value[row.id] ?? []).filter((c) => c.editPrice !== '' && c.editPrice !== c.price)
if (!cells.length) { ElMessage.info('价格没有修改'); return }
memberPriceSaving.value = row.id
try {
await productFamiliesApi.putOverrides(editFamilyId.value, cells.map((c) => ({
sizeId: c.sizeId, colorId: c.colorId, craft: c.craft, logistics: c.logistics, price: c.editPrice,
})))
ElMessage.success('价格已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberPriceSaving.value = '' }
}
async function resetMemberPrice(row: { id: string }, cell: any) {
memberPriceSaving.value = row.id
try {
await productFamiliesApi.deleteOverrides(editFamilyId.value, [
{ sizeId: cell.sizeId, colorId: cell.colorId, craft: cell.craft, logistics: cell.logistics },
])
ElMessage.success('已恢复推导价')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '恢复失败')
} finally { memberPriceSaving.value = '' }
}
function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
editFamilyId.value = family?.familyId ?? ''
editFamilyCode.value = family?.familyCode ?? null
editFamilyMembers.value = []
familyMemberCount.value = null
editFamilyLoaded.value = false
editFamilyAddKw.value = ''
editFamilyCandidates.value = []
expandedMemberKeys.value = new Set()
memberTagEdits.value = {}
memberPrices.value = {}
familyDetailRaw.value = null
if (editFamilyId.value) loadEditFamily()
}
async function loadEditFamily() {
if (!editFamilyId.value) return
try {
const f = await productFamiliesApi.detail(editFamilyId.value) as any
familyDetailRaw.value = f
editFamilyCode.value = f.familyCode
familyMemberCount.value = f._count.originGoods
const primaryOgId = String(editGood.value?.originGoodId ?? '')
const members: FamilyMemberRow[] = (f.originGoods ?? []).map((m: any) => ({
id: String(m.id),
goodName: m.goodName ?? '',
goodImage: m.goodImage ?? null,
source: m.source,
delisted: Boolean(m.delisted),
sdsGoodId: m.sdsGoodId ?? '',
goodPrice: m.goodPrice ?? null,
variantCount: m._count?.variants ?? 0,
logisticsLabel: m.logisticsLabel ?? null,
craftLabel: m.craftLabel ?? null,
warehouseLabel: m.warehouseLabel ?? null,
tagsManual: Boolean(m.tagsManual),
tags: (m.originGoodTags ?? []).map((t: any) => ({
id: String(t.tag.id),
tagName: t.tag.tagName,
tagColor: t.tag.tagColor ?? null,
manual: Boolean(t.manual),
})),
}))
// 当前商品的链接排最前,其余按 id 稳定排序(界面上不区分主次)
members.sort((a, b) => {
const aP = a.id === primaryOgId ? 0 : 1
const bP = b.id === primaryOgId ? 0 : 1
return aP - bP || a.id.localeCompare(b.id)
})
editFamilyMembers.value = members
const edits: Record<string, string[]> = {}
for (const m of members) edits[m.id] = m.tags.map((t) => t.id)
memberTagEdits.value = edits
for (const key of expandedMemberKeys.value) loadMemberPrices(key)
} finally {
editFamilyLoaded.value = true
}
}
async function saveMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
ElMessage.success('标签已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberTagSaving.value = '' }
}
async function resetMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.resetTags(row.id)
ElMessage.success('已恢复按链接名称自动解析')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '恢复失败')
} finally { memberTagSaving.value = '' }
}
async function searchFamilyCandidates(q: string) {
if (!q) { editFamilyCandidates.value = []; return }
try {
const res = await originGoodsApi.getOriginGoodsList({ keyword: q, page: 1, pageSize: 20 })
editFamilyCandidates.value = res.items.map((o: any) => ({ id: String(o.id), goodName: o.goodName ?? o.sdsGoodId }))
} catch { editFamilyCandidates.value = [] }
}
async function addFamilyMember(c: { id: string; goodName: string }) {
if (!editFamilyId.value) { ElMessage.warning('该商品尚未成族'); return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { addOriginGoodIds: [c.id] })
ElMessage.success('已加入族')
editFamilyAddKw.value = ''
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '加入失败')
}
}
async function removeFamilyMember(m: { id: string }) {
if (!editFamilyId.value) return
try {
await ElMessageBox.confirm('确定把该链接移出族吗?', '确认', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
})
} catch { return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { removeOriginGoodIds: [m.id] })
ElMessage.success('已移除出族')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '移除失败')
}
}
// ─── 打开 / 保存 / 删除 / 同步 ───
watch(() => props.visible, (v) => {
if (v && props.goodId) initLoad()
})
async function initLoad() {
editGood.value = null
editDetailLoading.value = true
try {
const detail = await goodsApi.getGoodById(props.goodId)
applyGood(detail)
} catch {
ElMessage.warning('商品详情加载失败')
} finally {
editDetailLoading.value = false
}
}
function applyGood(g: GoodDetail) {
editGood.value = g
// 名称展示解析为「品名 型号」,保存后即采用解析名
editForm.value = {
id: g.id,
goodName: cleanLinkName(g.goodName) || g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
countryId: g.countryId,
cascaderCategory: findCategoryPath(props.categories, g.categoryId),
categoryId: g.categoryId,
positionId: g.positionId || '',
}
initEditFamily((g as any).originGood?.family ?? null)
if (g.originGood?.isCustom) fillCustomContent(g)
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) editForm.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleSyncOneDetail() {
if (editIsCustom.value) return
const sdsGoodId = editGood.value?.originGood?.sdsGoodId
if (!sdsGoodId) return
detailSyncing.value = true
try {
const result = await syncApi.syncOneProductDetail(sdsGoodId)
const detail = await goodsApi.getGoodById(editGood.value!.id)
applyGood(detail)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
emit('changed')
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally { detailSyncing.value = false }
}
async function handleEditSubmit() {
let customPayload: any = null
if (editIsCustom.value) {
let sizeChart: Record<string, unknown>
let packageSpecs: Record<string, unknown>
let options: Record<string, unknown>
let media: Record<string, unknown>
try {
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
options = JSON.parse(customContentForm.value.optionsJson)
media = JSON.parse(customContentForm.value.mediaJson)
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
} catch {
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
return
}
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
ElMessage.error('SKU 不能为空')
return
}
customPayload = {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
goodPrice: customContentForm.value.goodPrice || null,
detail: {
productCode: customContentForm.value.productCode || null,
englishName: customContentForm.value.englishName || null,
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
minWeightG: customContentForm.value.minWeightG || null,
productionProcess: customContentForm.value.productionProcess || null,
materialDescription: customContentForm.value.materialDescription || null,
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
textureName: customContentForm.value.textureName || null,
reminder: customContentForm.value.reminder || null,
productPerformance: customContentForm.value.productPerformance || null,
applicableScenarios: customContentForm.value.applicableScenarios || null,
washingInstructions: customContentForm.value.washingInstructions || null,
specialDescription: customContentForm.value.specialDescription || null,
designExplanation: customContentForm.value.designExplanation || null,
designArea: customContentForm.value.designArea || null,
pictureRequest: customContentForm.value.pictureRequest || null,
sizeChart,
packageSpecs,
options,
media,
},
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
sku: variant.sku.trim(),
sizeId: variant.sizeId || null,
sizeName: variant.sizeName || null,
colorId: variant.colorId || null,
colorName: variant.colorName || null,
colorHex: variant.colorHex || null,
imageUrl: variant.imageUrl || null,
price: variant.price || null,
originalPrice: variant.originalPrice || null,
weightG: variant.weightG || null,
boxLengthCm: variant.boxLengthCm || null,
boxWidthCm: variant.boxWidthCm || null,
boxHeightCm: variant.boxHeightCm || null,
designData: JSON.parse(variant.designDataJson),
enabled: variant.enabled,
sortOrder: index,
})),
}
}
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
emit('update:visible', false)
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
}
async function handleDeleteGood() {
const g = editGood.value
if (!g) return
try {
await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
await goodsApi.deleteGood(g.id)
ElMessage.success('删除成功')
emit('update:visible', false)
emit('deleted')
}
</script>
<template>
<el-dialog :model-value="visible" :title="cleanLinkName(editGood?.goodName) || '编辑商品'" width="900px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵。展开成员可查看详情、修正标签、调整价格。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接</span>
</div>
<div class="edit-merged-list">
<template v-for="row in editFamilyMembers" :key="row.id">
<div class="edit-merged-item">
<el-icon
class="member-expand-toggle"
:class="{ 'is-expanded': expandedMemberKeys.has(row.id) }"
@click="toggleMemberExpand(row.id)"
><ArrowDown /></el-icon>
<span class="edit-merged-name" :title="row.goodName">{{ row.goodName }}</span>
<span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
<template v-if="row.tags.length">
<span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
</template>
<template v-else>
<span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview">{{ c }}</span>
</template>
</span>
<span v-if="row.tagsManual" class="member-manual-flag" title="人工接管:自动同步不再覆盖该链接的标签">人工</span>
<div class="edit-merged-actions">
<el-button size="small" link type="danger" @click="removeFamilyMember(row)">移除出族</el-button>
</div>
</div>
<div v-if="expandedMemberKeys.has(row.id)" class="member-detail-panel">
<div class="member-detail-grid">
<span class="md-label">SDS ID</span><span class="md-val">{{ row.sdsGoodId || '-' }}</span>
<span class="md-label">链接价格</span><span class="md-val">{{ row.goodPrice ? `¥${row.goodPrice}` : '-' }}</span>
<span class="md-label">SKU </span><span class="md-val">{{ row.id === editGood?.originGoodId ? (editGood?.originGood?.variantCount ?? row.variantCount) : row.variantCount }}</span>
<span class="md-label">物流备注</span><span class="md-val">{{ row.logisticsLabel || '-' }}</span>
<span class="md-label">工艺位置</span><span class="md-val">{{ row.craftLabel || '-' }}</span>
<span class="md-label">仓库</span><span class="md-val">{{ row.warehouseLabel || '-' }}</span>
</div>
<div class="member-tags-edit">
<span class="md-label">标签</span>
<el-select
v-model="memberTagEdits[row.id]"
multiple filterable size="small"
placeholder="选择标签"
style="flex:1"
>
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button size="small" type="primary" :loading="memberTagSaving === row.id" @click="saveMemberTags(row)">保存标签</el-button>
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.id" @click="resetMemberTags(row)">恢复自动</el-button>
<el-button
v-if="row.id === editGood?.originGoodId"
size="small"
:icon="Refresh"
:loading="detailSyncing"
@click="handleSyncOneDetail"
>同步详情</el-button>
</div>
<div v-if="row.craftLabel && row.logisticsLabel" class="member-price-block">
<div class="member-price-head">
<span class="md-label">尺码 × 颜色 价格</span>
<el-button size="small" type="primary" :loading="memberPriceSaving === row.id" @click="saveMemberPrices(row)">保存价格</el-button>
</div>
<el-table :data="memberPrices[row.id] ?? []" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" />
<el-table-column prop="colorName" label="颜色" min-width="120" />
<el-table-column label="价格" width="130">
<template #default="{ row: cell }">
<el-input v-model="cell.editPrice" size="small" @input="cell.editPrice = String(cell.editPrice)" />
</template>
</el-table-column>
<el-table-column label="来源" width="120">
<template #default="{ row: cell }">
<el-tag v-if="cell.manual" size="small" type="warning">人工改价</el-tag>
<span v-else class="md-val">链接价</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row: cell }">
<el-button v-if="cell.manual" size="small" link :loading="memberPriceSaving === row.id" @click="resetMemberPrice(row, cell)">还原</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div>
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div>
<el-select v-model="editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
placeholder="搜索原产品名称添加到族" clearable style="width:100%">
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
@click="addFamilyMember(c)" />
</el-select>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item>
</el-form>
<el-tabs v-if="editOriginDetail" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-form v-if="editIsCustom" label-width="100px" class="custom-detail-form">
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
</el-form>
<el-descriptions v-else :column="2" border size="small">
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
<el-descriptions-item label="净重">{{ editOriginDetail.minWeightG ?? '-' }} g</el-descriptions-item>
<el-descriptions-item label="生产工艺">{{ editOriginDetail.productionProcess || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ editOriginDetail.materialDescription || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
<el-input v-if="editIsCustom" v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
<el-table v-else :data="editSizeRows" border max-height="320">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in editSizeColumns"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="120"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
<el-input v-if="editIsCustom" v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
<el-table v-else :data="editPackageRows" border max-height="320">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="160">
<template #default="{ row }">
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="120" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${editVariants.length})`">
<template v-if="editIsCustom">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
<el-table :data="customContentForm.variants" border max-height="320">
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</template>
<el-table v-else :data="editVariants" border max-height="320">
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
<el-table-column label="物流" width="110">
<template #default>{{ editLinkDims.logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="100">
<template #default>{{ editLinkDims.crafts.join(' / ') || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="100">
<template #default>{{ editLinkDims.printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" width="100" />
<el-table-column prop="price" label="价格" width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
<template #footer>
<el-button type="danger" @click="handleDeleteGood">删除</el-button>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="handleEditSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.edit-merged-box { margin-bottom: 4px; }
.edit-merged-title {
display: flex; align-items: center; gap: 4px;
font-weight: 600; font-size: 13px; margin-bottom: 8px;
}
.edit-family-code { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--el-color-primary); }
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
}
.edit-merged-name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.edit-merged-actions { flex-shrink: 0; display: flex; align-items: center; }
.edit-merged-actions .el-button + .el-button { margin-left: 4px; }
.member-chips { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; }
.member-chip {
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap;
}
.member-chip.is-manual { background: #f0f9eb; color: var(--el-color-success); }
.member-chip.is-preview { background: #f4f4f5; color: #909399; border: 1px dashed #dcdfe6; }
.member-manual-flag {
font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 4px;
background: #f0f9eb; color: var(--el-color-success); flex-shrink: 0;
}
.member-expand-toggle { cursor: pointer; flex-shrink: 0; color: #909399; transition: transform 0.18s; }
.member-expand-toggle.is-expanded { transform: rotate(180deg); }
.member-detail-panel {
background: #fbfcfe; border: 1px solid #ebeef5; border-radius: 6px;
padding: 10px 12px; margin: -2px 0 2px 30px;
}
.member-detail-grid {
display: grid; grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
gap: 4px 10px; font-size: 12px; margin-bottom: 8px;
}
.md-label { color: #909399; font-size: 12px; }
.md-val { color: #606266; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-tags-edit { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.member-tags-edit .md-label { flex-shrink: 0; }
.member-price-block { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.member-price-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
.select-inline { display: flex; align-items: center; gap: 6px; width: 100%; }
</style>
@@ -0,0 +1,478 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, ArrowDown } from '@element-plus/icons-vue'
import type { Tag, TagGroup } from '@/types'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
const props = defineProps<{
tags: Tag[]
tagGroups: TagGroup[]
selected: string[]
}>()
const emit = defineEmits<{
(e: 'update:selected', v: string[]): void
/** 标签/分组变化后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const selectedTagIds = computed({
get: () => props.selected,
set: (v: string[]) => emit('update:selected', v),
})
interface TreeNode {
id: string
rawId: string | null
type: 'group' | 'tag'
label: string
disabled?: boolean
children?: TreeNode[]
}
const tagPopoverVisible = ref(false)
const hoveredNodeId = ref<string | null>(null)
const MAX_VISIBLE_TAGS = 1
const displayedSelectedTagIds = computed(() =>
selectedTagIds.value.slice(0, MAX_VISIBLE_TAGS),
)
const hiddenSelectedCount = computed(() =>
Math.max(0, selectedTagIds.value.length - MAX_VISIBLE_TAGS),
)
function getTagName(id: string): string {
return props.tags.find((t) => t.id === id)?.tagName ?? id
}
function removeSelectedTag(id: string): void {
selectedTagIds.value = selectedTagIds.value.filter((v) => v !== id)
}
function toggleTagInSelection(tagId: string): void {
if (selectedTagIds.value.includes(tagId)) {
selectedTagIds.value = selectedTagIds.value.filter((v) => v !== tagId)
} else {
selectedTagIds.value = [...selectedTagIds.value, tagId]
}
}
const tagTreeData = computed<TreeNode[]>(() => {
const groupNodes: TreeNode[] = props.tagGroups
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((g) => ({
id: `g-${g.id}`,
rawId: g.id,
type: 'group' as const,
label: g.groupName,
children: props.tags
.filter((t) => t.tagGroupId === g.id)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag' as const,
label: t.tagName,
})),
}))
const ungrouped: TreeNode[] = props.tags
.filter((t) => !t.tagGroupId)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag' as const,
label: t.tagName,
}))
if (ungrouped.length > 0) {
groupNodes.push({
id: 'g-ungrouped',
rawId: null,
type: 'group',
label: '未分组',
disabled: true,
children: ungrouped,
})
}
return groupNodes
})
function onTreeNodeClick(data: TreeNode): void {
if (data.type === 'group' && !data.disabled) {
// 点击分组 = 全选/取消该组全部标签
const tagIds = (data.children ?? []).map((c) => c.rawId!).filter(Boolean)
if (tagIds.length === 0) return
const allSelected = tagIds.every((id) => selectedTagIds.value.includes(id))
if (allSelected) {
selectedTagIds.value = selectedTagIds.value.filter((id) => !tagIds.includes(id))
} else {
selectedTagIds.value = [...new Set([...selectedTagIds.value, ...tagIds])]
}
}
}
// ─── 标签编辑小弹窗 ───
const tagEditVisible = ref(false)
const tagEditForm = ref({ id: '', tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff' })
const tagEditLoading = ref(false)
function openTagEdit(t: Tag) {
tagEditForm.value = { id: t.id, tagName: t.tagName, tagColor: t.tagColor || '#ff6800', tagFontColor: t.tagFontColor || '#ffffff' }
tagEditVisible.value = true
}
function openTagEditById(id: string): void {
const t = props.tags.find((tag) => tag.id === id)
if (t) openTagEdit(t)
}
async function handleTagEditSubmit() {
if (!tagEditForm.value.tagName.trim()) { ElMessage.warning('请输入标签名称'); return }
tagEditLoading.value = true
try {
await tagsApi.updateTag(tagEditForm.value.id, { tagName: tagEditForm.value.tagName.trim(), tagColor: tagEditForm.value.tagColor, tagFontColor: tagEditForm.value.tagFontColor } as any)
ElMessage.success('保存成功')
tagEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
finally { tagEditLoading.value = false }
}
async function handleTagEditDelete() {
try {
await ElMessageBox.confirm(`确定删除标签「${tagEditForm.value.tagName}」吗?`, '确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await tagsApi.deleteTag(tagEditForm.value.id)
ElMessage.success('删除成功')
tagEditVisible.value = false
emit('dictionaries-changed')
} catch { ElMessage.error('删除失败') }
}
// ─── 分组编辑小弹窗 ───
const groupEditVisible = ref(false)
const groupEditLoading = ref(false)
const groupEditForm = ref<{ id: string; groupName: string }>({ id: '', groupName: '' })
function openGroupEdit(node: TreeNode): void {
// 虚拟「未分组」节点不可编辑
if (node.id === 'g-ungrouped') return
groupEditForm.value = { id: node.rawId!, groupName: node.label }
groupEditVisible.value = true
}
async function handleGroupSave(): Promise<void> {
const name = groupEditForm.value.groupName.trim()
if (!name) { ElMessage.warning('请输入分组名称'); return }
groupEditLoading.value = true
try {
await tagGroupsApi.updateTagGroup(groupEditForm.value.id, { groupName: name })
ElMessage.success('已保存')
groupEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
groupEditLoading.value = false
}
}
async function handleGroupDelete(): Promise<void> {
try {
await ElMessageBox.confirm(
`删除分组「${groupEditForm.value.groupName}」后,组内标签将归为「未分组」。确认删除?`,
'确认删除',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch { return }
groupEditLoading.value = true
try {
await tagGroupsApi.deleteTagGroup(groupEditForm.value.id)
ElMessage.success('已删除')
groupEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '删除失败')
} finally {
groupEditLoading.value = false
}
}
async function quickCreateTagGroup() {
try {
const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
confirmButtonText: '新建', cancelButtonText: '取消', inputPlaceholder: '分组名称',
})
if (!value.trim()) return
const maxSort = Math.max(0, ...props.tagGroups.map((g) => g.sortOrder))
await tagGroupsApi.createTagGroup({ groupName: value.trim(), sortOrder: maxSort + 1 } as any)
ElMessage.success('已创建分组')
emit('dictionaries-changed')
} catch {}
}
</script>
<template>
<el-popover
v-model:visible="tagPopoverVisible"
placement="bottom-start"
:width="280"
trigger="click"
transition="el-zoom-in-top"
popper-class="tag-filter-popper"
>
<template #reference>
<div
class="tag-select-trigger"
:class="{ 'is-filled': selectedTagIds.length > 0, 'is-active': tagPopoverVisible }"
>
<template v-if="selectedTagIds.length === 0">
<span class="placeholder">标签筛选</span>
</template>
<template v-else>
<el-tag
v-for="id in displayedSelectedTagIds"
:key="id"
size="small"
closable
type="info"
@close.stop="removeSelectedTag(id)"
>
{{ getTagName(id) }}
</el-tag>
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
</template>
<el-icon class="tag-arrow"><ArrowDown /></el-icon>
</div>
</template>
<div class="tag-tree-panel">
<div class="tag-tree-toolbar">
<el-button text size="small" :icon="Plus" @click="quickCreateTagGroup">新建分组</el-button>
</div>
<el-tree
:data="tagTreeData"
node-key="id"
default-expand-all
:props="{ label: 'label', children: 'children' }"
@node-click="onTreeNodeClick"
>
<template #default="{ data }">
<div
class="tree-row"
:class="{
'is-group': data.type === 'group',
'is-tag': data.type === 'tag',
'is-disabled': data.disabled,
'is-checked': data.type === 'tag' && selectedTagIds.includes(data.rawId),
}"
@mouseenter="hoveredNodeId = data.id"
@mouseleave="hoveredNodeId = null"
>
<template v-if="data.type === 'group'">
<i class="fa-solid fa-folder node-icon" />
<span class="node-label">
{{ data.label }}
<span v-if="data.children && data.children.length" class="node-count">{{ data.children.length }}</span>
</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑分组" @click.stop="openGroupEdit(data)" />
</span>
</template>
<template v-else>
<el-checkbox
:model-value="selectedTagIds.includes(data.rawId)"
@change="toggleTagInSelection(data.rawId)"
@click.stop
/>
<span class="node-label">{{ data.label }}</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑标签" @click.stop="openTagEditById(data.rawId)" />
</span>
</template>
</div>
</template>
</el-tree>
</div>
</el-popover>
<!-- Tag Edit Modal -->
<el-dialog v-model="tagEditVisible" title="编辑标签" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="tagEditForm.tagName" placeholder="标签名称" /></el-form-item>
<el-form-item label="背景色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagColor" />
<el-input v-model="tagEditForm.tagColor" placeholder="#ff6800" style="width: 120px" />
</div>
</el-form-item>
<el-form-item label="字体色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagFontColor" />
<el-input v-model="tagEditForm.tagFontColor" placeholder="#ffffff" style="width: 120px" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="tagEditLoading" @click="handleTagEditDelete">删除</el-button>
<el-button @click="tagEditVisible = false">取消</el-button>
<el-button type="primary" :loading="tagEditLoading" @click="handleTagEditSubmit">保存</el-button>
</template>
</el-dialog>
<!-- Group Edit Modal -->
<el-dialog
v-model="groupEditVisible"
:title="`编辑分组「${groupEditForm.groupName}」`"
width="420px"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="分组名称">
<el-input v-model="groupEditForm.groupName" placeholder="请输入分组名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="groupEditLoading" @click="handleGroupDelete">删除</el-button>
<el-button @click="groupEditVisible = false">取消</el-button>
<el-button type="primary" :loading="groupEditLoading" @click="handleGroupSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
/* Custom trigger that looks like el-select */
.tag-select-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
width: 180px;
min-height: 24px;
padding: 0 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
font-size: 12px;
color: #606266;
cursor: pointer;
transition: border-color 0.2s;
box-sizing: border-box;
}
.tag-select-trigger:hover {
border-color: #c0c4cc;
}
.tag-select-trigger.is-active {
border-color: var(--brand-color);
}
.tag-select-trigger .placeholder {
color: #a8abb2;
}
.tag-select-trigger .tag-arrow {
margin-left: auto;
font-size: 12px;
color: #a8abb2;
transition: transform 0.3s ease, color 0.2s;
flex-shrink: 0;
}
.tag-select-trigger.is-active .tag-arrow {
transform: rotate(180deg);
color: var(--brand-color);
}
.tag-select-trigger.is-filled {
color: #111;
}
.tag-select-trigger .more-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
background: #f4f4f5;
border-radius: 3px;
font-size: 11px;
color: #606266;
}
</style>
<style>
/* Popover 挂载在 body 下,需要全局样式 */
.tag-filter-popper {
padding: 0 !important;
}
.tag-filter-popper .tag-tree-panel {
max-height: 380px;
overflow-y: auto;
padding: 4px 0;
}
.tag-tree-toolbar {
padding: 4px 8px 6px;
border-bottom: 1px solid #f0f0f0;
}
.tag-filter-popper .el-tree {
padding: 0 4px;
}
/* Tree row layout */
.tag-filter-popper .tree-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
position: relative;
}
.tag-filter-popper .tree-row.is-group {
font-weight: 600;
color: #111;
background: #fafafa;
}
.tag-filter-popper .tree-row.is-group:hover {
background: #f0f0f0;
}
.tag-filter-popper .tree-row.is-tag:hover {
background: #f5f7fa;
}
.tag-filter-popper .tree-row.is-tag.is-checked {
background: #fff2e8;
color: #ff6a00;
}
.tag-filter-popper .node-icon {
color: #f59e0b;
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
}
.tag-filter-popper .node-label {
flex: 1;
user-select: none;
}
.tag-filter-popper .node-count {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
font-size: 10px;
color: #909399;
background: #e9e9eb;
border-radius: 8px;
font-weight: 400;
}
.tag-filter-popper .node-actions {
display: inline-flex;
gap: 0;
margin-left: auto;
flex-shrink: 0;
}
.tag-filter-popper .node-actions .el-button {
padding: 2px 4px;
}
</style>
@@ -13,10 +13,14 @@ describe('auto-tag-rules / deriveLinkTagNames', () => {
).toEqual(['双面印花', '烫画', '不包邮']); ).toEqual(['双面印花', '烫画', '不包邮']);
}); });
it('不打印 + 光板(物流备注)+ 不包邮 → 两个工艺标签 + 不包邮,无印花数量标签', () => { it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => {
expect( expect(
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toEqual(['不打印', '光板', '不包邮']); ).toEqual(['不打印', '不包邮']);
});
it('仅光板(无不打印字样)同样归为 不打印', () => {
expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']);
}); });
it('直喷命中时不给默认烫画', () => { it('直喷命中时不给默认烫画', () => {
@@ -24,14 +24,19 @@ export interface DerivedTagGroupSpec {
tags: string[]; tags: string[];
} }
/** 派生标签所属组及其全部合法取值 */ /** 派生标签所属组及其全部合法取值(光板 = 不打印,不单独设标签) */
export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [ export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [
{ group: '物流渠道', tags: ['包邮', '不包邮'] }, { group: '物流渠道', tags: ['包邮', '不包邮'] },
{ group: '印花数量', tags: ['单面印花', '双面印花'] }, { group: '印花数量', tags: ['单面印花', '双面印花'] },
{ group: '印刷工艺', tags: ['烫画', '直喷', '不打印', '光板'] }, { group: '印刷工艺', tags: ['烫画', '直喷', '不打印'] },
]; ];
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const; /** 工艺关键字 → 标签名(光板即为不打印) */
const CRAFT_KEYWORD_MAP: Record<string, string> = {
: '直喷',
: '不打印',
: '不打印',
};
const CRAFT_DEFAULT = '烫画'; const CRAFT_DEFAULT = '烫画';
/** /**
@@ -45,8 +50,11 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
if (name.includes('双面印花')) names.push('双面印花'); if (name.includes('双面印花')) names.push('双面印花');
else if (name.includes('单面印花')) names.push('单面印花'); else if (name.includes('单面印花')) names.push('单面印花');
const craftHits = CRAFT_KEYWORDS.filter((keyword) => name.includes(keyword)); const craftTags = new Set<string>();
if (craftHits.length > 0) names.push(...craftHits); for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
if (name.includes(keyword)) craftTags.add(tagName);
}
if (craftTags.size > 0) names.push(...craftTags);
else names.push(CRAFT_DEFAULT); else names.push(CRAFT_DEFAULT);
if (name.includes('不包邮')) names.push('不包邮'); if (name.includes('不包邮')) names.push('不包邮');
@@ -123,15 +123,18 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
expect(r1.linksUpdated).toBe(2); expect(r1.linksUpdated).toBe(2);
expect(r1.goodsUpdated).toBe(2); expect(r1.goodsUpdated).toBe(2);
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印/光板/不包邮 // 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印光板归并为不打印)/不包邮
expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']); expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']);
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印', '光板']); expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']);
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除) // 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
const names1 = await goodTagNames(good1.id); const names1 = await goodTagNames(good1.id);
expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`])); expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`]));
expect(names1).not.toContain('单面印'); expect(names1).not.toContain('单面印');
expect(await goodTagNames(good2.id)).toEqual(['不包邮', '不打印', '光板']); const names2 = await goodTagNames(good2.id);
expect(names2).toEqual(['不包邮', '不打印']);
expect(names2).not.toContain('光板');
expect(names2).not.toContain('烫画');
// 幂等 // 幂等
const r2 = await recompute.syncFamilyTags(family.id); const r2 = await recompute.syncFamilyTags(family.id);
+14 -10
View File
@@ -129,14 +129,18 @@ pnpm --filter @inkreach/api backfill:product-families
**后台操作入口(族替代旧主源/副源,界面保持原有布局)** **后台操作入口(族替代旧主源/副源,界面保持原有布局)**
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」 - 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族); 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);族内**不区分主次**
- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,主/族成员行可**逐个展开**—— (成员列表无设为主链接按钮,主链接仅作为详情数据源的内部实现);
查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库 / 原始名称)、 - 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,成员行(原始链接名,便于核对)
**配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生);成员行仍支持 **逐个展开**——查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库)、
`设为主链接` / `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新); **配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生)、
- 编辑弹窗 SKU 表新增 **物流 / 工艺 / 印花数量** 三列:价格由 **改价格**(尺码 × 颜色 → 价格表格,写入族价格覆盖,人工格子可「还原」回推导价);
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定(族内同组合取最低价,人工改价走族价格覆盖); 成员行支持 `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新);
- 人工改价/自动成族等族管理 API`/product-families/*`)保留,供脚本或后续界面使用。 - 编辑弹窗 SKU 表含 **物流 / 工艺 / 印花数量** 维度列(价格由
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定);
- 人工改价/自动成族等族管理 API`/product-families/*`)保留,供脚本或后续界面使用;
- 页面组件化:`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog(编辑弹窗)、
GoodsConfigDialog(配置弹窗)、CustomGoodDialog(自定义商品)、TagFilterPopover(标签筛选弹层)。
**链接级标签(自动派生 + 人工修正,2026-08 规则改版)** **链接级标签(自动派生 + 人工修正,2026-08 规则改版)**
@@ -146,8 +150,8 @@ pnpm --filter @inkreach/api backfill:product-families
- 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端 - 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端
`utils/origin-name.ts#deriveLinkTagNames` 同构): `utils/origin-name.ts#deriveLinkTagNames` 同构):
- **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(组「印花数量」); - **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(组「印花数量」);
- **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」); - **工艺**:名称含「直喷」`直喷`;含「不打印」「光板」→ `不打印`**光板即为不打印**
都不含 → 默认 `烫画` 组「印刷工艺」);都不含 → 默认 `烫画`
- **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中); - **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中);
- 每次族重算/成员变更/商品创建更新时:未接管的 SDS 链接按名称刷新派生行; - 每次族重算/成员变更/商品创建更新时:未接管的 SDS 链接按名称刷新派生行;
**人工接管的链接(`tagsManual=true`)永不被覆盖**;商品镜像其链接的有效标签; **人工接管的链接(`tagsManual=true`)永不被覆盖**;商品镜像其链接的有效标签;
+3 -1
View File
@@ -163,7 +163,9 @@ apps/admin/
│ ├── types/index.ts # 共享类型 │ ├── types/index.ts # 共享类型
│ ├── views/ │ ├── views/
│ │ ├── login/LoginView.vue # 登录 │ │ ├── login/LoginView.vue # 登录
│ │ ├── goods/GoodsView.vue # 商品配置(编辑弹窗内管理族成员:设为主链接/移除出族/搜索添加 │ │ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表
│ │ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、
│ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover
│ │ ├── categories/CategoriesView.vue │ │ ├── categories/CategoriesView.vue
│ │ ├── countries/CountriesView.vue │ │ ├── countries/CountriesView.vue
│ │ ├── tags/TagsView.vue │ │ ├── tags/TagsView.vue