feat: API层与认证流(client拦截/守卫/登录改密实装/域API骨架/vitest)

- 自建轻量 axios client(src/api/client.ts):Bearer 注入、401 清会话跳登录
  (login 请求除外)、409 精确匹配"必须先修改初始密码"跳强制改密(区别于并发
  冲突 409)、429/网络错误统一 ElMessage、ApiError 标准化、showErrorMessage
  可opt-out——模板 utils/http 的 {code,msg,data} 信封与后端裸 HTTP 语义不符故弃用
- Pinia auth store(src/store/modules/auth.ts):token/role/tenant_slug/
  must_change 落地并持久化(all-kefu-auth key),login/logout/changePassword/
  会话过期/强制改密跳转(动态 import 断循环)
- 路由守卫:token 事实源判定、must_change 全站门(除改密页)、UserInfo 由登录
  快照派生(后端无用户信息端点);user store 残留自愈
- 登录页实装(去除角色调试开关)、强制改密页实装;用户菜单/锁屏对接真实 logout
- 域 API 骨架:tenants/config/conversations/stats/settings(类型+端点常量)
- mask 哨兵助手:__SET__ ↔ 已设置 表单语义
- vitest 25 例(client 11/store 8/mask 6);登录页拖拽验证 reset 空引用修复

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 16:28:11 +08:00
parent 6afa31ec85
commit 3973abea8b
23 changed files with 1809 additions and 132 deletions
@@ -1,4 +1,4 @@
<!-- 强制修改密码(骨架占位页;登录对接与提交逻辑在后续任务实现) -->
<!-- 强制修改密码(首登引导 / 管理员重置后的 must_change 语义) -->
<template>
<div class="force-change-password flex-cc w-full h-screen">
<ElCard shadow="never" class="card">
@@ -6,11 +6,20 @@
<span class="card-title">首次登录请修改密码</span>
</template>
<ElForm ref="formRef" :model="formData" :rules="rules" label-position="top">
<ElFormItem prop="oldPassword" label="原密码">
<ElInput
v-model.trim="formData.oldPassword"
type="password"
placeholder="请输入原密码"
show-password
autocomplete="off"
/>
</ElFormItem>
<ElFormItem prop="newPassword" label="新密码">
<ElInput
v-model.trim="formData.newPassword"
type="password"
placeholder="请输入新密码"
placeholder="请输入新密码(不少于 8 位)"
show-password
autocomplete="off"
/>
@@ -28,25 +37,31 @@
提交修改
</ElButton>
</ElForm>
<p class="tip">密码修改成功后方可进入后台(对接后端 API 在后续任务实现)</p>
<p class="tip">密码修改成功后方可进入后台;修改后当前会话保持有效,无需重新登录</p>
</ElCard>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/store/modules/auth'
defineOptions({ name: 'ForceChangePassword' })
const authStore = useAuthStore()
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const formData = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const rules: FormRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, message: '密码长度不少于 8 位', trigger: 'blur' }
@@ -66,15 +81,27 @@
]
}
// 骨架阶段仅做表单校验,不发起请求(Task 3 对接 /api/platform/auth/*)
// 提交改密(POST /api/platform/auth/change-password):
// 成功 → must_change 清零 → 进仪表盘;400 原密码不正确 / 401 会话过期
// 由 HTTP client 统一提示
const handleSubmit = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
setTimeout(() => {
try {
await authStore.changePassword({
old_password: formData.oldPassword,
new_password: formData.newPassword
})
ElMessage.success('密码修改成功,即将进入后台')
await router.push('/')
} catch {
// 错误提示已由 HTTP client 统一弹出(如 400 原密码不正确)
} finally {
loading.value = false
}, 600)
}
}
</script>
+15 -38
View File
@@ -78,15 +78,6 @@
{{ $t('login.btnText') }}
</ElButton>
</div>
<!-- 角色调试开关:脚手架阶段演示菜单权限控制,Task 3 接入真实登录后移除 -->
<div class="mt-5 text-sm text-gray-600 flex-cb">
<span>角色(开发调试)</span>
<ElRadioGroup v-model="devRole" size="small">
<ElRadioButton value="super">超管</ElRadioButton>
<ElRadioButton value="tenant">租户管理员</ElRadioButton>
</ElRadioGroup>
</div>
</ElForm>
</div>
</div>
@@ -96,9 +87,9 @@
<script setup lang="ts">
import AppConfig from '@/config'
import { useUserStore } from '@/store/modules/user'
import { useAuthStore } from '@/store/modules/auth'
import { useI18n } from 'vue-i18n'
import { fetchLogin, setDevStubRole, type DevStubRole } from '@/api/auth'
import { RoutesAlias } from '@/router/routesAlias'
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
import { useSettingStore } from '@/store/modules/setting'
@@ -116,7 +107,7 @@
const dragVerify = ref()
const userStore = useUserStore()
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const isPassing = ref(false)
@@ -125,8 +116,6 @@
const systemName = AppConfig.systemInfo.name
const formRef = ref<FormInstance>()
const devRole = ref<DevStubRole>('super')
const formData = reactive({
username: '',
password: '',
@@ -157,43 +146,31 @@
loading.value = true
// 记录角色调试开关(开发桩读取;Task 3 移除)
setDevStubRole(devRole.value)
// 登录请求
// 登录请求(token/role/tenant_slug/must_change 由 auth store 落地并持久化)
const { username, password } = formData
const { token, refreshToken } = await fetchLogin({
userName: username,
password
})
// 验证token
if (!token) {
throw new Error('Login failed - no token received')
}
// 存储 token 和登录状态
userStore.setToken(token, refreshToken)
userStore.setLoginStatus(true)
await authStore.login({ username, password })
// 登录成功处理
showLoginSuccessNotice()
// 获取 redirect 参数,如果存在则跳转到指定页面,否则跳转到首页
// 首登引导(must_change)直接进强制改密页,免一次中转;
// 正常登录按 redirect 回跳或进首页
const redirect = route.query.redirect as string
router.push(redirect || '/')
} catch (error) {
console.error('[Login] Unexpected error:', error)
await router.push(
authStore.mustChangePassword ? RoutesAlias.ForceChangePassword : redirect || '/'
)
} catch {
// 错误提示已由 HTTP client 统一弹出:
// 401 凭据错误 / 429 登录限流(后端 detail 中文文案)
} finally {
loading.value = false
resetDragVerify()
}
}
// 重置拖拽验证
// 重置拖拽验证(登录成功跳转后组件已卸载,ref 为 null——需可选链)
const resetDragVerify = () => {
dragVerify.value.reset()
dragVerify.value?.reset()
}
// 登录成功提示