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 结构映射参考)
This commit is contained in:
2026-09-08 21:45:34 +08:00
parent bbcb76485d
commit 2828ba6718
12 changed files with 1092 additions and 8 deletions
+530
View File
@@ -0,0 +1,530 @@
# 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:
```ts
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`:
```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`:
```ts
const components = await loadTemplate()
pageComponent.value = components?.ProductList || null
```
`app/pages/product/[id].vue` (module list/detail 2-in-1):
```ts
const { route, pageComponent } = await useModuleRoute('product')
```
`app/pages/renewal.vue`:
```ts
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`:
```ts
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:
```ts
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`:
```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`):
```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.vue``loadTemplate()``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`:**
```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`:**
```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`:
```ts
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:
```ts
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**:
```vue
<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}`.
```js
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`:
```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):
```vue
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].vue` → `useModuleRoute('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 (`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 (copy `ProductList.vue` styling).
- `app/templates/template-07/pages/TenderDetail.vue` — detail + buy/下载 (copy `ProductDetail.vue`; use `RichText` + `PageAttachments`).
- `app/pages/tender/index.vue` → `const 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:
```ts
TenderList?: Component
TenderDetail?: Component
BuyDocument?: Component
```
- Add to the `optionalComponents` array:
```ts
{ 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.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` (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`):
```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:
```ts
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
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.vue` → `components?.TenderList`
- `tender/[id].vue` → load detail + `components?.TenderDetail` (do NOT use `useModuleRoute`, it only supports article/product/case)
- `buy.vue` → `components?.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/...`.
+10 -1
View File
@@ -44,6 +44,12 @@ export interface TemplateComponents {
Contact?: Component Contact?: Component
/** 关于我们页 */ /** 关于我们页 */
About?: Component About?: Component
/** 标书列表页 */
TenderList?: Component
/** 标书详情页 */
TenderDetail?: Component
/** 标书购买流程壳 */
BuyDocument?: Component
/** Header 组件 */ /** Header 组件 */
Header: Component Header: Component
/** Footer 组件 */ /** Footer 组件 */
@@ -130,7 +136,10 @@ export function useTemplate() {
{ key: 'CaseList', file: 'CaseList' }, { key: 'CaseList', file: 'CaseList' },
{ key: 'CaseDetail', file: 'CaseDetail' }, { key: 'CaseDetail', file: 'CaseDetail' },
{ key: 'Contact', file: 'Contact' }, { key: 'Contact', file: 'Contact' },
{ key: 'About', file: 'About' } { key: 'About', file: 'About' },
{ key: 'TenderList', file: 'TenderList' },
{ key: 'TenderDetail', file: 'TenderDetail' },
{ key: 'BuyDocument', file: 'BuyDocument' }
] ]
for (const { key, file } of optionalComponents) { for (const { key, file } of optionalComponents) {
+18
View File
@@ -0,0 +1,18 @@
<template>
<div>
<Component :is="pageComponent" v-if="pageComponent" :key="route.fullPath" />
<SiteLoading v-else />
</div>
</template>
<script setup lang="ts">
/**
* 标书购买流程壳 /buy?id=xxx
*/
const route = useRoute()
const { loadTemplate } = useTemplate()
const pageComponent = shallowRef<Component | null>(null)
const components = await loadTemplate()
pageComponent.value = components?.BuyDocument || null
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div>
<Component :is="pageComponent" v-if="pageComponent" :key="route.fullPath" />
<SiteLoading v-else />
</div>
</template>
<script setup lang="ts">
/**
* 标书详情 /tender/:id
*/
const route = useRoute()
const { loadTemplate } = useTemplate()
const pageComponent = shallowRef<Component | null>(null)
const components = await loadTemplate()
pageComponent.value = components?.TenderDetail || null
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div>
<Component :is="pageComponent" v-if="pageComponent" />
<SiteLoading v-else />
</div>
</template>
<script setup lang="ts">
/**
* 标书中心(列表根) /tender
*/
const { loadTemplate } = useTemplate()
const pageComponent = shallowRef<Component | null>(null)
const components = await loadTemplate()
pageComponent.value = components?.TenderList || null
</script>
+108 -7
View File
@@ -1,11 +1,112 @@
<script setup lang="ts">
</script>
<template> <template>
<div>sdfsdfsdsdfbuy---</div> <div class="container mx-auto px-4 py-10">
<NuxtLink to="/tender" class="text-sm text-blue-600 hover:underline"> 返回标书中心</NuxtLink>
<div v-if="tender" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-6">
<h1 class="text-xl font-bold text-gray-900">购买标书</h1>
<div class="mt-4 rounded-md bg-gray-50 p-4 text-sm">
<p class="font-semibold text-gray-800">{{ tender.projectName }}</p>
<p class="mt-1 text-gray-500">项目编号{{ tender.projectNo }}</p>
<p class="mt-2 text-blue-600">
信息服务费<span class="text-2xl font-bold">¥{{ formatMoney(tender.tenderPrice) }}</span>
</p>
</div>
<form class="mt-6 space-y-4" @submit.prevent="submit">
<div>
<label class="text-sm text-gray-600">购买联系人</label>
<input v-model="form.contactName" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="姓名" />
</div>
<div>
<label class="text-sm text-gray-600">联系电话</label>
<input v-model="form.contactPhone" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="手机号" />
</div>
<div>
<label class="text-sm text-gray-600">联系邮箱</label>
<input v-model="form.contactEmail" type="email" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="邮箱" />
</div>
<div>
<label class="text-sm text-gray-600">购买数量</label>
<input v-model.number="form.quantity" type="number" min="1" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" />
</div>
<button
type="submit"
class="w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
:disabled="submitting"
>
{{ submitting ? '提交中...' : '提交订单并支付' }}
</button>
<p v-if="tip" class="text-center text-sm text-gray-500">{{ tip }}</p>
<p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
</form>
</div>
<div v-else-if="notFound" class="py-20 text-center text-gray-500">项目不存在</div>
<SiteLoading v-else />
</div>
</template> </template>
<style scoped> <script setup lang="ts">
import type { Tender } from '~/types/tender'
</style> const route = useRoute()
const id = route.query.id as string
const tender = shallowRef<Tender | null>(null)
const notFound = ref(false)
const submitting = ref(false)
const tip = ref('')
const error = ref('')
const form = reactive({
contactName: '',
contactPhone: '',
contactEmail: '',
quantity: 1
})
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
async function submit() {
error.value = ''
tip.value = ''
submitting.value = true
try {
const res: any = await $fetch('/api/tender/order', {
method: 'POST',
body: {
projectId: Number(id),
quantity: form.quantity || 1,
contactName: form.contactName,
contactPhone: form.contactPhone,
contactEmail: form.contactEmail
}
})
if (res && res.orderNo) {
tip.value = `下单成功,订单号:${res.orderNo},请扫码支付`
// TODO(阶段3b): 调 /api/tender/pay 获取微信二维码(需登录态)
} else {
error.value = res?.message || '下单失败'
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '下单失败'
} finally {
submitting.value = false
}
}
try {
if (!id) {
notFound.value = true
} else {
const res: any = await $fetch(`/api/tender/detail?id=${id}`)
if (res && res.id) tender.value = res
else notFound.value = true
}
} catch {
notFound.value = true
}
</script>
@@ -0,0 +1,121 @@
<template>
<div class="container mx-auto px-4 py-10">
<div v-if="tender" class="grid lg:grid-cols-[1fr_320px] gap-8">
<!-- 左侧项目信息 -->
<div>
<NuxtLink to="/tender" class="text-sm text-blue-600 hover:underline"> 返回标书中心</NuxtLink>
<h1 class="mt-3 text-2xl font-bold text-gray-900">{{ tender.projectName }}</h1>
<div class="mt-2 flex flex-wrap gap-3 text-sm text-gray-500">
<span>项目编号{{ tender.projectNo }}</span>
<span class="rounded bg-gray-100 px-2 py-0.5">{{ tender.category || '未分类' }}</span>
</div>
<div class="mt-6 space-y-3 text-sm">
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">发布时间</span>
<span>{{ fmt(tender.publishTime) }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">投标截止</span>
<span>{{ fmt(tender.deadlineTime) }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">招标人</span>
<span>{{ tender.tenderer || '-' }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">开售时间</span>
<span>{{ fmt(tender.onsaleTime) }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">停售时间</span>
<span>{{ fmt(tender.offsaleTime) }}</span>
</div>
</div>
<div v-if="tender.bulletinTitle || tender.bulletinContent" class="mt-8">
<h2 class="mb-3 text-lg font-semibold text-gray-900">中标公告</h2>
<div class="rounded-md border border-gray-200 p-5 text-sm text-gray-700">
<h3 class="mb-2 font-semibold">{{ tender.bulletinTitle }}</h3>
<p class="whitespace-pre-line leading-relaxed">{{ tender.bulletinContent }}</p>
</div>
</div>
</div>
<!-- 右侧购买卡片 -->
<aside class="lg:sticky lg:top-24 h-fit rounded-lg border border-gray-200 p-6">
<div class="flex items-end gap-1 text-blue-600">
<span class="text-3xl font-bold">¥{{ formatMoney(tender.tenderPrice) }}</span>
<span class="text-xs text-gray-400 mb-1">信息服务费</span>
</div>
<p class="mt-2 text-xs text-gray-400">购买成功后可在我的订单查看并下载标书</p>
<div class="mt-5 space-y-2 text-sm text-gray-600">
<p>购买人数{{ tender.saleCount || 0 }}</p>
<p>售卖方式{{ sellingMethodText }}</p>
</div>
<button
class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!buyable"
@click="goBuy"
>
{{ buyable ? '立即购买' : '已截止' }}
</button>
<p class="mt-3 text-xs text-gray-400 text-center">购买需通过企业资质审核</p>
</aside>
</div>
<div v-else-if="notFound" class="py-20 text-center text-gray-500">标书项目不存在或已下架</div>
<SiteLoading v-else />
</div>
</template>
<script setup lang="ts">
import type { Tender } from '~/types/tender'
const route = useRoute()
const id = route.params.id as string
const tender = shallowRef<Tender | null>(null)
const notFound = ref(false)
const buyable = computed(() => {
if (!tender.value) return false
if (tender.value.status !== undefined && tender.value.status !== 1) return false
if (tender.value.needSell !== undefined && tender.value.needSell === 0) return false
if (tender.value.offsaleTime) {
const t = new Date(tender.value.offsaleTime.replace('T', ' ')).getTime()
if (!Number.isNaN(t) && t < Date.now()) return false
}
return true
})
const sellingMethodText = computed(() => {
const m = tender.value?.sellingMethod
if (m === 2) return '公众号'
if (m === 3) return '交易中心'
if (m === 4) return '政采云'
return '公司财务'
})
function fmt(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-'
}
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
function goBuy() {
if (!tender.value) return
return navigateTo(`/buy?id=${tender.value.id}`)
}
try {
const res: any = await $fetch(`/api/tender/detail?id=${id}`)
tender.value = res && res.id ? res : null
if (!res) notFound.value = true
} catch {
notFound.value = true
}
</script>
@@ -0,0 +1,105 @@
<template>
<div class="container mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-900 mb-2">标书中心</h1>
<p class="text-sm text-gray-500 mb-6">汇聚各类采购项目选择您需要的标书进行购买</p>
<!-- 搜索 + 分类 -->
<div class="mb-6 flex flex-col sm:flex-row gap-3">
<input
v-model="keywords"
class="flex-1 rounded-md border border-gray-300 px-4 py-2 text-sm"
placeholder="搜索项目名称 / 采购编号"
@keyup.enter="load()"
/>
<select v-model="category" class="rounded-md border border-gray-300 px-4 py-2 text-sm">
<option value="">全部项目</option>
<option value="服务类">服务类</option>
<option value="工程类">工程类</option>
<option value="货物类">货物类</option>
</select>
<button
class="rounded-md bg-blue-600 px-5 py-2 text-sm text-white hover:bg-blue-700"
@click="load()"
>
搜索
</button>
</div>
<!-- 列表 -->
<div v-if="items.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<NuxtLink
v-for="item in items"
:key="item.id"
:to="`/tender/${item.id}`"
class="group rounded-lg border border-gray-200 p-5 hover:shadow-md transition"
>
<div class="text-xs text-gray-400 mb-1">项目编号{{ item.projectNo }}</div>
<h2 class="text-base font-semibold text-gray-900 group-hover:text-blue-600 line-clamp-2">
{{ item.projectName }}
</h2>
<div class="mt-3 flex items-center gap-2 text-xs text-gray-500">
<span class="rounded bg-gray-100 px-2 py-0.5">{{ item.category || '未分类' }}</span>
<span>截止{{ item.deadlineTime?.slice(0, 19)?.replace('T', ' ') || '-' }}</span>
</div>
<div class="mt-4 flex items-end justify-between">
<div class="text-blue-600">
<span class="text-xl font-bold">¥{{ formatMoney(item.tenderPrice) }}</span>
<span class="text-xs text-gray-400 ml-1">信息服务费</span>
</div>
<span class="text-sm text-blue-600 group-hover:underline">查看详情</span>
</div>
</NuxtLink>
</div>
<div v-else-if="loaded" class="py-16 text-center text-gray-500">暂无标书项目</div>
<SiteLoading v-show="!loaded" />
<!-- 加载更多 -->
<div v-if="items.length < count" class="mt-8 text-center">
<button class="rounded-md border border-gray-300 px-6 py-2 text-sm" @click="loadMore()">
加载更多
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Tender } from '~/types/tender'
const keywords = ref('')
const category = ref('')
const page = ref(1)
const limit = 12
const loaded = ref(false)
const listState = shallowRef<Tender[]>([])
const items = computed(() => listState.value)
const count = computed(() => (listState as any).__count || 0)
async function load() {
page.value = 1
const res: any = await $fetch('/api/tender/list', {
query: { page: page.value, limit, keywords: keywords.value || undefined, category: category.value || undefined }
})
listState.value = res?.list || []
;(listState as any).__count = res?.count || 0
loaded.value = true
}
async function loadMore() {
page.value += 1
const res: any = await $fetch('/api/tender/list', {
query: { page: page.value, limit, keywords: keywords.value || undefined, category: category.value || undefined }
})
const next = res?.list || []
listState.value = [...listState.value, ...next]
;(listState as any).__count = res?.count || 0
}
function formatMoney(n?: number | string) {
if (n === undefined || n === null) return '0.00'
return Number(n).toFixed(2)
}
await load()
</script>
+50
View File
@@ -0,0 +1,50 @@
/** 汇吉采标书项目(前端视图模型,与 mp-java HjcBidProject 对齐) */
export interface Tender {
id: number
/** 项目编号 */
projectNo: string
/** 项目名称 */
projectName: string
/** 分类:服务类/工程类/货物类 */
category?: string
/** 发布时间 */
publishTime?: string
/** 投标截止时间 */
deadlineTime?: string
/** 招标人 */
tenderer?: string
/** 中标公司 */
winnerSupplier?: string
/** 中标金额 */
bidAmount?: number
/** 公告标题 */
bulletinTitle?: string
/** 公告正文 */
bulletinContent?: string
/** 公告附件(JSON字符串) */
bulletinFileList?: string
/** 标书价格(信息服务费) */
tenderPrice?: number
/** 标书附件(JSON字符串) */
tenderFile?: string
/** 开售时间 */
onsaleTime?: string
/** 停售时间 */
offsaleTime?: string
/** 售卖方式:1公司财务 2公众号 3交易中心 4政采云 */
sellingMethod?: number
/** 是否卖标书 */
needSell?: number
/** 状态:1上架 0停售 */
status?: number
/** 来源 */
dataSource?: string
/** 已售数量 */
saleCount?: number
}
/** 分页返回结构 */
export interface TenderPage {
list: Tender[]
count: number
}
+41
View File
@@ -0,0 +1,41 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getRouterParam } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 标书项目详情
* GET /api/tender/detail?id=1 (或 /api/tender/{id}
* 代理到 mp-apimp-java) hjc 标书项目详情,返回扁平化项目对象;不存在则 404。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const id = getRouterParam(event, 'id') || (getQuery(event) as any)?.id
const modulesApiBase = config.public.modulesApiBase as string
if (!id) {
throw createError({ statusCode: 400, statusMessage: '缺少 id' })
}
try {
const res = await $fetch(`/hjc/bid-project/${id}`, {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId }
}) as any
const data = res && res.data !== undefined ? res.data : res
if (!data) {
throw createError({ statusCode: 404, statusMessage: '标书项目不存在' })
}
return data
} catch (error: any) {
if (error?.statusCode === 404) {
throw error
}
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch tender detail'
})
}
})
+39
View File
@@ -0,0 +1,39 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 标书项目列表
* GET /api/tender/list?page=1&limit=10&keywords=xx&category=xx
* 代理到 mp-apimp-java hjc 标书项目列表(仅上架 status=1),归一化为 { 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
try {
const res = await $fetch('/hjc/bid-project/list', {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId },
query: {
...query,
status: 1,
TenantId: ctx.tenantId
}
}) as any
const data = (res && res.data !== undefined ? res.data : res) || {}
return {
list: Array.isArray(data.list) ? data.list : [],
count: data.count ?? data.total ?? 0
}
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch tender list'
})
}
})
+35
View File
@@ -0,0 +1,35 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody, getHeader } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 创建标书订单
* POST /api/tender/order { projectId, quantity, contactName, contactPhone, contactEmail }
* 代理到 mp-apimp-java /api/hjc/order/create;需登录态(Authorization 透传)。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = await readBody(event)
const auth = getHeader(event, 'authorization')
try {
const res = await $fetch('/hjc/order/create', {
baseURL: config.public.modulesApiBase,
method: 'POST',
headers: {
TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {})
},
query: { TenantId: ctx.tenantId },
body
}) as any
return res?.data ?? res
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to create order'
})
}
})