feat(tender): 阶段3b 企业登录/注册 + 购买→支付→确认 闭环

- useHjcAuth:token 存 cookie(hjc_token),login/register/isLoggedIn
- 页面 /login /register(企业名称+密码)
- server/api/tender/{auth/login,auth/register,pay,mark-paid} 代理;order 代理从 cookie 读取 hjc_token 透传 Authorization
- BuyDocument:未登录跳登录;下单→pay(codeUrl)→确认 mark-paid→成功
- types 增 HjcUser
This commit is contained in:
2026-09-08 22:44:30 +08:00
parent 2828ba6718
commit 0fbf122a57
10 changed files with 389 additions and 25 deletions
+47
View File
@@ -0,0 +1,47 @@
import type { HjcUser } from '~/types/tender'
/** 登录态(token 存 cookieSSR 安全;服务器端代理从 cookie 读取并转发 Authorization */
export function useHjcAuth() {
const token = useCookie<string | null>('hjc_token', {
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
})
const user = useState<HjcUser | null>('hjc-user', () => null)
function isLoggedIn() {
return !!token.value
}
async function login(enterpriseName: string, password: string) {
const res: any = await $fetch('/api/tender/login', {
method: 'POST',
body: { enterpriseName, password }
})
if (res?.access_token) {
token.value = res.access_token
user.value = res.user || null
return { ok: true, user: res.user }
}
return { ok: false, message: res?.message || '登录失败' }
}
async function register(payload: Record<string, any>) {
const res: any = await $fetch('/api/tender/register', {
method: 'POST',
body: payload
})
if (res?.access_token) {
token.value = res.access_token
user.value = res.user || null
return { ok: true, user: res.user }
}
return { ok: false, message: res?.message || '注册失败' }
}
function logout() {
token.value = null
user.value = null
}
return { token, user, isLoggedIn, login, register, logout }
}
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="w-full max-w-md rounded-lg border border-gray-200 p-8">
<h1 class="text-2xl font-bold text-center text-gray-900">企业登录</h1>
<form class="mt-6 space-y-4" @submit.prevent="submitForm">
<div>
<label class="text-sm text-gray-600">企业名称</label>
<input v-model="enterpriseName" 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="password" type="password" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="请输入密码" />
</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="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
还没有账号
<NuxtLink to="/register" class="text-blue-600 hover:underline">立即注册企业</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
const { login } = useHjcAuth()
const route = useRoute()
const enterpriseName = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function submitForm() {
error.value = ''
loading.value = true
try {
const res = await login(enterpriseName.value, password.value)
if (res.ok) {
return navigateTo((route.query.redirect as string) || '/tender')
}
error.value = res.message || '登录失败'
} catch (e: any) {
error.value = e?.message || '登录失败'
} finally {
loading.value = false
}
}
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div class="min-h-[60vh] flex items-center justify-center px-4">
<div class="w-full max-w-md rounded-lg border border-gray-200 p-8">
<h1 class="text-2xl font-bold text-center text-gray-900">企业注册</h1>
<form class="mt-6 space-y-4" @submit.prevent="submitForm">
<div>
<label class="text-sm text-gray-600">企业名称</label>
<input v-model="form.enterpriseName" 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.password" type="password" 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.creditCode" 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="form.agentPhone" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="经办人手机号" />
</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="loading">
{{ loading ? '注册中...' : '注册并登录' }}
</button>
<p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
已注册
<NuxtLink to="/login" class="text-blue-600 hover:underline">去登录</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
const { register } = useHjcAuth()
const route = useRoute()
const loading = ref(false)
const error = ref('')
const form = reactive({
enterpriseName: '',
password: '',
creditCode: '',
contactPhone: '',
contactEmail: '',
agentPhone: ''
})
async function submitForm() {
error.value = ''
loading.value = true
try {
const res = await register({ ...form })
if (res.ok) {
return navigateTo((route.query.redirect as string) || '/tender')
}
error.value = res.message || '注册失败'
} catch (e: any) {
error.value = e?.message || '注册失败'
} finally {
loading.value = false
}
}
</script>
+68 -22
View File
@@ -2,7 +2,17 @@
<div class="container mx-auto px-4 py-10"> <div class="container mx-auto px-4 py-10">
<NuxtLink to="/tender" class="text-sm text-blue-600 hover:underline"> 返回标书中心</NuxtLink> <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"> <!-- 未登录提示 -->
<div v-if="!loggedIn" class="mx-auto mt-6 max-w-xl rounded-lg border border-gray-200 p-8 text-center">
<p class="text-lg text-gray-700">购买标书需要先登录企业账号</p>
<div class="mt-6 flex gap-4 justify-center">
<NuxtLink :to="`/login?redirect=${redirect}`" class="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">去登录</NuxtLink>
<NuxtLink :to="`/register?redirect=${redirect}`" class="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50">企业注册</NuxtLink>
</div>
</div>
<!-- 表单 -->
<div v-else-if="tender && step === 'form'" 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> <h1 class="text-xl font-bold text-gray-900">购买标书</h1>
<div class="mt-4 rounded-md bg-gray-50 p-4 text-sm"> <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="font-semibold text-gray-800">{{ tender.projectName }}</p>
@@ -30,19 +40,34 @@
<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" /> <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> </div>
<button <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">
type="submit" {{ submitting ? '提交中...' : '提交订单并用微信支付' }}
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> </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> <p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
</form> </form>
</div> </div>
<!-- 支付 -->
<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>
<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>
<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>
</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>
<p v-if="error" class="mt-3 text-sm text-red-500">{{ error }}</p>
</div>
<!-- 完成 -->
<div v-else-if="step === 'done'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-green-200 bg-green-50 p-8 text-center">
<p class="text-xl font-bold text-green-700">购买成功</p>
<p class="mt-2 text-sm text-green-600">订单号{{ orderNo }}标书已可查看/下载</p>
<NuxtLink to="/tender" class="mt-6 inline-block rounded-md bg-green-600 px-6 py-2 text-white hover:bg-green-700">返回标书中心</NuxtLink>
</div>
<div v-else-if="notFound" class="py-20 text-center text-gray-500">项目不存在</div> <div v-else-if="notFound" class="py-20 text-center text-gray-500">项目不存在</div>
<SiteLoading v-else /> <SiteLoading v-else />
</div> </div>
@@ -53,18 +78,21 @@ import type { Tender } from '~/types/tender'
const route = useRoute() const route = useRoute()
const id = route.query.id as string const id = route.query.id as string
const { isLoggedIn } = useHjcAuth()
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 tip = ref('') const marking = ref(false)
const error = ref('') const error = ref('')
const step = ref<'form' | 'pay' | 'done'>('form')
const orderNo = ref('')
const codeUrl = ref('')
const totalAmount = ref(0)
const form = reactive({ const form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
contactName: '', const redirect = computed(() => `/buy?id=${id}`)
contactPhone: '',
contactEmail: '',
quantity: 1
})
function formatMoney(n?: number | string) { function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2) return Number(n ?? 0).toFixed(2)
@@ -72,10 +100,9 @@ function formatMoney(n?: number | string) {
async function submit() { async function submit() {
error.value = '' error.value = ''
tip.value = ''
submitting.value = true submitting.value = true
try { try {
const res: any = await $fetch('/api/tender/order', { const order: any = await $fetch('/api/tender/order', {
method: 'POST', method: 'POST',
body: { body: {
projectId: Number(id), projectId: Number(id),
@@ -85,11 +112,15 @@ async function submit() {
contactEmail: form.contactEmail contactEmail: form.contactEmail
} }
}) })
if (res && res.orderNo) { if (order && order.orderNo) {
tip.value = `下单成功,订单号:${res.orderNo},请扫码支付` orderNo.value = order.orderNo
// TODO(阶段3b): 调 /api/tender/pay 获取微信二维码(需登录态) totalAmount.value = order.totalAmount || 0
// 发起支付
const pay: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: order.orderNo } })
codeUrl.value = pay?.codeUrl || ''
step.value = 'pay'
} else { } else {
error.value = res?.message || '下单失败' error.value = order?.message || '下单失败'
} }
} catch (e: any) { } catch (e: any) {
error.value = e?.data?.message || e?.message || '下单失败' error.value = e?.data?.message || e?.message || '下单失败'
@@ -98,6 +129,21 @@ async function submit() {
} }
} }
async function markPaid() {
error.value = ''
marking.value = true
try {
await $fetch('/api/tender/mark-paid', { method: 'PUT', body: { orderNo: orderNo.value } })
step.value = 'done'
} catch (e: any) {
error.value = e?.data?.message || e?.message || '确认失败'
} finally {
marking.value = false
}
}
loggedIn.value = isLoggedIn()
try { try {
if (!id) { if (!id) {
notFound.value = true notFound.value = true
+10
View File
@@ -48,3 +48,13 @@ export interface TenderPage {
list: Tender[] list: Tender[]
count: number count: number
} }
/** 登录用户(对应后端 common.system.User 关键字段) */
export interface HjcUser {
userId: number
username: string
nickname?: string
phone?: string
tenantId?: number
[key: string]: any
}
+32
View File
@@ -0,0 +1,32 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../../utils/tenant'
/**
* 企业登录
* POST /api/tender/login { enterpriseName, password }
* 代理到 mp-api /api/hjc/auth/login;成功返回 LoginResult{ access_token, user }。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = await readBody(event)
try {
const res = await $fetch('/hjc/auth/login', {
baseURL: config.public.modulesApiBase,
method: 'POST',
headers: { TenantId: ctx.tenantId },
query: { TenantId: ctx.tenantId },
body
}) as any
const data = res && res.data !== undefined ? res.data : res
return data
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to login'
})
}
})
+32
View File
@@ -0,0 +1,32 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../../utils/tenant'
/**
* 企业注册
* POST /api/tender/register { enterpriseName, password, creditCode, ... }
* 代理到 mp-api /api/hjc/auth/register;成功返回 LoginResult{ access_token, user }。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = await readBody(event)
try {
const res = await $fetch('/hjc/auth/register', {
baseURL: config.public.modulesApiBase,
method: 'POST',
headers: { TenantId: ctx.tenantId },
query: { TenantId: ctx.tenantId },
body
}) as any
const data = res && res.data !== undefined ? res.data : res
return data
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to register'
})
}
})
+37
View File
@@ -0,0 +1,37 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 标记订单已支付(幂等)并触发一站式推送
* PUT /api/tender/mark-paid { orderNo }
* 代理到 mp-api /api/hjc/order/mark-paid;需登录态。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = await readBody(event)
const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try {
const res = await $fetch('/hjc/order/mark-paid', {
baseURL: config.public.modulesApiBase,
method: 'PUT',
headers: {
TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {})
},
query: { TenantId: ctx.tenantId },
body
}) as any
const data = res && res.data !== undefined ? res.data : res
return data
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to mark paid'
})
}
})
+4 -3
View File
@@ -1,18 +1,19 @@
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody, getHeader } from 'h3' import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
import { useRuntimeConfig } from '#imports' import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant' import { getTenantFromContext } from '../../utils/tenant'
/** /**
* 创建标书订单 * 创建标书订单
* POST /api/tender/order { projectId, quantity, contactName, contactPhone, contactEmail } * POST /api/tender/order { projectId, quantity, contactName, contactPhone, contactEmail }
* 代理到 mp-apimp-java /api/hjc/order/create;需登录态(Authorization 透传)。 * 代理到 mp-apimp-java /api/hjc/order/create;需登录态(从 cookie hjc_token 或 Authorization 透传)。
*/ */
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 body = await readBody(event)
const auth = getHeader(event, 'authorization') const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try { try {
const res = await $fetch('/hjc/order/create', { const res = await $fetch('/hjc/order/create', {
+37
View File
@@ -0,0 +1,37 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 发起支付(微信Native扫码,返回 codeUrl
* POST /api/tender/pay { orderNo }
* 代理到 mp-api /api/hjc/order/pay;需登录态。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = await readBody(event)
const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try {
const res = await $fetch('/hjc/order/pay', {
baseURL: config.public.modulesApiBase,
method: 'POST',
headers: {
TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {})
},
query: { TenantId: ctx.tenantId },
body
}) as any
const data = res && res.data !== undefined ? res.data : res
return data
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to pay'
})
}
})