feat(auth): 登录页(用户名/密码换 API token)+ 前端全链路登录态
后端: - POST /api/auth/login:admin 账密(Settings 默认 admin/admin123, ADMIN_USER/ADMIN_PASSWORD 可覆盖)换取共享 api_token,免 token 中间件 - 防爆破:同 IP 15min 窗口内 5 次失败锁 15min(429),成功登录清零 - 错误信息统一「用户名或密码错误」(防用户名枚举) - 测试 7 个新增(TDD),全量 500 绿 前端: - LoginView + 路由守卫(未登录跳 /login,回跳 redirect) - axios 请求拦截器自动带 Bearer;401 清登录态回登录页 - 媒体直链统一 mediaUrl() 带 ?token=(img/audio 标签发不了 header), 替换 4 处裸拼;downloadBlob 手动带头 - isLoggedIn 按 key 存在判定 — 后端鉴权关闭时空 token 不锁死登录页 - MainLayout 侧栏底部退出登录 compose:两份编排透传 ADMIN_USER/ADMIN_PASSWORD(默认 admin/admin123) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { isLoggedIn } from '@/api/client'
|
||||
|
||||
// `/` redirects to `/customers`. Chat timeline params are path segments so the
|
||||
// URL is shareable + survives refresh:
|
||||
@@ -91,6 +92,13 @@ const routes: RouteRecordRaw[] = [
|
||||
props: true,
|
||||
meta: { title: '员工聊天窗口' },
|
||||
},
|
||||
{
|
||||
// 登录页(public:未登录可进;守卫据此放行)
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
// Anything else → back to the list.
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -106,6 +114,17 @@ export const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
// 登录守卫:非 public 页面未登录一律去 /login,登录后回跳。判定用
|
||||
// isLoggedIn(key 存在)而非 token 非空 — 后端鉴权关闭时 token 是空串,
|
||||
// 但用户确实登录过,不能把人锁在登录页。
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.public) return true
|
||||
if (!isLoggedIn()) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta.title as string | undefined) ?? ''
|
||||
document.title = title ? `${title} · 云客数据镜像` : '云客数据镜像'
|
||||
|
||||
Reference in New Issue
Block a user