From d3cbe1ba37241283e4e5abc50e9679881f1bec40 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 29 Sep 2026 04:56:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E5=AE=98=E7=BD=91=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BB=9F=E4=B8=80=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E9=A1=B5=EF=BC=8C=E4=B8=80=E6=AC=A1=E6=90=9C=E5=88=B0=E6=96=87?= =?UTF-8?q?=E7=AB=A0/=E6=A1=88=E4=BE=8B/=E6=A0=87=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本头部搜索框硬编码跳 /article,只能搜到文章——案例与标书虽然后端 早已支持 keywords 检索,前端却从未触达,这是「搜不到案例和标书」的根因。 本次新增 /search 统一结果页,三类一次搜全: - server/api/search.get.ts:新增聚合接口,并发查三个上游并归一化输出 {article, case, tender}。用 Promise.allSettled 做部分降级——某类挂了只 降级该类,返回 {error:true}(不是 {list:[]},否则前端会把接口故障渲染成 「暂无结果」,等于对用户说谎),其余两类照常,整体不 500。 沿用既有代理的 status/deleted 过滤(文章/案例 status:0+deleted:0、 标书 status:1),这些是内容安全边界,防止草稿/下架/软删内容泄露到 C 端。 不改动既有 article/case/tender 三个 list 代理,它们有大量现存消费方。 - app/pages/search.vue + app/components/SearchResult.vue:公共页,一套 UI 服务全部 10 套模板(不逐模板实现——10 套里只有 template-07 有标书 UI)。 Tab 切换(文章→案例→标书),默认停第一个非空类;标书卡片展示价格与 截止时间(购买决策的关键信息);带关键词时 noindex,follow,沿用既有约定。 - app/components/SiteSearchBox.vue:跳转改为 /search。该组件被 10 套模板的 12 处 Header 共用,改这一处即全部生效。/article?keywords= 仍作为 「只搜文章」的域内检索入口保留,只是不再由头部搜索框触达。 实测:关键词「消防」下文章 7 条、案例 2 条正常渲染,计数 7/2/0 与总数 9 均正确;标书用「测试」命中 2 条,价格/截止时间/编号/招标人字段齐全。 部分降级与故障态(计数显示 —、文案与「暂无」区分开)已用桩数据逐一验证。 修复实测中发现的一个缺陷:某类接口故障时整页会误报「没有找到与…相关的 内容」——把接口故障谎报成「确实没有」。原因是整页空态判定 allEmpty 在 「某类为 null(故障)+ 其余为空」时恰好成立,已改为 allEmpty && !hasFailure。 --- app/components/SearchResult.vue | 311 +++++++++++++++++++++++++++++++ app/components/SiteSearchBox.vue | 8 +- app/pages/search.vue | 16 ++ server/api/search.get.ts | 124 ++++++++++++ 4 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 app/components/SearchResult.vue create mode 100644 app/pages/search.vue create mode 100644 server/api/search.get.ts diff --git a/app/components/SearchResult.vue b/app/components/SearchResult.vue new file mode 100644 index 0000000..b4e4d1e --- /dev/null +++ b/app/components/SearchResult.vue @@ -0,0 +1,311 @@ + + + diff --git a/app/components/SiteSearchBox.vue b/app/components/SiteSearchBox.vue index 5f2dbb1..da15fe5 100644 --- a/app/components/SiteSearchBox.vue +++ b/app/components/SiteSearchBox.vue @@ -29,7 +29,7 @@ * 共享头部搜索框(Nuxt 自动导入,全局可用) * 由各模板 Header 在 logo 右侧以 挂载。 * 是否显示由 useSite().showHeaderSearch(后台 setting.searchBtn)控制。 - * 提交后带关键词跳转 /article?keywords=xxx(复用 fetchArticles 的 keywords 参数)。 + * 提交后带关键词跳转 /search?keywords=xxx(统一搜索结果页,覆盖文章/案例/标书三类)。 * * 定位(ml-8 / ml-auto 等)由父组件通过 class 透传(fallthrough 合并到根
); * 视觉主题通过 variant / accent 适配各模板配色。 @@ -67,6 +67,10 @@ const iconClass = computed(() => { function onSearch() { const kw = keyword.value.trim() - router.push({ path: '/article', query: kw ? { keywords: kw } : {} }) + // 跳到统一搜索结果页,一次覆盖文章 / 案例 / 标书三类。 + // 历史行为是跳 /article(只会搜到文章),即「搜不到案例和标书」的根因, + // 见 .scratch/web-unified-search/issues/01-search-page-url-and-routing.md。 + // /article?keywords= 仍作为「只搜文章」的域内检索入口保留,只是不再由本搜索框触达。 + router.push({ path: '/search', query: kw ? { keywords: kw } : {} }) } diff --git a/app/pages/search.vue b/app/pages/search.vue new file mode 100644 index 0000000..fb0bac0 --- /dev/null +++ b/app/pages/search.vue @@ -0,0 +1,16 @@ + + + diff --git a/server/api/search.get.ts b/server/api/search.get.ts new file mode 100644 index 0000000..2f008e8 --- /dev/null +++ b/server/api/search.get.ts @@ -0,0 +1,124 @@ +import { $fetch } from 'ofetch' +import { defineEventHandler, getQuery } from 'h3' +import { useRuntimeConfig } from '#imports' +// 注意:本文件在 server/api/ 根下(比 server/api/case/list.get.ts 浅一层), +// 故 utils 的相对路径是 ../utils/,不是 ../../utils/。 +import { getTenantFromContext } from '../utils/tenant' +import { normalizeCaseItem } from '../utils/case' + +/** + * 统一搜索(文章 / 案例 / 标书) + * GET /api/search?keywords=xxx + * + * 一次请求并发查询三个上游,归一化为统一结构: + * { + * article: { list, count } | { error: true }, + * case: { list, count } | { error: true }, + * tender: { list, count } | { error: true } + * } + * + * 设计要点(见 .scratch/web-unified-search/issues/02-aggregation-strategy.md): + * - 每类固定 limit=10,不做分页;结果页一次拿全,用户想看更多去各自模块页。 + * - 用 Promise.allSettled(不是 all):某类上游失败时**只降级那一类**,其余照常返回, + * 整页不 500。失败的那类返 { error: true } 而非 { list: [] }——后者会让前端把 + * 「接口故障」渲染成「暂无结果」,等于对用户说谎(前端据 error 标记区分文案)。 + * - 不改动既有的 article/case/tender 三个 list 代理,它们有大量现有消费方。 + * - status / deleted 是内容安全边界(防草稿、下架、软删内容泄露到 C 端), + * 与现有三个代理保持完全一致的取值,不可省略。 + */ + +/** 每类返回的条数上限(结果页一次性展示,不做分页) */ +const SEARCH_LIMIT = 10 + +/** 单类结果:成功为 { list, count },失败为 { error: true } */ +type SectionResult = { list: T[]; count: number } | { error: true } + +/** 从上游各种包装形态里取出 { list, count }(与 case/product/list 代理同一套兜底逻辑) */ +function unwrapList(res: any): { list: any[]; count: number } { + const envelopeData = (res && res.data !== undefined ? res.data : res) || {} + const list = Array.isArray(envelopeData.list) ? envelopeData.list : [] + const count = envelopeData.count ?? envelopeData.total ?? list.length + return { list, count } +} + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const ctx = getTenantFromContext(event, config) + const query = getQuery(event) + const modulesApiBase = config.public.modulesApiBase as string + + const keywords = String(query.keywords ?? '').trim() + + // 空关键词不是错误:不请求上游,直接返回三组空结果。 + // (避免无谓的上游调用,也让搜索页的「未输入关键词」态有确定的响应形状。) + if (!keywords) { + const empty: SectionResult = { list: [], count: 0 } + return { article: empty, case: empty, tender: empty } + } + + const commonHeaders = { TenantId: ctx.tenantId } + + // 三个上游并发;每个任务内部自己兜底成 SectionResult,故 allSettled 的 rejected 分支 + // 实际只会在「连 try/catch 都没兜住」的意外情况下触发,仍按 error 处理,不抛给调用方。 + const [article, caseRes, tender] = await Promise.allSettled([ + // 文章:/cms/cms-article/page + // status 0=已发布 1=草稿 2=下架;deleted=0 排除软删。放在 keywords 之后覆盖,防前端传入。 + $fetch('/cms/cms-article/page', { + baseURL: modulesApiBase, + headers: commonHeaders, + query: { + keywords, + page: 1, + limit: SEARCH_LIMIT, + status: 0, + deleted: 0 + } + }), + + // 案例:/cms/cms-case/page(注意是 cms-case 控制器,不是 cms-website 兜底路由) + $fetch('/cms/cms-case/page', { + baseURL: modulesApiBase, + headers: commonHeaders, + query: { + keywords, + page: 1, + limit: SEARCH_LIMIT, + TenantId: ctx.tenantId, + status: 0, + deleted: 0 + } + }), + + // 标书:/hjc/bid-project/list,status=1 只搜在售(与 /tender 列表口径一致)。 + // 上游 XML 已写死 deleted=0,无需也不应再传 deleted(HjcBidProjectParam 上无该字段)。 + $fetch('/hjc/bid-project/list', { + baseURL: modulesApiBase, + headers: commonHeaders, + query: { + keywords, + page: 1, + limit: SEARCH_LIMIT, + status: 1, + TenantId: ctx.tenantId + } + }) + ]) + + const section = (settled: PromiseSettledResult, map: (raw: any) => T): SectionResult => { + if (settled.status === 'rejected') { + return { error: true } + } + const { list, count } = unwrapList(settled.value) + return { list: list.map(map), count } + } + + return { + // 文章:前端直接消费上游字段(id/title/summary/publishTime/categoryName) + article: section(article, (raw: any) => raw), + // 案例:上游返回 caseId/caseName/customer/industry,模板按 id/title/clientName 渲染, + // 复用 case/list 代理同款归一化,避免搜索页再写一份字段映射 + case: section(caseRes, normalizeCaseItem), + // 标书:前端直接消费上游字段(id/projectName/tenderPrice/deadlineTime/projectNo/tenderer) + tender: section(tender, (raw: any) => raw) + } +})