Files
hjc-web/app/components/SearchResult.vue
T
weicw-dshsa 80136ac414 fix(search): 搜索页一律 noindex,并把配色对齐 template-07
两处收尾,均针对当前启用的 template-07。

1) SEO 收口(ticket 05)
原实现只在带关键词时 noindex,不带关键词时输出 index,follow——等于邀请
搜索引擎收录一个只有「请输入关键词」的空页。改为无条件 noindex,follow:

- 带关键词:同一批内容有无数种关键词组合入口,典型低质量/易重复页面
  (沿用 useModuleRoute.ts 对 /article?keywords= 的既有约定)
- 不带关键词:空页,对搜索引擎和用户都是垃圾页
- 保留 follow 而非 nofollow,让爬虫仍能顺着结果链接爬到详情页

无关键词时空页保持显示引导文案,不 404、不重定向——被索引才是问题,
而它已被 noindex 解决。canonical 维持指向 /search;sitemap 经运行时验证
本就不含搜索页(269 条 URL 无命中)。

明确不做:不把 /search 加进 robots.txt 的 Disallow。Disallow 是「禁止抓取」,
爬虫遵守后就不会去读页面里的 noindex,URL 仍可能出现在结果里(只是没有标题
摘要)——反而更糟。故 server/api/robots.txt.ts 未改动。

2) 配色对齐 template-07
搜索页原用 navy #1E2A47,与本模板不符:template-07 主色是 #1a6dff,
其 TenderList 用 text-blue-600 渲染价格。已改为:
- 主色(按钮/聚焦边框/选中 Tab/计数)#1E2A47 -> #1a6dff
- 标书价格 red #E5472E -> #2563eb(对齐 TenderList 的 text-blue-600)
- 链接蓝统一为小写 #2563eb

实测:带/不带关键词两种情况的 robots 均为 noindex,follow;页面总数与三个
Tab 渲染正常;eslint 0 error / 0 warning。
2026-09-29 05:18:29 +08:00

337 lines
14 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-[#1a6dff] focus:bg-white"
>
<button
type="submit"
class="shrink-0 rounded-full bg-[#1a6dff] 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-[#1a6dff]">{{ 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-[#1a6dff] font-semibold text-[#1a6dff]' : ''"
@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-[#2563eb]">
¥ {{ 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'
import type { Article, CaseItem } from '~/types'
import type { Tender } from '~/types/tender'
/**
* /api/search 的响应形状(见 server/api/search.get.ts)
*
* 某类上游失败时该类返回 `{ error: true }` 而非 `{ list: [] }`——前端据此把
* 「接口故障」与「确实没有结果」区分开,不能混为一谈。
*/
type Section<T> = { list: T[]; count: number } | { error: true }
interface SearchResponse {
article: Section<Article>
case: Section<CaseItem>
tender: Section<Tender>
}
/**
* 统一搜索结果页 /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,避免先闪空白(与各列表页的做法一致)。
// 用 useFetch(非裸 $fetch):SSR 期间它会转发请求头,租户中间件据此解析租户;
// 本页是匿名只读页,不需要转发 Cookie,故与各列表页一致用 useFetch 即可。
const { data } = await useFetch<SearchResponse>('/api/search', {
key: `search-${keywords.value || 'empty'}`,
query: { keywords: keywords.value || undefined },
watch: [keywords]
})
/** 某一类的类型 */
type SectionPayload<K extends SectionKey> =
K extends 'article' ? Article : K extends 'case' ? CaseItem : Tender
/** 取某一类的 { list, count };该类失败或数据缺失时返回 null */
function section<K extends SectionKey>(key: K): { list: SectionPayload<K>[]; count: number } | null {
const s = data.value?.[key] as Section<SectionPayload<K>> | undefined
if (!s || 'error' in s) return null
return { list: Array.isArray(s.list) ? s.list : [], count: s.count ?? 0 }
}
function isFailed(key: SectionKey): boolean {
const s = data.value?.[key]
return !!s && 'error' in s
}
const articleSection = computed(() => section('article'))
const caseSection = computed(() => section('case'))
const tenderSection = computed(() => section('tender'))
const articleList = computed(() => articleSection.value?.list ?? [])
const caseList = computed(() => caseSection.value?.list ?? [])
const tenderList = computed(() => 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] }))
/** 当前 Tab 的条目列表;类型为三类之并集,模板内按 activeTab 分支渲染 */
const activeList = computed<(Article | CaseItem | Tender)[]>(() => {
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: Article): 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 =====
// 搜索页一律 noindex,**不区分有没有关键词**:
// - 带关键词:同一批内容有无数种关键词组合入口,是典型低质量/易重复页面
// (沿用 useModuleRoute.ts 对 /article?keywords= 的既有约定)。
// - 不带关键词:一个只有「请输入关键词」的空页,对搜索引擎和用户都是垃圾页。
// follow 保留:让爬虫仍能顺着结果里的链接爬到详情页,不浪费抓取预算。
usePageSeo(
{
title: keywords.value ? `搜索"${keywords.value}"` : '搜索',
path: '/search'
},
siteInfo.value
)
useSeoMeta({ robots: 'noindex, follow' })
</script>