diff --git a/admin/src/App.vue b/admin/src/App.vue index 23ac712..eb3c12f 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -1,6 +1,7 @@ diff --git a/admin/src/api.ts b/admin/src/api.ts index ce133c2..97441f5 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -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 | null = null; + +export async function adminPasswordLogin(input: { + tenantCode: string; loginName: string; password: string; +}) { + return publicRequest('/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(session, '/stores'); } @@ -317,14 +348,22 @@ async function request( 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( return payload.data as T; } +async function publicRequest(path: string, options: RequestInit): Promise { + 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('/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 } diff --git a/admin/src/components/AdminLoginPanel.vue b/admin/src/components/AdminLoginPanel.vue new file mode 100644 index 0000000..0a709fb --- /dev/null +++ b/admin/src/components/AdminLoginPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/admin/src/components/MembersStaffPanel.vue b/admin/src/components/MembersStaffPanel.vue index 0d25507..1a7889a 100644 --- a/admin/src/components/MembersStaffPanel.vue +++ b/admin/src/components/MembersStaffPanel.vue @@ -47,7 +47,7 @@ - +
@@ -72,14 +72,26 @@ {{ item.label }} + + + + + + + + + 至少 12 位,同时包含字母、数字和特殊字符。 + + +