refactor(web): align resource APIs and forms with the starter conventions
Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors. Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
This commit is contained in:
1 parent
cef0298c34
commit
6b537c03e0
114 files changed
+2876
-2217
No files matched your search
+14
-14
@@ -1,4 +1,4 @@
|
||||
import { vaultApi } from '@@/apis/vault'
|
||||
import { readNoteFile } from '@@/utils/note-files'
|
||||
import { toasts } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
// @vitest-environment happy-dom
|
||||
@@ -7,7 +7,7 @@ import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { pinia } from '@/pinia'
|
||||
import { usePermissionStore } from '@/pinia/stores/permission'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { ApiError, requestJSON, requestRaw } from './axios'
|
||||
import { ApiError, request } from './axios'
|
||||
|
||||
const user = useUserStore(pinia)
|
||||
const permission = usePermissionStore(pinia)
|
||||
@@ -21,7 +21,7 @@ afterEach(() => {
|
||||
|
||||
it('使用 Bearer 和统一 JSON,省略 Cookie 且禁止缓存', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ path: 'a.md' }))
|
||||
expect(await requestJSON('POST', '/test', { json: { a: 1 } })).toEqual({ path: 'a.md' })
|
||||
expect(await request({ method: 'post', url: '/test', data: { a: 1 } })).toEqual({ code: 0, data: { path: 'a.md' }, message: '成功' })
|
||||
const req = fetch.mock.calls[0]![0] as Request
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer rms_test')
|
||||
expect(req.credentials).toBe('omit')
|
||||
@@ -33,11 +33,11 @@ it('401 清理当前身份及动态路由,403 保留身份,错误只提示
|
||||
permission.install(router, ['vault:read'])
|
||||
expect(router.hasRoute('Workspace')).toBe(true)
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(payload(null, 403, 40300, '权限不足')).mockResolvedValueOnce(payload(null, 401, 40100, '登录已失效'))
|
||||
await expect(requestJSON('GET', '/test')).rejects.toMatchObject({ status: 403, code: 40300 })
|
||||
await expect(request({ method: 'get', url: '/test' })).rejects.toMatchObject({ status: 403, code: 40300 })
|
||||
expect(user.token).toBe('rms_test')
|
||||
expect(router.hasRoute('Workspace')).toBe(true)
|
||||
expect(toasts.map(item => item.text)).toEqual(['权限不足'])
|
||||
await expect(requestJSON('GET', '/test')).rejects.toMatchObject({ status: 401 })
|
||||
await expect(request({ method: 'get', url: '/test' })).rejects.toMatchObject({ status: 401 })
|
||||
expect(user.token).toBe('')
|
||||
expect(router.hasRoute('Workspace')).toBe(false)
|
||||
expect(toasts.map(item => item.text)).toEqual(['权限不足', '登录已失效'])
|
||||
@@ -47,7 +47,7 @@ it.each([200, 401, 403])('旧会话的 HTTP %s 响应不得覆盖或注销新会
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const result = requestJSON('GET', '/slow').catch(error => error)
|
||||
const result = request({ method: 'get', url: '/slow' }).catch(error => error)
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
user.setToken('rms_new')
|
||||
finish(payload({ old: true }, status, status === 200 ? 0 : status * 100))
|
||||
@@ -76,17 +76,17 @@ it('身份恢复失败交给登录或路由页面展示,请求层不重复提
|
||||
})
|
||||
it('业务失败明确拒绝,保留冲突版本,由调用方显示时请求层不重复提示', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ rev: 'current' }, 412, 41200, '版本冲突'))
|
||||
await expect(requestJSON('PUT', '/file', { errorOwner: 'caller' })).rejects.toMatchObject({ status: 412, code: 41200, data: { rev: 'current' } })
|
||||
await expect(request({ method: 'put', url: '/file', errorOwner: 'caller' })).rejects.toMatchObject({ status: 412, code: 41200, data: { rev: 'current' } })
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
it('拒绝旧 JSON 格式及 HTTP 成功中的业务失败,文件与204保留原语义', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{"ok":true}')).mockResolvedValueOnce(payload(null, 200, 40900, '操作失败')).mockResolvedValueOnce(new Response('正文', { headers: { ETag: '"rev"' } })).mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
await expect(requestJSON('GET', '/old')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(requestJSON('GET', '/business')).rejects.toMatchObject({ code: 40900 })
|
||||
const raw = await requestRaw('GET', '/raw/a.md')
|
||||
expect(await raw.text()).toBe('正文')
|
||||
expect(raw.headers.get('ETag')).toBe('"rev"')
|
||||
expect(await requestJSON('POST', '/auth/logout')).toBeNull()
|
||||
await expect(request({ method: 'get', url: '/old' })).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(request({ method: 'get', url: '/business' })).rejects.toMatchObject({ code: 40900 })
|
||||
const raw = await request<string>({ method: 'get', url: '/raw/a.md', responseType: 'text', returnResponse: true })
|
||||
expect(raw.data).toBe('正文')
|
||||
expect(raw.headers.get('etag')).toBe('"rev"')
|
||||
expect(await request({ method: 'post', url: '/auth/logout' })).toBeUndefined()
|
||||
})
|
||||
it('服务端注销失败保留身份;成功才清除本地身份', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(payload(null, 500, 50000, '注销失败')).mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
@@ -102,7 +102,7 @@ it('读取笔记的原始响应解码期间切换会话,旧正文仍须拒绝'
|
||||
vi.spyOn(Response.prototype, 'text').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const pending = vaultApi.readFile('note.md').catch(error => error)
|
||||
const pending = readNoteFile('note.md').catch(error => error)
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
user.setToken('rms_new')
|
||||
finish('旧正文')
|
||||
|
||||
+28
-53
@@ -1,6 +1,6 @@
|
||||
import type { AxiosResponse, Method } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { reportError } from '@@/utils/notifications'
|
||||
import axios, { CanceledError, isAxiosError, isCancel } from 'axios'
|
||||
import axios, { AxiosHeaders, CanceledError, isAxiosError, isCancel } from 'axios'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
@@ -9,26 +9,19 @@ export class ApiError extends Error {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
interface Options {
|
||||
json?: unknown
|
||||
body?: BodyInit
|
||||
headers?: Record<string, string>
|
||||
keepalive?: boolean
|
||||
auth?: boolean
|
||||
interface RequestConfig extends Omit<AxiosRequestConfig<unknown>, 'auth'> {
|
||||
anonymous?: boolean
|
||||
errorOwner?: 'request' | 'caller'
|
||||
signal?: AbortSignal
|
||||
/** 原文和下载需要 ETag、Content-Disposition 等响应头。 */
|
||||
returnResponse?: boolean
|
||||
}
|
||||
const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCredentials: false })
|
||||
export function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
interface Envelope { code: number, data: unknown, message: string }
|
||||
function envelope(value: unknown, status: number): Envelope {
|
||||
function envelope(value: unknown, status: number): ApiResponseData<unknown> {
|
||||
if (!value || typeof value !== 'object' || !('code' in value) || typeof value.code !== 'number'
|
||||
|| !Number.isInteger(value.code) || !('data' in value) || !('message' in value) || typeof value.message !== 'string') {
|
||||
throw new ApiError(status, '服务器响应协议无效')
|
||||
}
|
||||
return value as Envelope
|
||||
return value as ApiResponseData<unknown>
|
||||
}
|
||||
function errorData(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
@@ -49,57 +42,39 @@ async function failure(error: unknown): Promise<ApiError> {
|
||||
const data = envelope(body, response.status)
|
||||
return new ApiError(response.status, data.message, errorData(data.data), data.code)
|
||||
} catch (cause) {
|
||||
return cause instanceof ApiError ? cause : new ApiError(response.status, '服务器响应协议无效')
|
||||
if (cause instanceof ApiError) return cause
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
async function execute<T>(method: string, url: string, options: Options, raw: boolean, decode: (response: AxiosResponse) => Promise<T>): Promise<T> {
|
||||
export function request<T>(config: RequestConfig & { returnResponse: true }): Promise<AxiosResponse<T, unknown, AxiosHeaders>>
|
||||
export function request<T>(config: RequestConfig): Promise<T>
|
||||
export async function request<T>(config: RequestConfig): Promise<T | AxiosResponse<T, unknown, AxiosHeaders>> {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
const token = options.auth === false ? '' : user.token
|
||||
const token = config.anonymous === true ? '' : user.token
|
||||
const headers = new AxiosHeaders(config.headers as ConstructorParameters<typeof AxiosHeaders>[0])
|
||||
headers.set('Authorization', token ? `Bearer ${token}` : undefined)
|
||||
try {
|
||||
const response = await instance.request({
|
||||
method: method as Method,
|
||||
url,
|
||||
headers: { 'Content-Type': options.json !== undefined ? 'application/json' : null, ...options.headers, 'Authorization': token ? `Bearer ${token}` : undefined },
|
||||
data: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
||||
responseType: raw ? 'blob' : 'text',
|
||||
signal: options.signal ? AbortSignal.any([user.getSessionSignal(), options.signal]) : user.getSessionSignal(),
|
||||
fetchOptions: { credentials: 'omit', cache: 'no-store', keepalive: options.keepalive },
|
||||
const response = await instance.request<T, AxiosResponse<T, unknown, AxiosHeaders>>({
|
||||
...config,
|
||||
headers,
|
||||
signal: config.signal ? AbortSignal.any([user.getSessionSignal(), config.signal as AbortSignal]) : user.getSessionSignal(),
|
||||
fetchOptions: { ...config.fetchOptions, credentials: 'omit', cache: 'no-store' },
|
||||
})
|
||||
user.assertSession(version)
|
||||
const result = await decode(response)
|
||||
user.assertSession(version)
|
||||
return result
|
||||
if (response.status === 204) return config.returnResponse ? response : undefined as T
|
||||
if (!config.responseType || config.responseType === 'json') {
|
||||
const result = envelope(response.data, response.status)
|
||||
if (result.code !== 0) throw new ApiError(response.status, result.message, errorData(result.data), result.code)
|
||||
}
|
||||
return config.returnResponse ? response : response.data
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) throw cause
|
||||
if (version !== user.getSessionVersion()) throw new CanceledError('会话已变化')
|
||||
const error = await failure(cause)
|
||||
user.assertSession(version)
|
||||
if (error.status === 401 && token) user.invalidate(version)
|
||||
if (options.errorOwner !== 'caller') reportError(error)
|
||||
if (config.errorOwner !== 'caller') reportError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export function requestJSON<T>(method: string, url: string, options: Options = {}): Promise<T> {
|
||||
return execute(method, url, options, false, async (response) => {
|
||||
if (response.status === 204) return null as T
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(response.data)
|
||||
} catch {
|
||||
throw new ApiError(response.status, '服务器返回了无效 JSON')
|
||||
}
|
||||
const result = envelope(parsed, response.status)
|
||||
if (result.code !== 0) throw new ApiError(response.status, result.message, errorData(result.data), result.code)
|
||||
return result.data as T
|
||||
})
|
||||
}
|
||||
export function requestRaw(method: string, url: string, options: Options = {}): Promise<Response> {
|
||||
return execute(method, url, options, true, async (response) => {
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value != null) headers.set(key, String(value))
|
||||
}
|
||||
return new Response(response.status === 204 ? null : response.data, { status: response.status, headers })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user