feat(tender): 支付链接改为站内二维码,支付确认改走微信侧查单
- BuyDocument:支付步骤原先把后端 Native 下单返回的 codeUrl(weixin://wxpay/...)当明文 链接渲染成一个 <a target="_blank">,而 weixin:// 在浏览器里点击没有任何有效行为——标题写 着「请使用微信扫码支付」,页面上却没有二维码,PC 端 Native 支付实质不可用。改为渲染真正 的二维码,并删掉那个链接。 - HjcPayQrcode(新):uqr 站内生成,renderSVG() → data:image/svg+xml → <img :src>。不用 v-html(避开 vue/no-v-html),不用第三方外链(hjc-h5 用的 api.qrserver.com 在境内不可靠, 且等于把支付凭据交给第三方),也不改后端契约。 - useHjcPayStatus(新):支付结果一律以**微信侧**为准——轮询 pay-status(3s,页面隐藏暂停, 2 小时上限),手动「我已完成支付」按钮也走同一条查单通道。原先只有 mark-paid 一条路,而它 不向微信查单(HjcOrderController 的 javadoc 自陈「订单置为已支付只有 /mark-paid 一条路」), 用户一分钱不付、点一下就能把订单置为已支付并触发一站式推送。故 hjc-web 弃用它,并删除 server/api/tender/mark-paid.put.ts,不留这个入口给下一个人误用。 - 查单的另一好处:微信已支付而本地仍未支付时它会就地补写状态(幂等)。于是「付了钱但页面被 关掉/断网」这类漏单能被认出来——进入支付步骤即开始轮询,正是为了认出这种订单。 - 拿不到二维码时(例如租户支付配置错误)不再显示空白卡片,而是失败原因 +「重新获取二维码」。 微信 Native 码默认 2 小时且服务端不下发 expireTime,故按发起支付时刻本地计时,过期把二维码 置灰并提示重新获取;「重新获取二维码」重调 /api/tender/pay(同 out_trade_no 的 Native→ Native 重下微信允许)。 - 由此 hjc-web 与 hjc-h5/小程序端的支付确认语义**有意不一致**(那两端仍在用 mark-paid,改动 它们等于同时动三端线上支付路径)。理由与边界记在 ADR 0009。 - 依赖:新增 uqr;两份锁文件同步更新(pnpm 装,package-lock.json 同步)。 - 删除 BUY_TENDER_MAP_REPORT.md:530 行历史快照,结论与现状全面矛盾(称购买流程是桩函数、 /buy 不可用、没有登录流程、pay 代理路径写错),已实际误导过一次调研;历史价值留在 git 里。
This commit is contained in:
@@ -1,530 +0,0 @@
|
|||||||
# 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/...`.
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<img
|
||||||
|
v-if="dataUrl"
|
||||||
|
:src="dataUrl"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
:alt="alt"
|
||||||
|
class="mx-auto block"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { renderSVG } from 'uqr'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付二维码(纯展示)。内容是后端下发的**支付链接** `codeUrl`,不是网址——
|
||||||
|
* 它只能被微信扫,点不开。见 CONTEXT.md 的「支付链接」与「支付二维码」。
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** 要编码的内容(支付链接 `codeUrl`);为空时不渲染任何东西 */
|
||||||
|
value?: string
|
||||||
|
/** 边长(像素) */
|
||||||
|
size?: number
|
||||||
|
alt?: string
|
||||||
|
}>(), {
|
||||||
|
value: '',
|
||||||
|
size: 240,
|
||||||
|
alt: '微信支付二维码'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出 `data:` URL 而不是 `v-html`:
|
||||||
|
* 内容是本地编码出的 SVG、不含任何外部引用,同时避开 `vue/no-v-html`。
|
||||||
|
* 纠错级别取 M(约 15%),与主流收银台二维码一致——支付码是短串,密度代价很小。
|
||||||
|
*/
|
||||||
|
const dataUrl = computed(() => {
|
||||||
|
const text = (props.value || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
const svg = renderSVG(text, { border: 2, ecc: 'M' })
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* 支付状态机(hjc-web 购标支付步骤用)。
|
||||||
|
*
|
||||||
|
* 唯一事实来源是**微信侧**:轮询 `GET /api/tender/pay-status`,由后端向微信查单后给出结论。
|
||||||
|
* 不要退回 `mark-paid`——那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信 Native 支付码的有效期。
|
||||||
|
*
|
||||||
|
* 微信侧默认 2 小时,且**服务端不下发过期时间**(`PaymentResponse.wechatNative()` 不设 `expireTime`),
|
||||||
|
* 所以只能以「本次拿到支付码的时刻」本地计时;到点停止轮询并提示重新获取。
|
||||||
|
*/
|
||||||
|
export const HJC_PAY_QR_TTL_MS = 2 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** 轮询间隔:查单是以微信为准的代价,取到「扫完到页面反应」不至于让人以为卡住 */
|
||||||
|
const POLL_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
/** 微信侧终态:不用再付了 */
|
||||||
|
const SETTLED_STATUS = new Set(['SUCCESS', 'REFUNDED'])
|
||||||
|
|
||||||
|
/** 微信侧终态(坏的那种):这单在微信那边已经付不了了,继续轮询没有意义 */
|
||||||
|
const DEAD_STATUS = new Set(['FAILED', 'CANCELLED', 'TIMEOUT', 'REFUND_FAILED'])
|
||||||
|
|
||||||
|
export interface HjcPayQueryResult {
|
||||||
|
/** 是否已按登录态问题处理(401 已跳登录 / 403 已给出提示) */
|
||||||
|
handled: boolean
|
||||||
|
/** 微信侧状态名;没查到结论时为 null */
|
||||||
|
status: string | null
|
||||||
|
/** 是否已付款(SUCCESS / REFUNDED) */
|
||||||
|
settled: boolean
|
||||||
|
/** 需要展示给用户的文案(登录态提示、业务失败、或"仍未收到付款") */
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param redirect 401 跳登录页时带上,便于登录后回跳
|
||||||
|
*/
|
||||||
|
export function useHjcPayStatus(redirect: () => string) {
|
||||||
|
const { handleAuthCode } = useHjcAuth()
|
||||||
|
/**
|
||||||
|
* 与 `orders.vue` 同理:服务端渲染期间发出的站内请求必须走 `useRequestFetch()`,
|
||||||
|
* 否则代理拿不到浏览器 Cookie,会被后端按匿名调用判成 `code=401`,
|
||||||
|
* 再经 `handleAuthCode` 变成"把自己登出"。客户端侧它等价于 `$fetch`,行为不变。
|
||||||
|
*/
|
||||||
|
const requestFetch = useRequestFetch()
|
||||||
|
|
||||||
|
const status = ref<string | null>(null)
|
||||||
|
const paid = ref(false)
|
||||||
|
const querying = ref(false)
|
||||||
|
/** 查单本身失败的提示(网络/业务失败),与订单是否付款无关 */
|
||||||
|
const error = ref('')
|
||||||
|
/** 支付码是否已超出本地计时(2 小时) */
|
||||||
|
const expired = ref(false)
|
||||||
|
|
||||||
|
/** 轮询是否应当进行(已开始、未付款、未过期) */
|
||||||
|
let active = false
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let orderNo = ''
|
||||||
|
let deadline = 0
|
||||||
|
|
||||||
|
function pauseTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTimer() {
|
||||||
|
if (!active || timer) return
|
||||||
|
timer = setInterval(() => { void tick() }, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查一次微信侧状态。手动确认按钮也复用它——两条路必须同源,否则又变成两种语义 */
|
||||||
|
async function query(): Promise<HjcPayQueryResult> {
|
||||||
|
if (!orderNo) {
|
||||||
|
return { handled: false, status: null, settled: false, message: '缺少订单号' }
|
||||||
|
}
|
||||||
|
querying.value = true
|
||||||
|
try {
|
||||||
|
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200)
|
||||||
|
const res: any = await requestFetch('/api/tender/pay-status', { query: { orderNo } })
|
||||||
|
const auth = await handleAuthCode(res, redirect())
|
||||||
|
if (auth.handled) {
|
||||||
|
// 401 已跳登录页;403 只提示。两种情况都不该继续轮询
|
||||||
|
stop()
|
||||||
|
return { handled: true, status: null, settled: false, message: auth.message }
|
||||||
|
}
|
||||||
|
if (res?.code !== 0) {
|
||||||
|
error.value = res?.message || '支付状态查询失败'
|
||||||
|
return { handled: false, status: null, settled: false, message: error.value }
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
const name = res.data?.paymentStatus ? String(res.data.paymentStatus) : null
|
||||||
|
status.value = name
|
||||||
|
if (name && SETTLED_STATUS.has(name)) {
|
||||||
|
paid.value = true
|
||||||
|
stop()
|
||||||
|
} else if (name && DEAD_STATUS.has(name)) {
|
||||||
|
// 微信侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转
|
||||||
|
stop()
|
||||||
|
error.value = '该支付单在微信侧已关闭,请重新获取二维码'
|
||||||
|
}
|
||||||
|
return { handled: false, status: name, settled: paid.value }
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.statusMessage || e?.data?.message || e?.message || '支付状态查询失败'
|
||||||
|
return { handled: false, status: null, settled: false, message: error.value }
|
||||||
|
} finally {
|
||||||
|
querying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (!active) return
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
// 到点:停止轮询并标记过期,由页面提示"重新获取二维码"
|
||||||
|
active = false
|
||||||
|
pauseTimer()
|
||||||
|
expired.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (querying.value) return // 上一次还没回来,不要叠加请求
|
||||||
|
await query()
|
||||||
|
if (paid.value) {
|
||||||
|
active = false
|
||||||
|
pauseTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始(或重新开始)轮询,并重置 2 小时计时 */
|
||||||
|
function start(no: string) {
|
||||||
|
orderNo = no
|
||||||
|
paid.value = false
|
||||||
|
expired.value = false
|
||||||
|
error.value = ''
|
||||||
|
status.value = null
|
||||||
|
deadline = Date.now() + HJC_PAY_QR_TTL_MS
|
||||||
|
active = true
|
||||||
|
pauseTimer()
|
||||||
|
ensureTimer()
|
||||||
|
void tick() // 立刻查一次,别让第一个 3 秒白等
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
active = false
|
||||||
|
pauseTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面切到后台时停表,回来再续上——收银台开着不动不该持续打接口 */
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
pauseTimer()
|
||||||
|
} else if (active) {
|
||||||
|
ensureTimer()
|
||||||
|
void tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status, paid, querying, error, expired, start, stop, query }
|
||||||
|
}
|
||||||
@@ -51,14 +51,41 @@
|
|||||||
<div v-else-if="step === 'pay'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
|
<div v-else-if="step === 'pay'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
|
||||||
<h1 class="text-xl font-bold text-gray-900">请使用微信扫码支付</h1>
|
<h1 class="text-xl font-bold text-gray-900">请使用微信扫码支付</h1>
|
||||||
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
|
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
|
||||||
<p class="my-6 text-2xl font-bold text-blue-600">¥{{ formatMoney(totalAmount) }}</p>
|
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(totalAmount) }}</p>
|
||||||
<div class="rounded-md bg-gray-50 p-4 text-sm break-all text-gray-700">
|
|
||||||
扫码链接:<a :href="codeUrl" target="_blank" class="text-blue-600 hover:underline">{{ codeUrl }}</a>
|
<template v-if="codeUrl">
|
||||||
|
<!-- 过期只是置灰提示,仍留在页面上:万一用户是刚过点就扫的,还能看见自己扫的是哪张码 -->
|
||||||
|
<div :class="payExpired ? 'opacity-30' : ''">
|
||||||
|
<HjcPayQrcode :value="codeUrl" :size="240" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm text-gray-500">请在 2 小时内用微信扫描上方二维码完成支付</p>
|
||||||
|
<p v-if="payExpired" class="mt-2 text-sm text-amber-600">二维码已过期,请重新获取后再扫码</p>
|
||||||
|
</template>
|
||||||
|
<div v-else class="rounded-md bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||||
|
暂未获取到支付二维码,请点击下方按钮重新获取
|
||||||
</div>
|
</div>
|
||||||
<button class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700" :disabled="marking" @click="markPaid">
|
|
||||||
{{ marking ? '确认中...' : '支付已完成,确认订单' }}
|
<button
|
||||||
|
v-if="codeUrl"
|
||||||
|
class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
:disabled="confirming"
|
||||||
|
@click="confirmPaid"
|
||||||
|
>
|
||||||
|
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="error" class="mt-3 text-sm text-red-500">{{ error }}</p>
|
<button
|
||||||
|
class="mt-6 w-full rounded-md py-3 font-medium disabled:opacity-50"
|
||||||
|
:class="codeUrl
|
||||||
|
? 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700'"
|
||||||
|
:disabled="refreshing"
|
||||||
|
@click="refreshQr"
|
||||||
|
>
|
||||||
|
{{ refreshing ? '获取中...' : (codeUrl ? '重新获取二维码' : '获取支付二维码') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="error || payError" class="mt-3 text-sm text-red-500">{{ error || payError }}</p>
|
||||||
|
<p v-else-if="querying" class="mt-3 text-sm text-gray-400">正在确认支付状态...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 完成 -->
|
<!-- 完成 -->
|
||||||
@@ -84,7 +111,8 @@ const loggedIn = ref(false)
|
|||||||
const tender = shallowRef<Tender | null>(null)
|
const tender = shallowRef<Tender | null>(null)
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const marking = ref(false)
|
const refreshing = ref(false)
|
||||||
|
const confirming = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const step = ref<'form' | 'pay' | 'done'>('form')
|
const step = ref<'form' | 'pay' | 'done'>('form')
|
||||||
const orderNo = ref('')
|
const orderNo = ref('')
|
||||||
@@ -94,6 +122,24 @@ const totalAmount = ref(0)
|
|||||||
const form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
|
const form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
|
||||||
const redirect = computed(() => `/buy?id=${id}`)
|
const redirect = computed(() => `/buy?id=${id}`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付确认一律以**微信侧**为准(轮询 + 手动按钮都走它)。
|
||||||
|
* 这里刻意不再调用 `mark-paid`:那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
paid,
|
||||||
|
querying,
|
||||||
|
error: payError,
|
||||||
|
expired: payExpired,
|
||||||
|
start: startPayPolling,
|
||||||
|
query: queryPayStatus
|
||||||
|
} = useHjcPayStatus(() => redirect.value)
|
||||||
|
|
||||||
|
/** 微信侧确认已付款后,页面自己走到完成态——用户不必再点任何按钮 */
|
||||||
|
watch(paid, (v) => {
|
||||||
|
if (v) step.value = 'done'
|
||||||
|
})
|
||||||
|
|
||||||
function formatMoney(n?: number | string) {
|
function formatMoney(n?: number | string) {
|
||||||
return Number(n ?? 0).toFixed(2)
|
return Number(n ?? 0).toFixed(2)
|
||||||
}
|
}
|
||||||
@@ -126,19 +172,22 @@ async function submit() {
|
|||||||
orderNo.value = order.data?.orderNo || ''
|
orderNo.value = order.data?.orderNo || ''
|
||||||
totalAmount.value = order.data?.totalAmount || 0
|
totalAmount.value = order.data?.totalAmount || 0
|
||||||
// 发起支付
|
// 发起支付
|
||||||
const pay: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
|
const payRes: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
|
||||||
const payAuth = await handleAuthCode(pay, redirect.value)
|
const payAuth = await handleAuthCode(payRes, redirect.value)
|
||||||
if (payAuth.handled) {
|
if (payAuth.handled) {
|
||||||
error.value = payAuth.message || ''
|
error.value = payAuth.message || ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 订单已创建:即使发起支付失败也停在支付步骤,避免用户重提交产生重复订单
|
// 订单已创建:即使发起支付失败也停在支付步骤,避免用户重提交产生重复订单
|
||||||
step.value = 'pay'
|
step.value = 'pay'
|
||||||
if (pay?.code === 0) {
|
if (payRes?.code === 0) {
|
||||||
codeUrl.value = pay.data?.codeUrl || ''
|
codeUrl.value = payRes.data?.codeUrl || ''
|
||||||
} else {
|
} else {
|
||||||
error.value = pay?.message || '发起支付失败,请稍后重试'
|
error.value = payRes?.message || '发起支付失败,请稍后重试'
|
||||||
}
|
}
|
||||||
|
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在微信侧付过了
|
||||||
|
//(例如上一次支付成功但页面被关掉),查单会把它认出来并直接进完成态。
|
||||||
|
startPayPolling(orderNo.value)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.data?.message || e?.message || '下单失败'
|
error.value = e?.data?.message || e?.message || '下单失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,25 +195,61 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markPaid() {
|
/**
|
||||||
|
* 手动确认(二维码扫不了时的兜底):**也走查单**,不代替微信下结论。
|
||||||
|
* 微信侧没收到钱就明确这么说,而不是像改造前那样把它当成"已支付"。
|
||||||
|
*/
|
||||||
|
async function confirmPaid() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
marking.value = true
|
confirming.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await $fetch('/api/tender/mark-paid', { method: 'PUT', body: { orderNo: orderNo.value } })
|
const res = await queryPayStatus()
|
||||||
|
if (res.handled) {
|
||||||
|
// 401 已跳登录页;403 只提示
|
||||||
|
error.value = res.message || ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.settled) {
|
||||||
|
step.value = 'done'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = res.message || '微信侧还未收到付款,请完成扫码支付后再试'
|
||||||
|
} finally {
|
||||||
|
confirming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新获取支付二维码。
|
||||||
|
*
|
||||||
|
* 微信允许对同一 `out_trade_no`(这里就是 hjc 订单号)重复发起 Native 下单,
|
||||||
|
* 所以"刷新二维码"是重新调 `/api/tender/pay` 拿一张新码,并**重置 2 小时计时**。
|
||||||
|
*/
|
||||||
|
async function refreshQr() {
|
||||||
|
error.value = ''
|
||||||
|
refreshing.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
|
||||||
const auth = await handleAuthCode(res, redirect.value)
|
const auth = await handleAuthCode(res, redirect.value)
|
||||||
if (auth.handled) {
|
if (auth.handled) {
|
||||||
error.value = auth.message || ''
|
error.value = auth.message || ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (res?.code !== 0) {
|
if (res?.code !== 0) {
|
||||||
error.value = res?.message || '确认失败'
|
error.value = res?.message || '获取支付二维码失败,请稍后重试'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
step.value = 'done'
|
codeUrl.value = res.data?.codeUrl || ''
|
||||||
|
if (res.data?.amount) totalAmount.value = Number(res.data.amount)
|
||||||
|
if (!codeUrl.value) {
|
||||||
|
error.value = '未获取到支付二维码,请稍后重试'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startPayPolling(orderNo.value)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.data?.message || e?.message || '确认失败'
|
error.value = e?.data?.message || e?.message || '获取支付二维码失败,请稍后重试'
|
||||||
} finally {
|
} finally {
|
||||||
marking.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -11,6 +11,7 @@
|
|||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "^4.2.2",
|
||||||
|
"uqr": "^0.1.3",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "^4.2.2",
|
||||||
|
"uqr": "^0.1.3",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+7
-4
@@ -20,12 +20,19 @@ importers:
|
|||||||
nuxt:
|
nuxt:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.40)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.5(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(terser@5.49.0)(typescript@6.0.3)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.40)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.5(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(terser@5.49.0)(typescript@6.0.3)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||||
|
uqr:
|
||||||
|
specifier: ^0.1.3
|
||||||
|
version: 0.1.3
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.26
|
specifier: ^3.5.26
|
||||||
version: 3.5.40(typescript@6.0.3)
|
version: 3.5.40(typescript@6.0.3)
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.6.4
|
specifier: ^4.6.4
|
||||||
version: 4.6.4(vue@3.5.40(typescript@6.0.3))
|
version: 4.6.4(vue@3.5.40(typescript@6.0.3))
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxc-parser/binding-darwin-arm64':
|
||||||
|
specifier: ^0.105.0
|
||||||
|
version: 0.105.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.2
|
specifier: ^9.39.2
|
||||||
@@ -51,10 +58,6 @@ importers:
|
|||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.50.1
|
specifier: ^8.50.1
|
||||||
version: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
|
version: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
|
||||||
optionalDependencies:
|
|
||||||
'@oxc-parser/binding-darwin-arm64':
|
|
||||||
specifier: ^0.105.0
|
|
||||||
version: 0.105.0
|
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
|
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
|
||||||
import { useRuntimeConfig } from '#imports'
|
import { useRuntimeConfig } from '#imports'
|
||||||
import { getTenantFromContext } from '../../utils/tenant'
|
import { getTenantFromContext } from '../../utils/tenant'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记订单已支付(幂等)并触发一站式推送
|
* 查询微信侧支付状态(已支付则后端顺带修复本地订单状态)
|
||||||
* PUT /api/tender/mark-paid { orderNo }
|
* GET /api/tender/pay-status?orderNo=xxx
|
||||||
* 代理到 mp-api /api/hjc/order/mark-paid;需登录态。
|
* 代理到 mp-api /api/hjc/order/pay-status/{orderNo};需登录态。
|
||||||
|
*
|
||||||
|
* 这是 hjc-web **唯一**的支付确认通道:它向微信查单,以微信侧的结论为准。
|
||||||
|
* 不要退回 mark-paid——那个接口不查单,点一下就能把订单置为已支付(见 ADR 0009)。
|
||||||
*
|
*
|
||||||
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
|
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const ctx = getTenantFromContext(event, config)
|
const ctx = getTenantFromContext(event, config)
|
||||||
const body = await readBody(event)
|
const { orderNo } = getQuery(event)
|
||||||
const cookieToken = getCookie(event, 'hjc_token')
|
const cookieToken = getCookie(event, 'hjc_token')
|
||||||
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await $fetch('/hjc/order/mark-paid', {
|
return await $fetch(`/hjc/order/pay-status/${encodeURIComponent(String(orderNo ?? ''))}`, {
|
||||||
baseURL: config.public.modulesApiBase,
|
baseURL: config.public.modulesApiBase,
|
||||||
method: 'PUT',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
TenantId: ctx.tenantId,
|
TenantId: ctx.tenantId,
|
||||||
...(auth ? { Authorization: auth } : {})
|
...(auth ? { Authorization: auth } : {})
|
||||||
},
|
},
|
||||||
query: { TenantId: ctx.tenantId },
|
query: { TenantId: ctx.tenantId }
|
||||||
body
|
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||||
statusMessage: error?.statusMessage || 'Failed to mark paid'
|
statusMessage: error?.statusMessage || 'Failed to query pay status'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user