2026-04-08 01:07:10 +08:00
|
|
|
/**
|
|
|
|
|
* SimApi Core — 纯 TypeScript 核心,无框架依赖
|
|
|
|
|
*
|
|
|
|
|
* 支持所有 JS/TS 环境(Node.js、浏览器、小程序等)。
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* import { SimApiCore } from '@simcu/simapi'
|
|
|
|
|
* const api = new SimApiCore()
|
|
|
|
|
* api.setEndpoints({ default: 'https://api.example.com' })
|
|
|
|
|
* const res = await api.query('/users/list', { page: 1 })
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
2026-05-03 23:11:55 +08:00
|
|
|
type SimApiVersions,
|
|
|
|
|
type SimApiAuthConfig,
|
|
|
|
|
type SimApiApiConfig,
|
|
|
|
|
type SimApiOptions,
|
|
|
|
|
type SimApiBaseResponse,
|
2026-04-08 01:07:10 +08:00
|
|
|
} from './types'
|
|
|
|
|
|
|
|
|
|
export type {
|
2026-05-03 23:11:55 +08:00
|
|
|
SimApiVersions,
|
|
|
|
|
SimApiAuthConfig,
|
|
|
|
|
SimApiApiConfig,
|
|
|
|
|
SimApiOptions,
|
|
|
|
|
SimApiBaseResponse,
|
2026-04-08 01:07:10 +08:00
|
|
|
} from './types'
|
|
|
|
|
|
2026-04-08 01:33:23 +08:00
|
|
|
declare const SimApiVersion: string;
|
2026-04-23 20:16:38 +08:00
|
|
|
declare const AppVersion: string;
|
2026-05-03 23:11:55 +08:00
|
|
|
|
2026-04-08 01:07:10 +08:00
|
|
|
// ── Helper: Fetch with Timeout ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function fetchWithTimeout(
|
2026-05-03 23:11:55 +08:00
|
|
|
url: string,
|
|
|
|
|
options: RequestInit,
|
|
|
|
|
timeout: number = 10000
|
2026-04-08 01:07:10 +08:00
|
|
|
): Promise<Response> {
|
2026-05-03 23:11:55 +08:00
|
|
|
return Promise.race([
|
|
|
|
|
fetch(url, options),
|
|
|
|
|
new Promise<never>((_, reject) =>
|
|
|
|
|
setTimeout(() => reject(new Error(`Request timeout after ${timeout}ms`)), timeout)
|
|
|
|
|
),
|
|
|
|
|
])
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helper: Fetch POST with JSON body ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function fetchPost<T = any>(
|
2026-05-03 23:11:55 +08:00
|
|
|
url: string,
|
|
|
|
|
body: any,
|
|
|
|
|
headers: Record<string, string>,
|
|
|
|
|
timeout: number
|
2026-04-08 01:07:10 +08:00
|
|
|
): Promise<SimApiBaseResponse<T>> {
|
2026-05-03 23:11:55 +08:00
|
|
|
const options: RequestInit = {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: headers as HeadersInit,
|
|
|
|
|
body: body instanceof FormData ? body : JSON.stringify(body),
|
|
|
|
|
credentials: 'omit', // 从不发送 Cookie
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
const response = await fetchWithTimeout(url, options, timeout)
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorData = await response.json().catch(() => ({}))
|
|
|
|
|
throw {
|
|
|
|
|
status: response.status,
|
|
|
|
|
statusText: response.statusText,
|
|
|
|
|
data: errorData,
|
|
|
|
|
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.json()
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── SimApiCore ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export class SimApiCore {
|
2026-05-04 03:32:05 +08:00
|
|
|
private debug: boolean = true
|
|
|
|
|
private auth: SimApiAuthConfig = {
|
2026-05-03 23:11:55 +08:00
|
|
|
token_name: 'simapi-auth-token',
|
2026-05-04 23:28:35 +08:00
|
|
|
check_url: '/user/info',
|
2026-05-03 23:11:55 +08:00
|
|
|
logout_url: '/auth/logout',
|
|
|
|
|
login_url: '/auth/login',
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
2026-08-10 21:01:21 +08:00
|
|
|
private webConfig: Map<string, Record<string, object>> = new Map();
|
2026-04-08 01:07:10 +08:00
|
|
|
|
2026-05-04 03:32:05 +08:00
|
|
|
private api: SimApiApiConfig = {
|
2026-05-03 23:11:55 +08:00
|
|
|
endpoints: {default: ''},
|
|
|
|
|
defaultEndpoint: 'default',
|
|
|
|
|
businessCallback: {
|
2026-05-04 16:57:08 +08:00
|
|
|
401: () => this.removeToken(),
|
2026-05-03 23:11:55 +08:00
|
|
|
common: () => {
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
responseCallback: {
|
|
|
|
|
success: (response: any) => response,
|
|
|
|
|
error: (_err: any) => {
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
timeout: 10000,
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
constructor(options?: SimApiOptions) {
|
|
|
|
|
if (options) {
|
|
|
|
|
this.configure(options)
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
/**
|
2026-05-04 19:56:48 +08:00
|
|
|
* 从 JSON 文件加载配置
|
2026-05-03 23:11:55 +08:00
|
|
|
*
|
2026-05-04 19:56:48 +08:00
|
|
|
* 部署时替换 config.json 即可切换环境,无需重新构建。
|
|
|
|
|
*
|
|
|
|
|
* @param file - 配置文件路径,默认 'config.json'
|
|
|
|
|
* @example
|
|
|
|
|
* await api.loadFromFile()
|
|
|
|
|
* await api.loadFromFile('/env/prod.json')
|
2026-05-03 23:11:55 +08:00
|
|
|
*/
|
2026-05-04 19:56:48 +08:00
|
|
|
async loadFromFile(file: string = 'config.json'): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(file)
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
this.logDebug(`配置文件 ${file} 加载失败: HTTP ${resp.status}`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const config = await resp.json()
|
|
|
|
|
if (config.debug !== undefined) {
|
|
|
|
|
this.debug = config.debug
|
|
|
|
|
}
|
|
|
|
|
if (config.endpoints) {
|
2026-05-04 23:28:35 +08:00
|
|
|
this.api.endpoints = {...this.api.endpoints, ...config.endpoints}
|
2026-05-04 19:56:48 +08:00
|
|
|
}
|
|
|
|
|
if (config.defaultEndpoint) {
|
|
|
|
|
this.api.defaultEndpoint = config.defaultEndpoint
|
|
|
|
|
}
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.logDebug(`配置文件 ${file} 加载失败: ${err.message}`)
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
configure(options: SimApiOptions): void {
|
|
|
|
|
if (options.debug !== undefined) {
|
|
|
|
|
this.debug = options.debug
|
|
|
|
|
}
|
|
|
|
|
if (options.auth) {
|
|
|
|
|
this.auth = {...this.auth, ...options.auth}
|
|
|
|
|
}
|
|
|
|
|
if (options.api) {
|
|
|
|
|
this.api = {
|
|
|
|
|
...this.api,
|
|
|
|
|
...options.api,
|
|
|
|
|
endpoints: {...this.api.endpoints, ...(options.api.endpoints ?? {})},
|
|
|
|
|
businessCallback: {...this.api.businessCallback, ...(options.api.businessCallback ?? {})},
|
|
|
|
|
responseCallback: {...this.api.responseCallback, ...(options.api.responseCallback ?? {})},
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 03:32:05 +08:00
|
|
|
get isDebug() {
|
|
|
|
|
return this.debug
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDebug(debug: boolean) {
|
|
|
|
|
this.debug = debug;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
setEndpoints(endpoints: { [name: string]: string }): void {
|
|
|
|
|
this.api.endpoints = {...this.api.endpoints, ...endpoints}
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
getEndpoint(name?: string): string {
|
|
|
|
|
return this.api.endpoints[name ?? this.api.defaultEndpoint] ?? ''
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
setBusinessCallback(code: number | string, callback: (data: any) => void): void {
|
|
|
|
|
this.api.businessCallback[code] = callback
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
getToken(): string {
|
2026-05-04 16:57:08 +08:00
|
|
|
const name = this.auth.token_name
|
|
|
|
|
const match = document.cookie.match(new RegExp(`(?:^|;)\\s?${name}=([^;]+)`))
|
|
|
|
|
return match ? match[1] : ''
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setToken(token: string): void {
|
2026-05-04 16:57:08 +08:00
|
|
|
const name = this.auth.token_name
|
2026-05-05 07:16:29 +08:00
|
|
|
document.cookie = `${name}=${token}; path=/; max-age=315360000; secure; samesite=none`
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeToken(): void {
|
2026-05-04 16:57:08 +08:00
|
|
|
const name = this.auth.token_name
|
2026-05-05 07:16:29 +08:00
|
|
|
document.cookie = `${name}=; path=/; max-age=0; secure; samesite=none`
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 16:57:08 +08:00
|
|
|
|
2026-05-03 23:11:55 +08:00
|
|
|
genS4(): string {
|
|
|
|
|
return (((1 + Math.random()) * 0x10000 * Date.parse(new Date().toString())) | 0)
|
|
|
|
|
.toString(16)
|
|
|
|
|
.substring(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 日志工具(仅在 debug 模式下输出)
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* api.logDebug('用户登录', { id: 1, name: 'test' })
|
|
|
|
|
* api.logDebug('请求开始', uri, params)
|
|
|
|
|
*/
|
|
|
|
|
logDebug(...args: any[]): void {
|
|
|
|
|
if (!this.debug) return
|
|
|
|
|
console.log('[DEBUG]', ...args)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 20:46:40 +08:00
|
|
|
async getConfig(reload: boolean = false, endpointName: string = 'default'): Promise<Record<string, object>> {
|
2026-08-10 20:37:23 +08:00
|
|
|
if (!this.webConfig.has(endpointName) || reload) {
|
|
|
|
|
const versions: SimApiVersions = {
|
|
|
|
|
uiApp: typeof AppVersion === 'undefined' ? "0.0.0-develop" : AppVersion,
|
|
|
|
|
uiSimApi: typeof SimApiVersion === 'undefined' ? "0.0.0-develop" : SimApiVersion,
|
|
|
|
|
apiApp: '0.0.0',
|
|
|
|
|
apiSimApi: '0.0.0',
|
|
|
|
|
apiAppFull: '0.0.0',
|
|
|
|
|
apiSimApiFull: '0.0.0',
|
|
|
|
|
};
|
|
|
|
|
const resp = await this.query<any>('/config', {}, endpointName)
|
2026-05-03 23:11:55 +08:00
|
|
|
if (resp?.data) {
|
2026-08-10 21:03:44 +08:00
|
|
|
const d = resp.data.Versions;
|
2026-05-03 23:11:55 +08:00
|
|
|
versions.apiApp = d.App?.split('+')[0] ?? '0.0.0';
|
|
|
|
|
versions.apiSimApi = d.SimApi?.split('+')[0] ?? '0.0.0';
|
|
|
|
|
versions.apiAppFull = d.App ?? '0.0.0';
|
|
|
|
|
versions.apiSimApiFull = d.SimApi ?? '0.0.0';
|
|
|
|
|
if (this.debug) {
|
|
|
|
|
console.log(`UI主应用版本: ${versions.uiApp}\nUISimApi版本: ${versions.uiSimApi}\nAPI主应用版本: ${versions.apiApp}\nAPISimApi版本: ${versions.apiSimApi}`)
|
|
|
|
|
}
|
2026-08-10 21:15:53 +08:00
|
|
|
const conf = JSON.parse(JSON.stringify(resp.data));
|
|
|
|
|
conf.Versions = versions;
|
|
|
|
|
this.webConfig.set(endpointName, conf);
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 20:37:23 +08:00
|
|
|
return this.webConfig.get(endpointName)!;
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async query<T = any>(
|
|
|
|
|
uri: string,
|
|
|
|
|
params: any = {},
|
|
|
|
|
endpointKey?: string,
|
2026-05-04 02:27:43 +08:00
|
|
|
extraHeaders?: Record<string, string>,
|
|
|
|
|
selfHandleError: boolean = false
|
2026-05-03 23:11:55 +08:00
|
|
|
): Promise<SimApiBaseResponse<T>> {
|
|
|
|
|
const headers: Record<string, string> = {...extraHeaders, ...{}}
|
|
|
|
|
const queryId = this.genS4()
|
|
|
|
|
|
|
|
|
|
if (!(params instanceof FormData)) {
|
|
|
|
|
headers['Content-Type'] = 'application/json'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const token = this.getToken()
|
|
|
|
|
if (token) {
|
|
|
|
|
headers['Token'] = token
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.debug) {
|
|
|
|
|
headers['Query-Id'] = queryId
|
2026-05-04 16:57:08 +08:00
|
|
|
console.log('[REQUEST*]', queryId, '->', uri, 'AUTH:', this.getToken())
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = this.getEndpoint(endpointKey) + uri
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const respData = await fetchPost<T>(
|
|
|
|
|
url,
|
|
|
|
|
params,
|
|
|
|
|
headers,
|
|
|
|
|
this.api.timeout ?? 10000
|
|
|
|
|
)
|
|
|
|
|
if (this.debug) {
|
|
|
|
|
console.log('[RESPONSE]', queryId, '->', respData)
|
|
|
|
|
}
|
|
|
|
|
const processedData = this.api.responseCallback.success(respData) as SimApiBaseResponse<T>
|
|
|
|
|
|
|
|
|
|
// 业务回调处理
|
2026-05-04 02:27:43 +08:00
|
|
|
if (!selfHandleError) {
|
|
|
|
|
if (this.api.businessCallback.hasOwnProperty(processedData.code)) {
|
|
|
|
|
this.api.businessCallback[processedData.code](processedData)
|
|
|
|
|
} else if (this.api.businessCallback['common'] && processedData.code !== 200) {
|
|
|
|
|
this.api.businessCallback['common'](processedData)
|
|
|
|
|
}
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// code != 200 时抛出业务错误
|
|
|
|
|
if (processedData.code !== 200) {
|
|
|
|
|
throw processedData
|
|
|
|
|
}
|
|
|
|
|
return processedData
|
|
|
|
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
if (this.debug) {
|
|
|
|
|
console.log('[RESPONSE]', queryId, '->', error)
|
|
|
|
|
}
|
|
|
|
|
// 网络/HTTP 错误:包装成标准响应格式抛出
|
|
|
|
|
if (!error?.code) {
|
|
|
|
|
this.api.responseCallback.error(error)
|
|
|
|
|
throw {
|
|
|
|
|
code: -1,
|
|
|
|
|
message: error?.message || '网络错误',
|
|
|
|
|
data: error,
|
|
|
|
|
} as SimApiBaseResponse<T>
|
|
|
|
|
}
|
|
|
|
|
// 业务错误直接抛出
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async login(request: Record<string, any>): Promise<SimApiBaseResponse<string>> {
|
|
|
|
|
const result = await this.query<string>(this.auth.login_url, request)
|
|
|
|
|
if (result?.data) {
|
|
|
|
|
this.setToken(result.data)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async logout(url?: string | null): Promise<any> {
|
|
|
|
|
if (url !== null) {
|
2026-05-04 00:15:23 +08:00
|
|
|
this.query(url ?? this.auth.logout_url).catch(() => true)
|
2026-05-03 23:11:55 +08:00
|
|
|
}
|
|
|
|
|
this.removeToken()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 23:28:35 +08:00
|
|
|
async checkLogin(url?: string | null): Promise<any> {
|
|
|
|
|
return this.query(url ?? this.auth.check_url);
|
2026-04-08 01:07:10 +08:00
|
|
|
}
|
|
|
|
|
}
|