Files
yunke/yunkes/src/router/index.ts
T
浅念 6880ff1b97 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>
2026-09-03 15:03:20 +08:00

132 lines
3.9 KiB
TypeScript

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:
// /chat/:channel/:ownerId/:talkerId? (1:1 DM; talkerId optional → account-level)
// /group/:channel/:ownerId/:roomId? (group chat — same component, room_id)
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: { name: 'customers' },
},
{
path: '/customers',
name: 'customers',
component: () => import('@/views/CustomerList.vue'),
meta: { title: '客户列表' },
},
{
path: '/staff',
name: 'staff',
component: () => import('@/views/StaffList.vue'),
meta: { title: '员工列表' },
},
{
// Standalone 员工详情 page mirroring 云客's
// /cms/customer/userDetailNew?userId=... — replaces the all-fields drawer
// degradation for the 员工姓名 click. Row-click in StaffList still opens
// the drawer for a quick preview.
path: '/staff-detail/:userId',
name: 'staff-detail',
component: () => import('@/views/StaffDetail.vue'),
props: true,
meta: { title: '员工详情' },
},
{
path: '/accounts',
redirect: { name: 'staff' },
},
{
path: '/groups',
name: 'groups',
component: () => import('@/views/GroupList.vue'),
meta: { title: '群列表' },
},
{
path: '/calls',
name: 'calls',
component: () => import('@/views/CallList.vue'),
meta: { title: '通话记录' },
},
{
path: '/sms',
name: 'sms',
component: () => import('@/views/SmsList.vue'),
meta: { title: '短信记录' },
},
{
path: '/sync',
name: 'sync',
component: () => import('@/views/SyncStatus.vue'),
meta: { title: '同步状态' },
},
{
path: '/search',
name: 'search',
component: () => import('@/views/SearchView.vue'),
meta: { title: '搜索' },
},
{
path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat',
component: () => import('@/views/ChatTimeline.vue'),
props: true,
meta: { title: '聊天记录' },
},
{
// Group chat timeline — same component, but with a `roomId` prop so it
// issues a room_id query instead of a talker_id query.
path: '/group/:channel/:ownerId/:roomId?',
name: 'group-chat',
component: () => import('@/views/ChatTimeline.vue'),
props: true,
meta: { title: '群聊记录' },
},
{
// 员工聊天窗口(对齐云客 wechat-window 五视图): tab=single|group|moments|contacts|stats
// tab=group 时 talkerId 语义为 roomId; ?msgid= 定位消息(红包转账明细跳转)
path: '/staff-chat/:ownerId/:tab(single|group|moments|contacts|stats)?/:talkerId?',
name: 'staff-chat',
component: () => import('@/views/StaffChat.vue'),
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(.*)*',
redirect: { name: 'customers' },
},
]
export const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 }
},
})
// 登录守卫:非 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} · 云客数据镜像` : '云客数据镜像'
})