You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
5.9 KiB
160 lines
5.9 KiB
import { useAuthStore } from '~/stores/auth'
|
|
|
|
interface ApiResponse<T = unknown> {
|
|
code: number
|
|
msg: string
|
|
data: T
|
|
}
|
|
|
|
async function request<T = unknown>(
|
|
url: string,
|
|
body: unknown,
|
|
extraHeaders?: Record<string, string>
|
|
): Promise<T> {
|
|
const auth = useAuthStore()
|
|
const router = useRouter()
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
|
|
if (auth.token) {
|
|
headers['Authorization'] = auth.token
|
|
}
|
|
|
|
if (extraHeaders) {
|
|
Object.assign(headers, extraHeaders)
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error: ${response.status}`)
|
|
}
|
|
|
|
const result: ApiResponse<T> = await response.json()
|
|
|
|
if (result.code === 18) {
|
|
auth.logout()
|
|
router.push('/')
|
|
throw new Error(result.msg || 'Unauthorized')
|
|
}
|
|
|
|
if (result.code !== 0) {
|
|
throw new Error(result.msg || 'Unknown error')
|
|
}
|
|
|
|
return result.data
|
|
}
|
|
|
|
// 读操作前缀:以这些开头的 api 方法视为只读,不触发处理中遮罩;其余(add/update/del/save/create…)视为写操作。
|
|
const READONLY_RE = /^(get|list|load|query|fetch|search|count|stat|export|check)/i
|
|
function isMutating(method: string): boolean {
|
|
return !READONLY_RE.test(method)
|
|
}
|
|
|
|
export function useApi() {
|
|
const auth = useAuthStore()
|
|
const { begin, end } = useLoading() // setup 期捕获全局遮罩控制
|
|
|
|
// 写操作(addproduct/updateproduct/delproduct/addproductversion… 及厂家/账号等增删改)
|
|
// 统一套全屏遮罩:请求期间挡住整页、拦截点击,从根上杜绝重复提交/误点。只读 getXxx 不遮罩。
|
|
async function webApi<T = unknown>(method: string, data?: unknown): Promise<T> {
|
|
const extraHeaders: Record<string, string> = {}
|
|
|
|
if (auth.currentAppId) {
|
|
extraHeaders['X-App-Id'] = String(auth.currentAppId)
|
|
}
|
|
|
|
if (!isMutating(method)) {
|
|
return request<T>(`/web/api/api_${method}`, data ?? {}, extraHeaders)
|
|
}
|
|
begin()
|
|
try {
|
|
return await request<T>(`/web/api/api_${method}`, data ?? {}, extraHeaders)
|
|
} finally {
|
|
end()
|
|
}
|
|
}
|
|
|
|
async function consoleApi<T = unknown>(path: string, data?: unknown): Promise<T> {
|
|
return request<T>(`/console/api/${path}`, data ?? {})
|
|
}
|
|
|
|
// 文件直传:后端(api_getcostoken)按「第三方服务配置→存储→阿里云 OSS」签发一个预签名 PUT URL,
|
|
// 浏览器用同样的 Content-Type PUT 直传,最终 URL(去签名 query)作为文件地址返回。
|
|
// 注意:OSS 预签名 PUT 把 Content-Type 计入签名,故这里 PUT 的 Content-Type 必须与提交给后端
|
|
// 签名的 content_type 完全一致;filename 也要一并提交,后端才能拼出完整 object key。
|
|
//
|
|
// opts.target='dist' 走「分发桶」(dl.ymaikj.com),用于 App 安装包;缺省走主桶(固件/产品图)。
|
|
// opts.onProgress 给大文件用:安装包几十 MB,没有进度的话用户只看到按钮转圈转两分钟,
|
|
// 分不清是在传还是卡死了。
|
|
async function uploadFile(
|
|
file: File,
|
|
path: string,
|
|
opts: { target?: string; appName?: string; onProgress?: (percent: number) => void } = {},
|
|
): Promise<string> {
|
|
const contentType = file.type || 'application/octet-stream'
|
|
// opts.appName:安装包是页面上选中的应用;固件/产品图是产品绑定的应用名。
|
|
// 安装包这条:X-App-Id 只有绑定了应用的运营/代理账号才有,超管页面上选的应用
|
|
// 不会进请求头,不带 app_name 就会被拒成「必须先选择应用」。
|
|
// 固件/产品图的目录由服务端按产品 appnames 决定,不看登录态的 X-App-Id。
|
|
const token = await webApi<{
|
|
upload_url?: string
|
|
url?: string
|
|
content_type?: string
|
|
}>('getcostoken', {
|
|
path,
|
|
filename: file.name,
|
|
content_type: contentType,
|
|
target: opts.target || '',
|
|
app_name: opts.appName || '',
|
|
})
|
|
|
|
// ⚠️ 以后端回带的为准:服务端会按扩展名强制改写 Content-Type(安装包就是这样),
|
|
// 而浏览器对 .apk 给出的 File.type 往往是**空字符串**。照 file.type 发 PUT 会与签名不符,
|
|
// OSS 回 403 SignatureDoesNotMatch,报错里完全看不出是类型对不上。
|
|
const putType = token.content_type || contentType
|
|
|
|
const uploadUrl = token.upload_url || token.url || ''
|
|
if (!uploadUrl) {
|
|
throw new Error('未获取到上传地址:请确认已在【第三方服务配置→存储】配置并启用「阿里云 OSS」')
|
|
}
|
|
|
|
// ⚠️ 用 XHR 不用 fetch:fetch 没有上传进度事件(只有下载方向的 stream),
|
|
// 而这个入口就是给几十 MB 的安装包用的。
|
|
await new Promise<void>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('PUT', uploadUrl, true)
|
|
// 必须与后端签名时用的 Content-Type 完全一致,理由见上面 putType
|
|
xhr.setRequestHeader('Content-Type', putType)
|
|
if (opts.onProgress) {
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) opts.onProgress!(Math.round((e.loaded / e.total) * 100))
|
|
}
|
|
}
|
|
xhr.onload = () =>
|
|
xhr.status >= 200 && xhr.status < 300
|
|
? resolve()
|
|
: reject(new Error(`上传失败:HTTP ${xhr.status}`))
|
|
// ⚠️ 跨域被拒时 XHR 只会走到 onerror 且拿不到任何细节(浏览器刻意不暴露),
|
|
// 所以这里必须把最可能的原因写进文案,否则排查时只看到一句「网络错误」。
|
|
xhr.onerror = () =>
|
|
reject(new Error('上传失败:可能是目标存储桶没有配置跨域(CORS)规则,需允许来源 ' + location.origin + ' 的 PUT'))
|
|
xhr.send(file)
|
|
})
|
|
|
|
// 优先用后端给的裸 URL;兜底自己去掉签名 query。
|
|
return token.url || uploadUrl.split('?')[0]
|
|
}
|
|
|
|
return {
|
|
webApi,
|
|
consoleApi,
|
|
uploadFile,
|
|
}
|
|
}
|
|
|