feat: 产品列表与解决方案列表的排序 #69

Manually merged
remilia merged 4 commits from feat/type-sort into master 2025-11-08 15:51:41 +08:00
11 changed files with 136 additions and 52 deletions

View File

@ -19,6 +19,7 @@ export const useProductList = () => {
{ {
translations: ['id', 'name'], translations: ['id', 'name'],
}, },
'sort',
], ],
}, },
], ],

View File

@ -12,7 +12,7 @@ export const useSolutionList = () => {
'id', 'id',
'cover', 'cover',
{ {
type: ['id', { translations: ['id', 'name'] }], type: ['id', { translations: ['id', 'name'] }, 'sort'],
}, },
{ {
translations: ['id', 'title', 'summary'], translations: ['id', 'title', 'summary'],

View File

@ -14,6 +14,7 @@ describe('toProductListView', () => {
product_type: { product_type: {
id: 1, id: 1,
translations: [{ id: 20, name: 'Type Name' }], translations: [{ id: 20, name: 'Type Name' }],
sort: 1,
}, },
}; };
@ -22,7 +23,11 @@ describe('toProductListView', () => {
name: 'Product Name', name: 'Product Name',
summary: 'Product Summary', summary: 'Product Summary',
cover: 'cover-file-uuid-1234', cover: 'cover-file-uuid-1234',
product_type: 'Type Name', product_type: {
id: 1,
name: 'Type Name',
sort: 1,
},
}); });
}); });
@ -34,6 +39,7 @@ describe('toProductListView', () => {
product_type: { product_type: {
id: 20, id: 20,
translations: [], translations: [],
sort: 1,
}, },
}; };
@ -42,7 +48,11 @@ describe('toProductListView', () => {
name: '', name: '',
summary: '', summary: '',
cover: 'cover-file-uuid-1234', cover: 'cover-file-uuid-1234',
product_type: '', product_type: {
id: 20,
name: '',
sort: 1,
},
}); });
}); });
}); });

View File

