Files
hjc-web/server/api/search.get.ts
T
weicw-dshsa d3cbe1ba37 feat(search): 官网新增统一搜索结果页,一次搜到文章/案例/标书
原本头部搜索框硬编码跳 /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。
2026-09-29 04:56:38 +08:00

125 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T> = { 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<never> = { 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 = <T>(settled: PromiseSettledResult<any>, map: (raw: any) => T): SectionResult<T> => {
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)
}
})