Files
hjc-web/app/components/SearchResult.vue
T
weicw-dshsa 5a1a8610e5 refactor(search): 搜索结果页补类型并去掉重复的 usePageSeo 调用
代码审查(standards)发现的唯一 should-fix 项:新增的搜索结果页通篇用 any,
而仓库既有 Article / CaseItem / Tender 类型且 sibling 页面一律在用。

- 引入 Article、CaseItem、Tender 类型,新增 SearchResponse / Section<T>
  描述 /api/search 的响应形状(Section 用联合类型表达「成功 | 故障」,
  让 `'error' in s` 的判别在类型层面成立)
- section() 改为泛型 + 按 key 推导载荷类型,去掉 7 处 any
- 补充注释说明为何用 useFetch 而非 useRequestFetch(匿名只读页无需转
  Cookie,与各列表页一致)

同时处理一个 nitpick:search.vue 与 SearchResult.vue 重复调用 usePageSeo,
且页面层那次漏传 siteInfo(后写覆盖先写,重复设置只造成「哪次生效」的歧义)。
按 sibling 惯例只保留组件内那次(它带 siteInfo,且标题能带回关键词)。

行为未变:回归实测「消防」总数 9、23 条详情链接;「测试」标书命中 2 条、
价格 0.01 与截止时间正常渲染、默认 Tab 正确落在标书(第一个非空类)。
eslint 对该文件从 7 个 warning 降到 0。
2026-09-29 05:01:44 +08:00

336 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-[#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'
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 =====
// 带关键词的结果页是低质量/易重复页面,禁止被索引(沿用 useModuleRoute.ts 的既有约定);
// follow 让爬虫仍能顺着结果里的链接爬到详情页。
usePageSeo(
{
title: keywords.value ? `搜索"${keywords.value}"` : '搜索',
path: '/search'
},
siteInfo.value
)
if (keywords.value) {
useSeoMeta({ robots: 'noindex, follow' })
}
</script>