feat(M08-D): 补后台登录与可撤销会话

This commit is contained in:
Codex
2026-08-10 13:54:03 +08:00
parent 93d83e81b9
commit 05110087d1
21 changed files with 1059 additions and 48 deletions
+93 -5
View File
@@ -1,6 +1,9 @@
import type {
Advertisement,
AdvertisementInput,
AdminIdentity,
AdminSessionPayload,
AdminAccess,
AuditLog,
CleaningSettlement,
CleaningSettlementDetail,
@@ -66,6 +69,34 @@ export interface ApiSession {
token: string;
}
const ACCESS_TOKEN_KEY = 'qipai.admin.token';
const REFRESH_TOKEN_KEY = 'qipai.admin.refreshToken';
let refreshInFlight: Promise<AdminSessionPayload> | null = null;
export async function adminPasswordLogin(input: {
tenantCode: string; loginName: string; password: string;
}) {
return publicRequest<AdminSessionPayload>('/auth/login', {
method: 'POST', body: JSON.stringify(input)
});
}
export function getAdminMe(session: ApiSession) {
return request<{ user: AdminIdentity; access: AdminAccess }>(session, '/auth/me');
}
export function logoutAdmin(session: ApiSession) {
return request<{ revoked: boolean }>(session, '/auth/logout', { method: 'POST' });
}
export function setAdminCredential(session: ApiSession, userId: string, input: {
loginName: string; password: string;
}) {
return request<{ userId: string; configured: boolean }>(session,
`/auth/credentials/${encodeURIComponent(userId)}`,
{ method: 'PUT', body: JSON.stringify(input) });
}
export function listManagedStores(session: ApiSession) {
return request<ManagedStore[]>(session, '/stores');
}
@@ -317,14 +348,22 @@ async function request<T>(
if (!session.token.trim()) {
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
}
const headers = new Headers(options.headers);
headers.set('authorization', `Bearer ${session.token.trim()}`);
if (options.body && !headers.has('content-type')) {
headers.set('content-type', 'application/json');
let response = await authorizedFetch(path, options, session.token.trim());
if (response.status === 401 && localStorage.getItem(REFRESH_TOKEN_KEY)) {
try {
const refreshed = await refreshAccessToken();
response = await authorizedFetch(path, options, refreshed.accessToken);
} catch {
clearStoredSession();
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
}
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 0) {
if (response.status === 401) {
clearStoredSession();
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
}
throw new ApiError(
String(payload.code || `HTTP_${response.status}`),
String(payload.message || '请求失败'),
@@ -334,6 +373,55 @@ async function request<T>(
return payload.data as T;
}
async function publicRequest<T>(path: string, options: RequestInit): Promise<T> {
const headers = new Headers(options.headers);
headers.set('content-type', 'application/json');
headers.set('x-request-id', requestId());
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 0) throw new ApiError(
String(payload.code || `HTTP_${response.status}`),
String(payload.message || '请求失败'), String(payload.traceId || '')
);
return payload.data as T;
}
async function authorizedFetch(path: string, options: RequestInit, token: string) {
const headers = new Headers(options.headers);
headers.set('authorization', `Bearer ${token}`);
headers.set('x-request-id', requestId());
if (options.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
return fetch(`${API_BASE}${path}`, { ...options, headers });
}
async function refreshAccessToken() {
if (!refreshInFlight) {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '';
refreshInFlight = publicRequest<AdminSessionPayload>('/auth/refresh', {
method: 'POST', body: JSON.stringify({ refreshToken })
}).then((payload) => {
storeAdminSession(payload);
window.dispatchEvent(new CustomEvent('qipai:admin-refreshed', { detail: payload }));
return payload;
}).finally(() => { refreshInFlight = null; });
}
return refreshInFlight;
}
export function storeAdminSession(payload: AdminSessionPayload) {
localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken);
}
export function clearStoredSession() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
function requestId() {
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function listCleaningTasks(
session: ApiSession,
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }