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。
This commit is contained in:
@@ -0,0 +1,311 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-[#F7F8FA] py-8">
|
||||||
|
<div class="container mx-auto px-4 max-w-4xl">
|
||||||
|
<!-- 搜索框(可继续改词) -->
|
||||||
|
<form class="mb-6 flex items-center gap-3 rounded-xl border border-gray-200 bg-white p-4" @submit.prevent="onSubmit">
|
||||||
|
<input
|
||||||
|
v-model="inputKeyword"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入关键词..."
|
||||||
|
class="min-w-0 flex-1 rounded-full border border-gray-300 bg-[#F9FAFB] px-4 py-2.5 text-sm outline-none transition-colors focus:border-[#1E2A47] focus:bg-white"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="shrink-0 rounded-full bg-[#1E2A47] px-6 py-2.5 text-sm text-white transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- 未输入关键词 -->
|
||||||
|
<div v-if="!keywords" class="rounded-xl border border-gray-200 bg-white py-20 text-center">
|
||||||
|
<p class="text-sm text-gray-400">请输入关键词开始搜索</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 结果统计 -->
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
找到 <span class="font-semibold text-[#1E2A47]">{{ totalCount }}</span> 条与「{{ keywords }}」相关的内容
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 全部为空(且无故障) -->
|
||||||
|
<div v-if="showAllEmpty" class="rounded-xl border border-gray-200 bg-white py-20 text-center">
|
||||||
|
<p class="text-sm text-gray-500">没有找到与「{{ keywords }}」相关的内容</p>
|
||||||
|
<p class="mt-2 text-xs text-gray-400">换个关键词试试</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Tab 栏 -->
|
||||||
|
<div class="flex gap-1 rounded-t-xl border-b-2 border-gray-200 bg-white px-2">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
class="relative -mb-0.5 cursor-pointer border-b-2 border-transparent bg-transparent px-4 py-3 text-sm text-gray-500 transition-colors"
|
||||||
|
:class="activeTab === tab.key ? 'border-b-[#1E2A47] font-semibold text-[#1E2A47]' : ''"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span class="ml-1 text-xs text-gray-400">{{ tabCountLabel(tab.key) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 面板 -->
|
||||||
|
<div class="rounded-b-xl border border-t-0 border-gray-200 bg-white">
|
||||||
|
<!-- 当前类发生接口故障 -->
|
||||||
|
<div v-if="isFailed(activeTab)" class="px-4 py-16 text-center text-sm text-amber-700">
|
||||||
|
该类内容暂时无法加载,请稍后重试
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前类无结果 -->
|
||||||
|
<div v-else-if="activeList.length === 0" class="px-4 py-16 text-center text-sm text-gray-400">
|
||||||
|
暂无相关{{ TAB_LABEL[activeTab] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果列表 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 文章 -->
|
||||||
|
<template v-if="activeTab === 'article'">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(item, i) in articleList"
|
||||||
|
:key="`a-${item.articleId ?? item.id ?? i}`"
|
||||||
|
:to="`/article/${item.articleId ?? item.id}`"
|
||||||
|
class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.title }}</h3>
|
||||||
|
<p v-if="summaryOf(item)" class="line-clamp-2 text-[13px] text-gray-500">{{ summaryOf(item) }}</p>
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400">
|
||||||
|
<span v-if="item.categoryName">{{ item.categoryName }}</span>
|
||||||
|
<span v-if="item.publishTime">{{ formatDate(item.publishTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 案例 -->
|
||||||
|
<template v-else-if="activeTab === 'case'">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(item, i) in caseList"
|
||||||
|
:key="`c-${item.id ?? i}`"
|
||||||
|
:to="`/case/${item.id}`"
|
||||||
|
class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.title }}</h3>
|
||||||
|
<p v-if="item.summary" class="line-clamp-2 text-[13px] text-gray-500">{{ item.summary }}</p>
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400">
|
||||||
|
<span v-if="item.clientName">客户:{{ item.clientName }}</span>
|
||||||
|
<span v-if="item.industry">行业:{{ item.industry }}</span>
|
||||||
|
<span v-if="item.publishTime">{{ formatDate(item.publishTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 标书 -->
|
||||||
|
<template v-else>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(item, i) in tenderList"
|
||||||
|
:key="`t-${item.id ?? i}`"
|
||||||
|
:to="`/tender/${item.id}`"
|
||||||
|
class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.projectName }}</h3>
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400">
|
||||||
|
<span v-if="item.projectNo">编号:{{ item.projectNo }}</span>
|
||||||
|
<span v-if="item.tenderer">招标人:{{ item.tenderer }}</span>
|
||||||
|
<span v-if="item.tenderPrice != null" class="font-semibold text-[#E5472E]">
|
||||||
|
¥ {{ formatMoney(item.tenderPrice) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="item.deadlineTime">截止:{{ formatDate(item.deadlineTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 查看更多(无结果/失败时不显示) -->
|
||||||
|
<div v-if="moreLink" class="border-t border-gray-100 px-5 py-3 text-right">
|
||||||
|
<NuxtLink :to="moreLink" class="text-[13px] text-[#2563EB] hover:underline">
|
||||||
|
查看更多{{ TAB_LABEL[activeTab] }} →
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一搜索结果页 /search?keywords=xxx
|
||||||
|
*
|
||||||
|
* 一套公共页服务全部 10 套模板(不跟随模板主题色)——见
|
||||||
|
* .scratch/web-unified-search/issues/01-search-page-url-and-routing.md 的决定 3。
|
||||||
|
* 数据来自 /api/search 聚合接口(issues/07),三类一次拿全。
|
||||||
|
* 展示形态为 Tab 切换——见 issues/03-result-page-layout.md。
|
||||||
|
*
|
||||||
|
* 注意:本页是独立静态路由,**不走** useModuleRoute,也不需要登记进
|
||||||
|
* useTemplate 的 optionalComponents 清单。Nuxt 静态段优先级高于动态段,
|
||||||
|
* 不会被 pages/[slug].vue 抢走。
|
||||||
|
*/
|
||||||
|
type SectionKey = 'article' | 'case' | 'tender'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { siteInfo, fetchSiteInfo } = useSite()
|
||||||
|
|
||||||
|
const TAB_LABEL: Record<SectionKey, string> = {
|
||||||
|
article: '文章',
|
||||||
|
case: '案例',
|
||||||
|
tender: '标书'
|
||||||
|
}
|
||||||
|
/** Tab 顺序固定:文章 → 案例 → 标书 */
|
||||||
|
const TAB_ORDER: SectionKey[] = ['article', 'case', 'tender']
|
||||||
|
|
||||||
|
const keywords = computed(() => String(route.query.keywords ?? '').trim())
|
||||||
|
const inputKeyword = ref(keywords.value)
|
||||||
|
|
||||||
|
// 路由关键词变化时同步输入框(如从头部搜索框再次提交)
|
||||||
|
watch(keywords, (kw) => {
|
||||||
|
inputKeyword.value = kw
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchSiteInfo()
|
||||||
|
|
||||||
|
// 服务端/客户端都直接 await,避免先闪空白(与各列表页的做法一致)
|
||||||
|
const { data } = await useFetch<any>('/api/search', {
|
||||||
|
key: `search-${keywords.value || 'empty'}`,
|
||||||
|
query: { keywords: keywords.value || undefined },
|
||||||
|
watch: [keywords]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 取某一类的 { list, count };失败或缺失时返回 null */
|
||||||
|
function section(key: SectionKey): { list: any[]; count: number } | null {
|
||||||
|
const s = data.value?.[key]
|
||||||
|
if (!s || s.error) return null
|
||||||
|
return { list: Array.isArray(s.list) ? s.list : [], count: s.count ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFailed(key: SectionKey): boolean {
|
||||||
|
return !!data.value?.[key]?.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleSection = computed(() => section('article'))
|
||||||
|
const caseSection = computed(() => section('case'))
|
||||||
|
const tenderSection = computed(() => section('tender'))
|
||||||
|
|
||||||
|
const articleList = computed<any[]>(() => articleSection.value?.list ?? [])
|
||||||
|
const caseList = computed<any[]>(() => caseSection.value?.list ?? [])
|
||||||
|
const tenderList = computed<any[]>(() => tenderSection.value?.list ?? [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整页"没有找到"态:**只有当三类都正常返回且都为空**时才显示。
|
||||||
|
*
|
||||||
|
* 必须排除故障类:若某类接口挂了(section 为 null),不能因此断定"全站没有相关内容"——
|
||||||
|
* 那会把接口故障谎报成"确实没有",用户会以为系统搜过了。有故障时照常渲染 Tab 栏,
|
||||||
|
* 让用户在对应 Tab 上看到"暂时无法加载"的准确提示。
|
||||||
|
*/
|
||||||
|
const allEmpty = computed(() =>
|
||||||
|
[articleSection.value, caseSection.value, tenderSection.value].every(s => s !== null && s.list.length === 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 是否存在故障类 */
|
||||||
|
const hasFailure = computed(() =>
|
||||||
|
TAB_ORDER.some(key => isFailed(key))
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 整页"没有找到"态:三类都正常返回且都为空,且没有任何故障 */
|
||||||
|
const showAllEmpty = computed(() => allEmpty.value && !hasFailure.value)
|
||||||
|
|
||||||
|
/** Tab 计数文案:故障显示 "—"(不能把故障谎报成 0) */
|
||||||
|
function tabCountLabel(key: SectionKey): string {
|
||||||
|
if (isFailed(key)) return '—'
|
||||||
|
return String(section(key)?.count ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果总数(只累加正常返回的类,故障类不计入,避免误导) */
|
||||||
|
const totalCount = computed(() =>
|
||||||
|
[articleSection.value, caseSection.value, tenderSection.value]
|
||||||
|
.reduce((sum, s) => sum + (s?.count ?? 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认停留 Tab,优先级:
|
||||||
|
* 1. 第一个**有结果**的类(按 文章→案例→标书 固定顺序)
|
||||||
|
* 2. 都没有结果时,第一个**正常返回**(非故障)的类——让用户看到"暂无相关"这种
|
||||||
|
* 准确信息,而不是一进来就撞上某类的故障提示
|
||||||
|
* 3. 全是故障时,才退回第一个
|
||||||
|
*
|
||||||
|
* 数据在 SSR 阶段已就绪,故这里直接按数据算,不需要等水合,避免 Tab 闪跳。
|
||||||
|
*/
|
||||||
|
const activeTab = ref<SectionKey>(defaultTab())
|
||||||
|
function defaultTab(): SectionKey {
|
||||||
|
const nonEmpty = TAB_ORDER.find(key => {
|
||||||
|
const s = section(key)
|
||||||
|
return s !== null && s.list.length > 0
|
||||||
|
})
|
||||||
|
if (nonEmpty) return nonEmpty
|
||||||
|
|
||||||
|
const healthy = TAB_ORDER.find(key => !isFailed(key))
|
||||||
|
return healthy ?? TAB_ORDER[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词变化后重算默认 Tab(客户端再次搜索时)
|
||||||
|
watch(keywords, () => {
|
||||||
|
activeTab.value = defaultTab()
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = TAB_ORDER.map(key => ({ key, label: TAB_LABEL[key] }))
|
||||||
|
|
||||||
|
const activeList = computed<any[]>(() => {
|
||||||
|
if (activeTab.value === 'article') return articleList.value
|
||||||
|
if (activeTab.value === 'case') return caseList.value
|
||||||
|
return tenderList.value
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 「查看更多」目标:文章可带词(/article?keywords= 是保留的域内检索入口);
|
||||||
|
* 案例列表页没有关键词检索入口,故不带词(见 issues/03 的遗留说明);
|
||||||
|
* 标书列表页同上。 */
|
||||||
|
const moreLink = computed(() => {
|
||||||
|
if (activeList.value.length === 0) return ''
|
||||||
|
if (activeTab.value === 'article') return `/article?keywords=${encodeURIComponent(keywords.value)}`
|
||||||
|
if (activeTab.value === 'case') return '/case'
|
||||||
|
return '/tender'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 文章摘要有两个可能字段名(上游 overview / summary),取第一个非空 */
|
||||||
|
function summaryOf(item: any): string {
|
||||||
|
return item?.overview || item?.summary || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const d = dayjs(value)
|
||||||
|
return d.isValid() ? d.format('YYYY-MM-DD') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(n?: number | string): string {
|
||||||
|
if (n === undefined || n === null || n === '') return '0.00'
|
||||||
|
return Number(n).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
const kw = inputKeyword.value.trim()
|
||||||
|
router.push({ path: '/search', query: kw ? { keywords: kw } : {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SEO =====
|
||||||
|
// 带关键词的结果页是低质量/易重复页面,禁止被索引(沿用 useModuleRoute.ts 的既有约定);
|
||||||
|
// follow 让爬虫仍能顺着结果里的链接爬到详情页。
|
||||||
|
usePageSeo(
|
||||||
|
{
|
||||||
|
title: keywords.value ? `搜索"${keywords.value}"` : '搜索',
|
||||||
|
path: '/search'
|
||||||
|
},
|
||||||
|
siteInfo.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if (keywords.value) {
|
||||||
|
useSeoMeta({ robots: 'noindex, follow' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
* 共享头部搜索框(Nuxt 自动导入,全局可用)
|
* 共享头部搜索框(Nuxt 自动导入,全局可用)
|
||||||
* 由各模板 Header 在 logo 右侧以 <SiteSearchBox v-if="showHeaderSearch" /> 挂载。
|
* 由各模板 Header 在 logo 右侧以 <SiteSearchBox v-if="showHeaderSearch" /> 挂载。
|
||||||
* 是否显示由 useSite().showHeaderSearch(后台 setting.searchBtn)控制。
|
* 是否显示由 useSite().showHeaderSearch(后台 setting.searchBtn)控制。
|
||||||
* 提交后带关键词跳转 /article?keywords=xxx(复用 fetchArticles 的 keywords 参数)。
|
* 提交后带关键词跳转 /search?keywords=xxx(统一搜索结果页,覆盖文章/案例/标书三类)。
|
||||||
*
|
*
|
||||||
* 定位(ml-8 / ml-auto 等)由父组件通过 class 透传(fallthrough 合并到根 <form>);
|
* 定位(ml-8 / ml-auto 等)由父组件通过 class 透传(fallthrough 合并到根 <form>);
|
||||||
* 视觉主题通过 variant / accent 适配各模板配色。
|
* 视觉主题通过 variant / accent 适配各模板配色。
|
||||||
@@ -67,6 +67,10 @@ const iconClass = computed(() => {
|
|||||||
|
|
||||||
function onSearch() {
|
function onSearch() {
|
||||||
const kw = keyword.value.trim()
|
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 } : {} })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<SearchResult />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 统一搜索结果页 /search?keywords=xxx
|
||||||
|
*
|
||||||
|
* 一套公共页服务全部 10 套模板(不走模板组件清单,见 issues/01 的决定 3)。
|
||||||
|
* 页面实现在 app/components/SearchResult.vue(Nuxt 自动导入)。
|
||||||
|
*
|
||||||
|
* 路由优先级:Nuxt 文件路由中静态段优先于动态段,故 /search 命中本文件,
|
||||||
|
* 不会被 app/pages/[slug].vue 抢走。
|
||||||
|
*/
|
||||||
|
usePageSeo({ title: '搜索', path: '/search' })
|
||||||
|
</script>
|
||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user