81ef9317c7
密码找回(材料审核制,见 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)。
131 lines
4.7 KiB
Vue
131 lines
4.7 KiB
Vue
<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>
|
||
<div>
|
||
<label class="text-sm text-gray-600">图形验证码</label>
|
||
<div class="mt-1 flex items-center gap-2">
|
||
<input
|
||
v-model="code"
|
||
class="w-full rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||
placeholder="请输入图片中的验证码"
|
||
autocomplete="off"
|
||
/>
|
||
<img
|
||
v-if="captchaImage"
|
||
:src="captchaImage"
|
||
alt="图形验证码"
|
||
title="看不清?点击换一张"
|
||
class="h-[38px] w-28 shrink-0 cursor-pointer rounded-md border border-gray-300 object-contain"
|
||
@click="refreshCaptcha"
|
||
/>
|
||
<button
|
||
v-else
|
||
type="button"
|
||
class="h-[38px] w-28 shrink-0 rounded-md border border-gray-300 text-xs text-gray-600 hover:bg-gray-50"
|
||
@click="refreshCaptcha"
|
||
>
|
||
获取验证码
|
||
</button>
|
||
</div>
|
||
</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>
|
||
<p class="mt-2 text-center text-sm text-gray-500">
|
||
<NuxtLink to="/forgot-password" class="text-blue-600 hover:underline">忘记密码?</NuxtLink>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
const { login, fetchCaptcha } = useHjcAuth()
|
||
const route = useRoute()
|
||
const enterpriseName = ref('')
|
||
const password = ref('')
|
||
/** 图形验证码(后端只下发图片,答案由用户肉眼识别后输入) */
|
||
const code = ref('')
|
||
const captchaImage = ref('')
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
|
||
/** 拉取/刷新图形验证码图片;失败时提示并允许点击重试 */
|
||
async function refreshCaptcha() {
|
||
code.value = ''
|
||
const res = await fetchCaptcha()
|
||
if (res.ok && res.image) {
|
||
captchaImage.value = res.image
|
||
return
|
||
}
|
||
captchaImage.value = ''
|
||
// 不覆盖已有错误(如登录失败 message),仅在无错误时提示验证码本身的问题
|
||
if (!error.value) error.value = res.message || '验证码获取失败,请点击重试'
|
||
}
|
||
|
||
/**
|
||
* 登录成功后的去向:`redirect` 可能被 encodeURIComponent 过(如 /buy%3Fid%3D3),
|
||
* 这里安全解码;仅接受站内路径,避免开放重定向。
|
||
*/
|
||
function resolveRedirect() {
|
||
const raw = (route.query.redirect as string) || ''
|
||
if (!raw) return '/tender'
|
||
let target = raw
|
||
try {
|
||
target = decodeURIComponent(raw)
|
||
} catch {
|
||
target = raw
|
||
}
|
||
// 必须以单个 '/' 开头:排除 http(s)://、协议相对地址 //evil.com,
|
||
// 以及反斜杠变体 /\evil.com(部分浏览器把 '\' 归一成 '/',等价于 //evil.com)
|
||
return target.startsWith('/') && !target.startsWith('//') && !target.includes('\\') ? target : '/tender'
|
||
}
|
||
|
||
onMounted(refreshCaptcha)
|
||
|
||
async function submitForm() {
|
||
error.value = ''
|
||
if (!enterpriseName.value.trim()) {
|
||
error.value = '请输入企业名称'
|
||
return
|
||
}
|
||
if (!password.value) {
|
||
error.value = '请输入密码'
|
||
return
|
||
}
|
||
if (!code.value.trim()) {
|
||
error.value = '请输入图形验证码'
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res = await login(enterpriseName.value.trim(), password.value, code.value.trim())
|
||
if (res.ok) {
|
||
return navigateTo(resolveRedirect())
|
||
}
|
||
error.value = res.message || '登录失败'
|
||
// 验证码一次性,失败后换一张,避免用户用同一个码反复提交
|
||
await refreshCaptcha()
|
||
} catch (e: any) {
|
||
error.value = e?.message || '登录失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
</script>
|