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:
eezd committed 2026-10-26 11:53:37 +08:00
1 parent cef0298c34
commit 6b537c03e0
114 files changed
+2876 -2217

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import type * as Assets from './type'
import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios'
/** 上传笔记图片。 */
export function uploadImageApi(data: Assets.UploadImageRequestData) {
const body = new FormData()
body.append('file', data.file, data.file.name || 'image')
return request<Assets.ImageResponseData>({
url: 'assets',
method: 'post',
params: { note: data.note },
data: body,
errorOwner: 'caller',
})
}
/** 上传目录图片。 */
export function uploadFolderImageApi(data: Assets.FolderImageRequestData) {
const body = new FormData()
body.append('file', data.file)
return request<Assets.ImageResponseData>({
url: 'assets',
method: 'post',
params: { folder: data.folder },
data: body,
errorOwner: 'caller',
})
}
/** 替换原路径图片。 */
export function replaceImageApi(data: Assets.ReplaceImageRequestData) {
const body = new FormData()
body.append('file', data.file)
return request<Assets.ReplaceImageResponseData>({
url: `assets/${encodePath(data.path)}`,
method: 'put',
data: body,
errorOwner: 'caller',
})
}
/** 下载并导入远程图片。 */
export function importImageApi(data: Assets.ImportImageRequestData) {
return request<Assets.ImageResponseData>({
url: 'assets/import',
method: 'post',
params: { note: data.note },
data: { url: data.url },
errorOwner: 'caller',
})
}
/** 核对引用后清理图片。 */
export function cleanupImagesApi(data: Assets.CleanupImagesRequestData) {
return request<Assets.CleanupImagesResponseData>({
url: 'assets/cleanup',
method: 'post',
data,
errorOwner: 'caller',
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { TrashItem } from '../trash/type'
export interface UploadImageRequestData { note: string, file: File }
export interface FolderImageRequestData { folder: string, file: File }
export interface ReplaceImageRequestData { path: string, file: File }
export interface ImportImageRequestData { note: string, url: string }
export interface CleanupImagesRequestData { paths: string[] }
export type ImageResponseData = ApiResponseData<{ link: string }>
export type ReplaceImageResponseData = ApiResponseData<{ ok: boolean }>
export type CleanupImagesResponseData = ApiResponseData<{ deleted: TrashItem[], skipped: { path: string, reason: string }[] }>
+80 -14
View File
@@ -1,16 +1,82 @@
import { requestJSON } from '@/http/axios'
import type * as Auth from './type'
import { request } from '@/http/axios'
export interface LoginResult { token: string, expiresAt: string }
export interface Identity { permissions: string[], expiresAt: string }
export interface Captcha { captchaId: string, imageDataUrl: string, expiresAt: string }
export interface ResourceTicket { url: string, expiresAt: string }
export type TicketRequest = { kind: 'images' | 'fonts' | 'events' } | { kind: 'plugin', pluginId: string, revision: string }
export const authApi = {
state: () => requestJSON<{ setUp: boolean }>('GET', '/auth/state', { auth: false, errorOwner: 'caller' }),
captcha: (replaceId?: string) => requestJSON<Captcha>('POST', '/auth/captcha', { auth: false, json: { replaceId }, errorOwner: 'caller' }),
setup: (password: string) => requestJSON<LoginResult>('POST', '/auth/setup', { auth: false, json: { password }, errorOwner: 'caller' }),
login: (password: string, captchaId: string, captchaCode: string) => requestJSON<LoginResult>('POST', '/auth/login', { auth: false, json: { password, captchaId, captchaCode }, errorOwner: 'caller' }),
me: () => requestJSON<Identity>('GET', '/auth/me', { errorOwner: 'caller' }),
logout: () => requestJSON<null>('POST', '/auth/logout'),
ticket: (data: TicketRequest) => requestJSON<ResourceTicket>('POST', '/auth/tickets', { json: data }),
/** 查询初始化状态。 */
export function getAuthStateApi() {
return request<Auth.StateResponseData>({
url: 'auth/state',
method: 'get',
anonymous: true,
errorOwner: 'caller',
})
}
/** 签发或刷新登录验证码。 */
export function getCaptchaApi(data: Auth.CaptchaRequestData) {
return request<Auth.CaptchaResponseData>({
url: 'auth/captcha',
method: 'post',
anonymous: true,
data,
errorOwner: 'caller',
})
}
/** 初始化登录密码。 */
export function setupApi(data: Auth.SetupRequestData) {
return request<Auth.LoginResponseData>({
url: 'auth/setup',
method: 'post',
anonymous: true,
data,
errorOwner: 'caller',
})
}
/** 使用密码和验证码登录。 */
export function loginApi(data: Auth.LoginRequestData) {
return request<Auth.LoginResponseData>({
url: 'auth/login',
method: 'post',
anonymous: true,
data,
errorOwner: 'caller',
})
}
/** 恢复当前身份与权限。 */
export function getCurrentUserApi() {
return request<Auth.IdentityResponseData>({
url: 'auth/me',
method: 'get',
errorOwner: 'caller',
})
}
/** 撤销当前服务端会话。 */
export function logoutApi() {
return request<void>({
url: 'auth/logout',
method: 'post',
})
}
/** 签发受限资源票据。 */
export function createResourceTicketApi(data: Auth.TicketRequestData) {
return request<Auth.TicketResponseData>({
url: 'auth/tickets',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 修改登录密码。 */
export function changePasswordApi(data: Auth.PasswordRequestData) {
return request<Auth.PasswordResponseData>({
url: 'auth/password',
method: 'post',
data,
errorOwner: 'caller',
})
}
-40
View File
@@ -1,40 +0,0 @@
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { authApi } from './index'
let version = -1
let imageRoot = ''
let fontRoot = ''
let pending: Promise<void> | undefined
export async function prepareResources() {
const user = useUserStore(pinia)
const current = user.getSessionVersion()
if (version !== current) {
version = current; imageRoot = ''; fontRoot = ''; pending = undefined
}
if (imageRoot && fontRoot) return
if (!pending) {
const request = Promise.all([authApi.ticket({ kind: 'images' }), authApi.ticket({ kind: 'fonts' })]).then(([images, fonts]) => {
user.assertSession(current)
imageRoot = images.url
fontRoot = fonts.url
})
pending = request
void request.finally(() => {
if (pending === request) pending = undefined
}).catch(() => { /* 由等待初始化的调用方处理失败。 */ })
}
await pending
user.assertSession(current)
}
export function imageResourceRoot(): string {
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('图片资源认证尚未就绪')
return imageRoot
}
export function fontResourceRoot(): string {
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('字体资源认证尚未就绪')
return fontRoot
}
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {
return `${(await authApi.ticket({ kind: 'plugin', pluginId, revision })).url}main.js`
}
+15
View File
@@ -0,0 +1,15 @@
export interface LoginRequestData { password: string, captchaId: string, captchaCode: string }
export interface SetupRequestData { password: string }
export interface CaptchaRequestData { replaceId?: string }
export interface PasswordRequestData { current: string, next: string }
export interface LoginResult { token: string, expiresAt: string }
export interface Identity { permissions: string[], expiresAt: string }
export interface Captcha { captchaId: string, imageDataUrl: string, expiresAt: string }
export interface ResourceTicket { url: string, expiresAt: string }
export type TicketRequestData = { kind: 'images' | 'fonts' | 'events' } | { kind: 'plugin', pluginId: string, revision: string }
export type StateResponseData = ApiResponseData<{ setUp: boolean }>
export type LoginResponseData = ApiResponseData<LoginResult>
export type IdentityResponseData = ApiResponseData<Identity>
export type CaptchaResponseData = ApiResponseData<Captcha>
export type TicketResponseData = ApiResponseData<ResourceTicket>
export type PasswordResponseData = ApiResponseData<{ ok: boolean }>
+46
View File
@@ -0,0 +1,46 @@
import * as authApi from '@@/apis/auth'
import { connectEventsApi } from '@@/apis/events'
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { apiSuccess } from '../../../../tests/api-response'
afterEach(() => {
vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals()
})
it('首次连接和重连均补同步,停止后不再连接', async () => {
vi.spyOn(authApi, 'createResourceTicketApi').mockResolvedValue(apiSuccess({ url: '/api/v1/resources/once/events/', expiresAt: '2030-01-01T00:00:00Z' }))
vi.useFakeTimers()
class Socket {
static CLOSED = 3
static instances: Socket[] = []
readyState = 0
onopen?: () => void
onclose?: () => void
close() {
this.readyState = Socket.CLOSED
}
constructor() {
Socket.instances.push(this)
}
}
vi.stubGlobal('WebSocket', Socket)
let synchronized = 0
const stop = connectEventsApi({ onEvents: () => {}, onResync: () => {
synchronized++
} })
await vi.waitFor(() => expect(Socket.instances).toHaveLength(1))
Socket.instances[0].onopen!()
expect(synchronized).toBe(1)
Socket.instances[0].onclose!()
await vi.advanceTimersByTimeAsync(1000)
Socket.instances[1].onopen!()
expect(synchronized).toBe(2)
stop()
Socket.instances[1].onclose!()
vi.advanceTimersByTime(30000)
expect(Socket.instances).toHaveLength(2)
expect(authApi.createResourceTicketApi).toHaveBeenCalledTimes(2)
})
+61
View File
@@ -0,0 +1,61 @@
import type { EventHandlers } from './type'
import { createResourceTicketApi } from '@@/apis/auth'
// 订阅仓库事件;每次连接领取一次性票据,重连后补全量同步。
import { isCancel } from 'axios'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
export function connectEventsApi(handlers: EventHandlers): () => void {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
let socket: WebSocket | undefined
let delay = 1000
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
let connecting = false
function reconnect() {
if (stopped || version !== user.getSessionVersion()) return
timer = setTimeout(() => void connect(), delay)
delay = Math.min(delay * 2, 30000)
}
async function connect() {
if (stopped || connecting) return
connecting = true
try {
const ticket = (await createResourceTicketApi({ kind: 'events' })).data
user.assertSession(version)
if (stopped) return
const url = new URL(ticket.url, location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(url)
socket = ws
ws.onopen = () => {
delay = 1000
handlers.onResync()
}
ws.onmessage = event => handlers.onEvents(JSON.parse(event.data))
ws.onclose = reconnect
} catch (error) {
if (!isCancel(error)) reconnect()
} finally {
connecting = false
}
}
void connect()
const onVisible = () => {
if (document.visibilityState === 'visible' && (!socket || socket.readyState === WebSocket.CLOSED)) {
clearTimeout(timer)
void connect()
}
}
document.addEventListener('visibilitychange', onVisible)
return () => {
stopped = true
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisible)
socket?.close()
}
}
+8
View File
@@ -0,0 +1,8 @@
export interface VaultEvent {
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
path?: string
from?: string
rev?: string
}
export interface EventHandlers { onEvents: (events: VaultEvent[]) => void, onResync: () => void }
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { getCaptchaApi, loginApi } from '@@/apis/auth'
import { importFontApi } from '@@/apis/fonts'
import { searchApi } from '@@/apis/search'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { apiSuccess } from '../../../../tests/api-response'
import { exportFilesApi, importNotesApi, writeFileApi } from './index'
const user = useUserStore(pinia)
beforeEach(() => user.setToken('rms_api_fixture'))
afterEach(() => {
vi.restoreAllMocks(); user.setToken('')
})
const json = (data: unknown) => new Response(JSON.stringify(apiSuccess(data)), { headers: { 'Content-Type': 'application/json' } })
it('aPI 返回完整 JSON 响应,验证码和登录使用真实请求体且不发送现有身份', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(json({ captchaId: 'challenge', imageDataUrl: 'data:image/png;base64,test', expiresAt: '2030-01-01' })).mockResolvedValueOnce(json({ token: 'rms_new', expiresAt: '2030-01-01' }))
const captcha = await getCaptchaApi({ replaceId: 'old' })
expect(captcha.code).toBe(0); expect(captcha.data.captchaId).toBe('challenge')
const fields = { password: 'fixture-password', captchaId: captcha.data.captchaId, captchaCode: '123456' }
expect((await loginApi(fields)).data.token).toBe('rms_new')
const requests = fetch.mock.calls.map(call => call[0] as Request)
expect(requests.every(request => !request.headers.has('Authorization'))).toBe(true)
expect(await requests[0]!.json()).toEqual({ replaceId: 'old' })
expect(await requests[1]!.json()).toEqual(fields)
})
it('gET 使用 params 编码查询,保留 Bearer 和完整搜索响应', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({ results: [] }))
expect(await searchApi({ q: '笔记 & 示例' })).toEqual(apiSuccess({ results: [] }))
const request = fetch.mock.calls[0]![0] as Request
expect(new URL(request.url).searchParams.get('q')).toBe('笔记 & 示例')
expect(request.headers.get('Authorization')).toBe('Bearer rms_api_fixture')
})
it('字体与笔记使用真实 multipart 文件,不被 JSON 请求头覆盖', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(json({ id: 'font' })).mockResolvedValueOnce(json({ notes: 1, assets: 0, paths: ['fixture.md'], warnings: [] }))
await importFontApi(new File(['font'], 'fixture.woff2'))
await importNotesApi({ dir: 'folder', folder: '', files: [new File(['# 正文'], 'fixture.md')] })
for (const [input] of fetch.mock.calls) expect((input as Request).headers.get('Content-Type')).toMatch(/^multipart\/form-data; boundary=/)
const body = await (fetch.mock.calls[1]![0] as Request).formData()
expect(body.get('dir')).toBe('folder')
expect(body.get('files')).toMatchObject({ name: 'fixture.md' })
})
it('版本写入保留 Markdown 原文、条件请求头与小文件卸载保存', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => json({ rev: 'next' }))
expect((await writeFileApi({ path: '目录/a.md', content: '# 原文', ifMatch: 'old', keepalive: true })).data.rev).toBe('next')
const request = fetch.mock.calls[0]![0] as Request
expect(request.url).toContain(encodeURIComponent('目录'))
expect(request.headers.get('If-Match')).toBe('"old"')
expect(request.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
expect(fetch.mock.calls[0]![1]).toMatchObject({ keepalive: true })
expect(await request.text()).toBe('# 原文')
await writeFileApi({ path: 'new.md', content: '', ifMatch: '' })
expect((fetch.mock.calls[1]![0] as Request).headers.get('If-None-Match')).toBe('*')
})
it('导出直接返回 Blob 与下载响应头,不模拟 Fetch Response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ZIP', { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="fixture.zip"' } }))
const response = await exportFilesApi({ paths: ['fixture.md'] })
expect(response.data).toBeInstanceOf(Blob)
expect(await response.data.text()).toBe('ZIP')
expect(response.headers.get('Content-Disposition')).toBe('attachment; filename="fixture.zip"')
})
+95
View File
@@ -0,0 +1,95 @@
import type * as Files from './type'
import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios'
/** 读取 Markdown 原文与版本。 */
export function getFileApi(path: string) {
return request<string>({
url: `files/${encodePath(path)}`,
method: 'get',
responseType: 'text',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 按版本写入 Markdown。 */
export function writeFileApi(data: Files.WriteFileRequestData) {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
if (data.ifMatch === '') headers['If-None-Match'] = '*'
else if (data.ifMatch !== undefined) headers['If-Match'] = `"${data.ifMatch}"`
return request<Files.WriteFileResponseData>({
url: `files/${encodePath(data.path)}`,
method: 'put',
data: data.content,
headers,
fetchOptions: { keepalive: !!data.keepalive && new Blob([data.content]).size < 60_000 },
errorOwner: 'caller',
})
}
/** 移入回收站。 */
export function deleteFileApi(path: string) {
return request<Files.DeleteFileResponseData>({
url: `files/${encodePath(path)}`,
method: 'delete',
errorOwner: 'caller',
})
}
/** 创建文件夹。 */
export function createFolderApi(data: Files.FileRequestData) {
return request<Files.FileOperationResponseData>({
url: 'folders',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 移动文件或目录。 */
export function moveFileApi(data: Files.MoveRequestData) {
return request<Files.FileOperationResponseData>({
url: 'move',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 导入 Markdown 或 ZIP。 */
export function importNotesApi(data: Files.ImportRequestData) {
const body = new FormData()
body.append('dir', data.dir)
body.append('folder', data.folder)
for (const file of data.files) body.append('files', file)
return request<Files.ImportResponseData>({
url: 'import',
method: 'post',
data: body,
errorOwner: 'caller',
})
}
/** 导出选中内容为 Markdown ZIP。 */
export function exportFilesApi(data: Files.ExportRequestData) {
return request<Blob>({
url: 'export',
method: 'post',
data,
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 下载原始文件。 */
export function getRawFileApi(path: string) {
return request<Blob>({
url: `raw/${encodePath(path)}`,
method: 'get',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
+12
View File
@@ -0,0 +1,12 @@
import type { TrashItem } from '../trash/type'
export interface FileRequestData { path: string }
export interface WriteFileRequestData { path: string, content: string, ifMatch?: string, keepalive?: boolean }
export interface MoveRequestData { from: string, to: string }
export interface ImportRequestData { dir: string, folder: string, files: File[] }
export interface ExportRequestData { paths: string[] }
export interface ImportResult { paths: string[], notes: number, assets: number, warnings: string[] }
export type WriteFileResponseData = ApiResponseData<{ rev: string }>
export type DeleteFileResponseData = ApiResponseData<TrashItem>
export type FileOperationResponseData = ApiResponseData<{ ok: boolean }>
export type ImportResponseData = ApiResponseData<ImportResult>
+32
View File
@@ -0,0 +1,32 @@
import type * as Fonts from './type'
import { request } from '@/http/axios'
/** 读取导入字体目录。 */
export function getFontsApi() {
return request<Fonts.FontsResponseData>({
url: 'fonts',
method: 'get',
errorOwner: 'caller',
})
}
/** 导入字体。 */
export function importFontApi(file: File) {
const data = new FormData()
data.append('file', file)
return request<Fonts.ImportFontResponseData>({
url: 'fonts',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 删除导入字体。 */
export function deleteFontApi(id: string) {
return request<Fonts.DeleteFontResponseData>({
url: `fonts/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
+4
View File
@@ -0,0 +1,4 @@
export interface CustomFont { id: string, name: string, format: 'ttf' | 'otf' | 'woff' | 'woff2' }
export type FontsResponseData = ApiResponseData<{ fonts: CustomFont[] }>
export type ImportFontResponseData = ApiResponseData<CustomFont>
export type DeleteFontResponseData = ApiResponseData<{ ok: boolean }>
+131
View File
@@ -0,0 +1,131 @@
import type * as Maintenance from './type'
import { request } from '@/http/axios'
/** 读取维护状态。 */
export function getMaintenanceStatusApi() {
return request<Maintenance.MaintenanceResponseData>({
url: 'maintenance/status',
method: 'get',
errorOwner: 'caller',
})
}
/** 导出实例或设置备份。 */
export function exportBackupApi(kind: Maintenance.BackupKind) {
return request<Blob>({
url: `maintenance/export/${kind}`,
method: 'post',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 导出全部 Markdown 笔记。 */
export function exportNotesApi() {
return request<Blob>({
url: 'maintenance/notes/export',
method: 'post',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 导出系统日志。 */
export function exportLogsApi() {
return request<Blob>({
url: 'maintenance/logs/export',
method: 'post',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 创建备份上传。 */
export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) {
return request<Maintenance.BackupUploadResponseData>({
url: 'maintenance/uploads',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 顺序上传备份分块。 */
export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
return request<Maintenance.BackupChunkResponseData>({
url: `maintenance/uploads/${encodeURIComponent(data.id)}`,
method: 'put',
data: data.body,
headers: { 'Upload-Offset': String(data.offset) },
errorOwner: 'caller',
})
}
/** 校验完整备份。 */
export function validateBackupApi(id: string) {
return request<Maintenance.BackupSummaryResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}/validate`,
method: 'post',
errorOwner: 'caller',
})
}
/** 取消备份上传并清理暂存。 */
export function cancelBackupUploadApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
/** 导入已确认的设置备份。 */
export function importSettingsApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/import',
method: 'post',
data: { id, confirmed: true },
errorOwner: 'caller',
})
}
/** 恢复已确认的实例备份。 */
export function restoreBackupApi(id: string) {
return request<Maintenance.RestoreBackupResponseData>({
url: 'maintenance/restore',
method: 'post',
data: { id, confirmed: true },
errorOwner: 'caller',
})
}
/** 备份后重置设置。 */
export function resetSettingsApi() {
return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/reset',
method: 'post',
data: { confirmed: true },
errorOwner: 'caller',
})
}
/** 优化或重建派生索引。 */
export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/index/${operation}`,
method: 'post',
errorOwner: 'caller',
})
}
/** 清理非活动临时文件。 */
export function clearTempApi() {
return request<Maintenance.ClearTempResponseData>({
url: 'maintenance/temp/clear',
method: 'post',
errorOwner: 'caller',
})
}
+12
View File
@@ -0,0 +1,12 @@
export interface MaintenanceStatus { busy: boolean, operation: string, error: string, finishedAt: number, backup?: string }
export interface BackupSummary { kind: 'full' | 'settings', createdAt: number, entries: number, size: number }
export type BackupKind = 'full' | 'settings'
export interface BackupUploadRequestData { kind: BackupKind, size: number }
export interface BackupChunkRequestData { id: string, offset: number, body: Blob }
export type MaintenanceResponseData = ApiResponseData<MaintenanceStatus>
export type BackupUploadResponseData = ApiResponseData<{ id: string }>
export type BackupChunkResponseData = ApiResponseData<{ offset: number }>
export type BackupSummaryResponseData = ApiResponseData<BackupSummary>
export type MaintenanceOperationResponseData = ApiResponseData<{ ok: boolean }>
export type RestoreBackupResponseData = ApiResponseData<{ restarting: boolean }>
export type ClearTempResponseData = ApiResponseData<{ files: number, bytes: number, failed: string[] }>
+11
View File
@@ -0,0 +1,11 @@
import type * as Metadata from './type'
import { request } from '@/http/axios'
/** 读取笔记元数据索引。 */
export function getMetadataApi() {
return request<Metadata.MetadataResponseData>({
url: 'metadata',
method: 'get',
errorOwner: 'caller',
})
}
+2
View File
@@ -0,0 +1,2 @@
export interface NoteMetadata { path: string, rev: string, title: string, tags: string[], links: { target: string, line: number }[], assets: { target: string, line: number }[], warnings: string[] }
export type MetadataResponseData = ApiResponseData<{ notes: NoteMetadata[], ready: boolean }>
+59 -13
View File
@@ -1,15 +1,61 @@
import type { InstalledPlugin } from './type'
import { requestJSON } from '@/http/axios'
import type * as Plugins from './type'
import { request } from '@/http/axios'
export const pluginsApi = {
plugins: async () => (await requestJSON<{ plugins: InstalledPlugin[] }>('GET', '/plugins', { errorOwner: 'caller' })).plugins,
installPlugin: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<InstalledPlugin>('POST', '/plugins', { errorOwner: 'caller', body })
},
togglePlugin: (id: string, enabled: boolean) => requestJSON<InstalledPlugin>('PATCH', `/plugins/${encodeURIComponent(id)}`, { errorOwner: 'caller', json: { enabled } }),
uninstallPlugin: (id: string) => requestJSON('DELETE', `/plugins/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
pluginData: (id: string) => requestJSON<{ data: unknown }>('GET', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller' }),
savePluginData: (id: string, data: unknown) => requestJSON('PUT', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller', json: { data } }),
/** 读取已安装插件。 */
export function getPluginsApi() {
return request<Plugins.PluginsResponseData>({
url: 'plugins',
method: 'get',
errorOwner: 'caller',
})
}
/** 安装插件 ZIP。 */
export function installPluginApi(file: File) {
const data = new FormData()
data.append('file', file)
return request<Plugins.PluginResponseData>({
url: 'plugins',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 启用或停用插件。 */
export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
return request<Plugins.PluginResponseData>({
url: `plugins/${encodeURIComponent(data.id)}`,
method: 'patch',
data: { enabled: data.enabled },
errorOwner: 'caller',
})
}
/** 卸载插件并保留插件数据。 */
export function uninstallPluginApi(id: string) {
return request<Plugins.PluginOperationResponseData>({
url: `plugins/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
/** 读取插件存储。 */
export function getPluginDataApi(id: string) {
return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(id)}/data`,
method: 'get',
errorOwner: 'caller',
})
}
/** 保存插件存储。 */
export function savePluginDataApi(data: Plugins.PluginDataRequestData) {
return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(data.id)}/data`,
method: 'put',
data: { data: data.data },
errorOwner: 'caller',
})
}
+7
View File
@@ -6,3 +6,10 @@ export interface InstalledPlugin {
revision: string
enabled: boolean
}
export interface TogglePluginRequestData { id: string, enabled: boolean }
export interface PluginDataRequestData { id: string, data: unknown }
export type PluginsResponseData = ApiResponseData<{ plugins: InstalledPlugin[] }>
export type PluginResponseData = ApiResponseData<InstalledPlugin>
export type PluginDataResponseData = ApiResponseData<{ data: unknown }>
export type PluginOperationResponseData = ApiResponseData<{ ok: boolean }>
-16
View File
@@ -1,16 +0,0 @@
import type { CustomFont, Theme } from './type'
import { requestJSON } from '@/http/axios'
export const preferencesApi = {
settings: () => requestJSON<Record<string, unknown>>('GET', '/settings', { errorOwner: 'caller' }),
fonts: async () => (await requestJSON<{ fonts: CustomFont[] }>('GET', '/fonts', { errorOwner: 'caller' })).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<CustomFont>('POST', '/fonts', { errorOwner: 'caller', body })
},
deleteFont: (id: string) => requestJSON('DELETE', `/fonts/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
themes: async () => (await requestJSON<{ themes: Theme[] }>('GET', '/themes', { errorOwner: 'caller' })).themes,
theme: (id: string) => requestJSON<Theme & { css: string }>('GET', `/themes/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
patchSettings: (patch: Record<string, unknown>) => requestJSON<Record<string, unknown>>('PATCH', '/settings', { errorOwner: 'caller', json: patch }),
}
-11
View File
@@ -1,11 +0,0 @@
export interface Theme {
id: string
name: string
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
+12
View File
@@ -0,0 +1,12 @@
import type * as Search from './type'
import { request } from '@/http/axios'
/** 搜索笔记。 */
export function searchApi(params: Search.SearchRequestData) {
return request<Search.SearchResponseData>({
url: 'search',
method: 'get',
params,
errorOwner: 'caller',
})
}
+8
View File
@@ -0,0 +1,8 @@
export interface SearchRequestData { q: string }
export interface SearchResult {
path: string
/** 第一处匹配行,从1开始;只有路径匹配时为0。 */
line: number
snippet: string
}
export type SearchResponseData = ApiResponseData<{ results: SearchResult[] }>
+21
View File
@@ -0,0 +1,21 @@
import type * as Settings from './type'
import { request } from '@/http/axios'
/** 读取设置。 */
export function getSettingsApi() {
return request<Settings.SettingsResponseData>({
url: 'settings',
method: 'get',
errorOwner: 'caller',
})
}
/** 更新设置。 */
export function patchSettingsApi(data: Settings.SettingsData) {
return request<Settings.SettingsResponseData>({
url: 'settings',
method: 'patch',
data,
errorOwner: 'caller',
})
}
+3
View File
@@ -0,0 +1,3 @@
/** 保留插件写入的未知设置,应用端按已知字段校验。 */
export type SettingsData = Record<string, unknown>
export type SettingsResponseData = ApiResponseData<SettingsData>
+11
View File
@@ -0,0 +1,11 @@
import type * as Storage from './type'
import { request } from '@/http/axios'
/** 统计实际存储占用。 */
export function getStorageApi() {
return request<Storage.StorageResponseData>({
url: 'storage',
method: 'get',
errorOwner: 'caller',
})
}
+2
View File
@@ -0,0 +1,2 @@
export interface StorageUsage { totalSize: number, categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other', size: number }[], builtinFontsSize: number, customFontsSize: number, assetsSize: number, trashSize: number, calculatedAt: number }
export type StorageResponseData = ApiResponseData<StorageUsage>
+20
View File
@@ -0,0 +1,20 @@
import type * as Themes from './type'
import { request } from '@/http/axios'
/** 读取可选主题。 */
export function getThemesApi() {
return request<Themes.ThemesResponseData>({
url: 'themes',
method: 'get',
errorOwner: 'caller',
})
}
/** 读取主题与样式。 */
export function getThemeApi(id: string) {
return request<Themes.ThemeResponseData>({
url: `themes/${encodeURIComponent(id)}`,
method: 'get',
errorOwner: 'caller',
})
}
+3
View File
@@ -0,0 +1,3 @@
export interface Theme { id: string, name: string, version: string }
export type ThemesResponseData = ApiResponseData<{ themes: Theme[] }>
export type ThemeResponseData = ApiResponseData<Theme & { css: string }>
+30
View File
@@ -0,0 +1,30 @@
import type * as Tokens from './type'
import { request } from '@/http/axios'
/** 读取 API 令牌列表。 */
export function getTokensApi() {
return request<Tokens.TokensResponseData>({
url: 'tokens',
method: 'get',
errorOwner: 'caller',
})
}
/** 创建 API 令牌。 */
export function createTokenApi(data: Tokens.CreateTokenRequestData) {
return request<Tokens.CreateTokenResponseData>({
url: 'tokens',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 撤销 API 令牌。 */
export function deleteTokenApi(id: string) {
return request<Tokens.DeleteTokenResponseData>({
url: `tokens/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
+5
View File
@@ -0,0 +1,5 @@
export interface ApiToken { id: string, name: string, scope: 'read' | 'write', createdAt: number, lastUsedAt: number }
export interface CreateTokenRequestData { name: string, scope: ApiToken['scope'] }
export type TokensResponseData = ApiResponseData<{ tokens: ApiToken[] }>
export type CreateTokenResponseData = ApiResponseData<{ token: string, info: ApiToken }>
export type DeleteTokenResponseData = ApiResponseData<{ ok: boolean }>
+36 -7
View File
@@ -1,9 +1,38 @@
import type { TrashItem, TrashPurgeResult } from './type'
import { requestJSON } from '@/http/axios'
import type * as Trash from './type'
import { request } from '@/http/axios'
export const trashApi = {
clearTrash: () => requestJSON<TrashPurgeResult>('DELETE', '/trash', { errorOwner: 'caller' }),
trash: async () => (await requestJSON<{ items: TrashItem[] }>('GET', '/trash', { errorOwner: 'caller' })).items,
restore: (id: string) => requestJSON<TrashItem>('POST', `/trash/${id}/restore`, { errorOwner: 'caller' }),
purge: (id: string) => requestJSON('DELETE', `/trash/${id}`, { errorOwner: 'caller' }),
/** 读取回收站。 */
export function getTrashApi() {
return request<Trash.TrashResponseData>({
url: 'trash',
method: 'get',
errorOwner: 'caller',
})
}
/** 恢复回收站条目。 */
export function restoreTrashApi(id: string) {
return request<Trash.RestoreTrashResponseData>({
url: `trash/${encodeURIComponent(id)}/restore`,
method: 'post',
errorOwner: 'caller',
})
}
/** 清空回收站并返回逐项结果。 */
export function clearTrashApi() {
return request<Trash.ClearTrashResponseData>({
url: 'trash',
method: 'delete',
errorOwner: 'caller',
})
}
/** 永久删除回收站条目。 */
export function purgeTrashApi(id: string) {
return request<Trash.PurgeTrashResponseData>({
url: `trash/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
+5
View File
@@ -9,3 +9,8 @@ export interface TrashPurgeResult {
deleted: string[]
failed: { id: string, error: string }[]
}
export type TrashResponseData = ApiResponseData<{ items: TrashItem[] }>
export type RestoreTrashResponseData = ApiResponseData<TrashItem>
export type ClearTrashResponseData = ApiResponseData<TrashPurgeResult>
export type PurgeTrashResponseData = ApiResponseData<{ ok: boolean }>
+21
View File
@@ -0,0 +1,21 @@
import type * as Tree from './type'
import { request } from '@/http/axios'
/** 读取目录树。 */
export function getTreeApi() {
return request<Tree.TreeResponseData>({
url: 'tree',
method: 'get',
errorOwner: 'caller',
})
}
/** 设置或重置目录项图标。 */
export function setTreeIconApi(data: Tree.TreeIconRequestData) {
return request<Tree.TreeIconResponseData>({
url: 'tree-icon',
method: 'put',
data,
errorOwner: 'caller',
})
}
@@ -9,3 +9,7 @@ export interface Entry {
mtime: number
icon?: TreeIcon
}
export interface TreeIconRequestData { path: string, icon: TreeIcon | null }
export type TreeResponseData = ApiResponseData<{ entries: Entry[] }>
export type TreeIconResponseData = ApiResponseData<{ ok: boolean }>
-16
View File
@@ -1,16 +0,0 @@
import type { Entry } from './type'
import { encodePath, requestJSON, requestRaw } from '@/http/axios'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
export const vaultApi = {
tree: async () => (await requestJSON<{ entries: Entry[] }>('GET', '/tree', { errorOwner: 'caller' })).entries,
async readFile(path: string): Promise<{ content: string, rev: string }> {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
const res = await requestRaw('GET', `/files/${encodePath(path)}`, { errorOwner: 'caller' })
const content = await res.text()
user.assertSession(version)
return { content, rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
},
}