merge: feature/goods-dialog-split-dev into develop (local only, not pushed)
This commit is contained in:
Vendored
-1
@@ -42,7 +42,6 @@ declare module 'vue' {
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -89,10 +89,10 @@ describe('deriveLinkTagNames', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('不打印 + 光板 + 不包邮 → 两工艺标签 + 不包邮', () => {
|
||||
it('不打印 + 光板 + 不包邮 → 归并为单个不打印(光板即不打印)', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
).toEqual(['不打印', '光板', '不包邮']);
|
||||
).toEqual(['不打印', '不包邮']);
|
||||
});
|
||||
|
||||
it('双面印花 + 不包邮,且不误命中包邮', () => {
|
||||
@@ -117,7 +117,7 @@ describe('linkDims', () => {
|
||||
});
|
||||
expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({
|
||||
logistics: '不包邮光板',
|
||||
crafts: ['不打印', '光板'],
|
||||
crafts: ['不打印'],
|
||||
printCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,11 @@ export function cleanLinkName(name: string | null | undefined): string {
|
||||
return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName;
|
||||
}
|
||||
|
||||
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
|
||||
const CRAFT_KEYWORD_MAP: Record<string, string> = {
|
||||
直喷: '直喷',
|
||||
不打印: '不打印',
|
||||
光板: '不打印', // 光板即为不打印
|
||||
};
|
||||
|
||||
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
|
||||
export interface LinkDims {
|
||||
@@ -68,7 +72,7 @@ export function linkDims(name: string | null | undefined): LinkDims {
|
||||
const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')'));
|
||||
const logistics =
|
||||
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('双面印花')
|
||||
? '双面印花'
|
||||
: name.includes('单面印花')
|
||||
@@ -83,7 +87,7 @@ export function linkDims(name: string | null | undefined): LinkDims {
|
||||
|
||||
/**
|
||||
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
|
||||
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画;
|
||||
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
|
||||
* 物流:不包邮 优先于 包邮。
|
||||
*/
|
||||
export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
@@ -91,7 +95,7 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
const names: string[] = [];
|
||||
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);
|
||||
else 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(['双面印花', '烫画', '不包邮']);
|
||||
});
|
||||
|
||||
it('不打印 + 光板(物流备注)+ 不包邮 → 两个工艺标签 + 不包邮,无印花数量标签', () => {
|
||||
it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
).toEqual(['不打印', '光板', '不包邮']);
|
||||
).toEqual(['不打印', '不包邮']);
|
||||
});
|
||||
|
||||
it('仅光板(无不打印字样)同样归为 不打印', () => {
|
||||
expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']);
|
||||
});
|
||||
|
||||
it('直喷命中时不给默认烫画', () => {
|
||||
|
||||
@@ -24,14 +24,19 @@ export interface DerivedTagGroupSpec {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** 派生标签所属组及其全部合法取值 */
|
||||
/** 派生标签所属组及其全部合法取值(光板 = 不打印,不单独设标签) */
|
||||
export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [
|
||||
{ group: '物流渠道', tags: ['包邮', '不包邮'] },
|
||||
{ group: '印花数量', tags: ['单面印花', '双面印花'] },
|
||||
{ group: '印刷工艺', tags: ['烫画', '直喷', '不打印', '光板'] },
|
||||
{ group: '印刷工艺', tags: ['烫画', '直喷', '不打印'] },
|
||||
];
|
||||
|
||||
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
|
||||
/** 工艺关键字 → 标签名(光板即为不打印) */
|
||||
const CRAFT_KEYWORD_MAP: Record<string, string> = {
|
||||
直喷: '直喷',
|
||||
不打印: '不打印',
|
||||
光板: '不打印',
|
||||
};
|
||||
const CRAFT_DEFAULT = '烫画';
|
||||
|
||||
/**
|
||||
@@ -45,8 +50,11 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
if (name.includes('双面印花')) names.push('双面印花');
|
||||
else if (name.includes('单面印花')) names.push('单面印花');
|
||||
|
||||
const craftHits = CRAFT_KEYWORDS.filter((keyword) => name.includes(keyword));
|
||||
if (craftHits.length > 0) names.push(...craftHits);
|
||||
const craftTags = new Set<string>();
|
||||
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);
|
||||
|
||||
if (name.includes('不包邮')) names.push('不包邮');
|
||||
|
||||
@@ -123,15 +123,18 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
||||
expect(r1.linksUpdated).toBe(2);
|
||||
expect(r1.goodsUpdated).toBe(2);
|
||||
|
||||
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印/光板/不包邮
|
||||
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮
|
||||
expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']);
|
||||
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印', '光板']);
|
||||
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']);
|
||||
|
||||
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
|
||||
const names1 = await goodTagNames(good1.id);
|
||||
expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`]));
|
||||
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);
|
||||
|
||||
@@ -129,14 +129,18 @@ pnpm --filter @inkreach/api backfill:product-families
|
||||
**后台操作入口(族替代旧主源/副源,界面保持原有布局)**:
|
||||
|
||||
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
||||
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);
|
||||
- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,主/族成员行可**逐个展开**——
|
||||
查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库 / 原始名称)、
|
||||
**配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生);成员行仍支持
|
||||
`设为主链接` / `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新);
|
||||
- 编辑弹窗 SKU 表新增 **物流 / 工艺 / 印花数量** 三列:价格由
|
||||
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定(族内同组合取最低价,人工改价走族价格覆盖);
|
||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。
|
||||
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);族内**不区分主次**
|
||||
(成员列表无设为主链接按钮,主链接仅作为详情数据源的内部实现);
|
||||
- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,成员行(原始链接名,便于核对)
|
||||
可**逐个展开**——查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库)、
|
||||
**配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生)、
|
||||
**改价格**(尺码 × 颜色 → 价格表格,写入族价格覆盖,人工格子可「还原」回推导价);
|
||||
成员行支持 `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新);
|
||||
- 编辑弹窗 SKU 表含 **物流 / 工艺 / 印花数量** 维度列(价格由
|
||||
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定);
|
||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用;
|
||||
- 页面组件化:`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog(编辑弹窗)、
|
||||
GoodsConfigDialog(配置弹窗)、CustomGoodDialog(自定义商品)、TagFilterPopover(标签筛选弹层)。
|
||||
|
||||
**链接级标签(自动派生 + 人工修正,2026-08 规则改版)**:
|
||||
|
||||
@@ -146,8 +150,8 @@ pnpm --filter @inkreach/api backfill:product-families
|
||||
- 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端
|
||||
`utils/origin-name.ts#deriveLinkTagNames` 同构):
|
||||
- **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(组「印花数量」);
|
||||
- **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」);
|
||||
都不含 → 默认 `烫画`;
|
||||
- **工艺**:名称含「直喷」→ `直喷`;含「不打印」或「光板」→ `不打印`(**光板即为不打印**,
|
||||
组「印刷工艺」);都不含 → 默认 `烫画`;
|
||||
- **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中);
|
||||
- 每次族重算/成员变更/商品创建更新时:未接管的 SDS 链接按名称刷新派生行;
|
||||
**人工接管的链接(`tagsManual=true`)永不被覆盖**;商品镜像其链接的有效标签;
|
||||
|
||||
@@ -163,7 +163,9 @@ apps/admin/
|
||||
│ ├── types/index.ts # 共享类型
|
||||
│ ├── views/
|
||||
│ │ ├── login/LoginView.vue # 登录
|
||||
│ │ ├── goods/GoodsView.vue # 商品配置(编辑弹窗内管理族成员:设为主链接/移除出族/搜索添加)
|
||||
│ │ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表)
|
||||
│ │ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、
|
||||
│ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover
|
||||
│ │ ├── categories/CategoriesView.vue
|
||||
│ │ ├── countries/CountriesView.vue
|
||||
│ │ ├── tags/TagsView.vue
|
||||
|
||||
Reference in New Issue
Block a user