/** * AKSRT Admin API Client * * Communicates with the WordPress REST API at /wp-json/aksrt/v1 * (or index.php?rest_route=/aksrt/v1 when pretty permalinks are off). * Methods are organized into domain modules while the client keeps the shared * request pipeline and schema-domain normalization contract. */ import { apiMethods } from './modules/index.js' const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1' const NONCE = window.aksrtAdminData?.nonce || '' function createIdempotencyKey() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') { throw new Error('当前浏览器无法生成安全操作标识') } const bytes = new Uint8Array(16) crypto.getRandomValues(bytes) bytes[6] = (bytes[6] & 0x0f) | 0x40 bytes[8] = (bytes[8] & 0x3f) | 0x80 const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` } function normalizeDomainKey(domain) { return String(domain || 'global').trim().replace(/-/g, '_') } /** * Join REST base + endpoint correctly for both pretty and plain permalinks. * * Pretty: https://site/wp-json/aksrt/v1 + /settings/schema/global/common * → https://site/wp-json/aksrt/v1/settings/schema/global/common * * Plain: https://site/index.php?rest_route=/aksrt/v1 + /settings/schema/global/common * → https://site/index.php?rest_route=/aksrt/v1/settings/schema/global/common * * @param {string} baseUrl * @param {string} endpoint path starting with /, may include ?query * @returns {string} */ function joinRestUrl(baseUrl, endpoint) { let path = endpoint == null || endpoint === '' ? '/' : String(endpoint) let extraQuery = '' const qIdx = path.indexOf('?') if (qIdx !== -1) { extraQuery = path.slice(qIdx + 1) path = path.slice(0, qIdx) } if (!path.startsWith('/')) { path = '/' + path } const base = String(baseUrl || '').trim() || '/wp-json/aksrt/v1' // Plain permalinks: rest_route query mode if (base.includes('rest_route=')) { let u try { u = new URL(base, typeof window !== 'undefined' ? window.location.origin : 'https://localhost') } catch { // Fallback: naive append (should not hit with valid rest_url) return base.replace(/\/?$/, '') + path + (extraQuery ? (base.includes('?') ? '&' : '?') + extraQuery : '') } const baseRoute = (u.searchParams.get('rest_route') || '/aksrt/v1').replace(/\/$/, '') || '/aksrt/v1' let fullRoute if (path === '/') { fullRoute = baseRoute } else if (path.startsWith('/aksrt/')) { fullRoute = path } else { fullRoute = baseRoute + path } u.searchParams.set('rest_route', fullRoute) if (extraQuery) { const extra = new URLSearchParams(extraQuery) extra.forEach((v, k) => { u.searchParams.set(k, v) }) } return u.toString() } // Pretty /wp-json mode const root = base.replace(/\/$/, '') let url = root + (path === '/' ? '' : path) if (extraQuery) { url += (url.includes('?') ? '&' : '?') + extraQuery } return url } class ApiClient { constructor() { this.baseUrl = API_ROOT this.pendingIdempotencyKeys = new Map() } normalizeDomainKey(domain) { return normalizeDomainKey(domain) } async request(endpointOrConfig, options = {}) { // Dual-signature support: // request('/users/1/restrictions') // request({ url: '/aksrt/v1/users/1/restrictions', method: 'GET' }) let endpoint, method, headers, params, body, rest if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) { const cfg = endpointOrConfig endpoint = cfg.url || cfg.endpoint || '' method = cfg.method || 'GET' params = cfg.params body = cfg.data headers = cfg.headers rest = { ...cfg } delete rest.url; delete rest.endpoint; delete rest.method delete rest.params; delete rest.data; delete rest.headers } else { endpoint = endpointOrConfig method = options.method || 'GET' params = options.params body = options.body !== undefined ? options.body : options.data headers = options.headers rest = { ...options } delete rest.method; delete rest.params; delete rest.body delete rest.data; delete rest.headers } if (endpoint.startsWith('/wp-json/aksrt/v1')) { endpoint = endpoint.replace('/wp-json/aksrt/v1', '') || '/' } else if (endpoint.startsWith('/aksrt/v1')) { endpoint = endpoint.replace('/aksrt/v1', '') || '/' } let url = joinRestUrl(this.baseUrl, endpoint) if (params && typeof params === 'object') { const qs = new URLSearchParams(params).toString() if (qs) { url += (url.includes('?') ? '&' : '?') + qs } } let finalBody = body if (body !== undefined && typeof body === 'object' && !(body instanceof FormData)) { finalBody = JSON.stringify(body) } const isWrite = method !== 'GET' && method !== 'HEAD' const suppliedKey = headers?.['Idempotency-Key'] || headers?.['idempotency-key'] || '' const idempotencySignature = isWrite ? `${method}:${url}:${finalBody ?? ''}` : '' const idempotencyKey = isWrite && !suppliedKey ? (this.pendingIdempotencyKeys.get(idempotencySignature) || createIdempotencyKey()) : suppliedKey if (isWrite && !suppliedKey) { this.pendingIdempotencyKeys.set(idempotencySignature, idempotencyKey) } const config = { method, headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...headers, }, ...rest, } if (finalBody !== undefined && method !== 'GET') { config.body = finalBody } try { const response = await fetch(url, config) const text = await response.text() let data = {} try { data = text ? JSON.parse(text) : {} } catch { throw new Error( `REST 返回非 JSON,endpoint=${endpoint},status=${response.status},body=${text.slice(0, 160)}` ) } if (!response.ok || data.success === false) { const msg = data.message || data.data?.message || `HTTP ${response.status}` if (response.status === 403 || response.status === 401) { console.warn(`[API Auth] ${endpoint}: ${msg} — 可能需要刷新页面重新登录`) } throw new Error(msg) } if (idempotencySignature !== '') { this.pendingIdempotencyKeys.delete(idempotencySignature) } return data } catch (error) { console.error(`[API Error] ${endpoint}:`, error) throw error } } } Object.assign(ApiClient.prototype, apiMethods) export { normalizeDomainKey, joinRestUrl } export default new ApiClient()