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