HarmonyOS 模板市场实战:64 款内置卡片、分类补齐与搜索过滤
HarmonyOS 模板市场实战:64 款内置卡片、分类补齐与搜索过滤
一个卡片工具如果只展示用户已经创建的卡片,首屏很容易空。这个项目采用了“真实用户数据 + 内置模板目录”的组合:用户数据为空时,首页和分类页仍然能展示完整内容;用户创建卡片后,管理页再承载真实卡片。
本章围绕模板目录、分类补齐和搜索过滤展开实现拆解。
内置模板不是写在页面里
项目的模板目录放在AppDataService.ets:
interfaceTemplateCatalogItem{id:string;title:string;subtitle:string;detail:string;value:string;footer:string;badge:string;tone:ToneName;categoryId:CardCategoryId;tags:string[];tabs:CatalogTabKey[];popularity:number;}一条模板包含展示文案、分类、标签、所属 Tab 和热度。页面不直接维护模板,只调用服务层方法:
appDataService.getCategoryCards('recommend',this.searchText)appDataService.getCategoryRows(this.selectedCategoryId,this.searchText)appDataService.getCategoryHotRows()这样模板市场、分类页、详情页都能使用同一份目录。
分类元信息单独维护
分类 ID 是内部 key,用户看到的是中文名、色调和标记:
constCATEGORY_META_COUNTDOWN:CategoryMeta={label:'倒计时',tone:'rose',mark:'倒'};这个元信息用于:
- 分类概览卡标题。
- 列表行左侧标记。
- 详情页类别中文展示。
- fallback 卡片的色调。
不要在页面里到处写countdown -> 倒计时,否则后期改文案会很痛苦。
分类概览:真实数据不足时用模板补齐
分类页的“分类概览”不是简单读取用户卡片。真实用户卡片可能只覆盖一两个分类,如果直接展示,就会造成页面严重空白。
项目的策略是:
- 先按真实卡片分组。
- 缺失分类时,从内置模板目录生成分类 fallback。
recommend视图覆盖 8 个分类。
fallback 卡片由服务层创建:
privatecreateCategoryOverviewFallbackCard(categoryId:CardCategoryId,templates:TemplateCatalogItem[]):ShowcaseCardModel|undefined{constcategoryTemplates=templates.filter((item)=>item.categoryId===categoryId);if(!categoryTemplates.length){returnundefined;}constcategoryMeta=this.getCategoryMeta(categoryId);return{id:`category-overview-${categoryId}`,title:categoryMeta.label,subtitle:`${categoryTemplates.length}款可用卡片`,value:`${categoryTemplates.length}款`,footer:this.getCategoryOverviewFooter(categoryId),tone:categoryMeta.tone,categoryId:categoryId,imageKey:imageKeyForCategory(categoryId)};}注意这里带的是categoryId,不是templateId。因为概览卡的语义是“进入分类”,不是“进入某一张模板详情”。
热门列表:列表项再携带 templateId
分类概览点击后,页面会显示同类模板列表。列表项才应该携带templateId:
consttemplateId:string=item.templateId?item.templateId:item.id;router.pushUrl({url:RoutePaths.cardDetail,params:{templateId:templateId}});这样用户路径是:
分类概览卡 -> 同类模板列表 -> 模板详情 -> 添加到我的卡片这个路径比直接从分类概览进某张默认模板更清晰。
搜索过滤覆盖多个字段
模板搜索不只搜标题,也包含副标题、详情、footer 和 tags:
privategetFilteredTemplates(tabId:string,query:string):TemplateCatalogItem[]{constnormalizedQuery:string=query.trim();returnTEMPLATE_CATALOG.filter((item)=>item.tabs.indexOf(tabIdasCatalogTabKey)>=0).filter((item)=>{if(!normalizedQuery.length){returntrue;}constsourceText=[item.title,item.subtitle,item.detail,item.footer,item.tags.join(' ')].join(' ');returnincludesText(sourceText,normalizedQuery);}).sort((left,right)=>right.popularity-left.popularity);}这种搜索对模板市场更友好。用户搜“考试”“备份”“喝水”,都能命中相关模板。
图片资源也按模板 ID 映射
每个模板都有对应图片,资源映射放在CardImages.ets:
exportfunctionimageKeyForTemplate(templateId:string,categoryId:CardCategoryId):string{switch(templateId){case'birthday':return'template-birthday';case'exam-countdown':return'template-exam-countdown';case'weather-brief':return'template-weather-brief';default:returnimageKeyForCategory(categoryId);}}如果新增模板但没补图片,项目会回退到分类图。这个 fallback 可以保证不崩,但长期不应该依赖。新增模板时最好同步补:
TEMPLATE_CATALOGcard_template_<templateId>.pngCardImages.ets映射
页面侧保持简单
分类页只关心数据和点击:
Grid(){ForEach(this.filteredCategoryCards(),(item:ShowcaseCardModel)=>{GridItem(){ShowcaseCard({item:item,compactBadge:true,onCardClick:()=>{this.openShowcaseCard(item);}})}},(item:ShowcaseCardModel)=>item.id)}分类数据怎么补齐、图片怎么映射、搜索怎么过滤,页面都不直接处理。
验证清单
模板目录调整后,需要检查:
recommend分类概览是否覆盖 8 个分类。- 每个分类下是否有足够模板,不出现空列表。
- 搜索关键字能命中标题、标签和详情文案。
- 分类概览点击后进入同类列表,而不是直接详情。
- 列表项进入详情时带
templateId。 - 新增模板图片是否在
CardImages.ets中映射。
小结
模板市场的重点不是“塞更多假数据”,而是把内置模板当作正式数据源管理。这个项目把模板目录、分类元信息、搜索过滤、图片映射都收在服务层和资源层,让页面只负责展示和交互。
对卡片类、工具类、模板类应用来说,这种设计可以同时解决首屏空、分类不足、搜索不好用和详情参数丢失几个常见问题。
模板市场不是静态列表,而是“入口、筛选、兜底”的组合题
如果只讲MarketPage.ets里的数组,会显得像一个 UI 摆放示例;必须把它拆成三条真实链路:第一条是首页/底部导航进入市场,第二条是标签、搜索和分类页之间的筛选协作,第三条是图片、标题、统计值缺失时的兜底。Project028 的市场页价值就在这里:它不是后端驱动的复杂商城,但已经具备一个可审核、可扩展的本地模板市场雏形。
在MarketPage.ets中,页面同时依赖PageHeader、ChipTabs、SearchBarStub、ShowcaseCard和BottomNavBar。这说明它不是孤立页面,而是复用项目基础组件来保持视觉一致。marketSummaryCard()从appDataService.getMarketSummaryCard()取摘要卡,marketHeroImage()再通过cardImageResource()做图片资源解析。这里最容易被忽略的是兜底:如果摘要卡没有imageKey,页面会回退到CardImageKeys.marketLight,避免市场头图空白。
privatemarketSummaryCard():ShowcaseCardModel{returnappDataService.getMarketSummaryCard(this.selectedTab,this.normalizedQuery());}privatemarketHeroImage():Resource{constsummary:ShowcaseCardModel=this.marketSummaryCard();returncardImageResource(summary.imageKey?summary.imageKey:CardImageKeys.marketLight);}筛选逻辑也要写清楚。市场页的本地搜索并不直接修改模板源数据,而是通过matchesQuery()过滤展示列表;分类入口则跳转到RoutePaths.category,把更细的分类浏览交给CategoryPage.ets。这种拆法适合轻量应用:市场页承担“发现”,分类页承担“检索”,详情页承担“转化”。如果把三者塞在一个页面里,后续要接远端模板、收藏、下载量排序时会很难维护。
这里的实践判断很明确:本地模板市场不是偷懒,而是阶段性架构选择。Project028 当前没有服务器,也不应该为了展示模板引入不必要的接口层。正确做法是先把数据结构、入口、筛选、兜底、跳转打稳,等模板源变成远端时,只替换数据服务,不改页面交互。
工程检查清单
MarketPage -> CategoryPage -> CardDetailPage的入口关系要清楚。imageKey缺失时必须有兜底,避免头图或模板图空白。ChipTabs、SearchBarStub、ShowcaseCard是复用组件,不是普通装饰。- 轻量项目可先本地数据闭环,不必过早接入后端。
- 真实路径:
entry/src/main/ets/pages/MarketPage.ets、entry/src/main/ets/pages/CategoryPage.ets、entry/src/main/ets/common/CardImages.ets。
