- server/api/tender/{list.get,detail.get,order.post}.ts 代理到 mp-api /api/hjc/bid-project|order
- app/types/tender.ts 前端视图模型
- app/templates/template-07/pages/{TenderList,TenderDetail,BuyDocument}.vue(Tailwind,仿 Product 页)
- app/pages/tender/index.vue + tender/[id].vue + buy.vue 路由,经 useTemplate 加载
- useTemplate.ts 注册 TenderList/TenderDetail/BuyDocument(全部模板安全可选)
- 附 BUY_TENDER_MAP_REPORT.md(hjc-web 结构映射参考)
35 KiB
Nuxt 4 Frontend Map — 标书购买 (Tender Buy) Feature
Repo: /c/Users/Administrator/workspace/code/wangsu/ZhaoBiao/hjc-web (Nuxt 4.2.2, Vue 3.5, TypeScript).
Backend base in use: NUXT_PUBLIC_MODULES_API_BASE=http://127.0.0.1:9200/api (from .env).
This is a READ-ONLY report. No files were modified for this report.
1. App structure / Nuxt module layout
Nuxt 4 uses the app/ srcDir convention automatically (there is no explicit srcDir in nuxt.config.ts; app/app.vue, app/pages/, etc. are the default). Everything user-facing lives under app/; server code lives under server/.
hjc-web/
├── app/ # Nuxt srcDir (auto in Nuxt 4)
│ ├── app.vue # root layout+router outlet
│ ├── pages/ # file-based routes
│ ├── layouts/ # default.vue, blank.vue
│ ├── components/ # global shared components
│ ├── composables/ # useTemplate, useSite, useCms, useModuleRoute, ...
│ ├── templates/ # template-01..10 (per-skin page components)
│ ├── utils/ # image, nav-tree, validators, helpers
│ ├── plugins/ # app-init.client/server, site-init.server, reveal.client
│ ├── assets/css/ # tailwind.css, animations.css
│ └── types/index.ts # TS domain types
├── server/
│ ├── api/ # Nitro proxy handlers (article, product, case, page, site, form, captcha, file, ...)
│ ├── middleware/ # tenant.ts, z-news-detail-redirect.ts
│ └── utils/ # tenant.ts, site.ts, app.ts, template-map.ts, guard.ts, ...
├── nuxt.config.ts
├── tailwind.config.cjs
├── .env # NUXT_PUBLIC_TEMPLATE_ID=template-07, NUXT_PUBLIC_MODULES_API_BASE=http://127.0.0.1:9200/api
└── scripts/ # crypto-hash-polyfill.mjs, cms_fill_image.py, publish-cms-content.mjs
package.json deps: @ant-design-vue/nuxt, ant-design-vue, dayjs, nuxt, vue, vue-router. Dev: @nuxtjs/tailwindcss, eslint.
nuxt.config.ts highlights:
modules: ['@nuxtjs/tailwindcss', '@ant-design-vue/nuxt'],
app: { head: { htmlAttrs: { lang: 'zh-CN' }, ... } },
tailwindcss: { cssPath: '~/assets/css/tailwind.css', configPath: 'tailwind.config.cjs' },
experimental: { appManifest: false },
routeRules: { '/renewal': { ssr: true } },
runtimeConfig: { subscriptionCacheTtl: ..., public: { tenantId, appId, templateId, forceTemplateId, siteName, serverApiBase, modulesApiBase, appApiBase, fileServerBase, baseDomain, baseDomains, subdomainPrefixes, phone, wxQrcode, contactCaptcha } },
css: ['~/assets/css/tailwind.css', '~/assets/css/animations.css'],
vite: { server: { allowedHosts: ['.shoplnk.cn', '.sitelink.cn', '.wsdns.cn', 'localhost', '127.0.0.1'], watch: { ignored } },
nitro: { watchOptions: { ignored } }, watchers: { chokidar: { ignored } }
Modules used: @nuxtjs/tailwindcss (Tailwind) and @ant-design-vue/nuxt (Ant Design Vue). Ant Design Vue is available globally via the module (auto-import / transform). compatibilityDate: '2025-07-15'.
2. Pages & routing
Routing is file-based under app/pages/. No custom router config; Nuxt generates route names from file paths.
app/pages/
├── 404.vue # blank layout, noindex
├── [slug].vue # catch-all → components?.Page (compat for old /about, /services paths)
├── about.vue # → components?.About
├── article/index.vue # (name: article) → components?.NewsList
├── article/[id].vue # (name: article-id) → useModuleRoute('article'); hardcoded TalentTeam for id 4722
├── case/index.vue # (name: case) → components?.CaseList
├── case/[id].vue # (name: case-id) → useModuleRoute('case')
├── cases.vue # 301 → /case
├── contact.vue # → components?.Contact
├── index.vue # (name: index) → <SiteHome />
├── page/[id].vue # (name: page-id) → components?.Page | About | Contact (slug-aware + 301 canonical)
├── preview.vue # /preview?templateId=template-02
├── product/index.vue # (name: product) → components?.ProductList
├── product/[id].vue # (name: product-id) → useModuleRoute('product')
└── renewal.vue # (name: renewal) → components?.Renewal ⚠️ see note
Route names (used by router): index, slug, about, article, article-id, case, case-id, cases, contact, page, page-id, preview, product, product-id, renewal.
Dynamic / catch-all:
[slug].vue— the old-path compat renderer; renderscomponents?.Pageand matches nav bypath === '/' + slug.- NOT
[...slug].vue— there is no recursive catch-all; it is a single[slug].
app/app.vue:
<template>
<NuxtLayout>
<NuxtRouteAnnouncer />
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
// 根入口组件:仅做布局和路由出口
</script>
Key route files read the template component via loadTemplate():
app/pages/product/index.vue:
const components = await loadTemplate()
pageComponent.value = components?.ProductList || null
app/pages/product/[id].vue (module list/detail 2-in-1):
const { route, pageComponent } = await useModuleRoute('product')
app/pages/renewal.vue:
const components = await loadTemplate()
pageComponent.value = components?.Renewal || null
⚠️ IMPORTANT correction about template pages/index.vue + routeMap
Each app/templates/template-XX/pages/index.vue contains a routeMap (e.g. template-07 maps news, products, renewal, buy, ... by route name). Example app/templates/template-07/pages/index.vue:
const routeMap: Record<string, () => Promise<{ default: Component }>> = {
news: () => import('./NewsList.vue'),
'news-id': () => import('./NewsDetail.vue'),
products: () => import('./ProductList.vue'),
'products-id': () => import('./ProductDetail.vue'),
cases: () => import('./CaseList.vue'),
'cases-id': () => import('./CaseDetail.vue'),
contact: () => import('./Contact.vue'),
renewal: () => import('./Renewal.vue'),
buy: () => import('./BuyDocument.vue') // ← the only place BuyDocument is referenced
}
This routeMap is DEAD / not wired into routing. These index.vue files live under app/templates/ (NOT app/pages/), so Nuxt never registers them as routes, and nothing imports them. The active dispatch mechanism is loadTemplate() in app/composables/useTemplate.ts, which returns named components by importing app/templates/<id>/pages/<Name>.vue (see §3). The actual routes live in app/pages/*.vue.
Evidence the routeMap is stale: its keys are plural (products, cases, news) while actual Nuxt route names for those pages are singular (product, case, article). So even if it were mounted it would never match.
Consequence for the buy route: there is currently no app/pages/buy.vue, and BuyDocument is not registered in loadTemplate — so /buy does not work today at all. BuyDocument.vue is only referenced by the dead routeMap.
3. Template system
app/templates/index.ts registers all templates in an array:
const templates: TemplateConfig[] = [template01, ..., template10]
export default templates
Each template-XX/config.ts defines the TemplateConfig (id/name/description/preview/supportedModules/themeConfig). E.g. app/templates/template-07/config.ts:
export default {
id: 'template-07',
name: '科技蓝定制模板',
...
supportedModules: ['home', 'about', 'products', 'cases', 'news', 'contact'],
themeConfig: { primaryColor: '#1a6dff', secondaryColor: '#0d4fb8', ... }
} satisfies TemplateConfig
Template selection is by templateId resolved in app/composables/useTemplate.ts. Precedence (from resolvedTemplateId):
forceTemplateId (NUXT_PUBLIC_FORCE_TEMPLATE_ID, local debug, highest)
> siteTemplateId (cms_website, tenant-isolated truth) [useSite().templateId]
> appTemplateId (app_product) [useApp().templateId]
> defaultTemplateId (NUXT_PUBLIC_TEMPLATE_ID)
NUXT_PUBLIC_TEMPLATE_ID=template-07 in .env; also resolved server-side from the domain (tenant middleware sets event.context.tenant.templateId, and templateCode is backfilled from the DB via server/utils/template-map.ts).
The core loader — loadTemplate() (in app/composables/useTemplate.ts):
const header = (await import(`~/templates/${targetId}/components/Header.vue`)).default
const footer = (await import(`~/templates/${targetId}/components/Footer.vue`)).default
const home = (await import(`~/templates/${targetId}/pages/Home.vue`)).default
const optionalComponents = [
{ key: 'Page', file: 'Page' },
{ key: 'NewsList', file: 'NewsList' },
{ key: 'NewsDetail', file: 'NewsDetail' },
{ key: 'ProductList', file: 'ProductList' },
{ key: 'ProductDetail', file: 'ProductDetail' },
{ key: 'CaseList', file: 'CaseList' },
{ key: 'CaseDetail', file: 'CaseDetail' },
{ key: 'Contact', file: 'Contact' },
{ key: 'About', file: 'About' }
]
for (const { key, file } of optionalComponents) {
try {
const mod = await import(`~/templates/${targetId}/pages/${file}.vue`)
if (mod.default) components[key] = mod.default
} catch { /* 可选组件不存在时跳过 */ }
}
Exactly how template pages are registered & rendered: a page file under app/pages/ calls loadTemplate() and reads a named property from the returned TemplateComponents object (e.g. components?.ProductList), then renders it with <Component :is="pageComponent" />. The TemplateComponents interface (same file) declares exactly: Home, Page?, NewsList?, NewsDetail?, ProductList?, ProductDetail?, CaseList?, CaseDetail?, Contact?, About?, Header, Footer.
⚠️ Renewal / BuyDocument / Culture / History / BranchOffice / TalentTeam are NOT in this interface, NOT in optionalComponents, and NOT loaded. They exist as files under app/templates/template-07/pages/ but are unreachable through loadTemplate(). renewal.vue reading components?.Renewal therefore resolves to undefined → it renders <SiteLoading /> forever (the route is effectively broken in this checkout). Only TalentTeam is reached — via a hardcoded import in app/pages/article/[id].vue (see below).
The index.vue (homepage) mapping:
app/pages/index.vuerenders<SiteHome />.app/components/SiteHome.vue→loadTemplate()→components?.Home(importsapp/templates/<id>/pages/Home.vue).- There is no room for a
buyroute here;index.vuedoes not dispatch by route name (that was the job of the dead routeMap).
4. API layer / data fetching
All frontend→backend calls go through Nitro handlers under server/api/. Handlers proxy to the backend using $fetch with baseURL: config.public.modulesApiBase and a TenantId header derived from getTenantFromContext(event, config).
Pattern (list) — server/api/product/list.get.ts:
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config) // ctx.tenantId
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
const categoryId = query.navigationId ?? query.categoryId
const res = await $fetch('/cms/cms-product/page', {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId },
query: { ...query, categoryId, TenantId: ctx.tenantId, status: 0, deleted: 0 }
})
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
return { list: Array.isArray(envelopeData.list) ? envelopeData.list : [], count: envelopeData.count ?? envelopeData.total ?? 0 }
})
server/api/article/list.get.ts, server/api/case/list.get.ts follow the same shape (/cms/cms-article/page, /cms/cms-case/page; article & product use categoryIds/categoryIdsStr for aggregate).
Pattern (detail) — server/api/product/detail.get.ts:
res = await $fetch(`/cms/cms-product/${query.id}`, { baseURL: modulesApiBase, headers: { TenantId: ctx.tenantId }, query: { TenantId: ctx.tenantId } })
const detail = res?.data ?? res
if (typeof res?.code === 'number' && res.code !== 0) throw createError({ statusCode: 404, statusMessage: 'Product not found or unpublished' })
return detail // flattened, field-normalized
Pattern (POST write) — server/api/form/submit.post.ts: reads await readBody(event), validates server-side (honeypot/phone/captcha-ticket/rate-limit/dedup via server/utils/guard.ts), then $fetch('/cms/cms-contact-lead/submit', { method:'POST', baseURL: modulesApiBase, headers: { TenantId, 'Content-Type':'application/json' }, body: submitBody }).
File proxy — server/api/file/[...path].ts: uses proxyRequest(event, target, { headers: { TenantId, Authorization? } }) against config.public.fileServerBase.
How pages consume APIs:
useFetch(SSR-friendly, dedup bykey) — e.g.app/templates/template-07/pages/ProductList.vue:const { data, pending, error } = await useFetch('/api/product/list', { key: `product-list-...-p${currentPage.value}`, query: { page: currentPage, limit, ... }, watch: [currentPage, categoryIds, navigationId] })$fetchinside a composable — e.g.useCms().fetchProducts,useSite().fetchSiteInfo()($fetch('/api/site/info')).useModuleRoute.tsusesuseFetch('/api/product/detail?id=' + id, { key: 'product-'+id })for SEO, same key as the component → Nuxt dedups.
runtimeConfig/useRuntimeConfig(): nuxt.config.ts exposes public.modulesApiBase, public.serverApiBase, public.appApiBase, public.fileServerBase. Components read it via useRuntimeConfig().public.* (e.g. useTemplate.ts reads public.templateId). Servers read useRuntimeConfig() (non-public too, e.g. subscriptionCacheTtl).
The runtimeConfig block:
runtimeConfig: {
subscriptionCacheTtl: Number(process.env.NUXT_SUBSCRIPTION_CACHE_TTL || 60),
public: {
tenantId, appId, templateId, forceTemplateId, siteName,
serverApiBase, modulesApiBase, appApiBase, fileServerBase,
baseDomain, baseDomains, subdomainPrefixes, phone,
wxQrcode, contactCaptcha
}
}
Default (absences): modulesApiBase = 'https://cms-api.websoft.top/api'; .env overrides it to http://127.0.0.1:9200/api.
TenantId / auth pass-through: server/middleware/tenant.ts runs on every request. It resolves event.context.tenant (tenantId/appId/templateId) from Host/domain/subdomain/env/header and prefetches site info. Every server handler does getTenantFromContext(event, config) → ctx.tenantId, then sets the TenantId header (and often the TenantId query param, because the upstream page/detail endpoints actually honor the query param) on the upstream $fetch. The file proxy also forwards a client Authorization header if present, but the site flows otherwise do not carry any user token (see §5).
5. Auth / login
There is NO user login / register / token / JWT flow in this frontend.
Grep for token|jwt|login|register|authorization|auth finds only:
- captcha challenge
token/captcha-ticket(slider verification), unrelated to user auth. SiteSetting.loginBtn(a backend setting flag, unused for auth in frontend).article.author(author of a news article).server/api/file/[...path].tsforwards an optionalAuthorizationheader (only used for protected file URLs, no frontend login state).server/middleware/tenant.tsreadstenantid/appidrequest headers (tenant identity, NOT user auth).
There are no app/pages/login.vue, app/pages/register.vue, no useAuth/useUser composable, no token storage in cookie/localStorage, no server/api/auth/* route, and no middleware that redirects unauthenticated users. The only redirect is useSubscription().checkAndRedirect() → /renewal on subscription expiry, and useApp().appExpired.
renewal.vue: app/pages/renewal.vue renders components?.Renewal; the template page app/templates/template-07/pages/Renewal.vue is a static "expired" screen with a 立即续费 button that window.open('/api/subscription/renew?appId=' + appId). Because Renewal is not in loadTemplate, this route currently renders <SiteLoading> (broken until you add Renewal to useTemplate).
BuyDocument.vue: app/templates/template-07/pages/BuyDocument.vue is a STUB:
<script setup lang="ts"></script>
<template><div>sdfsdfsdsdfbuy---</div></template>
<style scoped></style>
It is referenced only in the dead routeMap. It is NOT a login page—it's the placeholder for the buy/checkout flow you are to implement.
Auth implication for the buy flow: any order/purchase endpoint you proxy must work with only the TenantId identity (no user token), OR the mp-java backend must return a purchase/order context that the anonymous user supplies (e.g. a link/order id / captcha). There is no logged-in user state to reuse. If the backend requires a user token, you must ADD a token flow (cookie/localStorage + a $fetch wrapper) — none exists today.
6. Existing reusable components
app/components/:
AboutSection.vue CaptchaSlider.vue (⚠️ unused/orphan)
ConsultDialog.vue ContactForm.vue (✅ shared留言/consult form, captcha + honey pot + phone validation)
FeatureIcon.vue PageAttachments.vue (✅ file/download list)
RecommendArticlesSection.vue RecommendCasesSection.vue RecommendProductsSection.vue (✅ card grids, styled)
RichText.vue (✅ HTML content renderer)
SiteBrand.vue SiteError.vue (✅ error state)
SiteFooter.vue SiteHeader.vue (✅ template wrappers)
SiteHome.vue SiteLoading.vue (✅ loading state)
SiteSearchBox.vue SliderCaptcha.vue (✅ slider captcha, used by ContactForm)
Notably reusable for a Tender/buy flow:
ContactForm.vue— full shared form: name/phone/content + honey pot +SliderCaptcha+ submit viauseCms().submitForm(/api/form/submit). Acceptstype,contentLabel,submitText,accent,presetContent,sourceprops. Best template for a "contact to buy" / "留下联系方式" form.SliderCaptcha.vue— slider puzzle captcha, used with/api/captcha/challenge+/api/captcha/verify. Required if the buy/checkout form needs slider verification (reusesserver/utils/guard.tstickets).RichText.vue— renders HTML body (for tender detail / notice body).PageAttachments.vue— renders a downloadable attachment list (perfect for bid-document attachments).RecommendProductsSection.vue— card grid with title/subtitle/theme-color props,NuxtLinkcards, lazy images. Good base for a TenderList card grid.SiteLoading.vue/SiteError.vue— loading/error states.ConsultDialog.vue+useConsult()— global "contact/consult" modal, triggered viaopenConsult({ need, source }); useful as a buy/咨询 CTA.
There is no dedicated QR-code/payment component, no product-card nor general Pagination component (pagination is hand-rolled inside each list page, e.g. ProductList.vue). Note CaptchaSlider.vue is an orphan (superseded by SliderCaptcha.vue).
7. Styling / UI kit
- Tailwind is used throughout:
@nuxtjs/tailwindcssmodule +tailwind.config.cjs. Content globs include./app/**/*.{vue,js,ts},./app/templates/**/*.{vue,js,ts},./components/,./layouts/,./pages/,./plugins/,./nuxt.config.{js,ts}.module.exports = { content: ['./app/**/*.{vue,js,ts}', './app/templates/**/*.{vue,js,ts}', './components/**/*.{vue,js,ts}', './layouts/**/*.vue', './pages/**/*.vue', './plugins/**/*.{js,ts}', './nuxt.config.{js,ts}'], theme: { extend: { screens: { xs: '480px' }, container: { center: true, padding: { DEFAULT:'1rem', sm:'1.5rem', lg:'2rem' }, screens: { sm:'640px', md:'768px', lg:'1024px', xl:'1200px', '2xl':'1280px' } } } }, corePlugins: { preflight: true }, plugins: [] } - Global CSS
app/assets/css/tailwind.css+app/assets/css/animations.cssloaded innuxt.config.tscss array and viatailwindcss.cssPath. - Ant Design Vue is wired through
@ant-design-vue/nuxt(module). Components are auto-imported/registered globally — pages usea-button,a-form, etc. via the module transform (no manualimport { Button } from 'ant-design-vue'in the theme templates; e.g. antd usage appears via template tags).dayjsis included invite.optimizeDeps. - Theme CSS vars per template, scoped by
[data-template-id="template-07"], e.g.app/templates/template-07/theme.css:[data-template-id="template-07"] { --t1-primary: #1a6dff; --t1-primary-dark: #0d4fb8; --t1-primary-light: #e8f0ff; --t1-text: #1f2937; --t1-text-secondary: #6b7280; --t1-bg: #ffffff; --t1-bg-gray: #f9fafb; --t1-footer-bg: #111827; }app/layouts/default.vueimports theme.css for templates 01, 02, 04, 07, 08, 09, 10 and sets:data-template-idon the wrapper. Primary color for template-07 is#1a6dff/#0d4fb8(blue).
To match the existing look for new pages: use Tailwind utility classes (container mx-auto px-4 sm:px-6 lg:px-8, py-16, bg-gray-50/bg-white, rounded-xl, shadow-sm hover:shadow-md, text-gray-900/text-gray-600, text-blue-600, text-sm, line-clamp-2, grid sm:grid-cols-2 lg:grid-cols-3 gap-6) and the --t1-primary blue accent, exactly as ProductList.vue/ProductDetail.vue do. Use a-* Ant Design Vue components when a native control (modal/table/form) is needed; otherwise stay with Tailwind for consistency.
8. End-to-end example (template-07 ProductList / ProductDetail)
ProductList — page → composable → server → upstream → render:
-
Route
app/pages/product/index.vueloads template and setspageComponent = components?.ProductList. -
loadTemplate()inuseTemplate.tsdoesawait import('~/templates/template-07/pages/ProductList.vue'). -
app/templates/template-07/pages/ProductList.vue(trimmed):const { fileUrl } = useFileUrl() const { allNavigations, fetchSiteInfo } = useSite() await fetchSiteInfo() const navigationId = computed(() => { /* from ?navId= or /product/:id */ }) const categoryIds = computed(() => collectDescendantNavIds(navigationId.value, allNavigations.value || []).join(',')) const currentPage = ref(1); const limit = 12 const { data, pending, error } = await useFetch('/api/product/list', { key: `product-list-${categoryIds.value ?? navigationId.value ?? 'all'}-p${currentPage.value}`, query: { page: currentPage, limit, ...(categoryIds.value ? { categoryIds } : { navigationId }) }, watch: [currentPage, categoryIds, navigationId] }) const products = computed(() => (data.value as PageResult<Product>)?.list || []) const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / limit)))Template renders a
gridof<NuxtLink :to="'/product/' + item.id">cards + a hand-rolled pagination. -
server/api/product/list.get.tsforwards to$fetch('/cms/cms-product/page', { baseURL: modulesApiBase, headers: { TenantId: ctx.tenantId }, query: {...} })and normalizes{ list, count }. -
Backend base:
modulesApiBase=http://127.0.0.1:9200/api(env override), so upstream =http://127.0.0.1:9200/api/cms/cms-product/page.
ProductDetail:
app/pages/product/[id].vue→useModuleRoute('product').useModuleRouteloads template, decides list-vs-detail by whetheridis a known column navId; for details it pickscomponents.ProductDetailand injects SEO, usinguseFetch('/api/product/detail?id='+id, { key: 'product-'+id }).app/templates/template-07/pages/ProductDetail.vue:const { data: product } = await useFetch('/api/product/detail?id=' + id, { key: 'product-'+id }); template showsfileUrl(product.cover),product.productName,RichText :content="product.content", and a立即咨询button callingopenConsult({ need, source }).server/api/product/detail.get.tsforwards$fetch('/cms/cms-product/'+id, { baseURL: modulesApiBase, headers: { TenantId }, query:{TenantId} }), normalizes fields (content←description,subtitle←summary,navigationId←categoryId), and 404s if unpublished.
Copy this pattern exactly for TenderList/TenderDetail.
9. Where to add the bid-purchase pages cleanly
Two viable approaches; the recommended one (matches existing architecture, and the precedent set by app/pages/article/[id].vue which hardcodes TalentTeam for the tenant).
A. Template-agnostic, via loadTemplate + a dedicated route (RECOMMENDED)
This wires Tender pages the same way product/article pages are wired, so they render the active template's skin and stay template-extensible.
Frontend files to add:
app/templates/template-07/pages/TenderList.vue— list of bid-documents (copyProductList.vuestyling).app/templates/template-07/pages/TenderDetail.vue— detail + buy/下载 (copyProductDetail.vue; useRichText+PageAttachments).app/pages/tender/index.vue→const components = await loadTemplate(); pageComponent.value = components?.TenderList || null(route nametender).app/pages/tender/[id].vue→ a new composable or inline detail load (do NOT reuseuseModuleRoute, it's hardcoded toarticle|product|case). E.g.const { data: tender } = await useFetch('/api/tender/detail?id=' + id, { key: 'tender-'+id }); pageComponent.value = components?.TenderDetail.app/pages/buy.vue→ the buy/checkout flow:const components = await loadTemplate(); pageComponent.value = components?.BuyDocument || null(route namebuy).
Wiring changes in app/composables/useTemplate.ts (SHARED, low-risk):
- Add to the
TemplateComponentsinterface:TenderList?: Component TenderDetail?: Component BuyDocument?: Component - Add to the
optionalComponentsarray:(try/catch already skips templates that don't ship these files, so adding them is safe for all 10 templates.){ key: 'TenderList', file: 'TenderList' }, { key: 'TenderDetail', file: 'TenderDetail' }, { key: 'BuyDocument', file: 'BuyDocument' }
This is the cleanest, and it also fixes renewal.vue if you add { key: 'Renewal', file: 'Renewal' } the same way.
B. Direct route→component (least invasive, template-07-locked)
Same precedent as app/pages/article/[id].vue importing ~/templates/template-07/pages/TalentTeam.vue. Add:
app/pages/tender/index.vue→import TenderList from '~/templates/template-07/pages/TenderList.vue'and render it.app/pages/tender/[id].vue→import TenderDetail from '~/templates/template-07/pages/TenderDetail.vue'.app/pages/buy.vue→import BuyDocument from '~/templates/template-07/pages/BuyDocument.vue'(replace the stub with the real buy flow).
No useTemplate edit needed, but the pages become template-07-only (they won't auto-skin for other templates). Choose this only if the tender feature must NOT be re-skinned.
Ignore the template
routeMap(inapp/templates/template-07/pages/index.vue). It is not wired. Do not rely on editing it.
Server API to add (following the product/case handler pattern):
server/api/tender/list.get.ts → GET /api/tender/list → $fetch('/hjc/tender/list', { baseURL: modulesApiBase, headers/query: { TenantId } })
server/api/tender/detail.get.ts → GET /api/tender/detail?id=xxx → $fetch('/hjc/tender/xxx', { baseURL: modulesApiBase, headers/query: { TenantId } })
server/api/tender/order.post.ts → POST /api/tender/order → $fetch('/hjc/tender/order', { method:'POST', body, headers: { TenantId, 'Content-Type':'application/json' } })
server/api/tender/pay.post.ts → POST /api/tender/pay → $fetch('/hjc/tender/pay', { method:'POST', body, headers: { TenantId } })
Each handler: const config = useRuntimeConfig(); const ctx = getTenantFromContext(event, config); const modulesApiBase = config.public.modulesApiBase; try { return await $fetch('/hjc/...', { baseURL: modulesApiBase, headers: { TenantId: ctx.tenantId }, query: { ...query, TenantId: ctx.tenantId } }) } catch (e) { throw createError({ statusCode: e?.statusCode || 502, statusMessage: ... }) }.
Because modulesApiBase already equals http://127.0.0.1:9200/api, $fetch('/hjc/tender/...', { baseURL: modulesApiBase }) yields http://127.0.0.1:9200/api/hjc/tender/... — exactly the target. You do not need a new runtimeConfig key to hit the correct base today.
10. Env / config for the new backend base
NUXT_PUBLIC_MODULES_API_BASE is read in nuxt.config.ts (const modulesApiBase = process.env.NUXT_PUBLIC_MODULES_API_BASE || 'https://cms-api.websoft.top/api') and exposed as runtimeConfig.public.modulesApiBase. Every server handler uses config.public.modulesApiBase as baseURL for its upstream $fetch. .env currently sets it to http://127.0.0.1:9200/api, which IS the mp-api/mp-java backend base.
The runtimeConfig block (from nuxt.config.ts):
runtimeConfig: {
subscriptionCacheTtl: Number(process.env.NUXT_SUBSCRIPTION_CACHE_TTL || 60),
public: {
tenantId, // NUXT_PUBLIC_TENANT_ID
appId, // NUXT_PUBLIC_APP_ID
templateId, // NUXT_PUBLIC_TEMPLATE_ID
forceTemplateId, // NUXT_PUBLIC_FORCE_TEMPLATE_ID
siteName, // NUXT_PUBLIC_SITE_NAME
serverApiBase, // NUXT_PUBLIC_SERVER_API_BASE
modulesApiBase, // NUXT_PUBLIC_MODULES_API_BASE (= http://127.0.0.1:9200/api)
appApiBase, // NUXT_PUBLIC_APP_API_BASE
fileServerBase, // NUXT_PUBLIC_FILE_SERVER_BASE
baseDomain, // NUXT_PUBLIC_BASE_DOMAIN
baseDomains, // NUXT_PUBLIC_BASE_DOMAINS
subdomainPrefixes, // NUXT_PUBLIC_SUBDOMAIN_PREFIXES
phone, // NUXT_PUBLIC_PHONE
wxQrcode, // NUXT_PUBLIC_WX_QRCODE
contactCaptcha // NUXT_PUBLIC_CONTACT_CAPTCHA
}
}
Recommendation: you can reuse modulesApiBase directly for the /hjc/... endpoints (it already resolves to http://127.0.0.1:9200/api, and modulesApiBase + '/hjc/...' = http://127.0.0.1:9200/api/hjc/...). This is the lowest-friction path and requires no config change.
If you prefer isolation / a distinct base per feature (e.g. a different host in production), add an optional key:
const tenderApiBase = process.env.NUXT_PUBLIC_TENDER_API_BASE || modulesApiBase
...public: { ..., tenderApiBase }
and in the tense handlers use config.public.tenderApiBase as string. (Add NUXT_PUBLIC_TENDER_API_BASE to .env/deploy env when set.) But this is optional — modulesApiBase is sufficient and already correct.
Concrete steps to add TenderList / TenderDetail / buy flow
-
Backend path: confirm the mp-java tender endpoints. They live under
http://127.0.0.1:9200/api/hjc/...(base =modulesApiBase). You do NOT need a new env key if you usemodulesApiBase. -
Proxy handlers (
server/api/tender/): createlist.get.ts,detail.get.ts, and (for buy/pay)order.post.ts/pay.post.ts. Each:getTenantFromContext(event, config)→config.public.modulesApiBase→$fetch('/hjc/tender/...', { baseURL, headers:{TenantId}, query/body }). Normalize the envelope{ list, count }for the list and flatten the detail (likeproduct/list.get.ts+product/detail.get.ts). Adddeleted: 0/statusfilters as the CMS handlers do if the tender API supports them. -
Template pages (
app/templates/template-07/pages/): addTenderList.vue(copyProductList.vue; useuseFetch('/api/tender/list'),NuxtLinkto/tender/<id>,fileUrl(item.cover), hand-rolled pagination) andTenderDetail.vue(copyProductDetail.vue;useFetch('/api/tender/detail?id='+id),RichText :content,PageAttachments :attachments, and the primary购买/下载标书action). For the checkout, flesh outBuyDocument.vue(currently thesdfsdfsdsdfbuy---stub) with a form + captcha + pay action. -
Wire into
useTemplate(app/composables/useTemplate.ts): addTenderList,TenderDetail,BuyDocument(and optionallyRenewal) to theTemplateComponentsinterface AND to theoptionalComponentsarray (both required). Safe for all templates due to the try/catch. -
Routes (
app/pages/):tender/index.vue→components?.TenderListtender/[id].vue→ load detail +components?.TenderDetail(do NOT useuseModuleRoute, it only supports article/product/case)buy.vue→components?.BuyDocument(the pay/checkout page)
-
Types: add
Tender/TenderItem/TenderOrderinterfaces toapp/types/index.tsfollowing theProduct/Articleshape (id, title/name, content, attachments, price, etc.). -
Styling: use Tailwind exactly as
ProductList.vue/ProductDetail.vue(container mx-auto px-4 sm:px-6 lg:px-8,bg-gray-50,rounded-xl,text-blue-600,line-clamp-2); useRichTextfor body HTML,PageAttachmentsfor attachment downloads,ContactForm(orSliderCaptcha+useCms().submitForm) for a "获取标书/咨询" form. Optionally wrap a buy CTA withopenConsult(). Use theme-07 primary#1a6dff/#0d4fb8ortext-blue-600. -
Nav entry (optional): if a nav item should point to
/tenderor/buy, useCmsNavigation.path/urlviagetNavLink()(app/utils/index.ts); for a static in-template nav (template-native), add aNuxtLink to="/tender"/to="/buy"inapp/templates/template-07/components/Header.vue. -
Auth caveat: There is no user login. If the buy/order API requires a user identity beyond the tenant, you must introduce a token flow (cookie/localStorage + a
$fetchwrapper or an/api/auth/*proxy). Otherwise the flow runs fully anonymous underTenantId— add a captcha/slider (reuseSliderCaptcha+server/utils/guard.ts) if you need bot/rate-limit protection for order submission. -
Verify: run
pnpm run dev(ornpm run dev) athttp://localhost:3000; the.envalready setsNUXT_PUBLIC_TEMPLATE_ID=template-07andNUXT_PUBLIC_MODULES_API_BASE=http://127.0.0.1:9200/api. Confirm/tender,/tender/<id>, and/buyrender and thatserver/api/tender/*proxy succeeds against the running mp-java backend.
Key gotchas summary
- The template
routeMap(inapp/templates/*/pages/index.vue) is dead code — never mount/buythrough it. RenewalandBuyDocumentare not loaded byloadTemplate()→renewal.vuecurrently shows<SiteLoading>and/buyis unreachable. You must register them inuseTemplate.ts(interface +optionalComponents) or import them directly in route files.- Reuse
modulesApiBasefor tender endpoints (it already ==http://127.0.0.1:9200/api); a dedicatedtenderApiBaseis optional. - There is no auth/login — buy flow is anonymous (tenant-scoped) unless you add a token flow.
- The backend base per the task is
mp-api/mp-java; its routes arehttp://127.0.0.1:9200/api/hjc/....