feat(tender): 忘记密码与修改密码页(10 套模板通用)

密码找回(材料审核制,见 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)。
This commit is contained in:
2026-09-17 02:35:45 +08:00
parent 5b6e1b67bc
commit 81ef9317c7
9 changed files with 664 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="flex min-h-[60vh] items-center justify-center px-4 py-10">
<div class="w-full max-w-md rounded-lg border border-gray-200 p-8">
<h1 class="text-center text-2xl font-bold text-gray-900">修改密码</h1>
<!-- 改完提示去重新登录 token 依然有效但用户此刻应该用新密码进来 -->
<div v-if="done" class="mt-6 space-y-4">
<p class="rounded-md bg-green-50 px-3 py-3 text-sm leading-relaxed text-green-700">
密码已修改成功请使用新密码重新登录
</p>
<p class="text-xs leading-relaxed text-gray-500">
本机已退出登录其它设备上已登录的会话<strong>不会</strong>因此失效如需全部下线请联系平台客服
</p>
<NuxtLink
to="/login"
class="block w-full rounded-md bg-blue-600 py-3 text-center font-medium text-white hover:bg-blue-700"
>
去登录
</NuxtLink>
</div>
<form v-else class="mt-6 space-y-4" @submit.prevent="submitForm">
<div class="rounded-md bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-700">
修改密码需要同时验证旧密码账号绑定手机号的短信验证码
短信发到账号注册时绑定的手机号不可自行指定
</div>
<div>
<label class="text-sm text-gray-600">旧密码</label>
<input v-model="form.oldPassword" type="password" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="请输入当前登录密码" />
</div>
<div>
<label class="block text-sm text-gray-600">短信验证码</label>
<div class="mt-1 flex items-center gap-2">
<input
v-model="form.smsCode"
class="w-full rounded-md border border-gray-300 px-4 py-2 text-sm"
placeholder="发送至账号绑定手机号"
autocomplete="off"
/>
<button
type="button"
class="h-[38px] w-32 shrink-0 rounded-md border border-gray-300 text-xs text-gray-600 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="countdown > 0 || sending"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown} 秒后重发` : sending ? '发送中...' : '发送验证码' }}
</button>
</div>
</div>
<div>
<label class="text-sm text-gray-600">新密码</label>
<input v-model="form.newPassword" type="password" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="至少 8 位,且包含字母和数字" />
</div>
<div>
<label class="text-sm text-gray-600">确认新密码</label>
<input v-model="form.confirmPassword" 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 font-medium text-white 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>
</div>
</div>
</template>
<script setup lang="ts">
const { sendChangePasswordSms, changePassword } = useHjcPassword()
const { isLoggedIn, logout } = useHjcAuth()
const PASSWORD_PATTERN = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$/
const SMS_COUNTDOWN_SECONDS = 60
const form = ref({
oldPassword: '',
smsCode: '',
newPassword: '',
confirmPassword: ''
})
const loading = ref(false)
const sending = ref(false)
const error = ref('')
const done = ref(false)
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
countdown.value = 0
}
async function sendCode() {
error.value = ''
sending.value = true
try {
const res = await sendChangePasswordSms()
if (!res.ok) {
error.value = res.message || '验证码发送失败'
return
}
countdown.value = SMS_COUNTDOWN_SECONDS
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopCountdown()
}, 1000)
} finally {
sending.value = false
}
}
onUnmounted(stopCountdown)
async function submitForm() {
error.value = ''
const f = form.value
if (!f.oldPassword) {
error.value = '请输入旧密码'
return
}
if (!f.smsCode.trim()) {
error.value = '请输入短信验证码'
return
}
if (!f.newPassword || !f.confirmPassword) {
error.value = '请输入新密码并再次确认'
return
}
if (f.newPassword !== f.confirmPassword) {
error.value = '两次输入的新密码不一致'
return
}
if (!PASSWORD_PATTERN.test(f.newPassword)) {
error.value = '密码至少 8 位,且包含字母和数字'
return
}
if (f.newPassword === f.oldPassword) {
error.value = '新密码不能与旧密码相同'
return
}
loading.value = true
try {
const res = await changePassword({
oldPassword: f.oldPassword,
smsCode: f.smsCode.trim(),
newPassword: f.newPassword,
confirmPassword: f.confirmPassword
})
if (!res.ok) {
error.value = res.message || '修改密码失败'
return
}
// 与「改密后需重新登录」的习惯一致:清本机凭据。
// 只影响本机——token 是无状态 JWT,平台不做服务端登出与黑名单,页面上已如实告知。
if (isLoggedIn()) await logout()
done.value = true
} finally {
loading.value = false
}
}
</script>