Files
hjc-web/app/pages/login.vue
T
weicw1996 81ef9317c7 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)。
2026-09-17 02:35:45 +08:00

131 lines
4.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>