From 81ef9317c7c7942a845e3a146d354d17c4191d86 Mon Sep 17 00:00:00 2001
From: "weicw1996@qq.com"
Date: Thu, 17 Sep 2026 02:35:45 +0800
Subject: [PATCH] =?UTF-8?q?feat(tender):=20=E5=BF=98=E8=AE=B0=E5=AF=86?=
=?UTF-8?q?=E7=A0=81=E4=B8=8E=E4=BF=AE=E6=94=B9=E5=AF=86=E7=A0=81=E9=A1=B5?=
=?UTF-8?q?=EF=BC=8810=20=E5=A5=97=E6=A8=A1=E6=9D=BF=E9=80=9A=E7=94=A8?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
密码找回(材料审核制,见 ADR 0008)
- app/pages/forgot-password.vue:两个页签——「提交申请」(企业名称 + 纳税人识别号 + 新密码 + 确认
+ 授权委托书,复用 POST /api/tender/upload,并照 HjcEnterpriseForm 做 5M 校验)与「查询进度」
(双要素查询,展示状态与时间、驳回原因)。提交后自动把条件带到查询页签。文案纪律:
「已通过」≠「已重置」。
修改密码(登录态,双因子)
- app/pages/change-password.vue:旧密码 + 短信验证码(60s 倒计时,照 register.vue)+ 新密码 +
确认;成功后清本机凭据并显示「去登录」面板。
BFF 与封装
- server/api/tender/password/{apply.post,status.get,sms.post,change.put}.ts:四条都照
register.post.ts 的透传范式(modulesApiBase + TenantId(header 与 query 都带)+ 原样返回
ApiResult,HTTP 恒 200)。前两条**匿名**(与 sms/upload 一致,不读 cookie);后两条需登录态,
从 cookie hjc_token 或 Authorization 头取 token 拼 Bearer。
- app/composables/useHjcPassword.ts:四条请求单独成一支,不塞进 useHjcAuth——找回的两条是匿名的、
不做 401 跳转;修改密码的两条经 handleAuthCode 处理登录态。
接线
- app/pages/login.vue:加「忘记密码?」入口。
- app/components/HjcUserBar.vue:已登录菜单加「修改密码」。
落点说明:新页面直接放 app/pages/*.vue——静态段路由优先于 app/pages/[slug].vue 的动态段,故不必
动 useTemplate.ts / templates/index.ts,与 login.vue、register.vue 完全同款做法,10 套模板自动可用。
验证:针对本次文件 npx eslint 0 error;pnpm run build 成功且四条新 BFF 都出现在产物里。
未验:未起 mp-java 做端到端(见 .scratch/hjc-password/issues/06)。
---
app/components/HjcUserBar.vue | 1 +
app/composables/useHjcPassword.ts | 87 ++++++++
app/pages/change-password.vue | 171 +++++++++++++++
app/pages/forgot-password.vue | 259 +++++++++++++++++++++++
app/pages/login.vue | 3 +
server/api/tender/password/apply.post.ts | 33 +++
server/api/tender/password/change.put.ts | 38 ++++
server/api/tender/password/sms.post.ts | 36 ++++
server/api/tender/password/status.get.ts | 36 ++++
9 files changed, 664 insertions(+)
create mode 100644 app/composables/useHjcPassword.ts
create mode 100644 app/pages/change-password.vue
create mode 100644 app/pages/forgot-password.vue
create mode 100644 server/api/tender/password/apply.post.ts
create mode 100644 server/api/tender/password/change.put.ts
create mode 100644 server/api/tender/password/sms.post.ts
create mode 100644 server/api/tender/password/status.get.ts
diff --git a/app/components/HjcUserBar.vue b/app/components/HjcUserBar.vue
index b302800..8432f11 100644
--- a/app/components/HjcUserBar.vue
+++ b/app/components/HjcUserBar.vue
@@ -4,6 +4,7 @@
{{ displayName }}
我的订单
企业资质
+ 修改密码
diff --git a/app/composables/useHjcPassword.ts b/app/composables/useHjcPassword.ts
new file mode 100644
index 0000000..ae0a7f0
--- /dev/null
+++ b/app/composables/useHjcPassword.ts
@@ -0,0 +1,87 @@
+/**
+ * 密码找回(材料审核制)与修改密码的前端封装。
+ *
+ * 独立成一支而不是塞进 `useHjcAuth`:后者已承担登录/注册/退出,这里只放密码相关的四条请求。
+ *
+ * 两条链路的失败语义不同,注意别混:
+ * - **找回**(申请 / 查进度)是匿名的,后端对「企业不存在 / 信息不匹配」一律回同一句话,
+ * 前端拿不到区分性的原因,也不该试图区分。
+ * - **修改密码**的发送短信与提交都需登录态,401 要清凭据并跳登录页(交给 `handleAuthCode`)。
+ */
+export interface HjcPasswordApplyStatus {
+ status: number
+ statusText: string
+ applyTime?: string | null
+ auditTime?: string | null
+ resetTime?: string | null
+ rejectReason?: string | null
+}
+
+export function useHjcPassword() {
+ const { handleAuthCode } = useHjcAuth()
+
+ /** Nuxt 的 createError 会把后端的 statusMessage 放进 e.data,这里统一收口取文案 */
+ function errMessage(e: any, fallback: string) {
+ return e?.data?.statusMessage || e?.data?.message || e?.message || fallback
+ }
+
+ /** 提交找回申请(匿名)。成功时后端的 message 是统一话术,不含任何企业存在性信息 */
+ async function applyPasswordReset(payload: Record) {
+ try {
+ const res: any = await $fetch('/api/tender/password/apply', { method: 'POST', body: payload })
+ if (res?.code === 0) {
+ return { ok: true, message: res?.message as string }
+ }
+ return { ok: false, message: res?.message || '提交失败' }
+ } catch (e: any) {
+ return { ok: false, message: errMessage(e, '提交失败') }
+ }
+ }
+
+ /** 查询找回进度(匿名)。data 为 null 表示没有记录 */
+ async function fetchApplyStatus(enterpriseName: string, creditCode: string) {
+ try {
+ const res: any = await $fetch('/api/tender/password/status', {
+ query: { enterpriseName, creditCode }
+ })
+ if (res?.code === 0) {
+ return { ok: true, data: (res.data || null) as HjcPasswordApplyStatus | null }
+ }
+ return { ok: false, data: null, message: res?.message || '查询失败' }
+ } catch (e: any) {
+ return { ok: false, data: null, message: errMessage(e, '查询失败') }
+ }
+ }
+
+ /** 发送修改密码的短信验证码(登录态)。收件号由后端从登录态取,前端不能指定 */
+ async function sendChangePasswordSms() {
+ try {
+ const res: any = await $fetch('/api/tender/password/sms', { method: 'POST' })
+ if (res?.code === 0) {
+ return { ok: true, message: res?.message || '验证码已发送' }
+ }
+ const auth = await handleAuthCode(res, '/change-password')
+ if (auth.handled) return { ok: false, message: auth.message || '请先登录' }
+ return { ok: false, message: res?.message || '验证码发送失败' }
+ } catch (e: any) {
+ return { ok: false, message: errMessage(e, '验证码发送失败') }
+ }
+ }
+
+ /** 提交修改密码(登录态,旧密码 + 短信验证码) */
+ async function changePassword(payload: Record) {
+ try {
+ const res: any = await $fetch('/api/tender/password/change', { method: 'PUT', body: payload })
+ if (res?.code === 0) {
+ return { ok: true, message: res?.message || '密码修改成功' }
+ }
+ const auth = await handleAuthCode(res, '/change-password')
+ if (auth.handled) return { ok: false, message: auth.message || '请先登录' }
+ return { ok: false, message: res?.message || '修改密码失败' }
+ } catch (e: any) {
+ return { ok: false, message: errMessage(e, '修改密码失败') }
+ }
+ }
+
+ return { applyPasswordReset, fetchApplyStatus, sendChangePasswordSms, changePassword }
+}
diff --git a/app/pages/change-password.vue b/app/pages/change-password.vue
new file mode 100644
index 0000000..edad1e2
--- /dev/null
+++ b/app/pages/change-password.vue
@@ -0,0 +1,171 @@
+
+
+
+
修改密码
+
+
+
+
+ 密码已修改成功,请使用新密码重新登录。
+
+
+ 本机已退出登录。其它设备上已登录的会话不会因此失效,如需全部下线请联系平台客服。
+
+
+ 去登录
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/forgot-password.vue b/app/pages/forgot-password.vue
new file mode 100644
index 0000000..dc3abc4
--- /dev/null
+++ b/app/pages/forgot-password.vue
@@ -0,0 +1,259 @@
+
+
+
+
忘记密码
+
+ 提交企业材料后由平台人工审核,审核通过后重置密码
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 返回登录
+
+
+
+
+
+
diff --git a/app/pages/login.vue b/app/pages/login.vue
index 1e50745..d8b0fac 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -47,6 +47,9 @@
还没有账号?
立即注册企业
+
+ 忘记密码?
+
diff --git a/server/api/tender/password/apply.post.ts b/server/api/tender/password/apply.post.ts
new file mode 100644
index 0000000..8137a4e
--- /dev/null
+++ b/server/api/tender/password/apply.post.ts
@@ -0,0 +1,33 @@
+import { $fetch } from 'ofetch'
+import { createError, defineEventHandler, readBody } from 'h3'
+import { useRuntimeConfig } from '#imports'
+import { getTenantFromContext } from '../../../utils/tenant'
+
+/**
+ * 密码找回-提交申请(**匿名**,企业未登录时提交)
+ * POST /api/tender/password/apply { enterpriseName, creditCode, newPassword, confirmPassword, handbookUrl }
+ * 代理到 mp-api /api/hjc/auth/password/apply。
+ *
+ * 注意:该接口对「企业不存在 / 双要素不匹配 / 已有待审核申请」一律返回**同一句话且 data 为 null**,
+ * 以免暴露某个企业是否在本平台注册过。前端因此不能靠 message 区分结果,只提示「已提交」并引导去查进度。
+ */
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig()
+ const ctx = getTenantFromContext(event, config)
+ const body = await readBody(event)
+
+ try {
+ return await $fetch('/hjc/auth/password/apply', {
+ baseURL: config.public.modulesApiBase,
+ method: 'POST',
+ headers: { TenantId: ctx.tenantId },
+ query: { TenantId: ctx.tenantId },
+ body
+ })
+ } catch (error: any) {
+ throw createError({
+ statusCode: error?.statusCode || error?.response?.status || 502,
+ statusMessage: error?.data?.message || error?.statusMessage || '提交失败'
+ })
+ }
+})
diff --git a/server/api/tender/password/change.put.ts b/server/api/tender/password/change.put.ts
new file mode 100644
index 0000000..08ef259
--- /dev/null
+++ b/server/api/tender/password/change.put.ts
@@ -0,0 +1,38 @@
+import { $fetch } from 'ofetch'
+import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
+import { useRuntimeConfig } from '#imports'
+import { getTenantFromContext } from '../../../utils/tenant'
+
+/**
+ * 修改密码(**需登录态**,双因子:旧密码 + 账号绑定手机号短信验证码)
+ * PUT /api/tender/password/change { oldPassword, smsCode, newPassword, confirmPassword }
+ * 代理到 mp-api /api/hjc/auth/password/change。
+ *
+ * 原样透传 ApiResult:后端对「旧密码不正确 / 短信验证码不正确 / 密码强度不够」都有明确文案,
+ * 由前端直接展示。
+ */
+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 {
+ return await $fetch('/hjc/auth/password/change', {
+ baseURL: config.public.modulesApiBase,
+ method: 'PUT',
+ headers: {
+ TenantId: ctx.tenantId,
+ ...(auth ? { Authorization: auth } : {})
+ },
+ query: { TenantId: ctx.tenantId },
+ body
+ })
+ } catch (error: any) {
+ throw createError({
+ statusCode: error?.statusCode || error?.response?.status || 502,
+ statusMessage: error?.data?.message || error?.statusMessage || '修改密码失败'
+ })
+ }
+})
diff --git a/server/api/tender/password/sms.post.ts b/server/api/tender/password/sms.post.ts
new file mode 100644
index 0000000..508c46c
--- /dev/null
+++ b/server/api/tender/password/sms.post.ts
@@ -0,0 +1,36 @@
+import { $fetch } from 'ofetch'
+import { createError, defineEventHandler, getHeader, getCookie } from 'h3'
+import { useRuntimeConfig } from '#imports'
+import { getTenantFromContext } from '../../../utils/tenant'
+
+/**
+ * 修改密码-发送短信验证码(**需登录态**)
+ * POST /api/tender/password/sms(无 body)
+ * 代理到 mp-api /api/hjc/auth/password/sms。
+ *
+ * 收件号由后端从登录态里取核心实例上的手机号(账号绑定手机号),**前端不能指定号码**——
+ * 否则任何人都能给任意手机号发短信。
+ */
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig()
+ const ctx = getTenantFromContext(event, config)
+ const cookieToken = getCookie(event, 'hjc_token')
+ const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
+
+ try {
+ return await $fetch('/hjc/auth/password/sms', {
+ baseURL: config.public.modulesApiBase,
+ method: 'POST',
+ headers: {
+ TenantId: ctx.tenantId,
+ ...(auth ? { Authorization: auth } : {})
+ },
+ query: { TenantId: ctx.tenantId }
+ })
+ } catch (error: any) {
+ throw createError({
+ statusCode: error?.statusCode || error?.response?.status || 502,
+ statusMessage: error?.data?.message || error?.statusMessage || '验证码发送失败'
+ })
+ }
+})
diff --git a/server/api/tender/password/status.get.ts b/server/api/tender/password/status.get.ts
new file mode 100644
index 0000000..479d1bb
--- /dev/null
+++ b/server/api/tender/password/status.get.ts
@@ -0,0 +1,36 @@
+import { $fetch } from 'ofetch'
+import { createError, defineEventHandler, getQuery } from 'h3'
+import { useRuntimeConfig } from '#imports'
+import { getTenantFromContext } from '../../../utils/tenant'
+
+/**
+ * 密码找回-查询进度(**匿名**)
+ * GET /api/tender/password/status?enterpriseName=&creditCode=
+ * 代理到 mp-api /api/hjc/auth/password/apply/status。
+ *
+ * 返回的 data 为 null 表示「未查询到申请记录」(查不到企业与命中多条脏数据都归为这一种,
+ * 同样不区分企业是否存在);有记录时只含状态与时间,**不含材料与新密码**。
+ */
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig()
+ const ctx = getTenantFromContext(event, config)
+ const query = getQuery(event)
+
+ try {
+ return await $fetch('/hjc/auth/password/apply/status', {
+ baseURL: config.public.modulesApiBase,
+ method: 'GET',
+ headers: { TenantId: ctx.tenantId },
+ query: {
+ TenantId: ctx.tenantId,
+ enterpriseName: query.enterpriseName,
+ creditCode: query.creditCode
+ }
+ })
+ } catch (error: any) {
+ throw createError({
+ statusCode: error?.statusCode || error?.response?.status || 502,
+ statusMessage: error?.data?.message || error?.statusMessage || '查询失败'
+ })
+ }
+})