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:
2026-09-29 16:24:25 +08:00
parent 3b703c70e8
commit 4bb349fd56
12 changed files with 1141 additions and 1105 deletions
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+70 -70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
})
+40 -40
View File
@@ -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'
})
}
})
+40 -40
View File
@@ -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
View File
@@ -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
}
}