@ -1,3 +1,22 @@
/**
* 将Directus返回的ProductType数据转换为ProductTypeView视图模型
*
* @param raw: 原始的ProductType数据
* @returns 转换后的ProductTypeView对象
*
* @example
* const view = toProductTypeView(rawProductType);
*/
export function toProductTypeView(raw: ProductType): ProductTypeView {
const trans = raw.translations?.[0] ?? { name: '' };
return {
id: raw.id,
name: trans.name,
sort: raw?.sort ?? 999,
};
}
/** /**
* 将 Directus返回的 Product 数据转换为 ProductListView 视图模型 * 将 Directus返回的 Product 数据转换为 ProductListView 视图模型
* *
@ -11,10 +30,8 @@ export function toProductListView(raw: Product): ProductListView {
const trans = raw.translations?.[0] ?? { name: '', summary: '' }; const trans = raw.translations?.[0] ?? { name: '', summary: '' };
const type = isObject<ProductType>(raw.product_type) const type = isObject<ProductType>(raw.product_type)
? raw.product_type.translations.length === 0 ? toProductTypeView(raw.product_type)
? '' : undefined;
: raw.product_type.translations[0].name
: '';
return { return {
id: raw.id, id: raw.id,

View File

@ -13,6 +13,7 @@ describe('toSolutionListView', () => {
type: { type: {
id: 1, id: 1,
translations: [{ id: 1, name: 'Type Name' }], translations: [{ id: 1, name: 'Type Name' }],
sort: 1,
}, },
}; };
@ -20,7 +21,11 @@ describe('toSolutionListView', () => {
id: 1, id: 1,
title: 'Solution Title', title: 'Solution Title',
summary: 'Solution Summary', summary: 'Solution Summary',
solution_type: 'Type Name', solution_type: {
id: 1,
name: 'Type Name',
sort: 1,
},
}); });
}); });
@ -31,6 +36,7 @@ describe('toSolutionListView', () => {
type: { type: {
id: 1, id: 1,
translations: [], translations: [],
sort: null,
}, },
}; };
@ -38,7 +44,11 @@ describe('toSolutionListView', () => {
id: 1, id: 1,
title: '', title: '',
summary: '', summary: '',
solution_type: '', solution_type: {
id: 1,
name: '',
sort: 999,
},
}); });
}); });
}); });

View File

@ -1,3 +1,24 @@
/**
* 将 Directus 返回的 SolutionType 数据转换为 SolutionTypeView 视图模型
*
* @param raw: 原始的 SolutionType 数据
* @returns 转换后的 SolutionTypeView 对象
*
* ---
*
* @example
* const view = toSolutionTypeView(rawSolutionType);
*/
export function toSolutionTypeView(raw: SolutionType): SolutionTypeView {
const trans = raw.translations?.[0] ?? { name: '' };
return {
id: raw.id,
name: trans.name,
sort: raw?.sort ?? 999,
};
}
/** /**
* 将 Directus 返回的 Solution 数据转换为 SolutionListView 视图模型 * 将 Directus 返回的 Solution 数据转换为 SolutionListView 视图模型
* *
@ -16,10 +37,8 @@ export function toSolutionListView(raw: Solution): SolutionListView {
}; };
const type = isObject<SolutionType>(raw.type) const type = isObject<SolutionType>(raw.type)
? raw.type.translations.length === 0 ? toSolutionTypeView(raw.type)
? '' : undefined;
: raw.type.translations[0].name
: '';
return { return {
id: raw.id, id: raw.id,

View File

@ -1,3 +1,18 @@
/**
* 产品类型视图模型
* 用于产品列表页的section渲染与排序
*/
export interface ProductTypeView {
/** 唯一标识符 **/
id: number;
/** 类型名 **/
name: string;
/** 排序字段 **/
sort: number;
}
/** /**
* 产品列表视图模型 * 产品列表视图模型
* 用于产品列表(/products)渲染的数据结构 * 用于产品列表(/products)渲染的数据结构
@ -13,7 +28,7 @@ export interface ProductListView {
summary: string; summary: string;
/** 产品类型 **/ /** 产品类型 **/
product_type: string; product_type: ProductTypeView;
/** 产品封面(图片的id) **/ /** 产品封面(图片的id) **/
cover: string; cover: string;

View File

@ -1,3 +1,18 @@
/**
* 解决方案类型视图模型
* 用于解决方案列表页标签栏的渲染与排序
*/
export interface SolutionTypeView {
/** 唯一标识符 **/
id: number;
/** 类型名 **/
name: string;
/** 排序字段 **/
sort: number;
}
/** /**
* 解决方案列表模型 * 解决方案列表模型
* 用于解决方案列表(/solutions)渲染的数据结构 * 用于解决方案列表(/solutions)渲染的数据结构
@ -13,7 +28,7 @@ export interface SolutionListView {
summary: string; summary: string;
/** 解决方案类型 **/ /** 解决方案类型 **/
solution_type: string; solution_type: SolutionTypeView;
/** 解决方案封面(图片id) **/ /** 解决方案封面(图片id) **/
cover: string; cover: string;

View File

