Files
hjc-web/BUY_TENDER_MAP_REPORT.md
T
weicw1996 2828ba6718 feat(tender): 汇吉采标书购买 阶段3 - C端标书列表/详情/购买入口
- 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 结构映射参考)
2026-09-08 21:45:34 +08:00

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; renders components?.Page and matches nav by path === '/' + 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.vue renders <SiteHome />.
  • app/components/SiteHome.vueloadTemplate()components?.Home (imports app/templates/<id>/pages/Home.vue).
  • There is no room for a buy route here; index.vue does 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 by key) — 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]
    })
    
  • $fetch inside a composable — e.g. useCms().fetchProducts, useSite().fetchSiteInfo() ($fetch('/api/site/info')).
  • useModuleRoute.ts uses useFetch('/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].ts forwards an optional Authorization header (only used for protected file URLs, no frontend login state).
  • server/middleware/tenant.ts reads tenantid/appid request 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 via useCms().submitForm (/api/form/submit). Accepts type, contentLabel, submitText, accent, presetContent, source props. 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 (reuses server/utils/guard.ts tickets).
  • 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, NuxtLink cards, lazy images. Good base for a TenderList card grid.
  • SiteLoading.vue / SiteError.vue — loading/error states.
  • ConsultDialog.vue + useConsult() — global "contact/consult" modal, triggered via openConsult({ 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/tailwindcss module + 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.css loaded in nuxt.config.ts css array and via tailwindcss.cssPath.
  • Ant Design Vue is wired through @ant-design-vue/nuxt (module). Components are auto-imported/registered globally — pages use a-button, a-form, etc. via the module transform (no manual import { Button } from 'ant-design-vue' in the theme templates; e.g. antd usage appears via template tags). dayjs is included in vite.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.vue imports theme.css for templates 01, 02, 04, 07, 08, 09, 10 and sets :data-template-id on 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:

  1. Route app/pages/product/index.vue loads template and sets pageComponent = components?.ProductList.

  2. loadTemplate() in useTemplate.ts does await import('~/templates/template-07/pages/ProductList.vue').

  3. 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 grid of <NuxtLink :to="'/product/' + item.id"> cards + a hand-rolled pagination.

  4. server/api/product/list.get.ts forwards to $fetch('/cms/cms-product/page', { baseURL: modulesApiBase, headers: { TenantId: ctx.tenantId }, query: {...} }) and normalizes { list, count }.

  5. 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:

  1. app/pages/product/[id].vueuseModuleRoute('product').
  2. useModuleRoute loads template, decides list-vs-detail by whether id is a known column navId; for details it picks components.ProductDetail and injects SEO, using useFetch('/api/product/detail?id='+id, { key: 'product-'+id }).
  3. app/templates/template-07/pages/ProductDetail.vue: const { data: product } = await useFetch('/api/product/detail?id=' + id, { key: 'product-'+id }); template shows fileUrl(product.cover), product.productName, RichText :content="product.content", and a 立即咨询 button calling openConsult({ need, source }).
  4. server/api/product/detail.get.ts forwards $fetch('/cms/cms-product/'+id, { baseURL: modulesApiBase, headers: { TenantId }, query:{TenantId} }), normalizes fields (contentdescription, subtitlesummary, navigationIdcategoryId), 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).

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 (copy ProductList.vue styling).
  • app/templates/template-07/pages/TenderDetail.vue — detail + buy/下载 (copy ProductDetail.vue; use RichText + PageAttachments).
  • app/pages/tender/index.vueconst components = await loadTemplate(); pageComponent.value = components?.TenderList || null (route name tender).
  • app/pages/tender/[id].vue → a new composable or inline detail load (do NOT reuse useModuleRoute, it's hardcoded to article|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 name buy).

Wiring changes in app/composables/useTemplate.ts (SHARED, low-risk):

  • Add to the TemplateComponents interface:
    TenderList?: Component
    TenderDetail?: Component
    BuyDocument?: Component
    
  • Add to the optionalComponents array:
    { key: 'TenderList', file: 'TenderList' },
    { key: 'TenderDetail', file: 'TenderDetail' },
    { key: 'BuyDocument', file: 'BuyDocument' }
    
    (try/catch already skips templates that don't ship these files, so adding them is safe for all 10 templates.)

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.vueimport TenderList from '~/templates/template-07/pages/TenderList.vue' and render it.
  • app/pages/tender/[id].vueimport TenderDetail from '~/templates/template-07/pages/TenderDetail.vue'.
  • app/pages/buy.vueimport 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 (in app/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 optionalmodulesApiBase is sufficient and already correct.


Concrete steps to add TenderList / TenderDetail / buy flow

  1. 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 use modulesApiBase.

  2. Proxy handlers (server/api/tender/): create list.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 (like product/list.get.ts + product/detail.get.ts). Add deleted: 0 / status filters as the CMS handlers do if the tender API supports them.

  3. Template pages (app/templates/template-07/pages/): add TenderList.vue (copy ProductList.vue; use useFetch('/api/tender/list'), NuxtLink to /tender/<id>, fileUrl(item.cover), hand-rolled pagination) and TenderDetail.vue (copy ProductDetail.vue; useFetch('/api/tender/detail?id='+id), RichText :content, PageAttachments :attachments, and the primary 购买/下载标书 action). For the checkout, flesh out BuyDocument.vue (currently the sdfsdfsdsdfbuy--- stub) with a form + captcha + pay action.

  4. Wire into useTemplate (app/composables/useTemplate.ts): add TenderList, TenderDetail, BuyDocument (and optionally Renewal) to the TemplateComponents interface AND to the optionalComponents array (both required). Safe for all templates due to the try/catch.

  5. Routes (app/pages/):

    • tender/index.vuecomponents?.TenderList
    • tender/[id].vue → load detail + components?.TenderDetail (do NOT use useModuleRoute, it only supports article/product/case)
    • buy.vuecomponents?.BuyDocument (the pay/checkout page)
  6. Types: add Tender / TenderItem / TenderOrder interfaces to app/types/index.ts following the Product/Article shape (id, title/name, content, attachments, price, etc.).

  7. 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); use RichText for body HTML, PageAttachments for attachment downloads, ContactForm (or SliderCaptcha + useCms().submitForm) for a "获取标书/咨询" form. Optionally wrap a buy CTA with openConsult(). Use theme-07 primary #1a6dff/#0d4fb8 or text-blue-600.

  8. Nav entry (optional): if a nav item should point to /tender or /buy, use CmsNavigation.path/url via getNavLink() (app/utils/index.ts); for a static in-template nav (template-native), add a NuxtLink to="/tender" / to="/buy" in app/templates/template-07/components/Header.vue.

  9. 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 $fetch wrapper or an /api/auth/* proxy). Otherwise the flow runs fully anonymous under TenantId — add a captcha/slider (reuse SliderCaptcha + server/utils/guard.ts) if you need bot/rate-limit protection for order submission.

  10. Verify: run pnpm run dev (or npm run dev) at http://localhost:3000; the .env already sets NUXT_PUBLIC_TEMPLATE_ID=template-07 and NUXT_PUBLIC_MODULES_API_BASE=http://127.0.0.1:9200/api. Confirm /tender, /tender/<id>, and /buy render and that server/api/tender/* proxy succeeds against the running mp-java backend.


Key gotchas summary

  • The template routeMap (in app/templates/*/pages/index.vue) is dead code — never mount /buy through it.
  • Renewal and BuyDocument are not loaded by loadTemplate()renewal.vue currently shows <SiteLoading> and /buy is unreachable. You must register them in useTemplate.ts (interface + optionalComponents) or import them directly in route files.
  • Reuse modulesApiBase for tender endpoints (it already == http://127.0.0.1:9200/api); a dedicated tenderApiBase is 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 are http://127.0.0.1:9200/api/hjc/....