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:
@@ -42,6 +42,9 @@ services:
|
|||||||
YK_COOKIE: ${YK_COOKIE:-}
|
YK_COOKIE: ${YK_COOKIE:-}
|
||||||
API_TOKEN: ${API_TOKEN:-}
|
API_TOKEN: ${API_TOKEN:-}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
|
# 登录页账密(ADMIN_USER/ADMIN_PASSWORD,默认 admin/admin123,可在 .env 覆盖)
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ services:
|
|||||||
YK_COOKIE: ${YK_COOKIE:-}
|
YK_COOKIE: ${YK_COOKIE:-}
|
||||||
API_TOKEN: ${API_TOKEN:-}
|
API_TOKEN: ${API_TOKEN:-}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
|
# 登录页账密(默认 admin/admin123,可在 .env 覆盖)
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
volumes:
|
volumes:
|
||||||
# 媒体走 bind mount(不占 Docker 虚拟磁盘——本机实测 C 盘被 2.5GB
|
# 媒体走 bind mount(不占 Docker 虚拟磁盘——本机实测 C 盘被 2.5GB
|
||||||
# volume 撑爆导致引擎 500,2026-08-22);服务器上数据直观可见好备份
|
# volume 撑爆导致引擎 500,2026-08-22);服务器上数据直观可见好备份
|
||||||
|
|||||||
@@ -19,15 +19,47 @@ reach it without knowing the token — that exemption is encoded here in
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
# Request paths that bypass auth even when a token is configured. Keep this
|
# Request paths that bypass auth even when a token is configured. Keep this
|
||||||
# minimal so the protected surface is easy to reason about — currently just the
|
# minimal so the protected surface is easy to reason about — the liveness
|
||||||
# liveness probe.
|
# probe and the login endpoint itself (the caller has no token yet).
|
||||||
_PUBLIC_PATHS = frozenset({"/api/health"})
|
_PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/login"})
|
||||||
|
|
||||||
|
# ---- Brute-force guard for POST /api/auth/login ------------------------- #
|
||||||
|
# In-memory per-IP failure tracker: MAX failures within WINDOW seconds locks
|
||||||
|
# the IP out for LOCKOUT seconds. Process-local by design (single-backend
|
||||||
|
# deployment; a restart simply clears the slate).
|
||||||
|
_LOGIN_MAX_FAILURES = 5
|
||||||
|
_LOGIN_FAILURE_WINDOW_S = 15 * 60
|
||||||
|
_LOGIN_LOCKOUT_S = 15 * 60
|
||||||
|
# ip -> list of monotonic timestamps of recent failed logins
|
||||||
|
_login_failures: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Best-effort client IP for the lockout key (test transport has none)."""
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _login_locked_out(ip: str) -> bool:
|
||||||
|
"""True when ``ip`` burned through MAX failures inside the window."""
|
||||||
|
now = time.monotonic()
|
||||||
|
stamps = [t for t in _login_failures.get(ip, []) if now - t < _LOGIN_FAILURE_WINDOW_S]
|
||||||
|
_login_failures[ip] = stamps
|
||||||
|
return len(stamps) >= _LOGIN_MAX_FAILURES
|
||||||
|
|
||||||
|
|
||||||
|
def _record_login_failure(ip: str) -> None:
|
||||||
|
_login_failures.setdefault(ip, []).append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_login_failures(ip: str) -> None:
|
||||||
|
_login_failures.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
async def require_auth(request: Request) -> None:
|
async def require_auth(request: Request) -> None:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from app.api.routers import (
|
|||||||
customer,
|
customer,
|
||||||
export,
|
export,
|
||||||
groups,
|
groups,
|
||||||
|
login,
|
||||||
media,
|
media,
|
||||||
messages,
|
messages,
|
||||||
moments,
|
moments,
|
||||||
@@ -82,6 +83,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(customer.router)
|
app.include_router(customer.router)
|
||||||
|
app.include_router(login.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(organization.router)
|
app.include_router(organization.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""POST /api/auth/login — username/password sign-in for the SPA.
|
||||||
|
|
||||||
|
Exchanges admin credentials (``Settings.admin_user`` / ``admin_password``,
|
||||||
|
env-overridable) for the shared ``api_token`` so browser users never have to
|
||||||
|
handle the raw token string. The endpoint is exempt from the shared-token
|
||||||
|
middleware (see ``app.api.auth._PUBLIC_PATHS``) and rate-limited per IP by the
|
||||||
|
brute-force guard in ``app.api.auth``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.auth import (
|
||||||
|
_client_ip,
|
||||||
|
_login_locked_out,
|
||||||
|
_record_login_failure,
|
||||||
|
_reset_login_failures,
|
||||||
|
)
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(body: LoginBody, request: Request) -> dict[str, str]:
|
||||||
|
settings = get_settings()
|
||||||
|
ip = _client_ip(request)
|
||||||
|
|
||||||
|
if _login_locked_out(ip):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="尝试次数过多,请 15 分钟后再试",
|
||||||
|
)
|
||||||
|
|
||||||
|
credentials_ok = secrets.compare_digest(
|
||||||
|
body.username.encode("utf-8"), settings.admin_user.encode("utf-8")
|
||||||
|
) and secrets.compare_digest(
|
||||||
|
body.password.encode("utf-8"), settings.admin_password.encode("utf-8")
|
||||||
|
)
|
||||||
|
if not credentials_ok:
|
||||||
|
# Same generic message for unknown user / wrong password (no
|
||||||
|
# user enumeration).
|
||||||
|
_record_login_failure(ip)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
)
|
||||||
|
|
||||||
|
_reset_login_failures(ip)
|
||||||
|
# Empty token when auth is disabled — the SPA stores it and the request
|
||||||
|
# interceptor simply skips the empty value.
|
||||||
|
return {"token": settings.api_token}
|
||||||
@@ -34,6 +34,13 @@ class Settings(BaseSettings):
|
|||||||
# <token>`` or ``?token=<token>``. See app/api/auth.py.
|
# <token>`` or ``?token=<token>``. See app/api/auth.py.
|
||||||
api_token: str = ""
|
api_token: str = ""
|
||||||
|
|
||||||
|
# Username/password for the SPA login page (``POST /api/auth/login``).
|
||||||
|
# On success the login hands out the ``api_token`` above so the SPA can
|
||||||
|
# act as a Bearer client; defaults keep local dev zero-config. Change both
|
||||||
|
# in prod via ADMIN_USER / ADMIN_PASSWORD.
|
||||||
|
admin_user: str = "admin"
|
||||||
|
admin_password: str = "admin123"
|
||||||
|
|
||||||
# Comma-separated list of allowed CORS origins for the viewing API. Empty
|
# Comma-separated list of allowed CORS origins for the viewing API. Empty
|
||||||
# (default) -> only the dev fallback (``http://localhost:5173``) is allowed;
|
# (default) -> only the dev fallback (``http://localhost:5173``) is allowed;
|
||||||
# set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin
|
# set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Tests for ``POST /api/auth/login`` (username/password → shared API token).
|
||||||
|
|
||||||
|
Behavior pinned here:
|
||||||
|
|
||||||
|
* Correct credentials -> ``200 {"token": <api_token>}`` (the SPA then uses it
|
||||||
|
as the Bearer token for every subsequent call).
|
||||||
|
* Wrong username or password -> ``401`` with a generic message (no user
|
||||||
|
enumeration — the same detail for both cases).
|
||||||
|
* The login endpoint itself is exempt from the shared-token middleware —
|
||||||
|
obviously, the caller doesn't have a token yet.
|
||||||
|
* Brute-force guard: the 5th consecutive failure from one IP locks that IP
|
||||||
|
out for 15 minutes (``429`` even with correct credentials); a successful
|
||||||
|
login resets the failure counter.
|
||||||
|
* Works with auth disabled (empty ``API_TOKEN``) -> returns an empty token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api import auth as auth_module
|
||||||
|
from app.api.main import app
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def login_client(monkeypatch):
|
||||||
|
"""In-process client with a clean settings/login state per test."""
|
||||||
|
monkeypatch.delenv("API_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("ADMIN_USER", raising=False)
|
||||||
|
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
failures = getattr(auth_module, "_login_failures", None)
|
||||||
|
if failures is not None:
|
||||||
|
failures.clear()
|
||||||
|
ac = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||||||
|
await ac.__aenter__()
|
||||||
|
yield ac
|
||||||
|
await ac.aclose()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_correct_credentials_returns_token(login_client, monkeypatch):
|
||||||
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"token": "test-secret"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_wrong_password_returns_401(login_client, monkeypatch):
|
||||||
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "nope"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "用户名或密码错误"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_unknown_user_same_error_as_wrong_password(
|
||||||
|
login_client, monkeypatch
|
||||||
|
):
|
||||||
|
"""No user enumeration: unknown user and wrong password read identically."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "nobody", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "用户名或密码错误"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_credentials_come_from_env(login_client, monkeypatch):
|
||||||
|
"""ADMIN_USER / ADMIN_PASSWORD override the defaults."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "s")
|
||||||
|
monkeypatch.setenv("ADMIN_USER", "boss")
|
||||||
|
monkeypatch.setenv("ADMIN_PASSWORD", "boss-pw")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
ok = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "boss", "password": "boss-pw"}
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
legacy = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert legacy.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_auth_disabled_returns_empty_token(login_client):
|
||||||
|
"""Empty API_TOKEN (auth off) -> login still succeeds with an empty token."""
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"token": ""}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lockout_after_five_consecutive_failures(login_client, monkeypatch):
|
||||||
|
"""5 failures from one IP -> 6th attempt is 429 even with correct creds."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "s")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
for _ in range(5):
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
locked = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert locked.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
async def test_successful_login_resets_failure_counter(login_client, monkeypatch):
|
||||||
|
"""4 failures + a success + 4 more failures -> still not locked."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "s")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
for _ in range(4):
|
||||||
|
await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
||||||
|
)
|
||||||
|
ok = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
for _ in range(4):
|
||||||
|
await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
||||||
|
)
|
||||||
|
still_ok = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert still_ok.status_code == 200
|
||||||
@@ -16,6 +16,36 @@ export const http = axios.create({
|
|||||||
timeout: 15000,
|
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(
|
http.interceptors.response.use(
|
||||||
(response) => response.data,
|
(response) => response.data,
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -24,6 +54,14 @@ http.interceptors.response.use(
|
|||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
const status = error?.response?.status
|
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 detail = error?.response?.data?.detail ?? error?.response?.data
|
||||||
const message =
|
const message =
|
||||||
typeof detail === 'string' && detail
|
typeof detail === 'string' && detail
|
||||||
@@ -55,13 +93,28 @@ export function requestJson<T>(
|
|||||||
return http.request({ method, url, data }) as unknown as Promise<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 解包拦截器)。 */
|
/** 以 blob 下载(HTML 导出等附件)。从 Content-Disposition 取文件名(绕开 http 的 (r)=>r.data 解包拦截器)。 */
|
||||||
export async function downloadBlob(
|
export async function downloadBlob(
|
||||||
url: string,
|
url: string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// 裸 axios(补 /api 前缀)拿全响应(含 headers),不经 http 拦截器
|
// 裸 axios(补 /api 前缀)拿全响应(含 headers),不经 http 拦截器 —
|
||||||
const res = await axios.get(`/api${url}`, { params, responseType: 'blob' })
|
// 所以 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 cd = (res.headers['content-disposition'] as string) || ''
|
||||||
const m = /filename="?([^";]+)"?/i.exec(cd)
|
const m = /filename="?([^";]+)"?/i.exec(cd)
|
||||||
const filename = m ? m[1] : 'chat-export.html'
|
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 {
|
export type {
|
||||||
Account,
|
Account,
|
||||||
CallChannel,
|
CallChannel,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Microphone, VideoPlay, Document, Picture, Paperclip, User } from '@element-plus/icons-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 type { Message } from '@/api/types'
|
||||||
import { formatDuration, formatTime, wechatTypeLabel } from '@/utils/format'
|
import { formatDuration, formatTime, wechatTypeLabel } from '@/utils/format'
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ const bestMediaUrl = computed<string | undefined>(() => {
|
|||||||
*/
|
*/
|
||||||
const localSrc = computed<string | undefined>(() => {
|
const localSrc = computed<string | undefined>(() => {
|
||||||
const hash = props.message.media_hash
|
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'])
|
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'tif', 'tiff'])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getCustomers, getVoiceClips, matchVoiceClip, search } from '@/api'
|
import { getCustomers, getVoiceClips, matchVoiceClip, search } from '@/api'
|
||||||
|
import { mediaUrl } from '@/api/client'
|
||||||
import type { Customer, VoiceClip } from '@/api/types'
|
import type { Customer, VoiceClip } from '@/api/types'
|
||||||
import { formatCallDuration, formatTime } from '@/utils/format'
|
import { formatCallDuration, formatTime } from '@/utils/format'
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ const displayItems = computed(() =>
|
|||||||
function playClip(row: VoiceClip): void {
|
function playClip(row: VoiceClip): void {
|
||||||
// Local-first: 优先本地化的 /api/media/{media_hash},回退到 OSS 直链
|
// Local-first: 优先本地化的 /api/media/{media_hash},回退到 OSS 直链
|
||||||
// (mirrors CallList.playRecord)。
|
// (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
|
const url = local ?? row.oss_file_name
|
||||||
if (!url) {
|
if (!url) {
|
||||||
ElMessage.info('该片段无录音文件')
|
ElMessage.info('该片段无录音文件')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useChannelStore } from '@/stores/channel'
|
import { useChannelStore } from '@/stores/channel'
|
||||||
|
import { clearToken } from '@/api/client'
|
||||||
import type { Channel } from '@/api/types'
|
import type { Channel } from '@/api/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -32,6 +33,11 @@ function go(path: string, channel: Channel): void {
|
|||||||
function goPlain(path: string): void {
|
function goPlain(path: string): void {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logout(): void {
|
||||||
|
clearToken()
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -93,6 +99,11 @@ function goPlain(path: string): void {
|
|||||||
全局搜索
|
全局搜索
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
|
<div class="yk-sidenav__footer">
|
||||||
|
<el-button text class="yk-sidenav__logout" @click="logout">
|
||||||
|
退出登录
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-main class="yk-main">
|
<el-main class="yk-main">
|
||||||
@@ -104,6 +115,22 @@ function goPlain(path: string): void {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ══ 墨玉侧栏:深墨底 + 玉绿血脉(2026-08-22) ══ */
|
/* ══ 墨玉侧栏:深墨底 + 玉绿血脉(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 {
|
.yk-sidenav {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
background: linear-gradient(180deg, #1a2130 0%, #141927 100%);
|
background: linear-gradient(180deg, #1a2130 0%, #141927 100%);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { isLoggedIn } from '@/api/client'
|
||||||
|
|
||||||
// `/` redirects to `/customers`. Chat timeline params are path segments so the
|
// `/` redirects to `/customers`. Chat timeline params are path segments so the
|
||||||
// URL is shareable + survives refresh:
|
// URL is shareable + survives refresh:
|
||||||
@@ -91,6 +92,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { title: '员工聊天窗口' },
|
meta: { title: '员工聊天窗口' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 登录页(public:未登录可进;守卫据此放行)
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
meta: { title: '登录', public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Anything else → back to the list.
|
// Anything else → back to the list.
|
||||||
path: '/:pathMatch(.*)*',
|
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) => {
|
router.afterEach((to) => {
|
||||||
const title = (to.meta.title as string | undefined) ?? ''
|
const title = (to.meta.title as string | undefined) ?? ''
|
||||||
document.title = title ? `${title} · 云客数据镜像` : '云客数据镜像'
|
document.title = title ? `${title} · 云客数据镜像` : '云客数据镜像'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
* (anything not in the label map) fall back to the raw key under 其他, so a
|
* (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.
|
* new field added upstream still shows up instead of disappearing.
|
||||||
*/
|
*/
|
||||||
|
import { mediaUrl } from '@/api/client'
|
||||||
import type { PhoneCall } from '@/api/types'
|
import type { PhoneCall } from '@/api/types'
|
||||||
import { formatTime } from './format'
|
import { formatTime } from './format'
|
||||||
|
|
||||||
@@ -213,7 +214,7 @@ export function toDisplayField(c: PhoneCall, camelKey: string): DisplayField {
|
|||||||
}
|
}
|
||||||
// 录音:优先本地化的 media_hash(/api/media/{hash}),否则原 OSS URL
|
// 录音:优先本地化的 media_hash(/api/media/{hash}),否则原 OSS URL
|
||||||
if (camelKey === 'recordFile') {
|
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)
|
const url = local ?? (typeof value === 'string' && value ? value : null)
|
||||||
if (url) {
|
if (url) {
|
||||||
return { key: camelKey, label, value: url, render: 'audio', text: '' }
|
return { key: camelKey, label, value: url, render: 'audio', text: '' }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getCallStats, getCalls } from '@/api'
|
import { getCallStats, getCalls } from '@/api'
|
||||||
|
import { mediaUrl } from '@/api/client'
|
||||||
import type { CallChannel, CallStats, PhoneCall } from '@/api/types'
|
import type { CallChannel, CallStats, PhoneCall } from '@/api/types'
|
||||||
import {
|
import {
|
||||||
callDirectionLabel,
|
callDirectionLabel,
|
||||||
@@ -191,7 +192,7 @@ function timeCell(row: PhoneCall, camelKey: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playRecord(row: PhoneCall): void {
|
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 =
|
const file =
|
||||||
local ??
|
local ??
|
||||||
(typeof cell(row, 'recordFile') === 'string' ? (cell(row, 'recordFile') as string) : null) ??
|
(typeof cell(row, 'recordFile') === 'string' ? (cell(row, 'recordFile') as string) : null) ??
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user