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:
浅念
2026-09-03 15:03:20 +08:00
parent 0a5eb49359
commit 6880ff1b97
16 changed files with 460 additions and 9 deletions
+55 -2
View File
@@ -16,6 +16,36 @@ export const http = axios.create({
timeout: 15000,
})
// ---- 登录态(POST /api/auth/login 换取的共享 token,存 localStorage)----
const TOKEN_KEY = 'yunke_token'
/** 当前 token(空串=后端鉴权关闭;是否登录过看 isLoggedIn,key 存在即已登录)。 */
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
/** 已登录 = key 存在(哪怕值是空串 — 后端鉴权关闭时 login 返回空 token)。 */
export function isLoggedIn(): boolean {
return localStorage.getItem(TOKEN_KEY) !== null
}
export function setToken(token: string): void {
// 空串也写入:表示"已登录但服务端无鉴权",避免守卫把人锁在登录页
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
// 每个 API 请求自动带 Bearer 头(未登录/空 token 时不带 — 后端鉴权关闭时同样可通)
http.interceptors.request.use((config) => {
const token = getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(response) => response.data,
(error) => {
@@ -24,6 +54,14 @@ http.interceptors.response.use(
return Promise.reject(error)
}
const status = error?.response?.status
// 401 = token 失效/被改:清掉本地登录态回登录页(登录请求自身的 401
// 不重定向 — 那是密码错误,由登录页展示)
if (status === 401) {
clearToken()
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
const detail = error?.response?.data?.detail ?? error?.response?.data
const message =
typeof detail === 'string' && detail
@@ -55,13 +93,28 @@ export function requestJson<T>(
return http.request({ method, url, data }) as unknown as Promise<T>
}
/**
* 本地媒体直链 — `<img>/<audio>` 的 src 专用。浏览器原生标签不会带
* Authorization 头,所以 token 走后端同样支持的 `?token=` query。
*/
export function mediaUrl(hash: string): string {
const token = getToken()
return `/api/media/${hash}${token ? `?token=${encodeURIComponent(token)}` : ''}`
}
/** 以 blob 下载(HTML 导出等附件)。从 Content-Disposition 取文件名(绕开 http 的 (r)=>r.data 解包拦截器)。 */
export async function downloadBlob(
url: string,
params?: Record<string, unknown>,
): Promise<string> {
// 裸 axios(补 /api 前缀)拿全响应(含 headers),不经 http 拦截器
const res = await axios.get(`/api${url}`, { params, responseType: 'blob' })
// 裸 axios(补 /api 前缀)拿全响应(含 headers),不经 http 拦截器 —
// 所以 Bearer 头要手动带上
const token = getToken()
const res = await axios.get(`/api${url}`, {
params,
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
const cd = (res.headers['content-disposition'] as string) || ''
const m = /filename="?([^";]+)"?/i.exec(cd)
const filename = m ? m[1] : 'chat-export.html'
+8
View File
@@ -473,6 +473,14 @@ export function search(query: SearchQuery): Promise<SearchPayload> {
)
}
/** 登录:用户名/密码换共享 API token(成功后由调用方 setToken 持久化)。 */
export function login(username: string, password: string): Promise<{ token: string }> {
return requestJson<{ token: string }>('post', '/auth/login', {
username,
password,
})
}
export type {
Account,
CallChannel,
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Microphone, VideoPlay, Document, Picture, Paperclip, User } from '@element-plus/icons-vue'
import { mediaUrl } from '@/api/client'
import type { Message } from '@/api/types'
import { formatDuration, formatTime, wechatTypeLabel } from '@/utils/format'
@@ -103,7 +104,7 @@ const bestMediaUrl = computed<string | undefined>(() => {
*/
const localSrc = computed<string | undefined>(() => {
const hash = props.message.media_hash
return hash ? `/api/media/${hash}` : undefined
return hash ? mediaUrl(hash) : undefined
})
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'tif', 'tiff'])
+2 -1
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCustomers, getVoiceClips, matchVoiceClip, search } from '@/api'
import { mediaUrl } from '@/api/client'
import type { Customer, VoiceClip } from '@/api/types'
import { formatCallDuration, formatTime } from '@/utils/format'
@@ -149,7 +150,7 @@ const displayItems = computed(() =>
function playClip(row: VoiceClip): void {
// Local-first: 优先本地化的 /api/media/{media_hash},回退到 OSS 直链
// (mirrors CallList.playRecord)。
const local = row.media_hash ? `/api/media/${row.media_hash}` : null
const local = row.media_hash ? mediaUrl(row.media_hash) : null
const url = local ?? row.oss_file_name
if (!url) {
ElMessage.info('该片段无录音文件')
+27
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChannelStore } from '@/stores/channel'
import { clearToken } from '@/api/client'
import type { Channel } from '@/api/types'
const route = useRoute()
@@ -32,6 +33,11 @@ function go(path: string, channel: Channel): void {
function goPlain(path: string): void {
router.push(path)
}
function logout(): void {
clearToken()
router.push({ name: 'login' })
}
</script>
<template>
@@ -93,6 +99,11 @@ function goPlain(path: string): void {
全局搜索
</el-menu-item>
</el-menu>
<div class="yk-sidenav__footer">
<el-button text class="yk-sidenav__logout" @click="logout">
退出登录
</el-button>
</div>
</el-aside>
<el-main class="yk-main">
@@ -104,6 +115,22 @@ function goPlain(path: string): void {
<style scoped>
/* ══ 墨玉侧栏:深墨底 + 玉绿血脉(2026-08-22) ══ */
.yk-sidenav__footer {
margin-top: auto;
padding: 10px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.yk-sidenav__logout {
width: 100%;
color: rgba(255, 255, 255, 0.65);
}
.yk-sidenav__logout:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.yk-sidenav {
border-right: none;
background: linear-gradient(180deg, #1a2130 0%, #141927 100%);
+19
View File
@@ -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} · 云客数据镜像` : '云客数据镜像'
+2 -1
View File
@@ -17,6 +17,7 @@
* (anything not in the label map) fall back to the raw key under 其他, so a
* new field added upstream still shows up instead of disappearing.
*/
import { mediaUrl } from '@/api/client'
import type { PhoneCall } from '@/api/types'
import { formatTime } from './format'
@@ -213,7 +214,7 @@ export function toDisplayField(c: PhoneCall, camelKey: string): DisplayField {
}
// 录音:优先本地化的 media_hash(/api/media/{hash}),否则原 OSS URL
if (camelKey === 'recordFile') {
const local = c.record_media_hash ? `/api/media/${c.record_media_hash}` : null
const local = c.record_media_hash ? mediaUrl(c.record_media_hash) : null
const url = local ?? (typeof value === 'string' && value ? value : null)
if (url) {
return { key: camelKey, label, value: url, render: 'audio', text: '' }
+2 -1
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getCallStats, getCalls } from '@/api'
import { mediaUrl } from '@/api/client'
import type { CallChannel, CallStats, PhoneCall } from '@/api/types'
import {
callDirectionLabel,
@@ -191,7 +192,7 @@ function timeCell(row: PhoneCall, camelKey: string): number | undefined {
}
function playRecord(row: PhoneCall): void {
const local = row.record_media_hash ? `/api/media/${row.record_media_hash}` : null
const local = row.record_media_hash ? mediaUrl(row.record_media_hash) : null
const file =
local ??
(typeof cell(row, 'recordFile') === 'string' ? (cell(row, 'recordFile') as string) : null) ??
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '@/api'
import { setToken } from '@/api/client'
const router = useRouter()
const route = useRoute()
const form = reactive({ username: '', password: '' })
const loading = ref(false)
async function submit(): Promise<void> {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const { token } = await login(form.username, form.password)
setToken(token)
ElMessage.success('登录成功')
const redirect = route.query.redirect
router.push(typeof redirect === 'string' && redirect ? redirect : '/')
} catch (e) {
ElMessage.error((e as Error).message)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="yk-login">
<el-card class="yk-login__card" shadow="always">
<h2 class="yk-login__title">云客数据镜像</h2>
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="用户名">
<el-input
v-model="form.username"
placeholder="用户名"
size="large"
autocomplete="username"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
size="large"
show-password
autocomplete="current-password"
@keyup.enter="submit"
/>
</el-form-item>
<el-button
type="primary"
size="large"
class="yk-login__btn"
:loading="loading"
@click="submit"
>
登 录
</el-button>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.yk-login {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(160deg, #1c2431 0%, #2b3a4a 100%);
}
.yk-login__card {
width: 360px;
padding: 12px 8px;
}
.yk-login__title {
margin: 4px 0 20px;
font-size: 20px;
text-align: center;
color: #1c2431;
}
.yk-login__btn {
width: 100%;
margin-top: 4px;
}
</style>