@ -8,14 +8,14 @@
<div class="products-container"> <div class="products-container">
<el-collapse v-model="activeNames" class="product-collapse"> <el-collapse v-model="activeNames" class="product-collapse">
<el-collapse-item <el-collapse-item
v-for="(group, type) in groupedProducts" v-for="[key, value] in Object.entries(groupedProducts)"
:key="type" :key="key"
:title="type || '未分类'" :title="key || '未分类'"
:name="type || 'no-category'" :name="key || 'no-category'"
> >
<div class="group-list"> <div class="group-list">
<product-card <product-card
v-for="product in group" v-for="product in value.data"
:key="product.id" :key="product.id"
:slug="product.id.toString()" :slug="product.id.toString()"
:image-url="getImageUrl(product.cover.toString())" :image-url="getImageUrl(product.cover.toString())"
@ -51,25 +51,23 @@
productsRaw.value.map((item) => toProductListView(item)) productsRaw.value.map((item) => toProductListView(item))
); );
logger.debug('products: ', products.value);
// 按类型分组 // 按类型分组
// 兼容 product_type 既可能为对象也可能为字符串
const groupedProducts = computed(() => { const groupedProducts = computed(() => {
const groups: Record<string, ProductListView[]> = {}; const groups: Record<string, { data: ProductListView[]; sort: number }> =
{};
for (const prod of products.value) { for (const prod of products.value) {
let typeKey = ''; const typeKey = prod.product_type?.name ?? '';
if (typeof prod.product_type === 'string') { if (!groups[typeKey]) {
typeKey = prod.product_type; groups[typeKey] = { data: [], sort: prod.product_type?.sort ?? 999 };
} else if (
prod.product_type &&
typeof prod.product_type === 'object' &&
'name' in prod.product_type
) {
typeKey = prod.product_type || '';
} }
if (!groups[typeKey]) groups[typeKey] = []; groups[typeKey]?.data.push(prod);
groups[typeKey]?.push(prod);
} }
return groups; const sortedGroups = Object.fromEntries(
Object.entries(groups).sort(([, a], [, b]) => a.sort - b.sort)
);
return sortedGroups;
}); });
watch(groupedProducts, () => { watch(groupedProducts, () => {

View File

@ -19,14 +19,14 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane <el-tab-pane
v-for="(group, type) in groupedSolutions" v-for="[key, value] in Object.entries(groupedSolutions)"
:key="type" :key="key"
:label="type || '未分类'" :label="key || '未分类'"
:name="type || 'no-category'" :name="key || 'no-category'"
> >
<div class="solution-list"> <div class="solution-list">
<solution-card <solution-card
v-for="solution in group" v-for="solution in value.data"
:key="solution.id" :key="solution.id"
:document-id="solution.id.toString()" :document-id="solution.id.toString()"
:cover-url="getImageUrl(solution.cover || '')" :cover-url="getImageUrl(solution.cover || '')"
@ -63,22 +63,19 @@
// 按类型分组 // 按类型分组
const groupedSolutions = computed(() => { const groupedSolutions = computed(() => {
const gourps: Record<string, SolutionListView[]> = {}; const groups: Record<string, { data: SolutionListView[]; sort: number }> =
{};
for (const sol of solutions.value) { for (const sol of solutions.value) {
let typeKey = ''; const typeKey = sol.solution_type?.name ?? '';
if (typeof sol.solution_type === 'string') { if (!groups[typeKey]) {
typeKey = sol.solution_type; groups[typeKey] = { data: [], sort: sol.solution_type?.sort ?? 999 };
} else if (
sol.solution_type &&
typeof sol.solution_type === 'object' &&
'type' in sol.solution_type
) {
typeKey = sol.solution_type || '';
} }
if (!gourps[typeKey]) gourps[typeKey] = []; groups[typeKey]?.data.push(sol);
gourps[typeKey]?.push(sol);
} }
return gourps; const sortedGroups = Object.fromEntries(
Object.entries(groups).sort(([, a], [, b]) => a.sort - b.sort)
);
return sortedGroups;
}); });
watch(error, (value) => { watch(error, (value) => {

View File

@ -142,6 +142,7 @@ export interface ProductType {
id: number; id: number;
/** @description 当前产品条目的状态 */ /** @description 当前产品条目的状态 */
status?: 'published' | 'draft' | 'archived'; status?: 'published' | 'draft' | 'archived';
sort?: number | null;
/** @description i18n文本 */ /** @description i18n文本 */
translations?: ProductTypesTranslation[] | null; translations?: ProductTypesTranslation[] | null;
} }
@ -231,6 +232,7 @@ export interface SolutionType {
/** @primaryKey */ /** @primaryKey */
id: number; id: number;
status?: 'published' | 'draft' | 'archived'; status?: 'published' | 'draft' | 'archived';
sort?: number | null;
/** @description i18n字段 */ /** @description i18n字段 */
translations?: SolutionTypesTranslation[] | null; translations?: SolutionTypesTranslation[] | null;
} }