Files
miniprogram-1/miniprogram/utils/api.ts
2026-01-21 13:29:15 +08:00

2256 lines
77 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API 工具类 - 封装网络请求和认证逻辑
import {
IAppOption,
ILoginResponse,
IApiResponse,
IRecognitionResponse,
IAuditHistoryResponse,
IUserInfo,
IDailySummaryResponse,
YdWordDetail,
IQaExerciseCreateAccepted,
IQaExerciseTaskStatus,
IQaExerciseQueryResponse,
IQaQuestionAttemptAccepted,
IQaQuestionTaskStatus,
IQaResult,
IQaConversationSettingResponse,
IQaConversationDetail
} from '../types/app';
import { BASE_URL, USE_CLOUD } from './config';
import { cloudConfig } from './cloud.config';
// 添加对 ICloud 命名空间的引用
declare namespace ICloud {
interface CallContainerResult {
data: any;
header: Record<string, string>;
statusCode: number;
errMsg: string;
}
}
// 音频缓存映射
const audioCache: Map<string, string> = new Map()
// 图片缓存映射
const imageCache: Map<string, string> = new Map()
// 缓存大小统计(以字节为单位)
const cacheStats: Map<string, number> = new Map()
// 在类外部添加初始化缓存的方法
function initPersistentCache() {
try {
console.log('开始初始化持久化缓存')
const startTime = Date.now()
const fs = wx.getFileSystemManager()
const audioCacheDir = `${wx.env.USER_DATA_PATH}/audio_cache`
// 检查音频缓存目录是否存在
fs.accessSync(audioCacheDir)
// 递归遍历目录并重建缓存映射
traverseDirectory(audioCacheDir, '', fs)
const endTime = Date.now()
console.log('持久化缓存初始化完成,耗时:', endTime - startTime, 'ms音频文件数量:', audioCache.size)
} catch (e) {
// 目录不存在,这是正常的,不需要处理
console.log('音频缓存目录不存在,无需初始化')
}
}
// 递归遍历目录
function traverseDirectory(baseDir: string, relativePath: string, fs: any) {
try {
const currentPath = relativePath ? `${baseDir}/${relativePath}` : baseDir
// 限制遍历深度,避免过深的目录结构
const pathDepth = relativePath.split('/').filter(p => p.length > 0).length
if (pathDepth > 5) {
console.warn('目录深度超过限制,跳过:', relativePath)
return
}
const items = fs.readdirSync(currentPath)
// 限制文件数量,避免过多文件导致性能问题
const maxItems = 100
if (items.length > maxItems) {
console.warn('目录文件数量超过限制,仅处理前', maxItems, '个文件')
items.splice(maxItems)
}
items.forEach((item: string) => {
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item
const itemFullPath = `${baseDir}/${itemRelativePath}`
try {
const stat = fs.statSync(itemFullPath)
if (stat.isFile()) {
// 限制文件大小,避免处理过大的文件
const maxSize = 10 * 1024 * 1024 // 10MB
if (stat.size > maxSize) {
console.warn('文件过大,跳过:', itemRelativePath, '大小:', stat.size)
return
}
// 将文件添加到缓存映射中
audioCache.set(itemRelativePath, itemFullPath)
// 获取文件大小并更新统计
const fileSize = stat.size
cacheStats.set(`audio_${itemRelativePath}`, fileSize)
console.log(`初始化缓存文件: ${itemRelativePath}, 大小: ${fileSize} bytes`)
} else if (stat.isDirectory()) {
// 递归处理子目录
traverseDirectory(baseDir, itemRelativePath, fs)
}
} catch (err) {
// 单个文件处理出错不影响其他文件
console.warn(`处理项目 ${itemRelativePath} 时出错:`, err)
}
})
} catch (err) {
// 如果目录不存在或无法读取,记录日志但不中断
if (relativePath === '') {
console.log(`无法访问音频缓存目录:`, err)
} else {
console.warn(`遍历目录 ${relativePath} 时出错:`, err)
}
}
}
// 异步初始化缓存,避免阻塞主线程
function initPersistentCacheAsync() {
// 使用 setTimeout 将初始化操作推迟到下一个事件循环
setTimeout(() => {
try {
initPersistentCache()
} catch (e) {
console.error('初始化持久化缓存时出错:', e)
}
}, 0)
}
// 在文件末尾调用异步初始化方法
initPersistentCacheAsync()
// 词典等级映射
const DICT_LEVEL_OPTIONS = {
LEVEL1: "level1",
LEVEL2: "level2",
LEVEL3: "level3"
}
class ApiManager {
private loginPromise: Promise<ILoginResponse | null> | null = null // 防止重复登录
private isAudioPlaying: boolean = false // 音频播放状态标志
// 获取请求头
private getHeaders(): Record<string, string> {
const app = getApp<IAppOption>()
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (app.globalData.token) {
headers['Authorization'] = `Bearer ${app.globalData.token}`
}
return headers
}
// 专用于登录的请求方法不处理401重试
private loginRequest<T>(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
data?: any,
showLoading: boolean = true
): Promise<IApiResponse<T>> {
return new Promise((resolve, reject) => {
if (showLoading) {
wx.showLoading({ title: '登录中...' })
}
console.log('发起登录请求:', {
url: `${BASE_URL}${url}`,
method,
hasData: !!data
})
wx.request({
url: `${BASE_URL}${url}`,
method,
data,
header: {
'Content-Type': 'application/json'
// 登录请求不需要Authorization头
},
success: (res) => {
if (showLoading) {
wx.hideLoading()
}
console.log('登录请求响应:', {
statusCode: res.statusCode,
hasData: !!res.data,
success: true
})
if (res.statusCode === 200) {
const response = res.data as IApiResponse<T>
if (response.code === 0 || response.code === 200) {
resolve(response)
} else {
const errorMsg = response.message || response.msg || '登录失败'
console.error('登录业务错误:', errorMsg, response)
wx.showToast({
title: errorMsg,
icon: 'none'
})
reject(new Error(errorMsg))
}
} else {
console.error('登录HTTP错误:', res.statusCode, res.data)
let errorMsg = '登录失败'
if (res.statusCode === 401) {
errorMsg = '登录凭据无效'
} else if (res.statusCode >= 500) {
errorMsg = '服务器错误,请稍后重试'
}
wx.showToast({
title: errorMsg,
icon: 'none'
})
reject(new Error(`HTTP ${res.statusCode}`))
}
},
fail: (error) => {
if (showLoading) {
wx.hideLoading()
}
console.error('登录请求失败:', error)
wx.showToast({
title: '网络连接失败',
icon: 'none'
})
reject(new Error('网络连接失败: ' + JSON.stringify(error)))
}
})
})
}
// 通用请求方法支持401自动重试登录
private async request<T>(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
data?: any,
showLoading: boolean = true,
retryCount: number = 0
): Promise<IApiResponse<T>> {
const maxRetries = 1 // 最多重试1次首次请求 + 1次重试
if (USE_CLOUD){
return this.wx_request<T>(url, method, data, showLoading) as unknown as IApiResponse<T>
}
else {
return new Promise(async (resolve, reject) => {
if (showLoading && retryCount === 0) {
// wx.showLoading({ title: '加载中...' })
}
console.log('发起请求:', {
url: `${BASE_URL}${url}`,
method,
hasData: !!data,
hasHeaders: !!this.getHeaders(),
retryCount
})
wx.request({
url: `${BASE_URL}${url}`,
method,
data,
header: this.getHeaders(),
success: async (res) => {
// if (showLoading) {
// wx.hideLoading()
// }
console.log('请求响应:', {
statusCode: res.statusCode,
hasData: !!res.data,
retryCount,
success: true
})
if (res.statusCode === 200) {
const response = res.data as IApiResponse<T>
if (response.code === 0 || response.code === 200) {
resolve(response)
} else {
// 业务错误
const errorMsg = response.message || response.msg || '请求失败'
console.error('业务错误:', errorMsg, response)
wx.showToast({
title: errorMsg,
icon: 'none'
})
reject(new Error(errorMsg))
}
} else if (res.statusCode === 401) {
console.warn('401认证失败尝试自动登录', { retryCount, maxRetries })
// 如果还有重试机会,尝试自动登录并重试请求
if (retryCount < maxRetries) {
try {
console.log('开始401自动登录重试')
// 清理过期的认证数据
this.clearAuthData()
// 进行智能登录
const loginResult = await this.smartLogin(true) // 强制刷新token
if (loginResult) {
console.log('401自动登录成功重试请求')
// 登录成功后重试原请求
try {
const retryResult = await this.request<T>(url, method, data, false, retryCount + 1)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
} else {
console.error('401自动登录失败')
this.handleTokenExpired()
reject(new Error('登录失败,请重新登录'))
}
} catch (loginError) {
console.error('401自动登录发生错误:', loginError)
this.handleTokenExpired()
reject(new Error('登录失败,请重新登录'))
}
} else {
// 重试次数耗尽直接处理token过期
console.error('401重试次数耗尽跳转登录页')
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} else if (res.statusCode === 400) {
const response = res.data as IApiResponse<T>
const errorMsg = response.msg || '请求失败'
console.error('400 错误:', errorMsg, response)
reject(new Error(errorMsg))
} else if (res.statusCode === 403) {
const response = res.data as IApiResponse<T>
const errorMsg = response.msg || '请求失败'
console.error('403 错误:', errorMsg, response)
reject(new Error(errorMsg))
} else if (res.statusCode === 404 ) {
const response = res.data as IApiResponse<T>
const errorMsg = response.msg || '请求失败'
console.error('404 错误:', errorMsg, response)
reject(new Error(errorMsg))
} else {
console.error('HTTP错误:', res.statusCode, res.data)
wx.showToast({
title: `网络请求失败 (${res.statusCode})`,
icon: 'none'
})
reject(new Error(`HTTP ${res.statusCode}`))
}
},
fail: (error) => {
if (showLoading) {
wx.hideLoading()
}
console.error('请求失败:', error)
wx.showToast({
title: '网络连接失败',
icon: 'none'
})
reject(new Error('网络连接失败: ' + JSON.stringify(error)))
}
})
})
}
}
// 微信云托管请求方法
private async wx_request<T>(
path: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
data?: any,
showLoading: boolean = true
): Promise<ICloud.CallContainerResult> {
return new Promise((resolve, reject) => {
if (showLoading) {
// wx.showLoading({ title: '加载中...' });
}
console.log('发起微信云托管请求:', {
path,
method,
hasData: !!data
});
wx.cloud.callContainer({
config: {
env: cloudConfig.env // 使用配置文件中的环境ID
},
path: path.startsWith('/') ? path : `/${path}`, // 确保路径以 / 开头
method,
header: {
'X-WX-SERVICE': cloudConfig.service // 使用配置文件中的服务名称
},
data: data || {}, // 确保数据对象不为 undefined
success: (res: ICloud.CallContainerResult) => {
if (showLoading) {
wx.hideLoading();
}
console.log('微信云托管请求响应:', {
statusCode: res.statusCode,
hasData: !!res.data,
success: true
});
if (res.statusCode === 200) {
// 直接返回响应数据,由调用方处理业务逻辑
resolve(res.data);
} else if (res.statusCode === 403) {
const response = res.data as IApiResponse<T>
const errorMsg = response.msg || '请求失败'
console.error('403 错误:', errorMsg, response)
reject(new Error(errorMsg))
} else if (res.statusCode === 404 ) {
const response = res.data as IApiResponse<T>
const errorMsg = response.msg || '请求失败'
console.error('404 错误:', errorMsg, response)
reject(new Error(errorMsg))
} else {
const response = res.data as IApiResponse<T>
const errorMsg = response.msg || '请求失败'
console.error('微信云托管请求错误:', errorMsg, response)
reject(new Error(errorMsg))
}
},
fail: (error: any) => {
if (showLoading) {
wx.hideLoading();
}
console.error('微信云托管请求失败:', error);
wx.showToast({
title: '网络连接失败',
icon: 'none'
});
reject(new Error('网络连接失败: ' + JSON.stringify(error)));
}
});
});
}
// 处理 token 过期
private handleTokenExpired() {
console.log('处理token过期清理状态并跳转登录页')
const app = getApp<IAppOption>()
// 清理全局状态
app.globalData.isLoggedIn = false
app.globalData.token = undefined
app.globalData.userInfo = undefined
app.globalData.dictLevel = undefined
// 清理本地存储
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
wx.removeStorageSync('tokenExpiry')
wx.removeStorageSync('sessionUuid')
wx.removeStorageSync('dictLevel')
// 优化用户提示,不要太突充
console.log('登录状态已过期,即将跳转到登录页')
// 立即跳转到登录页,减少等待时间
wx.reLaunch({
url: '/pages/index/index'
})
}
// 智能登录优先检查本地token
async smartLogin(forceRefresh: boolean = false): Promise<ILoginResponse | null> {
if (USE_CLOUD) {
const app = getApp<IAppOption>()
let dictLevel = wx.getStorageSync('dictLevel') || 'level1'
try {
const resp = await this.request<{ user: IUserInfo; dict_level?: string; session_uuid?: string; settings?: { dict_level?: string }; points?: { balance: number; expired_time: string } }>(
'/api/v1/wx/user',
'GET',
undefined,
false
)
const data = (resp as any)?.data || resp
if (data.user) {
app.globalData.userInfo = data.user
wx.setStorageSync('userInfo', data.user)
}
const dl = data.dict_level || 'level1'
if (dl) {
dictLevel = dl
wx.setStorageSync('dictLevel', dictLevel)
}
if (data.session_uuid) {
wx.setStorageSync('sessionUuid', data.session_uuid)
}
} catch (e) {}
app.globalData.isLoggedIn = true
app.globalData.dictLevel = dictLevel
return {
access_token: '',
access_token_expire_time: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
session_uuid: wx.getStorageSync('sessionUuid') || '',
dict_level: dictLevel
}
}
if (this.loginPromise && !forceRefresh) {
return this.loginPromise
}
const app = getApp<IAppOption>()
if (!forceRefresh) {
const authInfo = this.getStoredAuthInfo()
if (authInfo && authInfo.token && authInfo.tokenExpiry) {
const isExpired = this.isTokenExpired()
if (!isExpired) {
app.globalData.isLoggedIn = true
app.globalData.token = authInfo.token
app.globalData.dictLevel = authInfo.dictLevel || 'level1'
return {
access_token: authInfo.token,
access_token_expire_time: new Date(authInfo.tokenExpiry).toISOString(),
session_uuid: authInfo.sessionUuid || '',
dict_level: authInfo.dictLevel || 'level1'
}
} else {
this.clearAuthData()
}
}
} else {
this.clearAuthData()
}
this.loginPromise = this.login().then(
(result) => {
this.loginPromise = null
return result
},
(error) => {
this.loginPromise = null
throw error
}
)
return this.loginPromise
}
// 清理认证数据
private clearAuthData() {
const app = getApp<IAppOption>()
// 清理全局状态
app.globalData.isLoggedIn = false
app.globalData.token = undefined
app.globalData.userInfo = undefined
app.globalData.dictLevel = undefined
// 清理本地存储
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
wx.removeStorageSync('tokenExpiry')
wx.removeStorageSync('sessionUuid')
wx.removeStorageSync('dictLevel')
}
// 用户登录(带重试机制)
async login(retryCount: number = 0, referrerId?: string): Promise<ILoginResponse> {
const maxRetries = 5 // 最多重试5次首轮 + 5次重试共最多6次尝试
return new Promise((resolve, reject) => {
if (retryCount > 0) {
console.log(`登录重试 ${retryCount}/${maxRetries + 1}`)
}
wx.login({
success: async (loginRes) => {
if (loginRes.code) {
try {
const data: any = { code: loginRes.code }
if (referrerId) {
data.referrer_id = referrerId
}
const response = await this.loginRequest<ILoginResponse>('/api/v1/wx/login', 'POST', data)
// 处理新的数据结构
const loginData = response.data
if (!loginData.access_token) {
throw new Error('服务器返回数据中缺少access_token')
}
// 存储 access_token
wx.setStorageSync('token', loginData.access_token)
// 处理过期时间,将字符串时间转换为时间戳
if (loginData.access_token_expire_time) {
const expireTime = new Date(loginData.access_token_expire_time).getTime()
if (isNaN(expireTime)) {
console.warn('过期时间格式错误,使用默认值')
const defaultExpiry = Date.now() + 30 * 24 * 60 * 60 * 1000
wx.setStorageSync('tokenExpiry', defaultExpiry)
} else {
wx.setStorageSync('tokenExpiry', expireTime)
}
} else {
const defaultExpiry = Date.now() + 30 * 24 * 60 * 60 * 1000
wx.setStorageSync('tokenExpiry', defaultExpiry)
}
// 存储 session_uuid
if (loginData.session_uuid) {
wx.setStorageSync('sessionUuid', loginData.session_uuid)
}
// 存储 dict_level
if (loginData.dict_level) {
wx.setStorageSync('dictLevel', loginData.dict_level)
// 更新全局状态
const app = getApp<IAppOption>()
app.globalData.dictLevel = loginData.dict_level
}
console.log('登录成功,数据存储完成')
resolve(loginData)
} catch (error: any) {
console.error(`登录请求失败:`, error.message)
// 如果还有重试次数,尝试重试
if (retryCount < maxRetries) {
setTimeout(async () => {
try {
const result = await this.login(retryCount + 1)
resolve(result)
} catch (retryError) {
reject(retryError)
}
}, 1500) // 减少重试间隔
} else {
reject(error)
}
}
} else {
const error = new Error('微信登录失败未能获取到登录code')
if (retryCount < maxRetries) {
setTimeout(async () => {
try {
const result = await this.login(retryCount + 1)
resolve(result)
} catch (retryError) {
reject(retryError)
}
}, 1000)
} else {
reject(error)
}
}
},
fail: (error) => {
console.error('微信登录接口调用失败:', error)
if (retryCount < maxRetries) {
setTimeout(async () => {
try {
const result = await this.login(retryCount + 1)
resolve(result)
} catch (retryError) {
reject(retryError)
}
}, 1000)
} else {
reject(new Error('微信登录接口调用失败: ' + JSON.stringify(error)))
}
}
})
})
}
// 上传文件第一步上传文件获取ID
async uploadFile(filePath: string, retryCount: number = 0): Promise<string> {
if (USE_CLOUD) {
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager()
fs.getFileInfo({
filePath,
success: async (infoRes) => {
try {
const filename = this.extractFileName(filePath)
const mime = this.getMimeType(filePath)
const initResp = await this.wx_request<{ file_id: string; cloud_path: string; env: string }>(
'/api/v1/file/upload/init',
'POST',
{ filename, size: infoRes.size, mime, biz_type: 'file' }
)
const initData = (initResp as any).data || initResp
const file_id = initData.file_id
const cloud_path = initData.cloud_path
const env = initData.env
const task = wx.cloud.uploadFile({
cloudPath: cloud_path,
filePath,
config: { env },
success: async (upRes) => {
try {
const completeResp = await this.wx_request<{ id: string; url?: string }>(
'/api/v1/file/upload/complete',
'POST',
{ file_id, cloud_path, fileID: upRes.fileID, size: infoRes.size, mime }
)
resolve(file_id)
} catch (e) {
reject(e)
}
},
fail: (e) => {
const info = String(e)
if (info.indexOf('abort') !== -1) {
reject(new Error('上传中断'))
} else {
reject(new Error('上传失败'))
}
}
})
} catch (err) {
reject(err)
}
},
fail: (err) => reject(err)
})
})
}
const maxRetries = 1
return new Promise(async (resolve, reject) => {
wx.uploadFile({
url: `${BASE_URL}/api/v1/file/upload`,
filePath,
name: 'file',
header: this.getHeaders(),
success: async (res) => {
if (res.statusCode === 200) {
try {
const response = JSON.parse(res.data) as IApiResponse<{ id: string }>
if (response.code === 0 || response.code === 200) {
if (response.data?.id) {
resolve(response.data.id)
} else {
reject(new Error('服务器返回数据中缺少文件ID'))
}
} else {
const errorMsg = response.message || response.msg || '文件上传失败'
wx.showToast({ title: errorMsg, icon: 'none' })
reject(new Error(errorMsg))
}
} catch (error) {
wx.showToast({ title: '数据解析错误', icon: 'none' })
reject(error)
}
} else if (res.statusCode === 401) {
if (retryCount < maxRetries) {
try {
this.clearAuthData()
const loginResult = await this.smartLogin(true)
if (loginResult) {
try {
const retryResult = await this.uploadFile(filePath, retryCount + 1)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
} else {
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} catch (loginError) {
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} else {
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} else {
wx.showToast({ title: '文件上传失败', icon: 'none' })
reject(new Error(`HTTP ${res.statusCode}`))
}
},
fail: (error) => {
wx.showToast({ title: '文件上传失败', icon: 'none' })
reject(error)
}
})
})
}
// 上传文件第一步上传文件获取ID
async uploadImageFile(filePath: string, retryCount: number = 0): Promise<string> {
if (USE_CLOUD) {
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager()
fs.getFileInfo({
filePath,
success: async (infoRes) => {
try {
const filename = this.extractFileName(filePath)
const mime = this.getMimeType(filePath)
const initResp = await this.wx_request<{ file_id: string; cloud_path: string; env: string }>(
'/api/v1/file/upload/init',
'POST',
{ filename, size: infoRes.size, mime, biz_type: 'image' }
)
const initData = (initResp as any).data || initResp
const file_id = initData.file_id
const cloud_path = initData.cloud_path
const env = initData.env
const task = wx.cloud.uploadFile({
cloudPath: cloud_path,
filePath,
config: { env },
success: async (upRes) => {
try {
const completeResp = await this.wx_request<{ id: string; url?: string }>(
'/api/v1/file/upload/complete',
'POST',
{ file_id, cloud_path, fileID: upRes.fileID, size: infoRes.size, mime }
)
resolve(file_id)
} catch (e) {
reject(e)
}
},
fail: (e) => {
const info = String(e)
if (info.indexOf('abort') !== -1) {
reject(new Error('上传中断'))
} else {
reject(new Error('上传失败'))
}
}
})
} catch (err) {
reject(err)
}
},
fail: (err) => reject(err)
})
})
}
const maxRetries = 1
return new Promise(async (resolve, reject) => {
wx.uploadFile({
url: `${BASE_URL}/api/v1/file/upload_image`,
filePath,
name: 'file',
header: this.getHeaders(),
success: async (res) => {
if (res.statusCode === 200) {
try {
const response = JSON.parse(res.data) as IApiResponse<{ id: string }>
if (response.code === 0 || response.code === 200) {
if (response.data?.id) {
resolve(response.data.id)
} else {
reject(new Error('服务器返回数据中缺少文件ID'))
}
} else {
const errorMsg = response.message || response.msg || '文件上传失败'
wx.showToast({ title: errorMsg, icon: 'none' })
reject(new Error(errorMsg))
}
} catch (error) {
wx.showToast({ title: '数据解析错误', icon: 'none' })
reject(error)
}
} else if (res.statusCode === 401) {
if (retryCount < maxRetries) {
try {
this.clearAuthData()
const loginResult = await this.smartLogin(true)
if (loginResult) {
try {
const retryResult = await this.uploadFile(filePath, retryCount + 1)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
} else {
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} catch (loginError) {
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} else {
this.handleTokenExpired()
reject(new Error('登录已过期'))
}
} else {
wx.showToast({ title: '文件上传失败', icon: 'none' })
reject(new Error(`HTTP ${res.statusCode}`))
}
},
fail: (error) => {
wx.showToast({ title: '文件上传失败', icon: 'none' })
reject(error)
}
})
})
}
private extractFileName(path: string): string {
const i = path.lastIndexOf('/')
return i >= 0 ? path.substring(i + 1) : path
}
private getMimeType(path: string): string {
const i = path.lastIndexOf('.')
const ext = i >= 0 ? path.substring(i + 1).toLowerCase() : ''
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
mpeg: 'audio/mpeg',
mp3: 'audio/mp3',
wav: 'audio/wav',
bmp: 'image/bmp'
}
return map[ext] || 'application/octet-stream'
}
// 图片识别第二步通过文件ID进行识别
async recognizeImageAsync(fileId: string, type: string = 'word'): Promise<{task_id: string, status: string, message: string}> {
// 获取当前的词典等级配置
const app = getApp<IAppOption>()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
console.log('开始图片识别请求:', { fileId, type, dictLevel })
const response = await this.request<{task_id: string, status: string, message: string}>('/api/v1/image/recognize/async', 'POST', {
file_id: fileId,
type: type
// dict_level: dictLevel // 添加词典等级参数
})
console.log('图片识别成功:', response.data)
return response.data
}
// 获取识别结果
async recognizeGetTask(taskId: string | number): Promise<IRecognitionResponse> {
const response = await this.request<IRecognitionResponse>(`/api/v1/image/recognize/task/${taskId}`, 'GET')
console.log('图片识别成功:', response.data)
return response.data
}
// 上传图片并识别(对外接口,整合两个步骤)
async uploadImage(filePath: string, type: string = 'word'): Promise<IRecognitionResponse> {
try {
// wx.showLoading({ title: '上传中...' })
console.log('开始图片上传和识别流程:', { filePath, type })
// 第一步上传文件获取ID
const fileId = await this.uploadImageFile(filePath)
try {
await this.cacheLocalImage(fileId, filePath)
} catch (e) {}
// 更新加载提示
// wx.showLoading({ title: '识别中...' })
// 第二步通过文件ID进行图片识别
const { task_id } = await this.recognizeImageAsync(fileId, type)
// 轮询获取识别结果
let recognitionResult: IRecognitionResponse | null = null
while (true) {
try {
const res = await this.recognizeGetTask(task_id)
console.log('--lisa-res', res)
if (res.status === 'completed') {
recognitionResult = res
break
}
if (res.status === 'failed') {
recognitionResult = res
throw new Error(recognitionResult.error_message || '图片识别失败')
}
await new Promise(resolve => setTimeout(resolve, 3000)) // 2秒轮询一次
} catch (error) {
console.error('轮询获取识别结果失败:', error)
throw error
}
}
try { this.refreshImageCache(fileId) } catch (e) {}
// wx.hideLoading()
console.log('图片上传和识别流程完成')
return recognitionResult
} catch (error) {
// wx.hideLoading()
console.error('图片上传和识别流程失败:', error)
// 根据错误类型提供更具体的错误信息
if (error instanceof Error) {
if (error.message.includes('登录')) {
// 登录相关错误,不需要额外处理,已经在内部处理了
throw error
} else {
wx.showToast({
title: error.message || '处理失败',
icon: 'none'
})
}
} else {
wx.showToast({
title: '处理失败',
icon: 'none'
})
}
throw error
}
}
private async cacheLocalImage(fileId: string, localPath: string): Promise<string> {
return new Promise((resolve) => {
try {
const fs = wx.getFileSystemManager()
const extMatch = localPath && localPath.match(/\.[a-zA-Z0-9]+$/)
if (!extMatch) {
imageCache.set(fileId, localPath)
resolve(localPath)
return
}
const ext = extMatch[0]
const destPath = `${wx.env.USER_DATA_PATH}/${fileId}${ext}`
try { fs.unlinkSync(destPath) } catch (e) {}
fs.saveFile({
tempFilePath: localPath,
filePath: destPath,
success: () => {
imageCache.set(fileId, destPath)
fs.getFileInfo({
filePath: destPath,
success: (infoRes) => {
cacheStats.set(`image_${fileId}`, infoRes.size)
resolve(destPath)
},
fail: () => resolve(destPath)
})
},
fail: () => {
imageCache.set(fileId, localPath)
resolve(localPath)
}
})
} catch (e) {
imageCache.set(fileId, localPath)
resolve(localPath)
}
})
}
private async refreshImageCache(fileId: string): Promise<void> {
try {
const path = await this.downloadFile(fileId)
imageCache.set(fileId, path)
const fs = wx.getFileSystemManager()
fs.getFileInfo({
filePath: path,
success: (infoRes) => {
cacheStats.set(`image_${fileId}`, infoRes.size)
}
})
} catch (e) {}
}
// 获取单词详情
async getWordDetail(word: string): Promise<YdWordDetail> {
console.log('获取单词详情')
// 首先检查本地缓存
const cacheKey = `word_detail_${word}`
try {
const cachedData = wx.getStorageSync(cacheKey)
if (cachedData) {
console.log('使用缓存的单词详情:', word)
return cachedData
}
} catch (e) {
console.warn('读取单词详情缓存失败:', e)
}
// 缓存中没有,从服务器获取
const response = await this.request<YdWordDetail>(`/api/v1/dict/word/${encodeURIComponent(word)}`)
const wordDetail = response.data
// 将结果缓存到本地存储设置7天过期时间
try {
wx.setStorageSync(cacheKey, wordDetail)
// 同时存储缓存时间,用于清理时判断
const cacheTimeKey = `word_detail_time_${word}`
wx.setStorageSync(cacheTimeKey, Date.now())
} catch (e) {
console.warn('存储单词详情缓存失败:', e)
}
console.log('获取单词详情成功:', response)
return wordDetail
}
// 播放音频文件
async playAudio(fileName: string): Promise<void> {
// 检查是否有音频正在播放
if (this.isAudioPlaying) {
console.log('音频正在播放中,跳过本次播放请求')
return Promise.reject(new Error('音频正在播放中,请等待播放完成'))
}
return new Promise((resolve: () => void, reject: (reason: any) => void) => {
console.log('播放音频文件:', fileName)
// 设置音频播放状态为true
this.isAudioPlaying = true
// 首先检查缓存中是否已有该音频文件
if (audioCache.has(fileName)) {
const cachedFilePath = audioCache.get(fileName)!
console.log('使用缓存的音频文件:', cachedFilePath)
// 获取缓存文件大小并更新统计(如果还没有统计信息)
if (!cacheStats.has(`audio_${fileName}`)) {
const fs = wx.getFileSystemManager()
fs.getFileInfo({
filePath: cachedFilePath,
success: (infoRes) => {
const fileSize = infoRes.size
cacheStats.set(`audio_${fileName}`, fileSize)
console.log(`缓存音频文件大小: ${fileSize} bytes`)
},
fail: (err) => {
console.error('获取缓存音频文件大小失败:', err)
}
})
}
// 直接播放缓存的音频文件
this.playAudioFromFile(cachedFilePath)
.then(() => {
this.isAudioPlaying = false // 播放完成后重置状态
resolve()
})
.catch((error) => {
this.isAudioPlaying = false // 播放失败后重置状态
reject(error)
})
return
}
// 构建音频文件的完整URL
const audioUrl = `${BASE_URL}/api/v1/dict/audio/${encodeURIComponent(fileName)}`
// 获取认证头
const headers = this.getHeaders()
// 先下载音频文件到本地
wx.downloadFile({
url: audioUrl,
header: headers,
success: (res) => {
if (res.statusCode === 200) {
// 确保音频缓存目录存在
const fs = wx.getFileSystemManager()
const audioCacheDir = `${wx.env.USER_DATA_PATH}/audio_cache`
try {
// 检查目录是否存在,不存在则创建
fs.accessSync(audioCacheDir)
} catch (dirError) {
// 目录不存在,创建目录(包括父级目录)
try {
fs.mkdirSync(audioCacheDir, true)
console.log('音频缓存目录创建成功:', audioCacheDir)
} catch (mkdirError) {
console.error('创建音频缓存目录失败:', mkdirError)
// 如果目录创建失败,仍然使用临时文件
this.handleAudioFileSaveFailure(res.tempFilePath, fileName, resolve, reject)
return
}
}
// 构建目标文件路径
const persistentFilePath = `${audioCacheDir}/${fileName}`
// 确保目标文件的父目录也存在(处理嵌套目录的情况)
const lastSlashIndex = fileName.lastIndexOf('/')
if (lastSlashIndex > 0) {
const subDir = fileName.substring(0, lastSlashIndex)
const fullSubDir = `${audioCacheDir}/${subDir}`
try {
fs.accessSync(fullSubDir)
} catch (subDirError) {
// 子目录不存在,创建子目录
try {
fs.mkdirSync(fullSubDir, true)
console.log('音频缓存子目录创建成功:', fullSubDir)
} catch (subMkdirError) {
console.error('创建音频缓存子目录失败:', subMkdirError)
}
}
}
// 将临时文件移动到持久化存储目录
fs.saveFile({
tempFilePath: res.tempFilePath,
filePath: persistentFilePath,
success: (saveRes: any) => {
// 将文件路径缓存起来
audioCache.set(fileName, persistentFilePath)
console.log('音频文件保存成功并已缓存:', persistentFilePath)
// 获取文件大小并更新统计
fs.getFileInfo({
filePath: persistentFilePath,
success: (infoRes) => {
const fileSize = infoRes.size
cacheStats.set(`audio_${fileName}`, fileSize)
console.log(`音频文件大小: ${fileSize} bytes`)
},
fail: (err) => {
console.error('获取音频文件大小失败:', err)
}
})
// 播放音频
this.playAudioFromFile(persistentFilePath)
.then(() => {
this.isAudioPlaying = false // 播放完成后重置状态
resolve()
})
.catch((error) => {
this.isAudioPlaying = false // 播放失败后重置状态
reject(error)
})
},
fail: (saveError) => {
console.error('保存音频文件失败:', saveError)
this.handleAudioFileSaveFailure(res.tempFilePath, fileName, resolve, reject)
}
})
} else {
console.error('下载音频文件失败,状态码:', res.statusCode)
this.isAudioPlaying = false // 下载失败后重置状态
reject(new Error(`下载音频文件失败,状态码: ${res.statusCode}`))
}
},
fail: (error) => {
console.error('下载音频文件失败:', error)
this.isAudioPlaying = false // 下载失败后重置状态
reject(error)
}
})
})
}
// 从本地文件播放音频
private playAudioFromFile(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
// 使用微信的音频播放API
const innerAudioContext = wx.createInnerAudioContext()
// 设置音频源
innerAudioContext.src = filePath
// 监听播放事件
innerAudioContext.onPlay(() => {
console.log('开始播放音频')
resolve()
})
innerAudioContext.onError((res) => {
console.error('音频播放错误:', res.errMsg)
console.error('错误码:', res.errCode)
this.isAudioPlaying = false // 播放错误后重置状态
reject(new Error(res.errMsg))
})
// 播放完成后清理资源并重置状态
innerAudioContext.onEnded(() => {
this.isAudioPlaying = false // 播放完成后重置状态
innerAudioContext.destroy()
})
// 开始播放
innerAudioContext.play()
})
}
// 处理音频文件保存失败的情况
private handleAudioFileSaveFailure(tempFilePath: string, fileName: string, resolve: (value: void | PromiseLike<void>) => void, reject: (reason?: any) => void) {
console.log('使用临时文件播放音频')
// 将临时文件路径缓存起来
audioCache.set(fileName, tempFilePath)
// 获取文件大小并更新统计
const fs = wx.getFileSystemManager()
fs.getFileInfo({
filePath: tempFilePath,
success: (infoRes) => {
const fileSize = infoRes.size
cacheStats.set(`audio_${fileName}`, fileSize)
console.log(`临时音频文件大小: ${fileSize} bytes`)
},
fail: (err) => {
console.error('获取临时音频文件大小失败:', err)
}
})
// 播放音频
this.playAudioFromFile(tempFilePath)
.then(() => resolve())
.catch((error) => reject(error))
}
// 清除音频缓存
clearAudioCache(): void {
console.log('清除音频缓存,缓存数量:', audioCache.size)
// 删除持久化存储的音频文件
const fs = wx.getFileSystemManager()
const audioCacheDir = `${wx.env.USER_DATA_PATH}/audio_cache`
try {
// 检查目录是否存在
fs.accessSync(audioCacheDir)
// 删除整个音频缓存目录
fs.rmdirSync(audioCacheDir, true)
console.log('音频缓存目录已删除:', audioCacheDir)
} catch (e) {
// 如果目录不存在或删除失败,记录日志但不中断
console.log('音频缓存目录不存在或删除失败:', e)
}
// 清空内存中的缓存映射
audioCache.clear()
// 清除音频相关的缓存统计
const keysToDelete: string[] = []
cacheStats.forEach((value, key) => {
if (key.startsWith('audio_')) {
keysToDelete.push(key)
}
})
keysToDelete.forEach(key => cacheStats.delete(key))
}
// 清除单词详情缓存
clearWordDetailCache(): void {
console.log('清除单词详情缓存')
try {
const storageInfo = wx.getStorageInfoSync()
// 查找所有单词详情相关的缓存键
const wordDetailKeys: string[] = []
storageInfo.keys.forEach(key => {
if (key.startsWith('word_detail_')) {
wordDetailKeys.push(key)
}
})
// 删除所有单词详情缓存
wordDetailKeys.forEach(key => {
wx.removeStorageSync(key)
})
console.log('已清除单词详情缓存,数量:', wordDetailKeys.length)
} catch (e) {
console.error('清除单词详情缓存失败:', e)
}
}
// 获取缓存统计信息
getCacheStats(): { audioCount: number; imageCount: number; thumbnailCount: number; totalSize: number; sizeByType: Record<string, number> } {
console.log('开始获取缓存统计信息')
const startTime = Date.now()
let audioCount = 0
let imageCount = 0
let thumbnailCount = 0
let totalSize = 0
const sizeByType: Record<string, number> = { audio: 0, image: 0, dict: 0, thumbnail: 0 }
// 遍历缓存统计信息
cacheStats.forEach((size, key) => {
totalSize += size
if (key.startsWith('audio_')) {
audioCount++
sizeByType.audio += size
} else if (key.startsWith('image_')) {
imageCount++
sizeByType.image += size
}
})
// 计算缩略图缓存大小
try {
const storageInfo = wx.getStorageInfoSync()
let thumbnailSize = 0
let thumbnailEntries = 0
storageInfo.keys.forEach(key => {
if (key.startsWith('thumbnail_cache_')) {
try {
const item = wx.getStorageSync(key)
if (item && item.size) {
thumbnailSize += item.size
thumbnailEntries++
}
} catch (e) {
console.warn('读取缩略图缓存大小失败:', key, e)
}
}
})
sizeByType.thumbnail = thumbnailSize
totalSize += thumbnailSize
thumbnailCount = thumbnailEntries
} catch (e) {
console.error('获取缩略图缓存统计失败:', e)
}
// 计算词典缓存大小
try {
const storageInfo = wx.getStorageInfoSync()
let dictSize = 0
storageInfo.keys.forEach(key => {
if (key.startsWith('word_detail_')) {
try {
const item = wx.getStorageSync(key)
if (item) {
// 估算 JSON 大小
const jsonString = JSON.stringify(item)
dictSize += jsonString.length
}
} catch (e) {
console.warn('读取词典缓存大小失败:', key, e)
}
}
})
sizeByType.dict = dictSize
totalSize += dictSize
} catch (e) {
console.error('获取词典缓存统计失败:', e)
}
const endTime = Date.now()
console.log('缓存统计信息获取完成,耗时:', endTime - startTime, 'ms', {
audioCount,
imageCount,
thumbnailCount,
totalSize,
sizeByType
})
return { audioCount, imageCount, thumbnailCount, totalSize, sizeByType }
}
// 格式化文件大小显示
formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes}B`
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)}KB`
} else if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
} else {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
}
}
// 获取用户信息
async getUserInfo(): Promise<IUserInfo> {
const response = await this.request<IUserInfo>('/api/v1/user/info')
return response.data
}
// 更新用户头像
async updateUserAvatar(fileId: string): Promise<{ url: string }> {
const response = await this.request<{ url: string }>('/api/v1/wx/user/avatar', 'PUT', {
avatar_file_id: fileId
})
return response.data
}
// 获取用户头像
async getUserAvatar(): Promise<{ url: string }> {
const response = await this.request<{ url: string }>('/api/v1/wx/user/avatar', 'GET')
return response.data
}
// 检查登录状态
async checkLoginStatus(): Promise<boolean> {
try {
const app = getApp<IAppOption>()
if (!app.globalData.token) {
return false
}
// 向服务器验证 token 是否有效
await this.request('/api/v1/auth/verify', 'GET', undefined, false)
return true
} catch (error) {
return false
}
}
// 检查本地 token 是否过期
isTokenExpired(): boolean {
try {
const tokenExpiry = wx.getStorageSync('tokenExpiry')
if (!tokenExpiry) {
return true
}
const now = Date.now()
const isExpired = now >= tokenExpiry
console.log('Token 过期检查:', {
current: new Date(now).toLocaleString(),
expiry: new Date(tokenExpiry).toLocaleString(),
isExpired
})
return isExpired
} catch (error) {
console.error('检查 token 过期时间失败:', error)
return true
}
}
// 获取存储的认证信息
getStoredAuthInfo() {
try {
return {
token: wx.getStorageSync('token'),
tokenExpiry: wx.getStorageSync('tokenExpiry'),
sessionUuid: wx.getStorageSync('sessionUuid'),
userInfo: wx.getStorageSync('userInfo'),
dictLevel: wx.getStorageSync('dictLevel') // 新增词典等级
}
} catch (error) {
console.error('获取存储的认证信息失败:', error)
return null
}
}
// 修改用户设置
// async updateUserSettings(settings: { dict_level: string }): Promise<boolean> {
// try {
// const response = await this.request<null>('/api/v1/wx/settings', 'PUT', settings)
// if (response.code === 0 || response.code === 200) {
// // 更新本地缓存和全局状态
// const app = getApp<IAppOption>()
// app.globalData.dictLevel = settings.dict_level
// wx.setStorageSync('dictLevel', settings.dict_level)
// console.log('用户设置更新成功:', settings)
// return true
// } else {
// throw new Error(response.message || response.msg || '更新失败')
// }
// } catch (error) {
// console.error('更新用户设置失败:', error)
// throw error
// }
// }
// 获取词典等级选项
getDictLevelOptions() {
return DICT_LEVEL_OPTIONS
}
// 测试401自动重试机制仅用于开发调试
async test401AutoRetry(): Promise<any> {
console.log('开始测试401自动重试机制')
try {
// 请求一个可能返回401的接口
const response = await this.request('/api/v1/auth/verify', 'GET', undefined, false)
console.log('401测试成功响应:', response)
return response
} catch (error) {
console.log('401测试结果:', error)
throw error
}
}
// 获取审核统计数据
async getAuditStatistics(): Promise<{ total_count: number; today_count: number; image_count: number }> {
try {
console.log('开始获取审核统计数据')
const response = await this.request<{ total_count: number; today_count: number; image_count: number }>('/api/v1/audit/statistics')
console.log('获取审核统计数据成功:', response.data)
return response.data
} catch (error) {
console.error('获取审核统计数据失败:', error)
throw error
}
}
// 获取历史记录数据
async getAuditHistory(page: number = 1, size: number = 15): Promise<IAuditHistoryResponse> {
try {
console.log('开始获取审核历史记录数据', { page, size });
const response = await this.request<IAuditHistoryResponse>(`/api/v1/audit/history?page=${page}&size=${size}`);
console.log('获取审核历史记录数据成功:', response.data);
return response.data;
} catch (error) {
console.error('获取审核历史记录数据失败:', error);
throw error;
}
}
async getDailySummary(page: number = 1, size: number = 15): Promise<IDailySummaryResponse> {
try {
console.log('开始获取历史记录数据', { page, size });
const response = await this.request<IDailySummaryResponse>(`/api/v1/audit/summary?page=${page}&size=${size}`);
console.log('获取历史记录数据成功:', response.data);
return response.data;
} catch (error) {
console.error('获取历史记录数据失败:', error);
throw error;
}
}
async getTodaySummary(page: number = 1, size: number = 15): Promise<IDailySummaryResponse> {
try {
console.log('开始获取今日总结数据', { page, size });
const response = await this.request<IDailySummaryResponse>(`/api/v1/audit/today_summary?page=${page}&size=${size}`);
console.log('获取今日总结数据成功:', response.data);
return response.data;
} catch (error) {
console.error('获取今日总结数据失败:', error);
throw error;
}
}
// 获取口语评估结果
async getAssessmentResult(fileId: string, imageTextId: string): Promise<any> {
try {
console.log('开始获取口语评估结果', { fileId });
const response = await this.request<any>(`/api/v1/recording/assessment`, 'POST', {
file_id: fileId,
image_text_id: imageTextId
})
console.log('获取口语评估结果成功:', response.data);
return response.data;
} catch (error) {
console.error('获取口语评估结果失败:', error);
throw error;
}
}
// 安全下载文件接口
async downloadFile(fileId: string, referrerId?: string): Promise<string> {
if (USE_CLOUD) {
try {
let url = `/api/v1/file/temp_url/${fileId}`
if (referrerId) {
url += `?r=${referrerId}`
}
const resp = await this.wx_request<{ url: string }>(url, 'GET')
const tempUrl = (resp as any)?.data?.url || (resp as any)?.url
if (!tempUrl) {
throw new Error('无法获取临时下载链接')
}
return new Promise((resolve, reject) => {
wx.downloadFile({
url: tempUrl,
success: (res) => {
if (res.statusCode === 200) {
const fs = wx.getFileSystemManager()
const tempPath = res.tempFilePath
const extMatch = tempPath && tempPath.match(/\.[a-zA-Z0-9]+$/)
if (!extMatch) {
resolve(tempPath)
return
}
const ext = extMatch[0]
const filePath = `${wx.env.USER_DATA_PATH}/${fileId}${ext}`
try { fs.unlinkSync(filePath) } catch (e) {}
fs.saveFile({
tempFilePath: tempPath,
filePath,
success: () => resolve(filePath),
fail: () => resolve(tempPath)
})
} else {
reject(new Error(`文件下载失败,状态码: ${res.statusCode}`))
}
},
fail: (error) => reject(new Error('文件下载失败'))
})
})
} catch (e) {
throw e
}
}
return new Promise((resolve, reject) => {
let fileUrl = `${BASE_URL}/api/v1/file/${fileId}`
if (referrerId) {
fileUrl += `?ReferrerId=${referrerId}`
}
const headers = this.getHeaders()
wx.downloadFile({
url: fileUrl,
header: headers,
success: (res) => {
if (res.statusCode === 200) {
const fs = wx.getFileSystemManager()
const tempPath = res.tempFilePath
const extMatch = tempPath && tempPath.match(/\.[a-zA-Z0-9]+$/)
if (!extMatch) {
resolve(tempPath)
return
}
const ext = extMatch[0]
const filePath = `${wx.env.USER_DATA_PATH}/${fileId}${ext}`
try { fs.unlinkSync(filePath) } catch (e) {}
fs.saveFile({
tempFilePath: tempPath,
filePath,
success: () => resolve(filePath),
fail: () => resolve(tempPath)
})
} else if (res.statusCode === 401) {
this.handleTokenExpired()
reject(new Error('认证失败,请重新登录'))
} else {
reject(new Error(`文件下载失败,状态码: ${res.statusCode}`))
}
},
fail: (error) => reject(new Error('文件下载失败'))
})
})
}
// 获取图片的 file_id
async getImageFileId(imageId: string): Promise<string> {
const resp = await this.request<{ file_id: string }>(`/api/v1/image/${imageId}/file_id`, 'GET')
return resp.data.file_id
}
// 将文件ID转换为可显示的图片URL
async getFileDisplayUrl(fileId: string, referrerId?: string): Promise<string> {
try {
// 首先检查图片缓存中是否已有该文件
if (imageCache.has(fileId)) {
const cachedFilePath = imageCache.get(fileId)!;
console.log('使用缓存的图片文件:', cachedFilePath);
return cachedFilePath;
}
// 下载文件
const filePath = await this.downloadFile(fileId, referrerId);
// 将文件路径缓存起来
imageCache.set(fileId, filePath);
// 获取文件大小并更新统计
const fs = wx.getFileSystemManager();
fs.getFileInfo({
filePath: filePath,
success: (infoRes) => {
const fileSize = infoRes.size;
cacheStats.set(`image_${fileId}`, fileSize);
// console.log(`图片文件大小: ${fileSize} bytes`);
},
fail: (err) => {
console.error('获取图片文件大小失败:', err);
}
});
return filePath;
} catch (error) {
console.error('获取文件显示URL失败:', error);
throw error;
}
}
// 清除图片缓存
clearImageCache(): void {
console.log('清除图片缓存,缓存数量:', imageCache.size);
// 删除持久化存储的图片文件
const fs = wx.getFileSystemManager();
// 清空内存中的缓存映射
imageCache.clear();
// 清除图片相关的缓存统计
const keysToDelete: string[] = [];
cacheStats.forEach((value, key) => {
if (key.startsWith('image_')) {
keysToDelete.push(key);
}
});
keysToDelete.forEach(key => cacheStats.delete(key));
}
// 清除缩略图缓存
clearThumbnailCache(): void {
console.log('清除缩略图缓存');
try {
const storageInfo = wx.getStorageInfoSync();
// 查找所有缩略图缓存键
const thumbnailKeys: string[] = [];
storageInfo.keys.forEach(key => {
if (key.startsWith('thumbnail_cache_')) {
thumbnailKeys.push(key);
}
});
// 删除所有缩略图缓存
thumbnailKeys.forEach(key => {
try {
const cacheEntry = wx.getStorageSync(key);
if (cacheEntry && cacheEntry.filePath) {
// 删除缓存的文件
const fs = wx.getFileSystemManager();
fs.unlinkSync(cacheEntry.filePath);
}
wx.removeStorageSync(key);
} catch (e) {
console.warn('删除缩略图缓存失败:', key, e);
}
});
console.log('已清除缩略图缓存,数量:', thumbnailKeys.length);
} catch (e) {
console.error('清除缩略图缓存失败:', e);
}
}
// 获取图片文本和评分信息
async getImageTextInit(imageId: string): Promise<{
image_file_id: string,
assessments: Array<{
id: string
content: string,
ipa: string,
file_id: string,
details: {
assessment: {
code: number
final: number
message: string
voice_id: string
result: {
RefTextId: number,
SentenceId: number,
PronAccuracy: number,
PronFluency: number,
PronCompletion: number,
SuggestedScore: number,
Words: {
Word: string
PronAccuracy: number,
PronFluency: number,
}[]
}
}
} | null
}>
}> {
try {
console.log('开始获取图片文本和评分信息', { imageId });
const app = getApp<IAppOption>()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
const response = await this.request<{
image_file_id: string,
assessments: Array<{
id: string
content: string,
ipa: string,
file_id: string,
details: {
assessment: {
code: number
final: number
message: string
voice_id: string
result: {
RefTextId: number,
SentenceId: number,
PronAccuracy: number,
PronFluency: number,
PronCompletion: number,
SuggestedScore: number,
Words: {
Word: string
PronAccuracy: number,
PronFluency: number,
}[]
}
}
} | null
}>
}>(`/api/v1/image_text/init`, 'POST', {
dict_level: dictLevel,
image_id: imageId
})
console.log('获取图片文本和评分信息成功:', response.data);
return response.data;
} catch (error) {
console.error('获取图片文本和评分信息失败:', error);
throw error;
}
}
async getStandardVoice(text_id: string | number): Promise<{audio_id: number | string}> {
try {
console.log('开始获取标准音频');
const response = await this.request<{audio_id: number | string}>(`/api/v1/image_text/standard/${text_id}`);
console.log('获取标准音频成功:', response.data);
return response.data;
} catch (error) {
console.error('获取标准音频失败:', error);
throw error;
}
}
// 获取积分数据
async getPointsData(): Promise<import("../types/app").IPointsData> {
try {
console.log('开始获取积分数据');
const response = await this.request<import("../types/app").IPointsData>('/api/v1/points/self');
console.log('获取积分数据成功:', response.data);
// 如果返回的数据为null则返回默认值
if (!response.data) {
return { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '', is_subscribed: false, subscription_expires_at: null as any };
}
const d: any = response.data as any
const normalized = {
balance: Number(d.balance || 0),
available_balance: Number(d.available_balance || d.balance || 0),
frozen_balance: Number(d.frozen_balance || 0),
total_purchased: Number(d.total_purchased || 0),
total_refunded: Number(d.total_refunded || 0),
expired_time: String(d.expired_time || ''),
is_subscribed: !!d.is_subscribed,
subscription_expires_at: d.subscription_expires_at || null
}
return normalized as import("../types/app").IPointsData;
} catch (error) {
console.error('获取积分数据失败:', error);
throw error;
}
}
// 兑换码兑换积分
async redeemCoupon(code: string): Promise<any> {
try {
console.log('开始兑换码兑换:', code);
const response = await this.request<any>('/api/v1/coupon/redeem', 'POST', {
code: code
});
console.log('兑换成功:', response.data);
return response.data;
} catch (error) {
console.error('兑换失败:', error);
throw error;
}
}
async createJsapiOrder(product_id: number): Promise<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}> {
const response = await this.request<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}>('/api/v1/wxpay/order/jsapi', 'POST', {
product_id
})
return response.data
}
async createSubscriptionOrder(product_id: string): Promise<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}> {
const response = await this.request<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}>('/api/v1/wxpay/order/jsapi/subscription', 'POST', {
product_id
})
return response.data
}
async getProductList(): Promise<Array<{
id: number
title: string
description: string
points: number
amount_cents: number
one_time: boolean
}>> {
const response = await this.request<Array<{
id: number
title: string
description: string
points: number
amount_cents: number
one_time: boolean
}>>('/api/v1/product/list', 'GET')
return response.data
}
async getSubscriptionPlans(): Promise<Array<{
id: string
name: string
price: number
cycle_type: string
cycle_length: number
max_cycle_usage: number
features?: {
label?: string
extra?: string
}
}>> {
const response = await this.request<Array<{
id: string
name: string
price: number
cycle_type: string
cycle_length: number
max_cycle_usage: number
features?: {
label?: string
extra?: string
}
}>>('/api/v1/product/plan', 'GET')
return response.data
}
async getWxpayOrder(out_trade_no: string): Promise<{
out_trade_no: string
trade_state: string
prepay_id?: string
}> {
const response = await this.request<{ out_trade_no: string; trade_state: string; prepay_id?: string }>(`/api/v1/wxpay/order/${out_trade_no}`, 'GET')
return response.data
}
async listOrders(page: number = 1, size: number = 20): Promise<{ items: Array<{ id: string; created_time: string; amount_cents: number; points?: number | null; product_id?: string | null; refund_status?: string | null }>; total: number }>{
const response = await this.request<{ items: Array<{ id: string; created_time: string; amount_cents: number; points?: number | null; product_id?: string | null; refund_status?: string | null }>; total: number }>(`/api/v1/wxpay/order/list`, 'POST', { page, size })
return response.data
}
async getOrderDetail(out_trade_no: string): Promise<{ out_trade_no: string; transaction_id?: string | null; trade_state: string; amount_cents: number; description: string; can_refund?: boolean | null; refund_status?: string | null; refundable_amount_cents?: number | null; refundable_amount_points?: number | null }>{
const response = await this.request<{ out_trade_no: string; transaction_id?: string | null; trade_state: string; amount_cents: number; description: string; can_refund?: boolean | null; refund_status?: string | null; refundable_amount_cents?: number | null; refundable_amount_points?: number | null }>(`/api/v1/wxpay/order/${out_trade_no}/details`, 'GET')
return response.data
}
async refund(out_trade_no: string, reason: string, amount_cents?: number): Promise<any> {
const payload: any = { out_trade_no, reason }
if (typeof amount_cents === 'number') payload.amount_cents = amount_cents
const response = await this.request<any>(`/api/v1/wxpay/refund`, 'POST', payload)
return response.data
}
async getRefundStatus(out_refund_no: string): Promise<{ out_refund_no: string; status: string }>{
const response = await this.request<{ out_refund_no: string; status: string }>(`/api/v1/wxpay/refund/${out_refund_no}`, 'GET')
return response.data
}
async getSceneSentence(imageId: string): Promise<any> {
const resp = await this.request<any>(`/api/v1/scene/image/${imageId}/sentence`, 'GET')
return resp.data
}
async getRecordResult(textId: string | number): Promise<any> {
const resp = await this.request<any>(`/api/v1/image_text/${textId}/record_result`, 'GET')
return resp.data
}
async createScene(imageId: string, scene_type: string = 'scene_sentence'): Promise<{ task_id: string; status?: string }> {
const resp = await this.request<{ task_id: string; status?: string }>(`/api/v1/scene/create`, 'POST', {
image_id: imageId,
scene_type
})
return resp.data
}
async getSceneTask(taskId: string | number): Promise<{ status: string; message?: string }> {
const resp = await this.request<{ status: string; message?: string }>(`/api/v1/scene/task/${taskId}`, 'GET')
return resp.data
}
async createQaExerciseTask(imageId: number | string, type?: string): Promise<IQaExerciseCreateAccepted> {
const payload: Record<string, any> = { image_id: imageId }
if (type !== undefined) payload.type = type
const resp = await this.request<IQaExerciseCreateAccepted>(`/api/v1/qa/exercises/tasks`, 'POST', payload)
return resp.data
}
async getQaExerciseTaskStatus(taskId: string): Promise<IQaExerciseTaskStatus> {
const resp = await this.request<IQaExerciseTaskStatus>(`/api/v1/qa/exercises/tasks/${taskId}/status`, 'GET')
return resp.data
}
async listQaExercisesByImage(imageId: string, type?: string): Promise<IQaExerciseQueryResponse | null> {
let url = `/api/v1/qa/${imageId}/exercises`
if (type) {
url += `?type=${type}`
}
const resp = await this.request<IQaExerciseQueryResponse | null>(url, 'GET')
return resp.data
}
async getQaConversationSetting(imageId: string): Promise<IQaConversationSettingResponse> {
const resp = await this.request<IQaConversationSettingResponse>('/api/v1/qa/conversations/setting', 'POST', {
image_id: imageId
})
return resp.data
}
async startQaConversation(payload: {
image_id: string | number
scene?: Array<{ en: string; zh: string }>
event?: Array<{ en: string; zh: string }>
user_role?: { en: string; zh: string }
assistant_role?: { en: string; zh: string }
level?: string
info?: string
style?: { en: string; zh: string }
}): Promise<any> {
const resp = await this.request<any>('/api/v1/qa/conversations/start', 'POST', payload)
return resp.data
}
async getQaConversationDetail(sessionId: string | number): Promise<IQaConversationDetail> {
const resp = await this.request<IQaConversationDetail>(`/api/v1/qa/conversations/${sessionId}`, 'GET')
return resp.data
}
async getQaConversationLatest(sessionId: string | number): Promise<IQaConversationDetail> {
const resp = await this.request<IQaConversationDetail>(`/api/v1/qa/conversations/${sessionId}/latest`, 'GET')
return resp.data
}
async listQaConversations(imageId: string | number, page: number = 1, pageSize: number = 10): Promise<any> {
const resp = await this.request<any>(`/api/v1/qa/conversations/${imageId}/list?page=${page}&page_size=${pageSize}`, 'GET')
return resp.data
}
async replyQaConversation(sessionId: string | number, content: string): Promise<any> {
const resp = await this.request<any>(`/api/v1/qa/conversations/${sessionId}/reply`, 'POST', {
content
})
return resp.data
}
async createQaQuestionAttempt(
questionId: string,
payload: {
exercise_id: string
mode: 'choice' | 'free_text' | 'cloze'
selected_options?: string[]
input_text?: string
file_id?: string
is_trial?: boolean
}
): Promise<IQaQuestionAttemptAccepted> {
const resp = await this.request<IQaQuestionAttemptAccepted>(`/api/v1/qa/questions/${questionId}/attempts`, 'POST', payload)
return resp.data
}
async getQaQuestionTaskStatus(taskId: string): Promise<IQaQuestionTaskStatus> {
const resp = await this.request<IQaQuestionTaskStatus>(`/api/v1/qa/question-tasks/${taskId}/status`, 'GET')
return resp.data
}
async getQaResult(questionId: string): Promise<IQaResult> {
const resp = await this.request<IQaResult>(`/api/v1/qa/questions/${questionId}/result`, 'GET')
return resp.data
}
async getQaQuestionAudio(questionId: string | number): Promise<{ file_id: string | number }> {
const resp = await this.request<{ file_id: string | number }>(`/api/v1/qa/questions/${questionId}/audio`, 'GET')
return resp.data
}
}
// 导出单例
const apiManager = new ApiManager()
export default apiManager
export { ApiManager }