const API_BASE = (import.meta.env.VITE_LISGLOSIPS_API_BASE || '/api/v2').replace(/\/$/, ''); const ACCESS_TOKEN_KEY = 'lisglosips.accessToken'; export function getAccessToken() { return window.localStorage.getItem(ACCESS_TOKEN_KEY); } export function setAccessToken(token) { if (token) { window.localStorage.setItem(ACCESS_TOKEN_KEY, token); } else { window.localStorage.removeItem(ACCESS_TOKEN_KEY); } } export class ApiError extends Error { constructor(message, { status, code } = {}) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; } } const DEFAULT_TIMEOUT_MS = 10_000; async function fetchWithTimeout(path, options = {}) { const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options; const controller = new AbortController(); const abortFromExternal = () => controller.abort(externalSignal?.reason); if (externalSignal) { if (externalSignal.aborted) { abortFromExternal(); } else { externalSignal.addEventListener('abort', abortFromExternal, { once: true }); } } const timeout = window.setTimeout(() => controller.abort('REQUEST_TIMEOUT'), timeoutMs); try { return await fetch(`${API_BASE}${path}`, { ...fetchOptions, signal: controller.signal, }); } catch (error) { if (controller.signal.aborted && !externalSignal?.aborted) { throw new ApiError('请求超过 10 秒未响应,请稍后重试。', { status: 408, code: 'REQUEST_TIMEOUT' }); } throw error; } finally { window.clearTimeout(timeout); externalSignal?.removeEventListener('abort', abortFromExternal); } } async function request(path, options = {}) { const token = window.localStorage.getItem(ACCESS_TOKEN_KEY); const headers = new Headers(options.headers || {}); headers.set('Accept', 'application/json'); if (options.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (token) { headers.set('Authorization', `Bearer ${token}`); } const response = await fetchWithTimeout(path, { ...options, headers, credentials: 'include', }); const contentType = response.headers.get('content-type') || ''; const payload = contentType.includes('application/json') ? await response.json() : await response.text(); if (!response.ok) { throw new ApiError(errorMessage(payload, response.status), { status: response.status, code: typeof payload === 'object' && payload ? payload.code : undefined, }); } return payload; } async function requestBlob(path, options = {}) { const token = window.localStorage.getItem(ACCESS_TOKEN_KEY); const headers = new Headers(options.headers || {}); if (token) { headers.set('Authorization', `Bearer ${token}`); } const response = await fetchWithTimeout(path, { ...options, headers, credentials: 'include', }); if (!response.ok) { const contentType = response.headers.get('content-type') || ''; const payload = contentType.includes('application/json') ? await response.json() : await response.text(); throw new ApiError(errorMessage(payload, response.status), { status: response.status, code: typeof payload === 'object' && payload ? payload.code : undefined, }); } return response.blob(); } function errorMessage(payload, status) { if (payload && typeof payload === 'object') { return payload.message || payload.error || `API request failed with HTTP ${status}.`; } return payload || `API request failed with HTTP ${status}.`; } function jsonBody(value) { return JSON.stringify(value); } function idempotencyKey(scope) { const random = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; return `${scope}:${random}`; } function queryString(params = {}) { const entries = Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '' && value !== 'all'); return entries.length ? `?${new URLSearchParams(Object.fromEntries(entries))}` : ''; } export const api = { captcha: () => request('/auth/captcha'), login: async (body) => { const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) }); setAccessToken(response.accessToken); return response; }, refresh: async () => { const response = await request('/auth/refresh', { method: 'POST' }); setAccessToken(response.accessToken); return response; }, logout: async () => { try { await request('/auth/logout', { method: 'POST' }); } finally { setAccessToken(''); } }, dashboardSummary: (options) => request('/dashboard/summary', options), dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/trends?${new URLSearchParams(params)}`, options), dashboardOverview: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/overview?${new URLSearchParams(params)}`, options), activeCalls: (options) => request('/active-calls', options), hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }), cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`), cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`), recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 50, ...params })}`), recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`), recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`), saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }), qualityRules: () => request('/quality/rules'), createQualityRule: (body) => request('/quality/rules', { method: 'POST', body: jsonBody(body) }), updateQualityRule: (id, body) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }), customers: (options) => request('/customers', options), createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }), updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteCustomer: (id) => request(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }), rechargeCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}/recharges`, { method: 'POST', body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }), }), vendors: (options) => request('/vendors', options), createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }), updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }), rechargeVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}/recharges`, { method: 'POST', body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }), }), customerGateways: (options) => request('/customer-gateways', options), createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }), updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }), vendorGateways: (options) => request('/vendor-gateways', options), createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }), updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }), landingLineGroups: (options) => request('/landing-line-groups', options), deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }), recharges: (options) => request('/recharges?take=100', options), users: (options) => request('/users', options), deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }), roles: (options) => request('/roles', options), deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }), auditLogs: (params = {}, options) => request(`/audit-logs${queryString({ take: 100, ...params })}`, options), businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`), createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }), updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }), numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 25, ...params })}`), importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }), numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 25, ...params })}`), importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }), numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 25, ...params })}`), importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }), numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 25, ...params })}`), importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }), }; export function explainApiError(error) { if (error instanceof ApiError && error.code === 'AUTH_CAPTCHA_INVALID') { return '验证码错误或已过期,请重新输入。'; } if (error instanceof ApiError && error.code === 'AUTH_INVALID_CREDENTIALS') { return '用户名、密码或验证码不正确。'; } if (error instanceof ApiError && error.code === 'CUSTOMER_HAS_GATEWAYS') { return '该客户仍有关联客户网关,不能删除。'; } if (error instanceof ApiError && error.code === 'VENDOR_HAS_GATEWAYS') { return '该供应商仍有关联落地网关,不能删除。'; } if (error instanceof ApiError && error.code === 'LINE_GROUP_IN_USE') { return '该落地线路组仍被客户网关使用,不能删除。'; } if (error instanceof ApiError && error.code === 'VENDOR_GATEWAY_IN_LINE_GROUP') { return '该落地网关仍被落地线路组引用,不能删除。'; } if (error instanceof ApiError && error.code === 'ROLE_HAS_USERS') { return '该角色仍有关联用户,不能删除。'; } if (error instanceof ApiError && error.code === 'BUILT_IN_ROLE_PROTECTED') { return '系统内置角色受保护,不能删除或修改关键权限。'; } if (error instanceof ApiError && error.code === 'BUSINESS_PREFIX_IN_USE') { return '该业务前缀仍被客户网关使用,不能删除。'; } if (error instanceof ApiError && error.code === 'BUSINESS_PREFIX_INVALID') { return '业务前缀只能包含 1-32 位英文或数字。'; } if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { return '登录状态已失效,请重新登录。'; } if (error instanceof ApiError && error.status === 429) { return '登录尝试过于频繁,请稍后再试。'; } return error instanceof Error ? error.message : '请求失败,请稍后重试。'; }