feat(tags): support separate font color for tag chips
- Add tagFontColor column to tags table (Prisma db push) - Update Create/UpdateTagDto, TagsService, PublicTagDto, GoodDto, OriginGoodsService - Expose tagFontColor in PublicGoodDto and backend product mapping - Admin TagsView: add 背景色/字体色 color pickers, separate table columns, live preview - Admin GoodsView inline tag dialog: add 字体色 picker alongside background color - Add product-category.html design reference
This commit is contained in:
@@ -141,6 +141,7 @@ export interface Tag {
|
||||
id: string
|
||||
tagName: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -149,12 +150,14 @@ export interface Tag {
|
||||
export interface CreateTagRequest {
|
||||
tagName: string
|
||||
tagColor?: string
|
||||
tagFontColor?: string
|
||||
timing?: string
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
tagName?: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
}
|
||||
|
||||
@@ -203,7 +206,7 @@ export interface OriginGoodsTreeNode {
|
||||
sdsGoodId: string
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
configuredTags: { tagName: string; tagColor: string | null }[]
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null }[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
|
||||
@@ -21,7 +21,6 @@ const loading = ref(false)
|
||||
|
||||
const leftTreeRef = ref()
|
||||
const rightTreeRef = ref()
|
||||
const leftTreeData = ref<any[]>([])
|
||||
const rightTreeData = ref<any[]>([])
|
||||
const allCategories = ref<CategoryTree[]>([])
|
||||
const allCountries = ref<Country[]>([])
|
||||
@@ -99,7 +98,6 @@ async function loadAll() {
|
||||
allCountries.value = Array.isArray(countries) ? countries : (countries.items ?? [])
|
||||
allTags.value = Array.isArray(tags) ? tags : (tags.items ?? [])
|
||||
allGoods.value = goodsRes?.items ?? []
|
||||
buildLeftTree()
|
||||
buildRightTree(ogTree)
|
||||
} catch (e) { console.error('加载失败', e) }
|
||||
finally { loading.value = false }
|
||||
@@ -125,24 +123,23 @@ function goodToNode(g: Good): any {
|
||||
}
|
||||
}
|
||||
|
||||
function buildLeftTree() {
|
||||
if (mode.value === 'category') {
|
||||
leftTreeData.value = mapCatNodes(allCategories.value, allGoods.value)
|
||||
} else {
|
||||
leftTreeData.value = allCountries.value.map(c => {
|
||||
const goods = allGoods.value.filter(g => g.countryId === c.id)
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.countryName,
|
||||
icon: c.countryIcon,
|
||||
isCat: true,
|
||||
raw: c,
|
||||
goodCount: goods.length,
|
||||
children: goods.map(goodToNode),
|
||||
}
|
||||
// Computed: goods after applying search + country + tag filters
|
||||
const filteredGoods = computed(() => {
|
||||
let result = allGoods.value
|
||||
if (searchKeyword.value) {
|
||||
result = result.filter(g => g.goodName?.includes(searchKeyword.value))
|
||||
}
|
||||
if (selectedCountryIds.value.length) {
|
||||
result = result.filter(g => selectedCountryIds.value.includes(g.countryId))
|
||||
}
|
||||
if (selectedTagIds.value.length) {
|
||||
result = result.filter(g => {
|
||||
const ids = (g.tags || []).map(t => t.id)
|
||||
return selectedTagIds.value.some(id => ids.includes(id))
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function mapCatNodes(nodes: CategoryTree[], goods: Good[]): any[] {
|
||||
return nodes.map(n => {
|
||||
@@ -162,6 +159,27 @@ function mapCatNodes(nodes: CategoryTree[], goods: Good[]): any[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Computed: left tree data — auto-rebuilds when any dependency changes
|
||||
const leftTreeData = computed(() => {
|
||||
const goods = filteredGoods.value
|
||||
if (mode.value === 'category') {
|
||||
return mapCatNodes(allCategories.value, goods)
|
||||
} else {
|
||||
return allCountries.value.map(c => {
|
||||
const cGoods = goods.filter(g => g.countryId === c.id)
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.countryName,
|
||||
icon: c.countryIcon,
|
||||
isCat: true,
|
||||
raw: c,
|
||||
goodCount: cGoods.length,
|
||||
children: cGoods.map(goodToNode),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function buildRightTree(tree: OriginGoodsTreeResponse) {
|
||||
function mapCat(node: any): any {
|
||||
const children = (node.children || []).map(mapCat)
|
||||
@@ -184,26 +202,13 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
||||
rightTreeData.value = tree.tree.map(mapCat)
|
||||
}
|
||||
|
||||
function leftFilterNode(value: string, data: any) {
|
||||
if (!data.isGood) return true
|
||||
if (searchKeyword.value && !data.goodName?.includes(searchKeyword.value)) return false
|
||||
if (selectedCountryIds.value.length && !selectedCountryIds.value.includes(data.raw?.countryId)) return false
|
||||
if (selectedTagIds.value.length) {
|
||||
const goodTagIds = (data.tags || []).map((t: any) => t.id)
|
||||
if (!selectedTagIds.value.some(id => goodTagIds.includes(id))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function rightFilterNode(value: string, data: any) {
|
||||
if (!value) return true
|
||||
if (data.isOG) return data.label.includes(value)
|
||||
return true
|
||||
}
|
||||
|
||||
watch([searchKeyword, selectedCountryIds, selectedTagIds], () => {
|
||||
leftTreeRef.value?.filter?.('')
|
||||
})
|
||||
// Right tree search filter
|
||||
watch(searchKeyword, () => {
|
||||
rightTreeRef.value?.filter?.('')
|
||||
})
|
||||
@@ -211,7 +216,6 @@ watch(searchKeyword, () => {
|
||||
function refreshLeftTree() {
|
||||
goodsApi.getGoodsList({ page: 1, pageSize: 200 } as any).then((res: any) => {
|
||||
allGoods.value = res?.items ?? []
|
||||
buildLeftTree()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -481,8 +485,8 @@ async function handleCatDelete(node: any) {
|
||||
|
||||
async function reloadCategories() {
|
||||
const cats = await categoriesApi.getCategoryTree() as any
|
||||
allCategories.value = Array.isArray(cats) ? cats : (cats.items ?? [])
|
||||
buildLeftTree()
|
||||
allCategories.value = (Array.isArray(cats) ? cats : (cats.items ?? []))
|
||||
.filter((c: any) => !c.sdsCategoryId)
|
||||
}
|
||||
|
||||
async function onLeftTreeDragEnd(dragNode: any, dropNode: any, position: string) {
|
||||
@@ -557,20 +561,40 @@ async function handleCountryDelete(node: any) {
|
||||
async function reloadCountries() {
|
||||
const res = await countriesApi.getCountriesList({ page: 1, pageSize: 200 } as any) as any
|
||||
allCountries.value = Array.isArray(res) ? res : (res.items ?? [])
|
||||
buildLeftTree()
|
||||
}
|
||||
|
||||
// ─── Tag rename (from filter dropdown) ───
|
||||
async function renameTag(t: Tag) {
|
||||
// ─── Tag edit modal (from filter dropdown) ───
|
||||
const tagEditVisible = ref(false)
|
||||
const tagEditForm = ref({ id: '', tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff' })
|
||||
const tagEditLoading = ref(false)
|
||||
|
||||
function openTagEditFromFilter(t: Tag) {
|
||||
tagEditForm.value = { id: t.id, tagName: t.tagName, tagColor: t.tagColor || '#ff6800', tagFontColor: t.tagFontColor || '#ffffff' }
|
||||
tagEditVisible.value = true
|
||||
}
|
||||
|
||||
async function handleTagEditSubmit() {
|
||||
if (!tagEditForm.value.tagName.trim()) { ElMessage.warning('请输入标签名称'); return }
|
||||
tagEditLoading.value = true
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入新的标签名称', '重命名标签', {
|
||||
inputValue: t.tagName, confirmButtonText: '保存', cancelButtonText: '取消',
|
||||
})
|
||||
if (!value.trim()) return
|
||||
await tagsApi.updateTag(t.id, { tagName: value.trim(), tagColor: t.tagColor || '#ff6800' } as any)
|
||||
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
|
||||
await reloadTags()
|
||||
ElMessage.success('已重命名')
|
||||
} catch {}
|
||||
} 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
|
||||
await reloadTags()
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function reloadTags() {
|
||||
@@ -578,17 +602,11 @@ async function reloadTags() {
|
||||
allTags.value = Array.isArray(res) ? res : (res.items ?? [])
|
||||
}
|
||||
|
||||
// ─── Country rename (from filter dropdown) ───
|
||||
async function renameCountry(c: Country) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入新的国家名称', '重命名国家', {
|
||||
inputValue: c.countryName, confirmButtonText: '保存', cancelButtonText: '取消',
|
||||
})
|
||||
if (!value.trim()) return
|
||||
await countriesApi.updateCountry(c.id, { countryName: value.trim() } as any)
|
||||
await reloadCountries()
|
||||
ElMessage.success('已重命名')
|
||||
} catch {}
|
||||
// ─── Country edit (from filter dropdown) ───
|
||||
function openCountryEditFromFilter(c: Country) {
|
||||
countryEditMode.value = 'edit'
|
||||
countryEditForm.value = { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' }
|
||||
countryEditVisible.value = true
|
||||
}
|
||||
|
||||
// ─── Quick create (inline in edit/config modal) ───
|
||||
@@ -618,7 +636,7 @@ async function quickCreateTag(targetForm: () => void) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onModeChange() { buildLeftTree() }
|
||||
function onModeChange() {}
|
||||
|
||||
onMounted(() => loadAll())
|
||||
</script>
|
||||
@@ -635,7 +653,7 @@ onMounted(() => loadAll())
|
||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id">
|
||||
<div class="filter-opt">
|
||||
<span>{{ c.countryName }}</span>
|
||||
<el-button text size="small" :icon="Edit" @click.stop="renameCountry(c)" />
|
||||
<el-button text size="small" :icon="Edit" @click.stop="openCountryEditFromFilter(c)" />
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
@@ -644,7 +662,7 @@ onMounted(() => loadAll())
|
||||
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id">
|
||||
<div class="filter-opt">
|
||||
<span class="filter-opt-tag" :style="{ '--c': t.tagColor || '#ccc' }">{{ t.tagName }}</span>
|
||||
<el-button text size="small" :icon="Edit" @click.stop="renameTag(t)" />
|
||||
<el-button text size="small" :icon="Edit" @click.stop="openTagEditFromFilter(t)" />
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
@@ -673,7 +691,6 @@ onMounted(() => loadAll())
|
||||
ref="leftTreeRef"
|
||||
:data="leftTreeData"
|
||||
:props="treeProps"
|
||||
:filter-node-method="leftFilterNode"
|
||||
node-key="id"
|
||||
:draggable="mode === 'category'"
|
||||
:expand-on-click-node="true"
|
||||
@@ -913,6 +930,30 @@ onMounted(() => loadAll())
|
||||
<el-button type="primary" :loading="countryEditLoading" @click="handleCountrySubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ const dialogLoading = ref(false)
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
})
|
||||
|
||||
@@ -61,7 +62,7 @@ const dialogRules = {
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', timing: '' })
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ function openEditDialog(t: Tag) {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
@@ -85,6 +87,7 @@ async function handleSubmit() {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
@@ -155,14 +158,16 @@ onMounted(fetchList)
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<el-tag v-if="row.tagColor" :color="row.tagColor" effect="dark">
|
||||
{{ row.tagName }}
|
||||
</el-tag>
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="颜色" width="140">
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
@@ -170,6 +175,14 @@ onMounted(fetchList)
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
@@ -212,10 +225,14 @@ onMounted(fetchList)
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
@@ -266,4 +283,12 @@ onMounted(fetchList)
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,12 +66,13 @@ model Category {
|
||||
|
||||
// ---------- Tags ----------
|
||||
model Tag {
|
||||
id BigInt @id @default(autoincrement()) @map("tag_id")
|
||||
tagName String @unique @map("tag_name")
|
||||
tagColor String? @map("tag_color")
|
||||
timing String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
id BigInt @id @default(autoincrement()) @map("tag_id")
|
||||
tagName String @unique @map("tag_name")
|
||||
tagColor String? @map("tag_color")
|
||||
tagFontColor String? @map("tag_font_color")
|
||||
timing String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
goods Good[]
|
||||
goodTags GoodTag[]
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Good as PrismaGood } from '@prisma/client';
|
||||
export interface GoodRelations {
|
||||
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||
tag?: { id: bigint; tagName: string; tagColor: string | null } | null;
|
||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
position?: { id: bigint; indexVal: number } | null;
|
||||
originGood?: {
|
||||
id: bigint;
|
||||
@@ -13,7 +13,7 @@ export interface GoodRelations {
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@@ -57,10 +57,10 @@ export class GoodDto {
|
||||
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
tag?: { id: string; tagName: string; tagColor: string | null } | null;
|
||||
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
@@ -109,6 +109,7 @@ export class GoodDto {
|
||||
id: rel.tag.id.toString(),
|
||||
tagName: rel.tag.tagName,
|
||||
tagColor: rel.tag.tagColor,
|
||||
tagFontColor: rel.tag.tagFontColor,
|
||||
}
|
||||
: null,
|
||||
tags: rel.goodTags
|
||||
@@ -116,6 +117,7 @@ export class GoodDto {
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
}))
|
||||
: [],
|
||||
position: rel.position
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface OriginGoodsTreeNode {
|
||||
sdsGoodId: string;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
configuredTags: { tagName: string; tagColor: string | null }[];
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null }[];
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
@@ -125,7 +125,7 @@ export class OriginGoodsService {
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
good: { select: { originGoodId: true } },
|
||||
tag: { select: { tagName: true, tagColor: true } },
|
||||
tag: { select: { tagName: true, tagColor: true, tagFontColor: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -145,10 +145,10 @@ export class OriginGoodsService {
|
||||
else countryMap.set(key, [name]);
|
||||
});
|
||||
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null }[]>();
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null }[]>();
|
||||
goodsWithTags.forEach((gt) => {
|
||||
const key = gt.good.originGoodId.toString();
|
||||
const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor };
|
||||
const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, tagFontColor: gt.tag.tagFontColor };
|
||||
const arr = tagMap.get(key);
|
||||
if (arr) {
|
||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||
|
||||
@@ -17,10 +17,10 @@ export class PublicGoodDto {
|
||||
category!: { id: string; categoryName: string; categoryIcon: string | null };
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tag!: { id: string; tagName: string; tagColor: string | null } | null;
|
||||
tag!: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
position!: { id: string; indexVal: number } | null;
|
||||
|
||||
@@ -36,11 +36,10 @@ export class PublicQueryGoodDto {
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
@IsString()
|
||||
tagIds?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Tag as PrismaTag } from '@prisma/client';
|
||||
|
||||
export class PublicTagDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagColor!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagFontColor!: string | null;
|
||||
|
||||
static from(tag: PrismaTag): PublicTagDto {
|
||||
return {
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
|
||||
@ApiTags('public')
|
||||
@Controller('public')
|
||||
@@ -26,6 +27,12 @@ export class PublicController {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: 'Public paginated goods with filters' })
|
||||
getGoods(@Query() query: PublicQueryGoodDto) {
|
||||
|
||||
@@ -2,10 +2,9 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import {
|
||||
PublicCategoryNodeDto,
|
||||
} from './dto/public-category.dto';
|
||||
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
||||
import { PublicCountryDto } from './dto/public-country.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
export interface PublicPaginatedGoods {
|
||||
@@ -29,11 +28,34 @@ export class PublicService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
|
||||
const categoriesWithGoods = await this.prisma.category.findMany({
|
||||
const leafCategories = await this.prisma.category.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return this.buildTree(categoriesWithGoods);
|
||||
const ancestorIds = new Set<bigint>();
|
||||
for (const leaf of leafCategories) {
|
||||
let cursor: bigint | null = leaf.parentCategoryId;
|
||||
while (cursor !== null && !ancestorIds.has(cursor)) {
|
||||
ancestorIds.add(cursor);
|
||||
const parent = await this.prisma.category.findUnique({
|
||||
where: { id: cursor },
|
||||
select: { id: true, parentCategoryId: true },
|
||||
});
|
||||
if (!parent) break;
|
||||
cursor = parent.parentCategoryId;
|
||||
}
|
||||
}
|
||||
const ancestorRows = ancestorIds.size > 0
|
||||
? await this.prisma.category.findMany({
|
||||
where: { id: { in: [...ancestorIds] } },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
: [];
|
||||
const allRows = [...leafCategories, ...ancestorRows].filter(
|
||||
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx,
|
||||
);
|
||||
allRows.sort((a, b) => Number(a.id - b.id));
|
||||
return this.buildTree(allRows);
|
||||
}
|
||||
|
||||
async getCountries(): Promise<PublicCountryDto[]> {
|
||||
@@ -44,11 +66,26 @@ export class PublicService {
|
||||
return rows.map(PublicCountryDto.from);
|
||||
}
|
||||
|
||||
async getTags(): Promise<PublicTagDto[]> {
|
||||
const rows = await this.prisma.tag.findMany({
|
||||
where: { goodTags: { some: {} } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.map(PublicTagDto.from);
|
||||
}
|
||||
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
||||
if (query.tagId !== undefined) {
|
||||
where.goodTags = { some: { tagId: BigInt(query.tagId) } };
|
||||
if (query.tagIds) {
|
||||
const ids = query.tagIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => BigInt(s));
|
||||
if (ids.length > 0) {
|
||||
where.goodTags = { some: { tagId: { in: ids } } };
|
||||
}
|
||||
}
|
||||
if (query.keyword) {
|
||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
@@ -99,13 +136,13 @@ export class PublicService {
|
||||
goodPriority: number;
|
||||
country: { id: bigint; countryName: string; countryIcon: string | null };
|
||||
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
||||
tag: { id: bigint; tagName: string; tagColor: string | null } | null;
|
||||
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
position: { id: bigint; indexVal: number } | null;
|
||||
originGood: {
|
||||
goodImage: string | null;
|
||||
goodPrice: { toString(): string } | null;
|
||||
} | null;
|
||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
|
||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
createdAt: Date;
|
||||
}): PublicGoodDto {
|
||||
return {
|
||||
@@ -127,12 +164,14 @@ export class PublicService {
|
||||
id: good.tag.id.toString(),
|
||||
tagName: good.tag.tagName,
|
||||
tagColor: good.tag.tagColor,
|
||||
tagFontColor: good.tag.tagFontColor,
|
||||
}
|
||||
: null,
|
||||
tags: good.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
})),
|
||||
position: good.position
|
||||
? {
|
||||
|
||||
@@ -22,6 +22,16 @@ export class CreateTagDto {
|
||||
@IsHexColor()
|
||||
tagColor?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex font color, e.g. #FFFFFF',
|
||||
example: '#FFFFFF',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -16,6 +16,11 @@ export class UpdateTagDto {
|
||||
@IsHexColor()
|
||||
tagColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -28,6 +28,7 @@ export class TagsService {
|
||||
data: {
|
||||
tagName: dto.tagName,
|
||||
tagColor: dto.tagColor ?? null,
|
||||
tagFontColor: dto.tagFontColor ?? null,
|
||||
timing: dto.timing ?? null,
|
||||
},
|
||||
});
|
||||
@@ -47,6 +48,7 @@ export class TagsService {
|
||||
const data: Prisma.TagUpdateInput = {};
|
||||
if (dto.tagName !== undefined) data.tagName = dto.tagName;
|
||||
if (dto.tagColor !== undefined) data.tagColor = dto.tagColor;
|
||||
if (dto.tagFontColor !== undefined) data.tagFontColor = dto.tagFontColor;
|
||||
if (dto.timing !== undefined) data.timing = dto.timing;
|
||||
try {
|
||||
return await this.prisma.tag.update({ where: { id }, data });
|
||||
|
||||
Submodule inkreach-official-website updated: 967cceb099...71650f3b22
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user