mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 23:36:55 +08:00
merge dev
This commit is contained in:
@@ -11,8 +11,9 @@ import type {
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
} from '../types';
|
||||
import { CryptoService } from './crypto';
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
@@ -33,51 +34,51 @@ function getAuthHeaders(): Record<string, string> {
|
||||
export const api = {
|
||||
// AI交易员管理接口
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/my-traders`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取trader列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取公开的交易员列表(无需认证)
|
||||
async getPublicTraders(): Promise<any[]> {
|
||||
const res = await fetch(`${API_BASE}/traders`)
|
||||
const res = await httpClient.get(`${API_BASE}/traders`)
|
||||
if (!res.ok) throw new Error('获取公开trader列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/traders`,
|
||||
request,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('创建交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async deleteTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.delete(
|
||||
`${API_BASE}/traders/${traderId}`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('删除交易员失败')
|
||||
},
|
||||
|
||||
async startTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/traders/${traderId}/start`,
|
||||
undefined,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('启动交易员失败')
|
||||
},
|
||||
|
||||
async stopTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/traders/${traderId}/stop`,
|
||||
undefined,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('停止交易员失败')
|
||||
},
|
||||
|
||||
@@ -85,18 +86,19 @@ export const api = {
|
||||
traderId: string,
|
||||
customPrompt: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ custom_prompt: customPrompt }),
|
||||
})
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/traders/${traderId}/prompt`,
|
||||
{ custom_prompt: customPrompt },
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新自定义策略失败')
|
||||
},
|
||||
|
||||
async getTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(
|
||||
`${API_BASE}/traders/${traderId}/config`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('获取交易员配置失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -105,52 +107,66 @@ export const api = {
|
||||
traderId: string,
|
||||
request: CreateTraderRequest
|
||||
): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/traders/${traderId}`,
|
||||
request,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// AI模型配置接口
|
||||
async getModelConfigs(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取模型配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取系统支持的AI模型列表(无需认证)
|
||||
async getSupportedModels(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-models`)
|
||||
const res = await httpClient.get(`${API_BASE}/supported-models`)
|
||||
if (!res.ok) throw new Error('获取支持的模型失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/models`,
|
||||
encryptedPayload,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新模型配置失败')
|
||||
},
|
||||
|
||||
// 交易所配置接口
|
||||
async getExchangeConfigs(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取交易所配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取系统支持的交易所列表(无需认证)
|
||||
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-exchanges`)
|
||||
const res = await httpClient.get(`${API_BASE}/supported-exchanges`)
|
||||
if (!res.ok) throw new Error('获取支持的交易所失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -158,46 +174,42 @@ export const api = {
|
||||
async updateExchangeConfigs(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/exchanges`,
|
||||
request,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 使用加密传输更新交易所配置
|
||||
async updateExchangeConfigsEncrypted(request: UpdateExchangeConfigRequest): Promise<void> {
|
||||
// 从系统配置获取公钥
|
||||
const configRes = await fetch(`${API_BASE}/config`);
|
||||
if (!configRes.ok) throw new Error('获取系统配置失败');
|
||||
const config = await configRes.json();
|
||||
|
||||
if (!config.rsa_public_key) {
|
||||
throw new Error('系统未配置RSA公钥,无法使用加密传输');
|
||||
}
|
||||
async updateExchangeConfigsEncrypted(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(config.rsa_public_key);
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || '';
|
||||
const sessionId = sessionStorage.getItem('session_id') || '';
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
);
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const res = await fetch(`${API_BASE}/exchanges/encrypted`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(encryptedPayload),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新交易所配置失败');
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/exchanges`,
|
||||
encryptedPayload,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 获取系统状态(支持trader_id)
|
||||
@@ -205,9 +217,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
: `${API_BASE}/status`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取系统状态失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -217,7 +227,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/account?trader_id=${traderId}`
|
||||
: `${API_BASE}/account`
|
||||
const res = await fetch(url, {
|
||||
const res = await httpClient.request(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
@@ -235,9 +245,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取持仓列表失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -247,21 +255,26 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取决策日志失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取最新决策(支持trader_id)
|
||||
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions/latest`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
// 获取最新决策(支持trader_id和limit参数)
|
||||
async getLatestDecisions(
|
||||
traderId?: string,
|
||||
limit: number = 5
|
||||
): Promise<DecisionRecord[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (traderId) {
|
||||
params.append('trader_id', traderId)
|
||||
}
|
||||
params.append('limit', limit.toString())
|
||||
|
||||
const res = await httpClient.get(
|
||||
`${API_BASE}/decisions/latest?${params}`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('获取最新决策失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -271,9 +284,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取统计信息失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -283,21 +294,15 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取历史数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 批量获取多个交易员的历史数据(无需认证)
|
||||
async getEquityHistoryBatch(traderIds: string[]): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/equity-history-batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ trader_ids: traderIds }),
|
||||
const res = await httpClient.post(`${API_BASE}/equity-history-batch`, {
|
||||
trader_ids: traderIds,
|
||||
})
|
||||
if (!res.ok) throw new Error('获取批量历史数据失败')
|
||||
return res.json()
|
||||
@@ -305,14 +310,14 @@ export const api = {
|
||||
|
||||
// 获取前5名交易员数据(无需认证)
|
||||
async getTopTraders(): Promise<any[]> {
|
||||
const res = await fetch(`${API_BASE}/top-traders`)
|
||||
const res = await httpClient.get(`${API_BASE}/top-traders`)
|
||||
if (!res.ok) throw new Error('获取前5名交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取公开交易员配置(无需认证)
|
||||
async getPublicTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/trader/${traderId}/config`)
|
||||
const res = await httpClient.get(`${API_BASE}/trader/${traderId}/config`)
|
||||
if (!res.ok) throw new Error('获取公开交易员配置失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -322,16 +327,14 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/performance?trader_id=${traderId}`
|
||||
: `${API_BASE}/performance`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取AI学习数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取竞赛数据(无需认证)
|
||||
async getCompetition(): Promise<CompetitionData> {
|
||||
const res = await fetch(`${API_BASE}/competition`)
|
||||
const res = await httpClient.get(`${API_BASE}/competition`)
|
||||
if (!res.ok) throw new Error('获取竞赛数据失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -341,9 +344,10 @@ export const api = {
|
||||
coin_pool_url: string
|
||||
oi_top_url: string
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(
|
||||
`${API_BASE}/user/signal-sources`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('获取用户信号源配置失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -352,26 +356,24 @@ export const api = {
|
||||
coinPoolUrl: string,
|
||||
oiTopUrl: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/user/signal-sources`,
|
||||
{
|
||||
coin_pool_url: coinPoolUrl,
|
||||
oi_top_url: oiTopUrl,
|
||||
}),
|
||||
})
|
||||
},
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败')
|
||||
},
|
||||
|
||||
// 获取服务器IP(需要认证,用于白名单配置)
|
||||
async getServerIP(): Promise<{
|
||||
public_ip: string;
|
||||
message: string;
|
||||
public_ip: string
|
||||
message: string
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/server-ip`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取服务器IP失败');
|
||||
return res.json();
|
||||
const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取服务器IP失败')
|
||||
return res.json()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
370
web/src/lib/apiGuard.test.ts
Normal file
370
web/src/lib/apiGuard.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* PR #669 測試: 防止 null token 導致未授權的 API 調用
|
||||
*
|
||||
* 問題:當用戶未登入時(user/token 為 null),SWR 仍然會使用空 key 發起 API 請求
|
||||
* 修復:在 SWR key 中添加 `user && token` 檢查,當未登入時返回 null,阻止 API 調用
|
||||
*/
|
||||
|
||||
describe('API Guard Logic (PR #669)', () => {
|
||||
/**
|
||||
* 測試 SWR key 生成邏輯
|
||||
* 核心修復:key 必須包含 user && token 檢查
|
||||
*/
|
||||
describe('SWR key generation', () => {
|
||||
it('should return null when user is null', () => {
|
||||
const user = null
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when token is null', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when both user and token are null', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when currentPage is not trader', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage: string = 'competition' // Not 'trader', so key should be null
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when traderId is not set', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = null
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return valid key when all conditions are met', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBe('status-123')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試不同 API 端點的條件邏輯
|
||||
* 所有需要認證的端點都應該檢查 user && token
|
||||
*/
|
||||
describe('multiple API endpoints', () => {
|
||||
it('should guard status API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const statusKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(statusKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard account API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const accountKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `account-${traderId}`
|
||||
: null
|
||||
|
||||
expect(accountKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard positions API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const positionsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `positions-${traderId}`
|
||||
: null
|
||||
|
||||
expect(positionsKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard decisions API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const decisionsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `decisions/latest-${traderId}`
|
||||
: null
|
||||
|
||||
expect(decisionsKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard statistics API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const statsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `statistics-${traderId}`
|
||||
: null
|
||||
|
||||
expect(statsKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should allow all API calls when authenticated', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const statusKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
const accountKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `account-${traderId}`
|
||||
: null
|
||||
const positionsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `positions-${traderId}`
|
||||
: null
|
||||
|
||||
expect(statusKey).toBe('status-123')
|
||||
expect(accountKey).toBe('account-123')
|
||||
expect(positionsKey).toBe('positions-123')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試 EquityChart 組件的條件邏輯
|
||||
* PR #669 同時修復了 EquityChart 中的相同問題
|
||||
*/
|
||||
describe('EquityChart API guard', () => {
|
||||
it('should return null key when user is not authenticated', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
|
||||
const equityKey =
|
||||
user && token && traderId ? `equity-history-${traderId}` : null
|
||||
|
||||
expect(equityKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null key when traderId is missing', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = null
|
||||
|
||||
const equityKey =
|
||||
user && token && traderId ? `equity-history-${traderId}` : null
|
||||
|
||||
expect(equityKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should return valid key when authenticated with traderId', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
|
||||
const equityKey =
|
||||
user && token && traderId ? `equity-history-${traderId}` : null
|
||||
const accountKey =
|
||||
user && token && traderId ? `account-${traderId}` : null
|
||||
|
||||
expect(equityKey).toBe('equity-history-123')
|
||||
expect(accountKey).toBe('account-123')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試邊界情況和特殊值
|
||||
*/
|
||||
describe('edge cases', () => {
|
||||
it('should treat empty string token as falsy', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = ''
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should treat empty string traderId as falsy', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = ''
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle undefined user', () => {
|
||||
const user = undefined
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle undefined token', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = undefined
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle numeric traderId', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = 123 // 數字而非字串
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBe('status-123')
|
||||
})
|
||||
|
||||
it('should handle zero traderId as falsy', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = 0
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull() // 0 is falsy
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試防止 API 調用的邏輯流程
|
||||
*/
|
||||
describe('API call prevention flow', () => {
|
||||
it('should prevent API call when key is null', () => {
|
||||
const key = null
|
||||
const shouldCallAPI = key !== null
|
||||
|
||||
expect(shouldCallAPI).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow API call when key is valid', () => {
|
||||
const key = 'status-123'
|
||||
const shouldCallAPI = key !== null
|
||||
|
||||
expect(shouldCallAPI).toBe(true)
|
||||
})
|
||||
|
||||
it('should simulate SWR behavior with null key', () => {
|
||||
// SWR 不會在 key 為 null 時發起請求
|
||||
const key = null
|
||||
const fetcher = (k: string) => `API response for ${k}`
|
||||
|
||||
// 模擬 SWR 行為:key 為 null 時不調用 fetcher
|
||||
const data = key ? fetcher(key) : undefined
|
||||
|
||||
expect(data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should simulate SWR behavior with valid key', () => {
|
||||
const key = 'status-123'
|
||||
const fetcher = (k: string) => `API response for ${k}`
|
||||
|
||||
const data = key ? fetcher(key) : undefined
|
||||
|
||||
expect(data).toBe('API response for status-123')
|
||||
})
|
||||
})
|
||||
})
|
||||
30
web/src/lib/clipboard.ts
Normal file
30
web/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { notify } from './notify'
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板,并显示轻量提示。
|
||||
*/
|
||||
export async function copyWithToast(text: string, successMsg = '已复制') {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// 兼容降级:创建临时文本域执行复制
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
notify.success(successMsg)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Clipboard copy failed:', err)
|
||||
notify.error('复制失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default { copyWithToast }
|
||||
7
web/src/lib/cn.ts
Normal file
7
web/src/lib/cn.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,47 +1,56 @@
|
||||
export interface EncryptedPayload {
|
||||
wrappedKey: string; // RSA-OAEP(K)
|
||||
iv: string; // 12 bytes
|
||||
ciphertext: string; // AES-GCM 输出(含 tag)
|
||||
aad?: string; // 可选:额外认证数据
|
||||
kid?: string; // 可选:服务端公钥标识
|
||||
ts?: number; // 可选:unix 秒,用于重放保护
|
||||
wrappedKey: string // RSA-OAEP(K)
|
||||
iv: string // 12 bytes
|
||||
ciphertext: string // AES-GCM 输出(含 tag)
|
||||
aad?: string // 可选:额外认证数据
|
||||
kid?: string // 可选:服务端公钥标识
|
||||
ts?: number // 可选:unix 秒,用于重放保护
|
||||
}
|
||||
|
||||
export interface WebCryptoEnvironmentInfo {
|
||||
isBrowser: boolean
|
||||
isSecureContext: boolean
|
||||
hasSubtleCrypto: boolean
|
||||
origin?: string
|
||||
protocol?: string
|
||||
hostname?: string
|
||||
isLocalhost?: boolean
|
||||
}
|
||||
|
||||
export class CryptoService {
|
||||
private static publicKey: CryptoKey | null = null;
|
||||
private static publicKeyPEM: string | null = null;
|
||||
private static publicKey: CryptoKey | null = null
|
||||
private static publicKeyPEM: string | null = null
|
||||
|
||||
static async initialize(publicKeyPEM: string) {
|
||||
// 检查 Web Crypto API 是否可用
|
||||
if (!window.crypto || !window.crypto.subtle) {
|
||||
throw new Error('Web Crypto API is not available. Please use HTTPS or localhost to access the application.');
|
||||
}
|
||||
|
||||
if (this.publicKey && this.publicKeyPEM === publicKeyPEM) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
this.publicKeyPEM = publicKeyPEM;
|
||||
this.publicKey = await this.importPublicKey(publicKeyPEM);
|
||||
this.publicKeyPEM = publicKeyPEM
|
||||
this.publicKey = await this.importPublicKey(publicKeyPEM)
|
||||
}
|
||||
|
||||
private static async importPublicKey(pem: string): Promise<CryptoKey> {
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||||
const pemFooter = '-----END PUBLIC KEY-----';
|
||||
const headerIndex = pem.indexOf(pemHeader);
|
||||
const footerIndex = pem.indexOf(pemFooter);
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----'
|
||||
const pemFooter = '-----END PUBLIC KEY-----'
|
||||
const headerIndex = pem.indexOf(pemHeader)
|
||||
const footerIndex = pem.indexOf(pemFooter)
|
||||
|
||||
if (headerIndex === -1 || footerIndex === -1 || headerIndex >= footerIndex) {
|
||||
throw new Error('Invalid PEM formatted public key');
|
||||
if (
|
||||
headerIndex === -1 ||
|
||||
footerIndex === -1 ||
|
||||
headerIndex >= footerIndex
|
||||
) {
|
||||
throw new Error('Invalid PEM formatted public key')
|
||||
}
|
||||
|
||||
const pemContents = pem
|
||||
.substring(headerIndex + pemHeader.length, footerIndex)
|
||||
.replace(/\s+/g, ''); // 移除所有空白字符(包括换行符、空格等)
|
||||
|
||||
const binaryDerString = atob(pemContents);
|
||||
const binaryDer = new Uint8Array(binaryDerString.length);
|
||||
.replace(/\s+/g, '') // 移除所有空白字符(包括换行符、空格等)
|
||||
|
||||
const binaryDerString = atob(pemContents)
|
||||
const binaryDer = new Uint8Array(binaryDerString.length)
|
||||
for (let i = 0; i < binaryDerString.length; i++) {
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i);
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i)
|
||||
}
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
@@ -53,7 +62,7 @@ export class CryptoService {
|
||||
},
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
static async encryptSensitiveData(
|
||||
@@ -62,7 +71,9 @@ export class CryptoService {
|
||||
sessionId?: string
|
||||
): Promise<EncryptedPayload> {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('Crypto service not initialized. Call initialize() first.');
|
||||
throw new Error(
|
||||
'Crypto service not initialized. Call initialize() first.'
|
||||
)
|
||||
}
|
||||
|
||||
// 1. 生成 256-bit AES 密钥
|
||||
@@ -73,24 +84,24 @@ export class CryptoService {
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
);
|
||||
)
|
||||
|
||||
// 2. 生成 12 字节随机 IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
|
||||
// 3. 准备 AAD (额外认证数据)
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
const aadObject = {
|
||||
userId: userId || '',
|
||||
sessionId: sessionId || '',
|
||||
ts: ts,
|
||||
purpose: 'sensitive_data_encryption'
|
||||
};
|
||||
const aadString = JSON.stringify(aadObject);
|
||||
const aadBytes = new TextEncoder().encode(aadString);
|
||||
purpose: 'sensitive_data_encryption',
|
||||
}
|
||||
const aadString = JSON.stringify(aadObject)
|
||||
const aadBytes = new TextEncoder().encode(aadString)
|
||||
|
||||
// 4. 使用 AES-GCM 加密数据
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
@@ -100,10 +111,10 @@ export class CryptoService {
|
||||
},
|
||||
aesKey,
|
||||
plaintextBytes
|
||||
);
|
||||
)
|
||||
|
||||
// 5. 导出 AES 密钥
|
||||
const aesKeyRaw = await crypto.subtle.exportKey('raw', aesKey);
|
||||
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey)
|
||||
|
||||
// 6. 使用 RSA-OAEP 加密 AES 密钥
|
||||
const wrappedKey = await crypto.subtle.encrypt(
|
||||
@@ -111,37 +122,114 @@ export class CryptoService {
|
||||
name: 'RSA-OAEP',
|
||||
},
|
||||
this.publicKey,
|
||||
aesKeyRaw
|
||||
);
|
||||
rawAesKey
|
||||
)
|
||||
|
||||
// 7. 转换为 base64url 格式
|
||||
// 7. 编码为 base64url
|
||||
return {
|
||||
wrappedKey: this.arrayBufferToBase64Url(wrappedKey),
|
||||
iv: this.arrayBufferToBase64Url(iv),
|
||||
iv: this.arrayBufferToBase64Url(iv.buffer),
|
||||
ciphertext: this.arrayBufferToBase64Url(ciphertext),
|
||||
aad: this.arrayBufferToBase64Url(aadBytes),
|
||||
kid: 'rsa-key-2025-11-05',
|
||||
aad: this.arrayBufferToBase64Url(aadBytes.buffer),
|
||||
ts: ts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
private static arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
static async encryptWalletPrivateKey(privateKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
|
||||
return this.encryptSensitiveData(privateKey, userId, sessionId);
|
||||
static async fetchPublicKey(): Promise<string> {
|
||||
const response = await fetch('/api/crypto/public-key')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch public key: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.public_key
|
||||
}
|
||||
|
||||
static async encryptExchangeSecret(secretKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
|
||||
return this.encryptSensitiveData(secretKey, userId, sessionId);
|
||||
static async decryptSensitiveData(
|
||||
payload: EncryptedPayload
|
||||
): Promise<string> {
|
||||
const response = await fetch('/api/crypto/decrypt', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Decryption failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.plaintext
|
||||
}
|
||||
}
|
||||
|
||||
// 生成混淆字符串(用于剪贴板混淆)
|
||||
export function generateObfuscation(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
// 验证私钥格式
|
||||
export function validatePrivateKeyFormat(
|
||||
value: string,
|
||||
expectedLength: number = 64
|
||||
): boolean {
|
||||
const normalized = value.startsWith('0x') ? value.slice(2) : value
|
||||
if (normalized.length !== expectedLength) {
|
||||
return false
|
||||
}
|
||||
return /^[0-9a-fA-F]+$/.test(normalized)
|
||||
}
|
||||
|
||||
export function diagnoseWebCryptoEnvironment(): WebCryptoEnvironmentInfo {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
isBrowser: false,
|
||||
isSecureContext: false,
|
||||
hasSubtleCrypto: false,
|
||||
}
|
||||
}
|
||||
|
||||
const { location } = window
|
||||
const hostname = location?.hostname
|
||||
const protocol = location?.protocol
|
||||
const origin = location?.origin
|
||||
const isLocalhost = hostname
|
||||
? ['localhost', '127.0.0.1', '::1'].includes(hostname)
|
||||
: false
|
||||
|
||||
const secureContext =
|
||||
typeof window.isSecureContext === 'boolean'
|
||||
? window.isSecureContext
|
||||
: protocol === 'https:' || (protocol === 'http:' && isLocalhost)
|
||||
|
||||
const hasSubtleCrypto =
|
||||
typeof window.crypto !== 'undefined' &&
|
||||
typeof window.crypto.subtle !== 'undefined'
|
||||
|
||||
return {
|
||||
isBrowser: true,
|
||||
isSecureContext: secureContext,
|
||||
hasSubtleCrypto,
|
||||
origin: origin || undefined,
|
||||
protocol: protocol || undefined,
|
||||
hostname,
|
||||
isLocalhost,
|
||||
}
|
||||
}
|
||||
|
||||
169
web/src/lib/httpClient.ts
Normal file
169
web/src/lib/httpClient.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* HTTP Client with unified error handling and 401 interception
|
||||
*
|
||||
* Features:
|
||||
* - Unified fetch wrapper
|
||||
* - Automatic 401 token expiration handling
|
||||
* - Auth state cleanup on unauthorized
|
||||
* - Automatic redirect to login page
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export class HttpClient {
|
||||
// Singleton flag to prevent duplicate 401 handling
|
||||
private static isHandling401 = false
|
||||
|
||||
/**
|
||||
* Reset 401 handling flag (call after successful login)
|
||||
*/
|
||||
public reset401Flag(): void {
|
||||
HttpClient.isHandling401 = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Show login required notification to user
|
||||
*/
|
||||
private showLoginRequiredNotification(): void {
|
||||
toast.warning('登录已过期,请先登录', { duration: 1800 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Response interceptor - handles common HTTP errors
|
||||
*
|
||||
* @param response - Fetch Response object
|
||||
* @returns Response if successful
|
||||
* @throws Error with user-friendly message
|
||||
*/
|
||||
private async handleResponse(response: Response): Promise<Response> {
|
||||
// Handle 401 Unauthorized - Token expired or invalid
|
||||
if (response.status === 401) {
|
||||
// Prevent duplicate 401 handling when multiple API calls fail simultaneously
|
||||
if (HttpClient.isHandling401) {
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// Set flag to prevent race conditions
|
||||
HttpClient.isHandling401 = true
|
||||
|
||||
// Clean up local storage
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
|
||||
// Notify global listeners (AuthContext will react to this)
|
||||
window.dispatchEvent(new Event('unauthorized'))
|
||||
|
||||
// Show user-friendly notification (only once)
|
||||
this.showLoginRequiredNotification()
|
||||
|
||||
// Delay redirect to let user see the notification
|
||||
setTimeout(() => {
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
// Save current location for post-login redirect
|
||||
const returnUrl = window.location.pathname + window.location.search
|
||||
if (returnUrl !== '/login' && returnUrl !== '/') {
|
||||
sessionStorage.setItem('returnUrl', returnUrl)
|
||||
}
|
||||
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// Note: No need to reset flag since we're redirecting
|
||||
}, 1500) // 1.5秒延迟,让用户看到提示
|
||||
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// Handle other common errors
|
||||
if (response.status === 403) {
|
||||
throw new Error('没有权限访问此资源')
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('请求的资源不存在')
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
throw new Error('服务器错误,请稍后重试')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get(url: string, headers?: Record<string, string>): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post(
|
||||
url: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put(
|
||||
url: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete(
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic request method for custom configurations
|
||||
*/
|
||||
async request(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const response = await fetch(url, options)
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const httpClient = new HttpClient()
|
||||
|
||||
// Export helper function to reset 401 flag
|
||||
export const reset401Flag = () => httpClient.reset401Flag()
|
||||
87
web/src/lib/notify.tsx
Normal file
87
web/src/lib/notify.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string
|
||||
message?: string
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
// 全局 confirm 函数的引用,将在 ConfirmDialogProvider 中设置
|
||||
let globalConfirm:
|
||||
| ((options: ConfirmOptions & { message: string }) => Promise<boolean>)
|
||||
| null = null
|
||||
|
||||
export function setGlobalConfirm(
|
||||
confirmFn: (options: ConfirmOptions & { message: string }) => Promise<boolean>
|
||||
) {
|
||||
globalConfirm = confirmFn
|
||||
}
|
||||
|
||||
// 确认对话框函数,使用 shadcn AlertDialog
|
||||
export function confirmToast(
|
||||
message: string,
|
||||
options: ConfirmOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (!globalConfirm) {
|
||||
console.error('ConfirmDialogProvider not initialized')
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
return globalConfirm({
|
||||
message,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// 统一通知封装,避免组件直接依赖 sonner
|
||||
type Message = string | ReactNode
|
||||
|
||||
function message(msg: Message, options?: Parameters<typeof toast>[1]) {
|
||||
return toast(msg as any, options)
|
||||
}
|
||||
|
||||
function success(msg: Message, options?: Parameters<typeof toast.success>[1]) {
|
||||
return toast.success(msg as any, options)
|
||||
}
|
||||
|
||||
function error(msg: Message, options?: Parameters<typeof toast.error>[1]) {
|
||||
return toast.error(msg as any, options)
|
||||
}
|
||||
|
||||
function info(msg: Message, options?: Parameters<typeof toast.info>[1]) {
|
||||
return toast.info?.(msg as any, options) ?? toast(msg as any, options)
|
||||
}
|
||||
|
||||
function warning(msg: Message, options?: Parameters<typeof toast.warning>[1]) {
|
||||
return toast.warning?.(msg as any, options) ?? toast(msg as any, options)
|
||||
}
|
||||
|
||||
function custom(
|
||||
renderer: Parameters<typeof toast.custom>[0],
|
||||
options?: Parameters<typeof toast.custom>[1]
|
||||
) {
|
||||
return toast.custom(renderer, options)
|
||||
}
|
||||
|
||||
function dismiss(id?: string | number) {
|
||||
return toast.dismiss(id as any)
|
||||
}
|
||||
|
||||
function promise<T>(p: Promise<T> | (() => Promise<T>), msgs: any) {
|
||||
return toast.promise<T>(p as any, msgs as any)
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
message,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning,
|
||||
custom,
|
||||
dismiss,
|
||||
promise,
|
||||
}
|
||||
|
||||
export default { confirmToast, notify }
|
||||
28
web/src/lib/text.ts
Normal file
28
web/src/lib/text.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 文本工具
|
||||
*
|
||||
* stripLeadingIcons: 去掉翻译文案或标题前面用于装饰的 Emoji/符号,
|
||||
* 以便在组件里自行放置图标时不重复显示。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 去掉开头的装饰性 Emoji/符号以及随后的分隔符(空格/冒号/点号等)。
|
||||
*/
|
||||
export function stripLeadingIcons(input: string | undefined | null): string {
|
||||
if (!input) return ''
|
||||
let s = String(input)
|
||||
|
||||
// 1) 去除常见的 Emoji/符号块(箭头、杂项符号、几何图形、表情等)
|
||||
// 覆盖常见范围,兼容性好于使用 Unicode 属性类。
|
||||
s = s.replace(
|
||||
/^[\s\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u25A0-\u25FF\u2600-\u27BF\u2B00-\u2BFF\u1F000-\u1FAFF]+/u,
|
||||
''
|
||||
)
|
||||
|
||||
// 2) 去掉开头可能残留的分隔符(空格、连字符、冒号、居中点等)
|
||||
s = s.replace(/^[\s\-:•·]+/, '')
|
||||
|
||||
return s.trim()
|
||||
}
|
||||
|
||||
export default { stripLeadingIcons }
|
||||
Reference in New Issue
Block a user