261 lines
13 KiB
JavaScript
261 lines
13 KiB
JavaScript
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 : '请求失败,请稍后重试。';
|
|
}
|