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:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user