登陆优化
This commit is contained in:
@@ -4,7 +4,7 @@ from django.conf import settings
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.accounts.models import Invitation, Team, TeamMember, User
|
from apps.accounts.models import Invitation, LoginSession, Team, TeamMember, User
|
||||||
from apps.billing.models import CreditAccount, CreditLedger
|
from apps.billing.models import CreditAccount, CreditLedger
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +65,45 @@ class AuthApiTests(TestCase):
|
|||||||
self.assertEqual(genesis.count(), 0)
|
self.assertEqual(genesis.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class SingleDeviceLoginTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="single-device", password="strong-password")
|
||||||
|
|
||||||
|
def test_new_login_invalidates_previous_device_and_session(self):
|
||||||
|
first = APIClient()
|
||||||
|
first_login = first.post(
|
||||||
|
"/api/auth/login/",
|
||||||
|
{"username": self.user.username, "password": "strong-password"},
|
||||||
|
format="json",
|
||||||
|
HTTP_USER_AGENT="Device A",
|
||||||
|
REMOTE_ADDR="10.0.0.1",
|
||||||
|
)
|
||||||
|
self.assertEqual(first_login.status_code, 200)
|
||||||
|
first_token = first_login.data["token"]
|
||||||
|
|
||||||
|
second = APIClient()
|
||||||
|
second_login = second.post(
|
||||||
|
"/api/auth/login/",
|
||||||
|
{"username": self.user.username, "password": "strong-password"},
|
||||||
|
format="json",
|
||||||
|
HTTP_USER_AGENT="Device B",
|
||||||
|
REMOTE_ADDR="10.0.0.2",
|
||||||
|
)
|
||||||
|
self.assertEqual(second_login.status_code, 200)
|
||||||
|
second_token = second_login.data["token"]
|
||||||
|
self.assertNotEqual(second_token, first_token)
|
||||||
|
|
||||||
|
first.credentials(HTTP_AUTHORIZATION=f"Token {first_token}")
|
||||||
|
self.assertEqual(first.get("/api/auth/me/").status_code, 401)
|
||||||
|
|
||||||
|
second.credentials(HTTP_AUTHORIZATION=f"Token {second_token}")
|
||||||
|
self.assertEqual(second.get("/api/auth/me/").status_code, 200)
|
||||||
|
|
||||||
|
active = LoginSession.objects.filter(user=self.user, revoked_at__isnull=True)
|
||||||
|
self.assertEqual(active.count(), 1)
|
||||||
|
self.assertEqual(active.get().user_agent, "Device B")
|
||||||
|
|
||||||
|
|
||||||
class InvitationFlowTests(TestCase):
|
class InvitationFlowTests(TestCase):
|
||||||
def _register(self, client, username, **extra):
|
def _register(self, client, username, **extra):
|
||||||
return client.post(
|
return client.post(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
||||||
@@ -67,8 +68,7 @@ def _client_ip(request):
|
|||||||
|
|
||||||
|
|
||||||
def record_login_session(request, user):
|
def record_login_session(request, user):
|
||||||
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
|
"""记录当前登录设备(UA / IP)。同一设备重复写入时只刷新最近活跃时间。"""
|
||||||
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。"""
|
|
||||||
try:
|
try:
|
||||||
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||||
ip_address = _client_ip(request)
|
ip_address = _client_ip(request)
|
||||||
@@ -87,14 +87,29 @@ def record_login_session(request, user):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def issue_single_device_token(request, user):
|
||||||
|
"""签发账号唯一有效 Token。
|
||||||
|
|
||||||
|
锁住用户行后再旋转 Token,确保两个设备同时登录时严格由最后完成的登录获胜;
|
||||||
|
旧设备持有的 Token 会立即在下一次请求时得到 401。LoginSession 仅保留当前
|
||||||
|
设备为 active,供设置页展示和审计。
|
||||||
|
"""
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_user = User.objects.select_for_update().get(pk=user.pk)
|
||||||
|
LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||||
|
Token.objects.filter(user=locked_user).delete()
|
||||||
|
token = Token.objects.create(user=locked_user)
|
||||||
|
record_login_session(request, locked_user)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([])
|
@permission_classes([])
|
||||||
def register(request):
|
def register(request):
|
||||||
serializer = RegisterSerializer(data=request.data)
|
serializer = RegisterSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.save()
|
data = serializer.save()
|
||||||
token, _ = Token.objects.get_or_create(user=data["user"])
|
token = issue_single_device_token(request, data["user"])
|
||||||
record_login_session(request, data["user"])
|
|
||||||
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
|
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,15 +157,17 @@ def login(request):
|
|||||||
# 团队被平台超管停用:其成员一律不准登录(平台超管无团队,不受影响)
|
# 团队被平台超管停用:其成员一律不准登录(平台超管无团队,不受影响)
|
||||||
if team is not None and team.status == Team.Status.DISABLED:
|
if team is not None and team.status == Team.Status.DISABLED:
|
||||||
return Response({"detail": "团队已停用,请联系平台管理员"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "团队已停用,请联系平台管理员"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
token, _ = Token.objects.get_or_create(user=user)
|
token = issue_single_device_token(request, user)
|
||||||
record_login_session(request, user)
|
|
||||||
return Response(auth_payload(user, team, token))
|
return Response(auth_payload(user, team, token))
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def logout(request):
|
def logout(request):
|
||||||
Token.objects.filter(user=request.user).delete()
|
with transaction.atomic():
|
||||||
|
locked_user = User.objects.select_for_update().get(pk=request.user.pk)
|
||||||
|
LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||||
|
Token.objects.filter(user=locked_user).delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,8 +206,7 @@ def change_password(request):
|
|||||||
return Response({"new_password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"new_password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
user.save(update_fields=["password"])
|
user.save(update_fields=["password"])
|
||||||
Token.objects.filter(user=user).delete()
|
token = issue_single_device_token(request, user)
|
||||||
token, _ = Token.objects.get_or_create(user=user)
|
|
||||||
return Response({"token": token.key})
|
return Response({"token": token.key})
|
||||||
|
|
||||||
|
|
||||||
@@ -508,8 +524,6 @@ def revoke_login_session(request, session_id):
|
|||||||
只吊销某一台,唯一能真正下线的做法 = 旋转 token(删旧 + 发新),令所有端旧 token
|
只吊销某一台,唯一能真正下线的做法 = 旋转 token(删旧 + 发新),令所有端旧 token
|
||||||
立即失效,再把新 token 发回当前设备(同 revoke_other_sessions 的思路)。
|
立即失效,再把新 token 发回当前设备(同 revoke_other_sessions 的思路)。
|
||||||
被下线的目标设备下次请求即 401,真正离线。"""
|
被下线的目标设备下次请求即 401,真正离线。"""
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
|
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
|
||||||
if not target:
|
if not target:
|
||||||
return Response({"revoked": 0})
|
return Response({"revoked": 0})
|
||||||
@@ -520,10 +534,7 @@ def revoke_login_session(request, session_id):
|
|||||||
revoked_at__isnull=True,
|
revoked_at__isnull=True,
|
||||||
).update(revoked_at=timezone.now())
|
).update(revoked_at=timezone.now())
|
||||||
# 真正吊销:旋转用户 token(令被下线设备旧 token 立即失效),再发新 token 给当前设备。
|
# 真正吊销:旋转用户 token(令被下线设备旧 token 立即失效),再发新 token 给当前设备。
|
||||||
Token.objects.filter(user=request.user).delete()
|
token = issue_single_device_token(request, request.user)
|
||||||
token, _ = Token.objects.get_or_create(user=request.user)
|
|
||||||
# 刷新当前设备这条会话(旋转后当前设备等价于「重新登录」),避免它被自己下线后又冒出来
|
|
||||||
record_login_session(request, request.user)
|
|
||||||
return Response({"revoked": updated, "token": token.key})
|
return Response({"revoked": updated, "token": token.key})
|
||||||
|
|
||||||
|
|
||||||
@@ -531,10 +542,5 @@ def revoke_login_session(request, session_id):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def revoke_other_sessions(request):
|
def revoke_other_sessions(request):
|
||||||
"""下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。"""
|
"""下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。"""
|
||||||
from django.utils import timezone
|
token = issue_single_device_token(request, request.user)
|
||||||
|
|
||||||
LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
|
||||||
Token.objects.filter(user=request.user).delete()
|
|
||||||
token, _ = Token.objects.get_or_create(user=request.user)
|
|
||||||
record_login_session(request, request.user)
|
|
||||||
return Response({"token": token.key})
|
return Response({"token": token.key})
|
||||||
|
|||||||
@@ -1373,6 +1373,8 @@ class QuickCreateCoordinatorTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { api, ApiError, getToken, setToken } from "./api";
|
import { MonitorOff } from "lucide-react";
|
||||||
|
import { api, ApiError, AUTH_INVALIDATED_EVENT, getToken, setToken } from "./api";
|
||||||
import { IconKitSvg } from "./components/IconKitSvg";
|
import { IconKitSvg } from "./components/IconKitSvg";
|
||||||
import type {
|
import type {
|
||||||
AITask,
|
AITask,
|
||||||
@@ -24,6 +25,7 @@ import { generationErrorText } from "./generation-error";
|
|||||||
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
||||||
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
||||||
import { SystemLoading } from "./components/loading";
|
import { SystemLoading } from "./components/loading";
|
||||||
|
import { ConfirmModal } from "./components/overlays";
|
||||||
import {
|
import {
|
||||||
AccountPage,
|
AccountPage,
|
||||||
AssetFactoryPage,
|
AssetFactoryPage,
|
||||||
@@ -110,6 +112,7 @@ export function App() {
|
|||||||
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
|
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
|
||||||
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
|
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
|
||||||
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
|
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
|
||||||
|
const [sessionInvalidated, setSessionInvalidated] = useState(false);
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [team, setTeam] = useState<Team | null>(null);
|
const [team, setTeam] = useState<Team | null>(null);
|
||||||
@@ -157,6 +160,13 @@ export function App() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [notice]);
|
}, [notice]);
|
||||||
|
|
||||||
|
// 页面自己的轮询即使吞掉接口错误,也不能吞掉“账号已在其他设备登录”的全局提示。
|
||||||
|
useEffect(() => {
|
||||||
|
const onInvalidated = () => setSessionInvalidated(true);
|
||||||
|
window.addEventListener(AUTH_INVALIDATED_EVENT, onInvalidated);
|
||||||
|
return () => window.removeEventListener(AUTH_INVALIDATED_EVENT, onInvalidated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeProduct = useMemo(
|
const activeProduct = useMemo(
|
||||||
() => products.find((product) => product.id === activeProductId) || products[0],
|
() => products.find((product) => product.id === activeProductId) || products[0],
|
||||||
[products, activeProductId]
|
[products, activeProductId]
|
||||||
@@ -292,6 +302,12 @@ export function App() {
|
|||||||
} catch (bootError) {
|
} catch (bootError) {
|
||||||
// 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处
|
// 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处
|
||||||
console.error("[boot] identity failed:", bootError);
|
console.error("[boot] identity failed:", bootError);
|
||||||
|
const status = bootError instanceof ApiError ? bootError.status : 0;
|
||||||
|
if (status === 401) {
|
||||||
|
if (!cancelled) setSessionInvalidated(true);
|
||||||
|
if (!cancelled) setBooting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setToken(null);
|
setToken(null);
|
||||||
if (!cancelled) setAuthed(false);
|
if (!cancelled) setAuthed(false);
|
||||||
if (!cancelled) setBooting(false);
|
if (!cancelled) setBooting(false);
|
||||||
@@ -867,6 +883,7 @@ export function App() {
|
|||||||
|
|
||||||
async function onAuthed(payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) {
|
async function onAuthed(payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) {
|
||||||
setToken(payload.token, payload.remember ?? true);
|
setToken(payload.token, payload.remember ?? true);
|
||||||
|
setSessionInvalidated(false);
|
||||||
setUser(payload.user);
|
setUser(payload.user);
|
||||||
setTeam(payload.team);
|
setTeam(payload.team);
|
||||||
setRole(payload.role || "");
|
setRole(payload.role || "");
|
||||||
@@ -901,9 +918,38 @@ export function App() {
|
|||||||
window.history.replaceState(null, "", "/login");
|
window.history.replaceState(null, "", "/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function returnToLoginAfterInvalidation() {
|
||||||
|
setToken(null);
|
||||||
|
setSessionInvalidated(false);
|
||||||
|
setAuthed(false);
|
||||||
|
setBooting(false);
|
||||||
|
setUser(null);
|
||||||
|
setTeam(null);
|
||||||
|
setRole("");
|
||||||
|
setAuthMode("login");
|
||||||
|
window.history.replaceState(null, "", "/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionInvalidatedModal = (
|
||||||
|
<ConfirmModal
|
||||||
|
open={sessionInvalidated}
|
||||||
|
title="当前账号已在其他设备登录"
|
||||||
|
subtitle="// SESSION REPLACED"
|
||||||
|
icon={<MonitorOff size={16} />}
|
||||||
|
detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。"
|
||||||
|
confirmText="确认并返回登录"
|
||||||
|
dismissable={false}
|
||||||
|
showCancel={false}
|
||||||
|
priority
|
||||||
|
onCancel={() => undefined}
|
||||||
|
onConfirm={returnToLoginAfterInvalidation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Auth gate ----
|
// ---- Auth gate ----
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<AuthScreen
|
<AuthScreen
|
||||||
initialMode={authMode}
|
initialMode={authMode}
|
||||||
onModeChange={(next) => {
|
onModeChange={(next) => {
|
||||||
@@ -912,12 +958,15 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
onAuthed={onAuthed}
|
onAuthed={onAuthed}
|
||||||
/>
|
/>
|
||||||
|
{sessionInvalidatedModal}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。
|
// 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。
|
||||||
if (!booting && user && route.admin !== undefined && user.is_platform_admin) {
|
if (!booting && user && route.admin !== undefined && user.is_platform_admin) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<AdminApp
|
<AdminApp
|
||||||
section={route.admin}
|
section={route.admin}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -926,17 +975,22 @@ export function App() {
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
logout={logout}
|
logout={logout}
|
||||||
/>
|
/>
|
||||||
|
{sessionInvalidatedModal}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booting || !user || !team) {
|
if (booting || !user || !team) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SystemLoading
|
<SystemLoading
|
||||||
variant="fullscreen"
|
variant="fullscreen"
|
||||||
title="正在进入工作台"
|
title="正在进入工作台"
|
||||||
description="正在同步账号与团队数据,请稍候。"
|
description="正在同步账号与团队数据,请稍候。"
|
||||||
icon="layoutDashboard"
|
icon="layoutDashboard"
|
||||||
/>
|
/>
|
||||||
|
{sessionInvalidatedModal}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1303,6 +1357,7 @@ export function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
|
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -1348,5 +1403,7 @@ export function App() {
|
|||||||
canManageBilling={isOwner}
|
canManageBilling={isOwner}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{sessionInvalidatedModal}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
|||||||
const TOKEN_KEY = "airshelf_token";
|
const TOKEN_KEY = "airshelf_token";
|
||||||
const REMEMBER_KEY = "airshelf_remember"; // { username, expireAt } · 勾选「记住我 7 天」时写
|
const REMEMBER_KEY = "airshelf_remember"; // { username, expireAt } · 勾选「记住我 7 天」时写
|
||||||
const REMEMBER_DAYS = 7;
|
const REMEMBER_DAYS = 7;
|
||||||
|
export const AUTH_INVALIDATED_EVENT = "airshelf:auth-invalidated";
|
||||||
|
let authInvalidationNotified = false;
|
||||||
const AUTH_MISSING_MESSAGES = new Set([
|
const AUTH_MISSING_MESSAGES = new Set([
|
||||||
"身份认证信息未提供。",
|
"身份认证信息未提供。",
|
||||||
"Authentication credentials were not provided."
|
"Authentication credentials were not provided."
|
||||||
@@ -159,10 +161,17 @@ export function setToken(token: string | null, remember = true) {
|
|||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
authInvalidationNotified = false;
|
||||||
if (remember) localStorage.setItem(TOKEN_KEY, token);
|
if (remember) localStorage.setItem(TOKEN_KEY, token);
|
||||||
else sessionStorage.setItem(TOKEN_KEY, token);
|
else sessionStorage.setItem(TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyAuthInvalidated() {
|
||||||
|
if (authInvalidationNotified || typeof window === "undefined") return;
|
||||||
|
authInvalidationNotified = true;
|
||||||
|
window.dispatchEvent(new CustomEvent(AUTH_INVALIDATED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
@@ -173,6 +182,9 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// 已携带凭证却收到 401,说明 Token 已被服务端吊销。先通知应用壳弹出
|
||||||
|
// 单设备登录提示,再保留原始错误给具体调用方处理。
|
||||||
|
if (response.status === 401 && token && path !== "/api/auth/logout/") notifyAuthInvalidated();
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
|
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
|
||||||
let message = text || `${response.status} ${response.statusText}`;
|
let message = text || `${response.status} ${response.statusText}`;
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true }: {
|
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true, showCancel = true, priority = false }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
detail: ReactNode;
|
detail: ReactNode;
|
||||||
@@ -145,18 +145,22 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "",
|
|||||||
onConfirm: () => void | Promise<unknown>;
|
onConfirm: () => void | Promise<unknown>;
|
||||||
/** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */
|
/** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */
|
||||||
dismissable?: boolean;
|
dismissable?: boolean;
|
||||||
|
/** 强制确认场景可隐藏取消按钮;默认显示,保持现有弹窗行为。 */
|
||||||
|
showCancel?: boolean;
|
||||||
|
/** 系统级强制提示置于灯箱、抽屉和普通确认框之上。 */
|
||||||
|
priority?: boolean;
|
||||||
}) {
|
}) {
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
const { mounted, show } = useOverlayTransition(open, onCancel);
|
const { mounted, show } = useOverlayTransition(open, onCancel);
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
|
<div className={`modal-bg${priority ? " modal-priority" : ""}${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
|
||||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal" role="alertdialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}{subtitle ? <span>{subtitle}</span> : null}</div></div>
|
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}{subtitle ? <span>{subtitle}</span> : null}</div></div>
|
||||||
<div className="modal-b">{detail}</div>
|
<div className="modal-b">{detail}</div>
|
||||||
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
<div className="modal-f">{showCancel ? <button className="btn" type="button" onClick={onCancel}>取消</button> : null}<button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -2598,6 +2598,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
transition: opacity .2s ease, visibility .2s ease;
|
transition: opacity .2s ease, visibility .2s ease;
|
||||||
}
|
}
|
||||||
.modal-bg.show { opacity: 1; visibility: visible; pointer-events: auto; }
|
.modal-bg.show { opacity: 1; visibility: visible; pointer-events: auto; }
|
||||||
|
.modal-bg.modal-priority { z-index: 11000; }
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
|
|||||||
@@ -397,6 +397,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 卖点确认与剧情方向共用追问容器,不能在容器内再次计算宽度和左缩进。 */
|
||||||
|
.omni-elicit-slot .omni-selling-point-card {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.omni-strategy-card {
|
.omni-strategy-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-top: 3px solid var(--klein);
|
border-top: 3px solid var(--klein);
|
||||||
@@ -1471,8 +1477,8 @@
|
|||||||
/* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */
|
/* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */
|
||||||
.omni-direction-card {
|
.omni-direction-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: min(760px, calc(100% - 44px));
|
width: 100%;
|
||||||
margin: 0 0 24px 44px;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
Reference in New Issue
Block a user