chore: 加 .gitattributes 统一行尾为 LF,并规范化索引里的 CRLF
索引里其实几乎全是 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 放行。
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 统一行尾:仓库里存 LF,检出到工作区也是 LF。
|
||||
#
|
||||
# 为什么加这份文件:四个仓库的行尾原本是混着存的(hjc-java/src 237 LF + 553 CRLF,
|
||||
# hjc-web/app 85 LF + 173 CRLF),又没有 .gitattributes,于是任何工具把整份文件
|
||||
# 翻一次行尾,改 5 行就变成 500 行 diff,真实改动被埋掉。
|
||||
# 2026-09-29 做了一次性规范化(git add --renormalize)。
|
||||
#
|
||||
# 工作区里已存在的文件不会因此被改写,下次检出才是 LF;git 比较时同样会做这个
|
||||
# 转换,所以 status 保持干净。
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows 专用脚本要留在 CRLF(目前仓库里没有,留给以后)
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# 二进制:不做任何行尾转换
|
||||
*.jar binary
|
||||
*.skill binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.xlsx binary
|
||||
*.xls binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
+336
-336
@@ -1,336 +1,336 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
+19
-19
@@ -1,19 +1,19 @@
|
||||
<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 抢走。
|
||||
*
|
||||
* SEO 由 SearchResult.vue 统一设置(标题带回关键词、带关键词时 noindex,follow)——
|
||||
* 本文件不重复调用 usePageSeo:两次调用后写会覆盖先写,且页面层拿不到 siteInfo,
|
||||
* 重复设置只会造成「哪次生效」的歧义。
|
||||
*/
|
||||
</script>
|
||||
<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 抢走。
|
||||
*
|
||||
* SEO 由 SearchResult.vue 统一设置(标题带回关键词、带关键词时 noindex,follow)——
|
||||
* 本文件不重复调用 usePageSeo:两次调用后写会覆盖先写,且页面层拿不到 siteInfo,
|
||||
* 重复设置只会造成「哪次生效」的歧义。
|
||||
*/
|
||||
</script>
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
# 模板切换功能闭环 — 改动与验证清单
|
||||
|
||||
> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。
|
||||
> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。
|
||||
|
||||
## 〇、架构路由(关键:写/读分属两个后端,共享 Redis)
|
||||
- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`:
|
||||
后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。
|
||||
`CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。
|
||||
- **读路径** `GET /cms/cms-website/getSiteInfo`:
|
||||
公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.sitelnk.cn` = **mp-java**。
|
||||
返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。
|
||||
- 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码),
|
||||
故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。
|
||||
|
||||
## 一、已实施的代码改动
|
||||
### 读路径(mp-java / cms-api,必须部署)
|
||||
**A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段
|
||||
```java
|
||||
@Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入")
|
||||
private Integer templateId;
|
||||
```
|
||||
> 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。
|
||||
|
||||
**B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段
|
||||
```java
|
||||
// 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX
|
||||
vo.setTemplateId(website.getTemplateId());
|
||||
```
|
||||
|
||||
### 写路径(websopy-java / websopy-api)
|
||||
**C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存
|
||||
```java
|
||||
website.setTemplateId(templateId);
|
||||
if (cmsWebsiteService.updateById(website)) {
|
||||
// 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板
|
||||
// (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis)
|
||||
cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId());
|
||||
return success("选用成功");
|
||||
}
|
||||
```
|
||||
> 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。
|
||||
|
||||
## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用)
|
||||
迁移脚本:`website-template/database/migrate-cms-website-template-id.sql`
|
||||
作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。
|
||||
核查:
|
||||
```sql
|
||||
SHOW COLUMNS FROM cms_website LIKE 'template_id';
|
||||
SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id';
|
||||
```
|
||||
存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。
|
||||
|
||||
## 三、前端改动(website-template,已加临时日志)
|
||||
- `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。**
|
||||
- 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。
|
||||
|
||||
## 四、端到端验证
|
||||
1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。
|
||||
2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。
|
||||
3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。
|
||||
4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见
|
||||
`[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。
|
||||
5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。
|
||||
6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。
|
||||
7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。
|
||||
|
||||
## 五、回滚
|
||||
A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。
|
||||
需回滚时各自 `git revert` 对应提交即可。
|
||||
# 模板切换功能闭环 — 改动与验证清单
|
||||
|
||||
> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。
|
||||
> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。
|
||||
|
||||
## 〇、架构路由(关键:写/读分属两个后端,共享 Redis)
|
||||
- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`:
|
||||
后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。
|
||||
`CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。
|
||||
- **读路径** `GET /cms/cms-website/getSiteInfo`:
|
||||
公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.sitelnk.cn` = **mp-java**。
|
||||
返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。
|
||||
- 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码),
|
||||
故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。
|
||||
|
||||
## 一、已实施的代码改动
|
||||
### 读路径(mp-java / cms-api,必须部署)
|
||||
**A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段
|
||||
```java
|
||||
@Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入")
|
||||
private Integer templateId;
|
||||
```
|
||||
> 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。
|
||||
|
||||
**B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段
|
||||
```java
|
||||
// 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX
|
||||
vo.setTemplateId(website.getTemplateId());
|
||||
```
|
||||
|
||||
### 写路径(websopy-java / websopy-api)
|
||||
**C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存
|
||||
```java
|
||||
website.setTemplateId(templateId);
|
||||
if (cmsWebsiteService.updateById(website)) {
|
||||
// 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板
|
||||
// (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis)
|
||||
cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId());
|
||||
return success("选用成功");
|
||||
}
|
||||
```
|
||||
> 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。
|
||||
|
||||
## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用)
|
||||
迁移脚本:`website-template/database/migrate-cms-website-template-id.sql`
|
||||
作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。
|
||||
核查:
|
||||
```sql
|
||||
SHOW COLUMNS FROM cms_website LIKE 'template_id';
|
||||
SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id';
|
||||
```
|
||||
存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。
|
||||
|
||||
## 三、前端改动(website-template,已加临时日志)
|
||||
- `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。**
|
||||
- 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。
|
||||
|
||||
## 四、端到端验证
|
||||
1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。
|
||||
2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。
|
||||
3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。
|
||||
4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见
|
||||
`[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。
|
||||
5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。
|
||||
6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。
|
||||
7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。
|
||||
|
||||
## 五、回滚
|
||||
A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。
|
||||
需回滚时各自 `git revert` 对应提交即可。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,91 +1,91 @@
|
||||
# 汇吉采「标书购买」前端清单与后端接口清单
|
||||
|
||||
> 阶段:**编码前最终对齐**(评估已完成;独立端 `website-huijicai` 已 fork 就绪)。
|
||||
> 后端落点:**cms-api(cms-java-code)**,复用其 `shop` + `payment`;前端代理预留双后端切换。
|
||||
> 对应规划:`开发计划.md` 第十节(10.1.1 前端隔离策略、10.6 拍板结果)。
|
||||
|
||||
---
|
||||
|
||||
## 一、前端清单(website-huijicai 独立端)
|
||||
|
||||
### 1.1 页面 / 路由
|
||||
|
||||
| 路由 | 文件 | 说明 | 复用/新增 |
|
||||
|---|---|---|---|
|
||||
| `/buy` | `app/pages/buy.vue` | 标书购买流程壳入口。**照搬 `renewal.vue` 加载 `components.BuyDocument`**,无需改 `useTemplate` 核心;`index.vue` 的 routeMap `buy` 钩子已存在 | 新增(激活入口) |
|
||||
| 标书列表 | `app/templates/template-07/pages/TenderList.vue` | 列表 + 状态=onsale 筛选 + 关键词 + 分类下拉,仿 `ProductList` | 新增 |
|
||||
| 标书详情 | `app/templates/template-07/pages/TenderDetail.vue` | 售价 / 招标编号 / 发售起止 / 购买按钮,仿 `ProductDetail` | 新增 |
|
||||
| 购买表单 | 内嵌于 `BuyDocument` 或 `TenderDetail` | 姓名/电话/邮箱/公司,**复用 `ContactForm` 手机号校验 + 滑块验证码** | 复用 |
|
||||
| 支付页 | 内嵌于 `BuyDocument` 流程 | 展示后端返回的 `code_url` 二维码 + **轮询订单状态** + 支付成功展示「下载标书」/「已发邮件」 | 新增 |
|
||||
| 结果页 | 内嵌于 `BuyDocument` 流程 | 支付成功/失败/超时态 | 新增 |
|
||||
| `/register`、`/login` | `app/pages/register.vue`、`login.vue` | 供应商注册/登录 + 权限门控(未登录访问 `/buy` 先跳这里) | 新增(若采用账号密码) |
|
||||
| (可选)我的购买记录 | `app/templates/template-07/pages/TenderOrders.vue` | 已购标书列表 + 复下载 | 新增(P1) |
|
||||
|
||||
> 说明:`BuyDocument.vue` 当前是占位 stub,改为购买流程壳(列表/详情/表单/支付/结果 编排)。`TenderList/TenderDetail` 需注册进 `useTemplate` 的 `optionalComponents` 列表(仿现有 Page/NewsList 写法),并在 `supportedModules` 加 `'tender'`。
|
||||
|
||||
### 1.2 composable / 状态
|
||||
|
||||
| 名称 | 职责 | 复用/新增 |
|
||||
|---|---|---|
|
||||
| `useSupplier` | 供应商登录态(Token 存 cookie)+ 是否登录判断 + 购买入口门控 | 新增 |
|
||||
| `useTender` | 标书列表/详情/下单/订单状态查询的接口封装 | 新增 |
|
||||
| 请求层 | 调 `server/api/tender/*`(见下),不直接连后端 | 新增 |
|
||||
|
||||
### 1.3 Server 代理层(`website-huijicai/server/api/`)
|
||||
|
||||
| 代理端点 | 转发目标 | 备注 |
|
||||
|---|---|---|
|
||||
| `server/api/tender/list.get.ts` | `tenderApiBase/tender/list` | `tenderApiBase` 可配置,默认 cms-api |
|
||||
| `server/api/tender/detail.get.ts` | `tenderApiBase/tender/detail` | |
|
||||
| `server/api/tender/order.post.ts` | `tenderApiBase/tender/order` | 建单+发起支付,要求登录态 |
|
||||
| `server/api/tender/order/status.get.ts` | `tenderApiBase/tender/order/status` | 轮询 |
|
||||
| `server/api/tender/order/download.get.ts` | `tenderApiBase/tender/order/download` | 登录态+订单校验后转发文件 |
|
||||
| `server/api/payment/query.get.ts` | `tenderApiBase/payment/query` | 支付状态查询(复用现有支付查询) |
|
||||
| `server/api/supplier/login.post.ts` 等 | `tenderApiBase/...` | 仅当采用账号密码注册登录时新增 |
|
||||
|
||||
> **双后端兼容**:所有 `tenderApiBase` 走统一 runtimeConfig(`NUXT_TENDER_API_BASE`),默认 `https://hjc-api.sitelnk.cn/api`;若确认交易走 guilixu-java,改 env 即可,前端代码不变。租户头 `TenantId`、JWT 透传沿用现有中间件。
|
||||
|
||||
---
|
||||
|
||||
## 二、后端接口清单(cms-java-code,新增 `tender` 包)
|
||||
|
||||
### 2.1 实体
|
||||
|
||||
| 实体 | 位置 | 字段要点 | 动作 |
|
||||
|---|---|---|---|
|
||||
| `Tender`(新建) | `com.gxwebsoft.tender.entity` | `id, tenantId, tenderNo`(招标编号), `title, cover, price, status`(0下架/1onsale/2ended), `startSaleTime, endSaleTime, fileId/fileUrl`(标书文件), `content, sortNumber, createTime, updateTime` | 新建 |
|
||||
| `ShopUser`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/phone/email/realName/companyId/certification/tenantId`;**新增 `supplierStatus`**(0非供应商/1待认证/2已认证) | 加字段 |
|
||||
| `ShopOrder`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/orderNo/payStatus/orderStatus/userId/totalPrice/tenantId` 等;标书订单用 **`type=3`** | 复用 + 扩展 type |
|
||||
| `TenderOrderExt`(新建,可选) | `com.gxwebsoft.tender.entity` | `orderNo, supplierId, contactName, contactPhone, contactEmail, tenderId` | 新建(存联系人/邮箱;或扩展 `PaymentWithOrderRequest`,二选一) |
|
||||
|
||||
### 2.2 接口
|
||||
|
||||
| 接口 | 方法 | 入参 | 出参 | 复用/新建 |
|
||||
|---|---|---|---|---|
|
||||
| 标书列表 | `GET /api/tender/list` | `status`(默认1onsale)、`category`、`keyword`、`page`、`size` | `Tender` 分页列表(不含文件下载链接) | 新建 |
|
||||
| 标书详情 | `GET /api/tender/detail` | `id` | `Tender` 详情 | 新建 |
|
||||
| **创建标书订单+支付** | `POST /api/tender/order` | `tenderId`、`contactName`、`contactPhone`、`contactEmail`、`quantity`(默认1) | `orderNo`、`codeUrl`(微信扫码)、`payAmount` | **新建(编排层)**:内部①校验 `status=onsale` ②组装 `PaymentWithOrderRequest`(`type=3`,`goodsId=tenderId`,`amount=price`) ③调 `paymentService.createPaymentWithOrder(要求 loginUser)` ④落 `TenderOrderExt` ⑤返回 `codeUrl` |
|
||||
| 订单状态 | `GET /api/tender/order/status` | `orderNo` | `payStatus`、`orderStatus` | 新建(包装 `GET /payment/query` 或查 `ShopOrder`) |
|
||||
| 下载标书 | `GET /api/tender/order/download` | `orderNo` | 文件流 / 下载链接 | 新建:校验 `payStatus=已支付` + `loginUser=下单人` → 返回 `Tender.fileUrl`(复用 file 存储) |
|
||||
| 微信异步回调 | `POST /api/payment/notify/wechat`(已有) | 微信回调报文 | 成功/失败 | **复用** `PaymentNotifyController` + `WxPayNotifyService`(置 `ShopOrder.payStatus=已支付`;标书订单 type=3 同样处理,可触发"发邮件给供应商") |
|
||||
| 发起支付 | `POST /api/payment/create-with-order`(已有) | `PaymentWithOrderRequest` | `codeUrl` 等 | **复用**(内部 `WechatNativeStrategy` 返回扫码 `codeUrl`) |
|
||||
| 支付查询 | `GET /api/payment/query`(已有) | `orderNo` 等 | 支付状态 | **复用** |
|
||||
|
||||
### 2.3 必须改造 / 注意(P0 增量)
|
||||
|
||||
1. **`PaymentWithOrderRequest.OrderInfo.type` 当前 `@Max(2)`**,标书订单 `type=3` 会被校验拦截 → 放开到 3,并确认支付/订单状态机对 `type=3` 的处理(不应走"发货/物流"逻辑)。
|
||||
2. **会员注册/登录接口缺口**(见 10.6-2):若客户要账号密码/短信注册,需新建 `SupplierAuthController`(注册/登录/发短信),并接入现有 JWT(`JwtAuthenticationFilter`)。若客户接受微信登录,则直接复用 `WxLoginController.loginByMpWxPhone`,零新增。
|
||||
3. **下单强制校验 `status=onsale`**(后端):防前端绕过筛选取已截止/未发售项目。
|
||||
4. **联系人/邮箱落库**:建议新建 `TenderOrderExt`,与 `ShopOrder` 通过 `orderNo` 关联;不要污染 `ShopOrder` 通用字段。
|
||||
5. **标书文件存储**:复用现有 file 模块(上传/下载/权限);`Tender.fileId` 关联文件记录。
|
||||
6. **租户隔离**:`Tender`、`TenderOrderExt` 全部带 `tenantId=10626`;查询自动按当前租户过滤(沿用现有多租户拦截)。
|
||||
|
||||
---
|
||||
|
||||
## 三、阻塞编码的待确认项
|
||||
|
||||
1. **生产拓扑最终敲定**:cms-api 单库闭环 vs guilixu-java 双后端 → 决定 `tenderApiBase` 默认值。
|
||||
2. **会员登录方式**:微信登录(免新增)vs 账号密码/短信注册(需新建 `SupplierAuthController`)→ 决定 P0 是否含注册登录接口。
|
||||
3. **标书交付文件**:确认复用现有 file 模块,及文件上传入口(后台上架标书时上传)。
|
||||
|
||||
> 三项确认后即可进入编码:后端先落 `Tender` 实体 + `tender` 包接口(复用 shop/payment),前端在 `website-huijicai` 做 `/buy` 入口 + 流程页 + `server/api` 代理。
|
||||
# 汇吉采「标书购买」前端清单与后端接口清单
|
||||
|
||||
> 阶段:**编码前最终对齐**(评估已完成;独立端 `website-huijicai` 已 fork 就绪)。
|
||||
> 后端落点:**cms-api(cms-java-code)**,复用其 `shop` + `payment`;前端代理预留双后端切换。
|
||||
> 对应规划:`开发计划.md` 第十节(10.1.1 前端隔离策略、10.6 拍板结果)。
|
||||
|
||||
---
|
||||
|
||||
## 一、前端清单(website-huijicai 独立端)
|
||||
|
||||
### 1.1 页面 / 路由
|
||||
|
||||
| 路由 | 文件 | 说明 | 复用/新增 |
|
||||
|---|---|---|---|
|
||||
| `/buy` | `app/pages/buy.vue` | 标书购买流程壳入口。**照搬 `renewal.vue` 加载 `components.BuyDocument`**,无需改 `useTemplate` 核心;`index.vue` 的 routeMap `buy` 钩子已存在 | 新增(激活入口) |
|
||||
| 标书列表 | `app/templates/template-07/pages/TenderList.vue` | 列表 + 状态=onsale 筛选 + 关键词 + 分类下拉,仿 `ProductList` | 新增 |
|
||||
| 标书详情 | `app/templates/template-07/pages/TenderDetail.vue` | 售价 / 招标编号 / 发售起止 / 购买按钮,仿 `ProductDetail` | 新增 |
|
||||
| 购买表单 | 内嵌于 `BuyDocument` 或 `TenderDetail` | 姓名/电话/邮箱/公司,**复用 `ContactForm` 手机号校验 + 滑块验证码** | 复用 |
|
||||
| 支付页 | 内嵌于 `BuyDocument` 流程 | 展示后端返回的 `code_url` 二维码 + **轮询订单状态** + 支付成功展示「下载标书」/「已发邮件」 | 新增 |
|
||||
| 结果页 | 内嵌于 `BuyDocument` 流程 | 支付成功/失败/超时态 | 新增 |
|
||||
| `/register`、`/login` | `app/pages/register.vue`、`login.vue` | 供应商注册/登录 + 权限门控(未登录访问 `/buy` 先跳这里) | 新增(若采用账号密码) |
|
||||
| (可选)我的购买记录 | `app/templates/template-07/pages/TenderOrders.vue` | 已购标书列表 + 复下载 | 新增(P1) |
|
||||
|
||||
> 说明:`BuyDocument.vue` 当前是占位 stub,改为购买流程壳(列表/详情/表单/支付/结果 编排)。`TenderList/TenderDetail` 需注册进 `useTemplate` 的 `optionalComponents` 列表(仿现有 Page/NewsList 写法),并在 `supportedModules` 加 `'tender'`。
|
||||
|
||||
### 1.2 composable / 状态
|
||||
|
||||
| 名称 | 职责 | 复用/新增 |
|
||||
|---|---|---|
|
||||
| `useSupplier` | 供应商登录态(Token 存 cookie)+ 是否登录判断 + 购买入口门控 | 新增 |
|
||||
| `useTender` | 标书列表/详情/下单/订单状态查询的接口封装 | 新增 |
|
||||
| 请求层 | 调 `server/api/tender/*`(见下),不直接连后端 | 新增 |
|
||||
|
||||
### 1.3 Server 代理层(`website-huijicai/server/api/`)
|
||||
|
||||
| 代理端点 | 转发目标 | 备注 |
|
||||
|---|---|---|
|
||||
| `server/api/tender/list.get.ts` | `tenderApiBase/tender/list` | `tenderApiBase` 可配置,默认 cms-api |
|
||||
| `server/api/tender/detail.get.ts` | `tenderApiBase/tender/detail` | |
|
||||
| `server/api/tender/order.post.ts` | `tenderApiBase/tender/order` | 建单+发起支付,要求登录态 |
|
||||
| `server/api/tender/order/status.get.ts` | `tenderApiBase/tender/order/status` | 轮询 |
|
||||
| `server/api/tender/order/download.get.ts` | `tenderApiBase/tender/order/download` | 登录态+订单校验后转发文件 |
|
||||
| `server/api/payment/query.get.ts` | `tenderApiBase/payment/query` | 支付状态查询(复用现有支付查询) |
|
||||
| `server/api/supplier/login.post.ts` 等 | `tenderApiBase/...` | 仅当采用账号密码注册登录时新增 |
|
||||
|
||||
> **双后端兼容**:所有 `tenderApiBase` 走统一 runtimeConfig(`NUXT_TENDER_API_BASE`),默认 `https://hjc-api.sitelnk.cn/api`;若确认交易走 guilixu-java,改 env 即可,前端代码不变。租户头 `TenantId`、JWT 透传沿用现有中间件。
|
||||
|
||||
---
|
||||
|
||||
## 二、后端接口清单(cms-java-code,新增 `tender` 包)
|
||||
|
||||
### 2.1 实体
|
||||
|
||||
| 实体 | 位置 | 字段要点 | 动作 |
|
||||
|---|---|---|---|
|
||||
| `Tender`(新建) | `com.gxwebsoft.tender.entity` | `id, tenantId, tenderNo`(招标编号), `title, cover, price, status`(0下架/1onsale/2ended), `startSaleTime, endSaleTime, fileId/fileUrl`(标书文件), `content, sortNumber, createTime, updateTime` | 新建 |
|
||||
| `ShopUser`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/phone/email/realName/companyId/certification/tenantId`;**新增 `supplierStatus`**(0非供应商/1待认证/2已认证) | 加字段 |
|
||||
| `ShopOrder`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/orderNo/payStatus/orderStatus/userId/totalPrice/tenantId` 等;标书订单用 **`type=3`** | 复用 + 扩展 type |
|
||||
| `TenderOrderExt`(新建,可选) | `com.gxwebsoft.tender.entity` | `orderNo, supplierId, contactName, contactPhone, contactEmail, tenderId` | 新建(存联系人/邮箱;或扩展 `PaymentWithOrderRequest`,二选一) |
|
||||
|
||||
### 2.2 接口
|
||||
|
||||
| 接口 | 方法 | 入参 | 出参 | 复用/新建 |
|
||||
|---|---|---|---|---|
|
||||
| 标书列表 | `GET /api/tender/list` | `status`(默认1onsale)、`category`、`keyword`、`page`、`size` | `Tender` 分页列表(不含文件下载链接) | 新建 |
|
||||
| 标书详情 | `GET /api/tender/detail` | `id` | `Tender` 详情 | 新建 |
|
||||
| **创建标书订单+支付** | `POST /api/tender/order` | `tenderId`、`contactName`、`contactPhone`、`contactEmail`、`quantity`(默认1) | `orderNo`、`codeUrl`(微信扫码)、`payAmount` | **新建(编排层)**:内部①校验 `status=onsale` ②组装 `PaymentWithOrderRequest`(`type=3`,`goodsId=tenderId`,`amount=price`) ③调 `paymentService.createPaymentWithOrder(要求 loginUser)` ④落 `TenderOrderExt` ⑤返回 `codeUrl` |
|
||||
| 订单状态 | `GET /api/tender/order/status` | `orderNo` | `payStatus`、`orderStatus` | 新建(包装 `GET /payment/query` 或查 `ShopOrder`) |
|
||||
| 下载标书 | `GET /api/tender/order/download` | `orderNo` | 文件流 / 下载链接 | 新建:校验 `payStatus=已支付` + `loginUser=下单人` → 返回 `Tender.fileUrl`(复用 file 存储) |
|
||||
| 微信异步回调 | `POST /api/payment/notify/wechat`(已有) | 微信回调报文 | 成功/失败 | **复用** `PaymentNotifyController` + `WxPayNotifyService`(置 `ShopOrder.payStatus=已支付`;标书订单 type=3 同样处理,可触发"发邮件给供应商") |
|
||||
| 发起支付 | `POST /api/payment/create-with-order`(已有) | `PaymentWithOrderRequest` | `codeUrl` 等 | **复用**(内部 `WechatNativeStrategy` 返回扫码 `codeUrl`) |
|
||||
| 支付查询 | `GET /api/payment/query`(已有) | `orderNo` 等 | 支付状态 | **复用** |
|
||||
|
||||
### 2.3 必须改造 / 注意(P0 增量)
|
||||
|
||||
1. **`PaymentWithOrderRequest.OrderInfo.type` 当前 `@Max(2)`**,标书订单 `type=3` 会被校验拦截 → 放开到 3,并确认支付/订单状态机对 `type=3` 的处理(不应走"发货/物流"逻辑)。
|
||||
2. **会员注册/登录接口缺口**(见 10.6-2):若客户要账号密码/短信注册,需新建 `SupplierAuthController`(注册/登录/发短信),并接入现有 JWT(`JwtAuthenticationFilter`)。若客户接受微信登录,则直接复用 `WxLoginController.loginByMpWxPhone`,零新增。
|
||||
3. **下单强制校验 `status=onsale`**(后端):防前端绕过筛选取已截止/未发售项目。
|
||||
4. **联系人/邮箱落库**:建议新建 `TenderOrderExt`,与 `ShopOrder` 通过 `orderNo` 关联;不要污染 `ShopOrder` 通用字段。
|
||||
5. **标书文件存储**:复用现有 file 模块(上传/下载/权限);`Tender.fileId` 关联文件记录。
|
||||
6. **租户隔离**:`Tender`、`TenderOrderExt` 全部带 `tenantId=10626`;查询自动按当前租户过滤(沿用现有多租户拦截)。
|
||||
|
||||
---
|
||||
|
||||
## 三、阻塞编码的待确认项
|
||||
|
||||
1. **生产拓扑最终敲定**:cms-api 单库闭环 vs guilixu-java 双后端 → 决定 `tenderApiBase` 默认值。
|
||||
2. **会员登录方式**:微信登录(免新增)vs 账号密码/短信注册(需新建 `SupplierAuthController`)→ 决定 P0 是否含注册登录接口。
|
||||
3. **标书交付文件**:确认复用现有 file 模块,及文件上传入口(后台上架标书时上传)。
|
||||
|
||||
> 三项确认后即可进入编码:后端先落 `Tender` 实体 + `tender` 包接口(复用 shop/payment),前端在 `website-huijicai` 做 `/buy` 入口 + 流程页 + `server/api` 代理。
|
||||
|
||||
+98
-98
@@ -1,98 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。
|
||||
用法: python3 cms_fill_image.py <mapping.json> <result.json>
|
||||
mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...]
|
||||
"""
|
||||
import os, json, sys, subprocess, time, urllib.request, urllib.error
|
||||
|
||||
TOKEN = os.environ.get("TOKEN", "")
|
||||
UPLOAD = "https://server.websoft.top/api/file/upload"
|
||||
CMS = "https://hjc-api.sitelnk.cn/api"
|
||||
FILE_BASE = "https://file.websoft.top/api/file/"
|
||||
HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN}
|
||||
|
||||
|
||||
def _retry(fn, tries=4, sleep=2):
|
||||
last = None
|
||||
for i in range(tries):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
last = e
|
||||
time.sleep(sleep * (i + 1))
|
||||
raise last
|
||||
|
||||
|
||||
def upload(local_path: str) -> str:
|
||||
def _do():
|
||||
out = subprocess.run(
|
||||
["curl", "-s", "-m", "60", "-X", "POST", UPLOAD,
|
||||
"-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}",
|
||||
"-F", f"file=@{local_path}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
try:
|
||||
d = json.loads(out.stdout)
|
||||
except Exception:
|
||||
raise RuntimeError(f"upload non-json: {out.stdout[:200]}")
|
||||
if d.get("code") != 0:
|
||||
raise RuntimeError(f"upload code={d.get('code')} msg={d.get('message')} err={str(d.get('error'))[:120]}")
|
||||
p = (d.get("data") or {}).get("path", "").lstrip("/")
|
||||
if not p:
|
||||
raise RuntimeError("upload returned empty path")
|
||||
return FILE_BASE + p
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def get_item(module: str, id_: int):
|
||||
def _do():
|
||||
url = f"{CMS}/cms/{module}/{id_}"
|
||||
req = urllib.request.Request(url, headers=HDR)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())["data"]
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()[:300]
|
||||
raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}")
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def put_item(module: str, obj: dict):
|
||||
def _do():
|
||||
url = f"{CMS}/cms/{module}"
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(obj).encode(),
|
||||
headers={**HDR, "Content-Type": "application/json"}, method="PUT",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def main():
|
||||
mapping = json.load(open(sys.argv[1]))
|
||||
result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json"
|
||||
results = []
|
||||
for it in mapping:
|
||||
mod, iid, fpath = it["module"], it["id"], it["file"]
|
||||
rec = {"module": mod, "id": iid}
|
||||
try:
|
||||
url = upload(fpath)
|
||||
obj = get_item(mod, iid)
|
||||
obj["image"] = url
|
||||
out = put_item(mod, obj)
|
||||
rec["url"] = url
|
||||
rec["put_code"] = out.get("code")
|
||||
rec["ok"] = out.get("code") == 0
|
||||
print(f"[{mod} {iid}] upload OK -> PUT code={out.get('code')} {'OK' if rec['ok'] else 'FAIL'} | {url[:70]}")
|
||||
except Exception as e:
|
||||
rec["error"] = str(e)[:300]
|
||||
print(f"[{mod} {iid}] ERROR: {e}")
|
||||
results.append(rec)
|
||||
time.sleep(1)
|
||||
json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2)
|
||||
print(f"\n=== done {len(results)} items, saved {result_path} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
#!/usr/bin/env python3
|
||||
"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。
|
||||
用法: python3 cms_fill_image.py <mapping.json> <result.json>
|
||||
mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...]
|
||||
"""
|
||||
import os, json, sys, subprocess, time, urllib.request, urllib.error
|
||||
|
||||
TOKEN = os.environ.get("TOKEN", "")
|
||||
UPLOAD = "https://server.websoft.top/api/file/upload"
|
||||
CMS = "https://hjc-api.sitelnk.cn/api"
|
||||
FILE_BASE = "https://file.websoft.top/api/file/"
|
||||
HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN}
|
||||
|
||||
|
||||
def _retry(fn, tries=4, sleep=2):
|
||||
last = None
|
||||
for i in range(tries):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
last = e
|
||||
time.sleep(sleep * (i + 1))
|
||||
raise last
|
||||
|
||||
|
||||
def upload(local_path: str) -> str:
|
||||
def _do():
|
||||
out = subprocess.run(
|
||||
["curl", "-s", "-m", "60", "-X", "POST", UPLOAD,
|
||||
"-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}",
|
||||
"-F", f"file=@{local_path}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
try:
|
||||
d = json.loads(out.stdout)
|
||||
except Exception:
|
||||
raise RuntimeError(f"upload non-json: {out.stdout[:200]}")
|
||||
if d.get("code") != 0:
|
||||
raise RuntimeError(f"upload code={d.get('code')} msg={d.get('message')} err={str(d.get('error'))[:120]}")
|
||||
p = (d.get("data") or {}).get("path", "").lstrip("/")
|
||||
if not p:
|
||||
raise RuntimeError("upload returned empty path")
|
||||
return FILE_BASE + p
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def get_item(module: str, id_: int):
|
||||
def _do():
|
||||
url = f"{CMS}/cms/{module}/{id_}"
|
||||
req = urllib.request.Request(url, headers=HDR)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())["data"]
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()[:300]
|
||||
raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}")
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def put_item(module: str, obj: dict):
|
||||
def _do():
|
||||
url = f"{CMS}/cms/{module}"
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(obj).encode(),
|
||||
headers={**HDR, "Content-Type": "application/json"}, method="PUT",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def main():
|
||||
mapping = json.load(open(sys.argv[1]))
|
||||
result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json"
|
||||
results = []
|
||||
for it in mapping:
|
||||
mod, iid, fpath = it["module"], it["id"], it["file"]
|
||||
rec = {"module": mod, "id": iid}
|
||||
try:
|
||||
url = upload(fpath)
|
||||
obj = get_item(mod, iid)
|
||||
obj["image"] = url
|
||||
out = put_item(mod, obj)
|
||||
rec["url"] = url
|
||||
rec["put_code"] = out.get("code")
|
||||
rec["ok"] = out.get("code") == 0
|
||||
print(f"[{mod} {iid}] upload OK -> PUT code={out.get('code')} {'OK' if rec['ok'] else 'FAIL'} | {url[:70]}")
|
||||
except Exception as e:
|
||||
rec["error"] = str(e)[:300]
|
||||
print(f"[{mod} {iid}] ERROR: {e}")
|
||||
results.append(rec)
|
||||
time.sleep(1)
|
||||
json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2)
|
||||
print(f"\n=== done {len(results)} items, saved {result_path} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+141
-141
@@ -1,141 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CMS 内容批量发布脚本
|
||||
* ============================================================================
|
||||
* 背景(2026-08-05):
|
||||
* website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入
|
||||
* status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线,
|
||||
* C 端 website-template(server/api/{article,product,case}/list.get.ts)只查
|
||||
* status=0,于是后台明明「已发布」,官网却一条都不显示。
|
||||
*
|
||||
* 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。
|
||||
*
|
||||
* 用法:
|
||||
* # 1) 先空跑,只打印将要修改的内容,不写任何数据
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* # 2) 确认无误后真正执行
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 APPLY=1 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* # 只处理文章
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 MODULES=article APPLY=1 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization
|
||||
* (连同 "Bearer " 前缀一起复制即可,脚本会自动补全)
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.CMS_API_BASE || 'https://hjc-api.sitelnk.cn/api'
|
||||
const TOKEN_RAW = process.env.ADMIN_TOKEN || ''
|
||||
const TENANT_ID = process.env.TENANT_ID || '10546'
|
||||
const APPLY = process.env.APPLY === '1'
|
||||
const MODULES = (process.env.MODULES || 'article,product,case')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
/** CMS 权威语义:0=已发布 / 在售(C 端可见) */
|
||||
const PUBLISHED = 0
|
||||
|
||||
/** 各模块的接口路径与主键字段 */
|
||||
const MODULE_CONF = {
|
||||
article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' },
|
||||
product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' },
|
||||
case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' }
|
||||
}
|
||||
|
||||
if (!TOKEN_RAW) {
|
||||
console.error('❌ 缺少 ADMIN_TOKEN。')
|
||||
console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}`
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: TOKEN,
|
||||
TenantId: String(TENANT_ID)
|
||||
}
|
||||
|
||||
async function fetchList(conf) {
|
||||
const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200`
|
||||
const res = await fetch(url, { headers })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!json || json.code !== 0) {
|
||||
throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`)
|
||||
}
|
||||
return json.data?.list || []
|
||||
}
|
||||
|
||||
async function updateStatus(conf, id) {
|
||||
const url = `${API_BASE}/cms/${conf.path}/status`
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED })
|
||||
})
|
||||
const text = await res.text()
|
||||
let json = null
|
||||
try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ }
|
||||
return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`)
|
||||
console.log('─'.repeat(70))
|
||||
|
||||
let totalPending = 0
|
||||
let totalOk = 0
|
||||
let totalFail = 0
|
||||
|
||||
for (const mod of MODULES) {
|
||||
const conf = MODULE_CONF[mod]
|
||||
if (!conf) {
|
||||
console.log(`跳过未知模块 ${mod}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let list
|
||||
try {
|
||||
list = await fetchList(conf)
|
||||
} catch (e) {
|
||||
console.log(`\n[${mod}] 拉取失败:${e.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pending = list.filter((it) => Number(it.status) !== PUBLISHED)
|
||||
console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`)
|
||||
totalPending += pending.length
|
||||
|
||||
for (const it of pending) {
|
||||
const id = it[conf.idKey] ?? it.id
|
||||
const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30)
|
||||
if (!APPLY) {
|
||||
console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`)
|
||||
continue
|
||||
}
|
||||
const r = await updateStatus(conf, id)
|
||||
if (r.ok) {
|
||||
totalOk++
|
||||
console.log(` ✓ ${id} → 已发布 | ${title}`)
|
||||
} else {
|
||||
totalFail++
|
||||
console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(70))
|
||||
if (APPLY) {
|
||||
console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`)
|
||||
console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。')
|
||||
} else {
|
||||
console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`)
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('执行出错:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CMS 内容批量发布脚本
|
||||
* ============================================================================
|
||||
* 背景(2026-08-05):
|
||||
* website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入
|
||||
* status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线,
|
||||
* C 端 website-template(server/api/{article,product,case}/list.get.ts)只查
|
||||
* status=0,于是后台明明「已发布」,官网却一条都不显示。
|
||||
*
|
||||
* 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。
|
||||
*
|
||||
* 用法:
|
||||
* # 1) 先空跑,只打印将要修改的内容,不写任何数据
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* # 2) 确认无误后真正执行
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 APPLY=1 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* # 只处理文章
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 MODULES=article APPLY=1 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization
|
||||
* (连同 "Bearer " 前缀一起复制即可,脚本会自动补全)
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.CMS_API_BASE || 'https://hjc-api.sitelnk.cn/api'
|
||||
const TOKEN_RAW = process.env.ADMIN_TOKEN || ''
|
||||
const TENANT_ID = process.env.TENANT_ID || '10546'
|
||||
const APPLY = process.env.APPLY === '1'
|
||||
const MODULES = (process.env.MODULES || 'article,product,case')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
/** CMS 权威语义:0=已发布 / 在售(C 端可见) */
|
||||
const PUBLISHED = 0
|
||||
|
||||
/** 各模块的接口路径与主键字段 */
|
||||
const MODULE_CONF = {
|
||||
article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' },
|
||||
product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' },
|
||||
case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' }
|
||||
}
|
||||
|
||||
if (!TOKEN_RAW) {
|
||||
console.error('❌ 缺少 ADMIN_TOKEN。')
|
||||
console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}`
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: TOKEN,
|
||||
TenantId: String(TENANT_ID)
|
||||
}
|
||||
|
||||
async function fetchList(conf) {
|
||||
const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200`
|
||||
const res = await fetch(url, { headers })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!json || json.code !== 0) {
|
||||
throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`)
|
||||
}
|
||||
return json.data?.list || []
|
||||
}
|
||||
|
||||
async function updateStatus(conf, id) {
|
||||
const url = `${API_BASE}/cms/${conf.path}/status`
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED })
|
||||
})
|
||||
const text = await res.text()
|
||||
let json = null
|
||||
try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ }
|
||||
return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`)
|
||||
console.log('─'.repeat(70))
|
||||
|
||||
let totalPending = 0
|
||||
let totalOk = 0
|
||||
let totalFail = 0
|
||||
|
||||
for (const mod of MODULES) {
|
||||
const conf = MODULE_CONF[mod]
|
||||
if (!conf) {
|
||||
console.log(`跳过未知模块 ${mod}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let list
|
||||
try {
|
||||
list = await fetchList(conf)
|
||||
} catch (e) {
|
||||
console.log(`\n[${mod}] 拉取失败:${e.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pending = list.filter((it) => Number(it.status) !== PUBLISHED)
|
||||
console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`)
|
||||
totalPending += pending.length
|
||||
|
||||
for (const it of pending) {
|
||||
const id = it[conf.idKey] ?? it.id
|
||||
const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30)
|
||||
if (!APPLY) {
|
||||
console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`)
|
||||
continue
|
||||
}
|
||||
const r = await updateStatus(conf, id)
|
||||
if (r.ok) {
|
||||
totalOk++
|
||||
console.log(` ✓ ${id} → 已发布 | ${title}`)
|
||||
} else {
|
||||
totalFail++
|
||||
console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(70))
|
||||
if (APPLY) {
|
||||
console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`)
|
||||
console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。')
|
||||
} else {
|
||||
console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`)
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('执行出错:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
+124
-124
@@ -1,124 +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)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 查询支付渠道侧的状态(已支付则后端顺带修复本地订单状态)
|
||||
* GET /api/tender/pay-status?orderNo=xxx
|
||||
* 代理到 mp-api /api/hjc/order/pay-status/{orderNo};需登录态。
|
||||
*
|
||||
* 这是 hjc-web **唯一**的支付确认通道:它向渠道查单,以渠道侧的结论为准。
|
||||
* 后端按订单的 pay_method 自动打到微信或支付宝,**前端不需要传渠道**。
|
||||
* 不要退回 mark-paid——那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。
|
||||
*
|
||||
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const { orderNo } = getQuery(event)
|
||||
const cookieToken = getCookie(event, 'hjc_token')
|
||||
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||
|
||||
try {
|
||||
return await $fetch(`/hjc/order/pay-status/${encodeURIComponent(String(orderNo ?? ''))}`, {
|
||||
baseURL: config.public.modulesApiBase,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
TenantId: ctx.tenantId,
|
||||
...(auth ? { Authorization: auth } : {})
|
||||
},
|
||||
query: { TenantId: ctx.tenantId }
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to query pay status'
|
||||
})
|
||||
}
|
||||
})
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 查询支付渠道侧的状态(已支付则后端顺带修复本地订单状态)
|
||||
* GET /api/tender/pay-status?orderNo=xxx
|
||||
* 代理到 mp-api /api/hjc/order/pay-status/{orderNo};需登录态。
|
||||
*
|
||||
* 这是 hjc-web **唯一**的支付确认通道:它向渠道查单,以渠道侧的结论为准。
|
||||
* 后端按订单的 pay_method 自动打到微信或支付宝,**前端不需要传渠道**。
|
||||
* 不要退回 mark-paid——那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。
|
||||
*
|
||||
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const { orderNo } = getQuery(event)
|
||||
const cookieToken = getCookie(event, 'hjc_token')
|
||||
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||
|
||||
try {
|
||||
return await $fetch(`/hjc/order/pay-status/${encodeURIComponent(String(orderNo ?? ''))}`, {
|
||||
baseURL: config.public.modulesApiBase,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
TenantId: ctx.tenantId,
|
||||
...(auth ? { Authorization: auth } : {})
|
||||
},
|
||||
query: { TenantId: ctx.tenantId }
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to query pay status'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 发起支付(微信 Native 扫码 / 支付宝电脑网站支付)
|
||||
* POST /api/tender/pay { orderNo, payType? }
|
||||
* 代理到 mp-api /api/hjc/order/pay;需登录态。
|
||||
*
|
||||
* `payType`:`wechat`(默认,与接入支付宝前行为一致)/ `alipay`。
|
||||
* 请求体原样透传,因此新增支付方式**不需要改这个代理**。
|
||||
*
|
||||
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const body = await readBody(event)
|
||||
const cookieToken = getCookie(event, 'hjc_token')
|
||||
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||
|
||||
try {
|
||||
return await $fetch('/hjc/order/pay', {
|
||||
baseURL: config.public.modulesApiBase,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
TenantId: ctx.tenantId,
|
||||
...(auth ? { Authorization: auth } : {})
|
||||
},
|
||||
query: { TenantId: ctx.tenantId },
|
||||
body
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to pay'
|
||||
})
|
||||
}
|
||||
})
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 发起支付(微信 Native 扫码 / 支付宝电脑网站支付)
|
||||
* POST /api/tender/pay { orderNo, payType? }
|
||||
* 代理到 mp-api /api/hjc/order/pay;需登录态。
|
||||
*
|
||||
* `payType`:`wechat`(默认,与接入支付宝前行为一致)/ `alipay`。
|
||||
* 请求体原样透传,因此新增支付方式**不需要改这个代理**。
|
||||
*
|
||||
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const body = await readBody(event)
|
||||
const cookieToken = getCookie(event, 'hjc_token')
|
||||
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||
|
||||
try {
|
||||
return await $fetch('/hjc/order/pay', {
|
||||
baseURL: config.public.modulesApiBase,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
TenantId: ctx.tenantId,
|
||||
...(auth ? { Authorization: auth } : {})
|
||||
},
|
||||
query: { TenantId: ctx.tenantId },
|
||||
body
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to pay'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+137
-137
@@ -1,137 +1,137 @@
|
||||
import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
/**
|
||||
* 从统一响应封装中取出 data
|
||||
* 后端可能返回 { code, data } 也可能直接返回对象
|
||||
*
|
||||
* 关键:未找到时上游返回的是「无 data 字段的信封」,
|
||||
* 例如 { "code": 0, "message": "操作成功" }。
|
||||
* 旧实现在无 data 字段时会把整个信封当结果返回(truthy),
|
||||
* 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。
|
||||
* 因此:无 data 字段的信封一律视为「未找到」返回 null。
|
||||
*/
|
||||
function unwrap<T>(res: ApiEnvelope<T> | T): T | null {
|
||||
if (!res || typeof res !== 'object') return null
|
||||
const obj = res as Record<string, unknown>
|
||||
// 标准信封:存在 data 字段则取 data(未找到时 data 为 null)
|
||||
if ('data' in obj) {
|
||||
return (obj.data as T) ?? null
|
||||
}
|
||||
// 信封但无 data(如 { code, message })→ 视为无结果
|
||||
if ('code' in obj || 'message' in obj) {
|
||||
return null
|
||||
}
|
||||
// 直接返回对象(无信封包裹)的情况
|
||||
return (res as T) ?? null
|
||||
}
|
||||
|
||||
/** 不应暴露到前端 / SSR payload 的敏感字段 */
|
||||
const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason']
|
||||
|
||||
/**
|
||||
* 脱敏:移除应用密钥等敏感字段
|
||||
*/
|
||||
function sanitize(app: AppProduct | null): AppProduct | null {
|
||||
if (!app) return null
|
||||
const clean = { ...app }
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
delete clean[key]
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 productId 查询应用产品信息
|
||||
* 后端: GET {appApiBase}/api/app/product/detail/{productId}
|
||||
* 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID)
|
||||
*/
|
||||
export async function getAppProductById(
|
||||
productId: string | number,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppProduct | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!productId) return null
|
||||
|
||||
// 防御:productId 是 app_product.product_id 数字主键。
|
||||
// 传入 productCode(如 'website')会打到 /api/app/product/detail/{productId}
|
||||
// 导致后端 Integer 转换失败,且每个请求都会重复报错。直接短路返回 null。
|
||||
if (!/^\d+$/.test(String(productId))) {
|
||||
console.warn('[app] getAppProductById 收到非数字 productId=%s,跳过查询', productId)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
|
||||
`/api/app/product/detail/${productId}`,
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return sanitize(unwrap<AppProduct>(res))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按域名查询应用产品信息
|
||||
* 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain}
|
||||
* 用于生产环境(根据当前访问域名解析应用)
|
||||
* 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId)
|
||||
*/
|
||||
export async function getAppProductByDomain(
|
||||
domain: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppProduct | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!domain) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
|
||||
'/api/app/product/getByDomain',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { domain },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return sanitize(unwrap<AppProduct>(res))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按域名查询应用域名绑定(appDomain 表)
|
||||
* 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain}
|
||||
* 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表)
|
||||
* 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息
|
||||
*/
|
||||
export async function getAppDomainByDomain(
|
||||
domain: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppDomain | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!domain) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>(
|
||||
'/api/app/domain/getByDomain',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { domain },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return unwrap<AppDomain>(res)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
/**
|
||||
* 从统一响应封装中取出 data
|
||||
* 后端可能返回 { code, data } 也可能直接返回对象
|
||||
*
|
||||
* 关键:未找到时上游返回的是「无 data 字段的信封」,
|
||||
* 例如 { "code": 0, "message": "操作成功" }。
|
||||
* 旧实现在无 data 字段时会把整个信封当结果返回(truthy),
|
||||
* 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。
|
||||
* 因此:无 data 字段的信封一律视为「未找到」返回 null。
|
||||
*/
|
||||
function unwrap<T>(res: ApiEnvelope<T> | T): T | null {
|
||||
if (!res || typeof res !== 'object') return null
|
||||
const obj = res as Record<string, unknown>
|
||||
// 标准信封:存在 data 字段则取 data(未找到时 data 为 null)
|
||||
if ('data' in obj) {
|
||||
return (obj.data as T) ?? null
|
||||
}
|
||||
// 信封但无 data(如 { code, message })→ 视为无结果
|
||||
if ('code' in obj || 'message' in obj) {
|
||||
return null
|
||||
}
|
||||
// 直接返回对象(无信封包裹)的情况
|
||||
return (res as T) ?? null
|
||||
}
|
||||
|
||||
/** 不应暴露到前端 / SSR payload 的敏感字段 */
|
||||
const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason']
|
||||
|
||||
/**
|
||||
* 脱敏:移除应用密钥等敏感字段
|
||||
*/
|
||||
function sanitize(app: AppProduct | null): AppProduct | null {
|
||||
if (!app) return null
|
||||
const clean = { ...app }
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
delete clean[key]
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 productId 查询应用产品信息
|
||||
* 后端: GET {appApiBase}/api/app/product/detail/{productId}
|
||||
* 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID)
|
||||
*/
|
||||
export async function getAppProductById(
|
||||
productId: string | number,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppProduct | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!productId) return null
|
||||
|
||||
// 防御:productId 是 app_product.product_id 数字主键。
|
||||
// 传入 productCode(如 'website')会打到 /api/app/product/detail/{productId}
|
||||
// 导致后端 Integer 转换失败,且每个请求都会重复报错。直接短路返回 null。
|
||||
if (!/^\d+$/.test(String(productId))) {
|
||||
console.warn('[app] getAppProductById 收到非数字 productId=%s,跳过查询', productId)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
|
||||
`/api/app/product/detail/${productId}`,
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return sanitize(unwrap<AppProduct>(res))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按域名查询应用产品信息
|
||||
* 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain}
|
||||
* 用于生产环境(根据当前访问域名解析应用)
|
||||
* 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId)
|
||||
*/
|
||||
export async function getAppProductByDomain(
|
||||
domain: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppProduct | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!domain) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
|
||||
'/api/app/product/getByDomain',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { domain },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return sanitize(unwrap<AppProduct>(res))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按域名查询应用域名绑定(appDomain 表)
|
||||
* 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain}
|
||||
* 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表)
|
||||
* 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息
|
||||
*/
|
||||
export async function getAppDomainByDomain(
|
||||
domain: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppDomain | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!domain) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>(
|
||||
'/api/app/domain/getByDomain',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { domain },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return unwrap<AppDomain>(res)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user