Files
hjc-web/app/components/SearchResult.vue
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

312 lines
13 KiB
Vue
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.
<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>