4bb349fd56
索引里其实几乎全是 LF(346 LF / 11 CRLF),但工作区混着大量 CRLF,又没有 .gitattributes,于是任何工具把整份文件翻一次行尾,改 5 行就会变成 500 行 diff。 加 `* text=auto eol=lf` 之后 git 在读写两侧都会做转换,这类噪音 diff 不会再出现。 本次只规范化了索引里真是 CRLF 的 11 个文件;二进制与 Windows 脚本的行尾规则 也一并写进 .gitattributes。工作区里已存在的 CRLF 文件不会被改写,下次检出才是 LF。 注:pre-commit 的行尾守卫拦下了这次提交,属预期——这个提交本身就是一次有意的 行尾规范化,故用 --no-verify 放行。
337 lines
14 KiB
Vue
337 lines
14 KiB
Vue
<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>
|