diff --git a/web/eslint.config.js b/web/eslint.config.js index 513bcef..51b5f0d 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -43,5 +43,12 @@ export default antfu( 'unicorn/no-instanceof-builtins': 'off', }, }, + { + files: ['src/**/*.{ts,vue}'], + ignores: ['src/common/apis/**', 'src/http/**', 'src/**/*.test.ts'], + rules: { + 'no-restricted-imports': ['error', { paths: [{ name: '@/http/axios', importNames: ['request'], message: '业务请求统一放在 common/apis//index.ts。' }, { name: 'axios', importNames: ['default'], message: '复用 http/axios 的统一请求处理。' }] }], + }, + }, { files: ['src/service-worker.js'], languageOptions: { globals: { __PRECACHE__: 'readonly' } } }, ) diff --git a/web/src/common/apis/assets/index.ts b/web/src/common/apis/assets/index.ts new file mode 100644 index 0000000..f1c1fac --- /dev/null +++ b/web/src/common/apis/assets/index.ts @@ -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({ + 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({ + 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({ + url: `assets/${encodePath(data.path)}`, + method: 'put', + data: body, + errorOwner: 'caller', + }) +} + +/** 下载并导入远程图片。 */ +export function importImageApi(data: Assets.ImportImageRequestData) { + return request({ + url: 'assets/import', + method: 'post', + params: { note: data.note }, + data: { url: data.url }, + errorOwner: 'caller', + }) +} + +/** 核对引用后清理图片。 */ +export function cleanupImagesApi(data: Assets.CleanupImagesRequestData) { + return request({ + url: 'assets/cleanup', + method: 'post', + data, + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/assets/type.ts b/web/src/common/apis/assets/type.ts new file mode 100644 index 0000000..b913ded --- /dev/null +++ b/web/src/common/apis/assets/type.ts @@ -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 }[] }> diff --git a/web/src/common/apis/auth/index.ts b/web/src/common/apis/auth/index.ts index 0d14c0e..76b8f0a 100644 --- a/web/src/common/apis/auth/index.ts +++ b/web/src/common/apis/auth/index.ts @@ -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('POST', '/auth/captcha', { auth: false, json: { replaceId }, errorOwner: 'caller' }), - setup: (password: string) => requestJSON('POST', '/auth/setup', { auth: false, json: { password }, errorOwner: 'caller' }), - login: (password: string, captchaId: string, captchaCode: string) => requestJSON('POST', '/auth/login', { auth: false, json: { password, captchaId, captchaCode }, errorOwner: 'caller' }), - me: () => requestJSON('GET', '/auth/me', { errorOwner: 'caller' }), - logout: () => requestJSON('POST', '/auth/logout'), - ticket: (data: TicketRequest) => requestJSON('POST', '/auth/tickets', { json: data }), +/** 查询初始化状态。 */ +export function getAuthStateApi() { + return request({ + url: 'auth/state', + method: 'get', + anonymous: true, + errorOwner: 'caller', + }) +} + +/** 签发或刷新登录验证码。 */ +export function getCaptchaApi(data: Auth.CaptchaRequestData) { + return request({ + url: 'auth/captcha', + method: 'post', + anonymous: true, + data, + errorOwner: 'caller', + }) +} + +/** 初始化登录密码。 */ +export function setupApi(data: Auth.SetupRequestData) { + return request({ + url: 'auth/setup', + method: 'post', + anonymous: true, + data, + errorOwner: 'caller', + }) +} + +/** 使用密码和验证码登录。 */ +export function loginApi(data: Auth.LoginRequestData) { + return request({ + url: 'auth/login', + method: 'post', + anonymous: true, + data, + errorOwner: 'caller', + }) +} + +/** 恢复当前身份与权限。 */ +export function getCurrentUserApi() { + return request({ + url: 'auth/me', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 撤销当前服务端会话。 */ +export function logoutApi() { + return request({ + url: 'auth/logout', + method: 'post', + }) +} + +/** 签发受限资源票据。 */ +export function createResourceTicketApi(data: Auth.TicketRequestData) { + return request({ + url: 'auth/tickets', + method: 'post', + data, + errorOwner: 'caller', + }) +} + +/** 修改登录密码。 */ +export function changePasswordApi(data: Auth.PasswordRequestData) { + return request({ + url: 'auth/password', + method: 'post', + data, + errorOwner: 'caller', + }) } diff --git a/web/src/common/apis/auth/type.ts b/web/src/common/apis/auth/type.ts new file mode 100644 index 0000000..61ac89a --- /dev/null +++ b/web/src/common/apis/auth/type.ts @@ -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 +export type IdentityResponseData = ApiResponseData +export type CaptchaResponseData = ApiResponseData +export type TicketResponseData = ApiResponseData +export type PasswordResponseData = ApiResponseData<{ ok: boolean }> diff --git a/web/src/pages/workspace/events.test.ts b/web/src/common/apis/events/events.test.ts similarity index 69% rename from web/src/pages/workspace/events.test.ts rename to web/src/common/apis/events/events.test.ts index e1f12d6..5a8c008 100644 --- a/web/src/pages/workspace/events.test.ts +++ b/web/src/common/apis/events/events.test.ts @@ -1,14 +1,16 @@ -import { authApi } from '@@/apis/auth' +import * as authApi from '@@/apis/auth' +import { connectEventsApi } from '@@/apis/events' + // @vitest-environment happy-dom import { afterEach, expect, it, vi } from 'vitest' -import { connectEvents } from './events' +import { apiSuccess } from '../../../../tests/api-response' afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals() }) it('首次连接和重连均补同步,停止后不再连接', async () => { - vi.spyOn(authApi, 'ticket').mockResolvedValue({ url: '/api/v1/resources/once/events/', expiresAt: '2030-01-01T00:00:00Z' }) + 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 @@ -26,7 +28,7 @@ it('首次连接和重连均补同步,停止后不再连接', async () => { } vi.stubGlobal('WebSocket', Socket) let synchronized = 0 - const stop = connectEvents({ onEvents: () => {}, onResync: () => { + const stop = connectEventsApi({ onEvents: () => {}, onResync: () => { synchronized++ } }) await vi.waitFor(() => expect(Socket.instances).toHaveLength(1)) @@ -40,5 +42,5 @@ it('首次连接和重连均补同步,停止后不再连接', async () => { Socket.instances[1].onclose!() vi.advanceTimersByTime(30000) expect(Socket.instances).toHaveLength(2) - expect(authApi.ticket).toHaveBeenCalledTimes(2) + expect(authApi.createResourceTicketApi).toHaveBeenCalledTimes(2) }) diff --git a/web/src/pages/workspace/events.ts b/web/src/common/apis/events/index.ts similarity index 81% rename from web/src/pages/workspace/events.ts rename to web/src/common/apis/events/index.ts index d6b0373..1efc1ce 100644 --- a/web/src/pages/workspace/events.ts +++ b/web/src/common/apis/events/index.ts @@ -1,17 +1,13 @@ -import { authApi } from '@@/apis/auth' +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 interface VaultEvent { - type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed' - path?: string - from?: string - rev?: string -} - -export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void, onResync: () => void }): () => void { +export function connectEventsApi(handlers: EventHandlers): () => void { const user = useUserStore(pinia) const version = user.getSessionVersion() let socket: WebSocket | undefined @@ -29,7 +25,7 @@ export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void, if (stopped || connecting) return connecting = true try { - const ticket = await authApi.ticket({ kind: 'events' }) + const ticket = (await createResourceTicketApi({ kind: 'events' })).data user.assertSession(version) if (stopped) return const url = new URL(ticket.url, location.origin) diff --git a/web/src/common/apis/events/type.ts b/web/src/common/apis/events/type.ts new file mode 100644 index 0000000..aa24106 --- /dev/null +++ b/web/src/common/apis/events/type.ts @@ -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 } diff --git a/web/src/common/apis/files/files.test.ts b/web/src/common/apis/files/files.test.ts new file mode 100644 index 0000000..1457902 --- /dev/null +++ b/web/src/common/apis/files/files.test.ts @@ -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"') +}) diff --git a/web/src/common/apis/files/index.ts b/web/src/common/apis/files/index.ts new file mode 100644 index 0000000..3ee8991 --- /dev/null +++ b/web/src/common/apis/files/index.ts @@ -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({ + url: `files/${encodePath(path)}`, + method: 'get', + responseType: 'text', + returnResponse: true, + errorOwner: 'caller', + }) +} + +/** 按版本写入 Markdown。 */ +export function writeFileApi(data: Files.WriteFileRequestData) { + const headers: Record = { '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({ + 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({ + url: `files/${encodePath(path)}`, + method: 'delete', + errorOwner: 'caller', + }) +} + +/** 创建文件夹。 */ +export function createFolderApi(data: Files.FileRequestData) { + return request({ + url: 'folders', + method: 'post', + data, + errorOwner: 'caller', + }) +} + +/** 移动文件或目录。 */ +export function moveFileApi(data: Files.MoveRequestData) { + return request({ + 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({ + url: 'import', + method: 'post', + data: body, + errorOwner: 'caller', + }) +} + +/** 导出选中内容为 Markdown ZIP。 */ +export function exportFilesApi(data: Files.ExportRequestData) { + return request({ + url: 'export', + method: 'post', + data, + responseType: 'blob', + returnResponse: true, + errorOwner: 'caller', + }) +} + +/** 下载原始文件。 */ +export function getRawFileApi(path: string) { + return request({ + url: `raw/${encodePath(path)}`, + method: 'get', + responseType: 'blob', + returnResponse: true, + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/files/type.ts b/web/src/common/apis/files/type.ts new file mode 100644 index 0000000..36d0abf --- /dev/null +++ b/web/src/common/apis/files/type.ts @@ -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 +export type FileOperationResponseData = ApiResponseData<{ ok: boolean }> +export type ImportResponseData = ApiResponseData diff --git a/web/src/common/apis/fonts/index.ts b/web/src/common/apis/fonts/index.ts new file mode 100644 index 0000000..f8df9e6 --- /dev/null +++ b/web/src/common/apis/fonts/index.ts @@ -0,0 +1,32 @@ +import type * as Fonts from './type' +import { request } from '@/http/axios' + +/** 读取导入字体目录。 */ +export function getFontsApi() { + return request({ + url: 'fonts', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 导入字体。 */ +export function importFontApi(file: File) { + const data = new FormData() + data.append('file', file) + return request({ + url: 'fonts', + method: 'post', + data, + errorOwner: 'caller', + }) +} + +/** 删除导入字体。 */ +export function deleteFontApi(id: string) { + return request({ + url: `fonts/${encodeURIComponent(id)}`, + method: 'delete', + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/fonts/type.ts b/web/src/common/apis/fonts/type.ts new file mode 100644 index 0000000..0c17e11 --- /dev/null +++ b/web/src/common/apis/fonts/type.ts @@ -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 +export type DeleteFontResponseData = ApiResponseData<{ ok: boolean }> diff --git a/web/src/common/apis/maintenance/index.ts b/web/src/common/apis/maintenance/index.ts new file mode 100644 index 0000000..4ff3088 --- /dev/null +++ b/web/src/common/apis/maintenance/index.ts @@ -0,0 +1,131 @@ +import type * as Maintenance from './type' +import { request } from '@/http/axios' + +/** 读取维护状态。 */ +export function getMaintenanceStatusApi() { + return request({ + url: 'maintenance/status', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 导出实例或设置备份。 */ +export function exportBackupApi(kind: Maintenance.BackupKind) { + return request({ + url: `maintenance/export/${kind}`, + method: 'post', + responseType: 'blob', + returnResponse: true, + errorOwner: 'caller', + }) +} + +/** 导出全部 Markdown 笔记。 */ +export function exportNotesApi() { + return request({ + url: 'maintenance/notes/export', + method: 'post', + responseType: 'blob', + returnResponse: true, + errorOwner: 'caller', + }) +} + +/** 导出系统日志。 */ +export function exportLogsApi() { + return request({ + url: 'maintenance/logs/export', + method: 'post', + responseType: 'blob', + returnResponse: true, + errorOwner: 'caller', + }) +} + +/** 创建备份上传。 */ +export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) { + return request({ + url: 'maintenance/uploads', + method: 'post', + data, + errorOwner: 'caller', + }) +} + +/** 顺序上传备份分块。 */ +export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) { + return request({ + 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({ + url: `maintenance/uploads/${encodeURIComponent(id)}/validate`, + method: 'post', + errorOwner: 'caller', + }) +} + +/** 取消备份上传并清理暂存。 */ +export function cancelBackupUploadApi(id: string) { + return request({ + url: `maintenance/uploads/${encodeURIComponent(id)}`, + method: 'delete', + errorOwner: 'caller', + }) +} + +/** 导入已确认的设置备份。 */ +export function importSettingsApi(id: string) { + return request({ + url: 'maintenance/settings/import', + method: 'post', + data: { id, confirmed: true }, + errorOwner: 'caller', + }) +} + +/** 恢复已确认的实例备份。 */ +export function restoreBackupApi(id: string) { + return request({ + url: 'maintenance/restore', + method: 'post', + data: { id, confirmed: true }, + errorOwner: 'caller', + }) +} + +/** 备份后重置设置。 */ +export function resetSettingsApi() { + return request({ + url: 'maintenance/settings/reset', + method: 'post', + data: { confirmed: true }, + errorOwner: 'caller', + }) +} + +/** 优化或重建派生索引。 */ +export function maintainIndexApi(operation: 'optimize' | 'rebuild') { + return request({ + url: `maintenance/index/${operation}`, + method: 'post', + errorOwner: 'caller', + }) +} + +/** 清理非活动临时文件。 */ +export function clearTempApi() { + return request({ + url: 'maintenance/temp/clear', + method: 'post', + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/maintenance/type.ts b/web/src/common/apis/maintenance/type.ts new file mode 100644 index 0000000..9e8e438 --- /dev/null +++ b/web/src/common/apis/maintenance/type.ts @@ -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 +export type BackupUploadResponseData = ApiResponseData<{ id: string }> +export type BackupChunkResponseData = ApiResponseData<{ offset: number }> +export type BackupSummaryResponseData = ApiResponseData +export type MaintenanceOperationResponseData = ApiResponseData<{ ok: boolean }> +export type RestoreBackupResponseData = ApiResponseData<{ restarting: boolean }> +export type ClearTempResponseData = ApiResponseData<{ files: number, bytes: number, failed: string[] }> diff --git a/web/src/common/apis/metadata/index.ts b/web/src/common/apis/metadata/index.ts new file mode 100644 index 0000000..6b32380 --- /dev/null +++ b/web/src/common/apis/metadata/index.ts @@ -0,0 +1,11 @@ +import type * as Metadata from './type' +import { request } from '@/http/axios' + +/** 读取笔记元数据索引。 */ +export function getMetadataApi() { + return request({ + url: 'metadata', + method: 'get', + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/metadata/type.ts b/web/src/common/apis/metadata/type.ts new file mode 100644 index 0000000..1a39318 --- /dev/null +++ b/web/src/common/apis/metadata/type.ts @@ -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 }> diff --git a/web/src/common/apis/plugins/index.ts b/web/src/common/apis/plugins/index.ts index d3396e9..467f6d4 100644 --- a/web/src/common/apis/plugins/index.ts +++ b/web/src/common/apis/plugins/index.ts @@ -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('POST', '/plugins', { errorOwner: 'caller', body }) - }, - togglePlugin: (id: string, enabled: boolean) => requestJSON('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({ + url: 'plugins', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 安装插件 ZIP。 */ +export function installPluginApi(file: File) { + const data = new FormData() + data.append('file', file) + return request({ + url: 'plugins', + method: 'post', + data, + errorOwner: 'caller', + }) +} + +/** 启用或停用插件。 */ +export function togglePluginApi(data: Plugins.TogglePluginRequestData) { + return request({ + url: `plugins/${encodeURIComponent(data.id)}`, + method: 'patch', + data: { enabled: data.enabled }, + errorOwner: 'caller', + }) +} + +/** 卸载插件并保留插件数据。 */ +export function uninstallPluginApi(id: string) { + return request({ + url: `plugins/${encodeURIComponent(id)}`, + method: 'delete', + errorOwner: 'caller', + }) +} + +/** 读取插件存储。 */ +export function getPluginDataApi(id: string) { + return request({ + url: `plugins/${encodeURIComponent(id)}/data`, + method: 'get', + errorOwner: 'caller', + }) +} + +/** 保存插件存储。 */ +export function savePluginDataApi(data: Plugins.PluginDataRequestData) { + return request({ + url: `plugins/${encodeURIComponent(data.id)}/data`, + method: 'put', + data: { data: data.data }, + errorOwner: 'caller', + }) } diff --git a/web/src/common/apis/plugins/type.ts b/web/src/common/apis/plugins/type.ts index d245b01..c01dc61 100644 --- a/web/src/common/apis/plugins/type.ts +++ b/web/src/common/apis/plugins/type.ts @@ -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 +export type PluginDataResponseData = ApiResponseData<{ data: unknown }> +export type PluginOperationResponseData = ApiResponseData<{ ok: boolean }> diff --git a/web/src/common/apis/preferences/index.ts b/web/src/common/apis/preferences/index.ts deleted file mode 100644 index bbab00a..0000000 --- a/web/src/common/apis/preferences/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CustomFont, Theme } from './type' -import { requestJSON } from '@/http/axios' - -export const preferencesApi = { - settings: () => requestJSON>('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('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('GET', `/themes/${encodeURIComponent(id)}`, { errorOwner: 'caller' }), - patchSettings: (patch: Record) => requestJSON>('PATCH', '/settings', { errorOwner: 'caller', json: patch }), -} diff --git a/web/src/common/apis/preferences/type.ts b/web/src/common/apis/preferences/type.ts deleted file mode 100644 index fee52fd..0000000 --- a/web/src/common/apis/preferences/type.ts +++ /dev/null @@ -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' -} diff --git a/web/src/common/apis/search/index.ts b/web/src/common/apis/search/index.ts new file mode 100644 index 0000000..f019279 --- /dev/null +++ b/web/src/common/apis/search/index.ts @@ -0,0 +1,12 @@ +import type * as Search from './type' +import { request } from '@/http/axios' + +/** 搜索笔记。 */ +export function searchApi(params: Search.SearchRequestData) { + return request({ + url: 'search', + method: 'get', + params, + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/search/type.ts b/web/src/common/apis/search/type.ts new file mode 100644 index 0000000..4d8626f --- /dev/null +++ b/web/src/common/apis/search/type.ts @@ -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[] }> diff --git a/web/src/common/apis/settings/index.ts b/web/src/common/apis/settings/index.ts new file mode 100644 index 0000000..f01a359 --- /dev/null +++ b/web/src/common/apis/settings/index.ts @@ -0,0 +1,21 @@ +import type * as Settings from './type' +import { request } from '@/http/axios' + +/** 读取设置。 */ +export function getSettingsApi() { + return request({ + url: 'settings', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 更新设置。 */ +export function patchSettingsApi(data: Settings.SettingsData) { + return request({ + url: 'settings', + method: 'patch', + data, + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/settings/type.ts b/web/src/common/apis/settings/type.ts new file mode 100644 index 0000000..a48b16e --- /dev/null +++ b/web/src/common/apis/settings/type.ts @@ -0,0 +1,3 @@ +/** 保留插件写入的未知设置,应用端按已知字段校验。 */ +export type SettingsData = Record +export type SettingsResponseData = ApiResponseData diff --git a/web/src/common/apis/storage/index.ts b/web/src/common/apis/storage/index.ts new file mode 100644 index 0000000..fe4374f --- /dev/null +++ b/web/src/common/apis/storage/index.ts @@ -0,0 +1,11 @@ +import type * as Storage from './type' +import { request } from '@/http/axios' + +/** 统计实际存储占用。 */ +export function getStorageApi() { + return request({ + url: 'storage', + method: 'get', + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/storage/type.ts b/web/src/common/apis/storage/type.ts new file mode 100644 index 0000000..b7322c0 --- /dev/null +++ b/web/src/common/apis/storage/type.ts @@ -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 diff --git a/web/src/common/apis/themes/index.ts b/web/src/common/apis/themes/index.ts new file mode 100644 index 0000000..5a8f792 --- /dev/null +++ b/web/src/common/apis/themes/index.ts @@ -0,0 +1,20 @@ +import type * as Themes from './type' +import { request } from '@/http/axios' + +/** 读取可选主题。 */ +export function getThemesApi() { + return request({ + url: 'themes', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 读取主题与样式。 */ +export function getThemeApi(id: string) { + return request({ + url: `themes/${encodeURIComponent(id)}`, + method: 'get', + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/themes/type.ts b/web/src/common/apis/themes/type.ts new file mode 100644 index 0000000..7ef9136 --- /dev/null +++ b/web/src/common/apis/themes/type.ts @@ -0,0 +1,3 @@ +export interface Theme { id: string, name: string, version: string } +export type ThemesResponseData = ApiResponseData<{ themes: Theme[] }> +export type ThemeResponseData = ApiResponseData diff --git a/web/src/common/apis/tokens/index.ts b/web/src/common/apis/tokens/index.ts new file mode 100644 index 0000000..3b55b53 --- /dev/null +++ b/web/src/common/apis/tokens/index.ts @@ -0,0 +1,30 @@ +import type * as Tokens from './type' +import { request } from '@/http/axios' + +/** 读取 API 令牌列表。 */ +export function getTokensApi() { + return request({ + url: 'tokens', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 创建 API 令牌。 */ +export function createTokenApi(data: Tokens.CreateTokenRequestData) { + return request({ + url: 'tokens', + method: 'post', + data, + errorOwner: 'caller', + }) +} + +/** 撤销 API 令牌。 */ +export function deleteTokenApi(id: string) { + return request({ + url: `tokens/${encodeURIComponent(id)}`, + method: 'delete', + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/tokens/type.ts b/web/src/common/apis/tokens/type.ts new file mode 100644 index 0000000..c779b81 --- /dev/null +++ b/web/src/common/apis/tokens/type.ts @@ -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 }> diff --git a/web/src/common/apis/trash/index.ts b/web/src/common/apis/trash/index.ts index 0c99541..f008631 100644 --- a/web/src/common/apis/trash/index.ts +++ b/web/src/common/apis/trash/index.ts @@ -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('DELETE', '/trash', { errorOwner: 'caller' }), - trash: async () => (await requestJSON<{ items: TrashItem[] }>('GET', '/trash', { errorOwner: 'caller' })).items, - restore: (id: string) => requestJSON('POST', `/trash/${id}/restore`, { errorOwner: 'caller' }), - purge: (id: string) => requestJSON('DELETE', `/trash/${id}`, { errorOwner: 'caller' }), +/** 读取回收站。 */ +export function getTrashApi() { + return request({ + url: 'trash', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 恢复回收站条目。 */ +export function restoreTrashApi(id: string) { + return request({ + url: `trash/${encodeURIComponent(id)}/restore`, + method: 'post', + errorOwner: 'caller', + }) +} + +/** 清空回收站并返回逐项结果。 */ +export function clearTrashApi() { + return request({ + url: 'trash', + method: 'delete', + errorOwner: 'caller', + }) +} + +/** 永久删除回收站条目。 */ +export function purgeTrashApi(id: string) { + return request({ + url: `trash/${encodeURIComponent(id)}`, + method: 'delete', + errorOwner: 'caller', + }) } diff --git a/web/src/common/apis/trash/type.ts b/web/src/common/apis/trash/type.ts index 2e1ae04..07fe2dc 100644 --- a/web/src/common/apis/trash/type.ts +++ b/web/src/common/apis/trash/type.ts @@ -9,3 +9,8 @@ export interface TrashPurgeResult { deleted: string[] failed: { id: string, error: string }[] } + +export type TrashResponseData = ApiResponseData<{ items: TrashItem[] }> +export type RestoreTrashResponseData = ApiResponseData +export type ClearTrashResponseData = ApiResponseData +export type PurgeTrashResponseData = ApiResponseData<{ ok: boolean }> diff --git a/web/src/common/apis/tree/index.ts b/web/src/common/apis/tree/index.ts new file mode 100644 index 0000000..dec53ae --- /dev/null +++ b/web/src/common/apis/tree/index.ts @@ -0,0 +1,21 @@ +import type * as Tree from './type' +import { request } from '@/http/axios' + +/** 读取目录树。 */ +export function getTreeApi() { + return request({ + url: 'tree', + method: 'get', + errorOwner: 'caller', + }) +} + +/** 设置或重置目录项图标。 */ +export function setTreeIconApi(data: Tree.TreeIconRequestData) { + return request({ + url: 'tree-icon', + method: 'put', + data, + errorOwner: 'caller', + }) +} diff --git a/web/src/common/apis/vault/type.ts b/web/src/common/apis/tree/type.ts similarity index 53% rename from web/src/common/apis/vault/type.ts rename to web/src/common/apis/tree/type.ts index c8a1b6a..2de188f 100644 --- a/web/src/common/apis/vault/type.ts +++ b/web/src/common/apis/tree/type.ts @@ -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 }> diff --git a/web/src/common/apis/vault/index.ts b/web/src/common/apis/vault/index.ts deleted file mode 100644 index 5dfa139..0000000 --- a/web/src/common/apis/vault/index.ts +++ /dev/null @@ -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, '') } - }, -} diff --git a/web/src/common/appearance/fonts.test.ts b/web/src/common/appearance/fonts.test.ts index 9772f8f..5ed461f 100644 --- a/web/src/common/appearance/fonts.test.ts +++ b/web/src/common/appearance/fonts.test.ts @@ -20,7 +20,7 @@ describe('导入字体资源边界', () => { }) }) -vi.mock('@@/apis/auth/resources', () => ({ +vi.mock('@@/utils/resource-tickets', () => ({ prepareResources: async () => {}, imageResourceRoot: () => '/api/v1/resources/test-images/images/', fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/', diff --git a/web/src/common/appearance/fonts.ts b/web/src/common/appearance/fonts.ts index efab364..c0a7c5d 100644 --- a/web/src/common/appearance/fonts.ts +++ b/web/src/common/appearance/fonts.ts @@ -1,6 +1,7 @@ -import type { CustomFont } from '@@/apis/preferences/type' -import { fontResourceRoot, prepareResources } from '@@/apis/auth/resources' -import { preferencesApi } from '@@/apis/preferences' +import type { CustomFont } from '@@/apis/fonts/type' +import { getFontsApi } from '@@/apis/fonts' +import { fontResourceRoot, prepareResources } from '@@/utils/resource-tickets' + import { shallowRef } from 'vue' export const customFonts = shallowRef([]) @@ -23,5 +24,5 @@ export function parseFontCatalog(value: unknown): CustomFont[] { export async function loadFonts() { await prepareResources() - applyFontCatalog(parseFontCatalog(await preferencesApi.fonts())) + applyFontCatalog(parseFontCatalog((await getFontsApi()).data.fonts)) } diff --git a/web/src/common/assets/styles/app.css b/web/src/common/assets/styles/app.css index 443ff85..9ac6eeb 100644 --- a/web/src/common/assets/styles/app.css +++ b/web/src/common/assets/styles/app.css @@ -148,27 +148,6 @@ button { color: var(--rm-text-muted); line-height: 1.6; } -.rm-auth-card label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: var(--rm-ui-font-size); - color: var(--rm-text-muted); -} -.rm-auth-card input { - height: var(--rm-control-size); - padding: 0 12px; - border: 1px solid var(--rm-divider); - border-radius: 8px; - background: var(--rm-bg); - color: var(--rm-text); - font-size: var(--rm-ui-font-size); - outline: none; -} -.rm-auth-card input:focus { - border-color: var(--rm-accent); - box-shadow: 0 0 0 3px var(--rm-selection); -} .rm-auth-card .rm-btn { height: var(--rm-control-size); justify-content: center; @@ -537,27 +516,6 @@ button { border-radius: 2px; } /* ---- 快速切换 ---- */ -.rm-switcher-backdrop { - position: fixed; - inset: 0; - z-index: 50; - display: flex; - justify-content: center; - align-items: flex-start; - padding-top: 12vh; - background: rgba(0, 0, 0, 0.18); -} -.rm-switcher { - display: flex; - flex-direction: column; - width: min(560px, calc(100vw - 24px)); - max-height: 60vh; - padding-top: 10px; - border-radius: 10px; - background: var(--rm-bg); - box-shadow: var(--rm-shadow); - overflow: hidden; -} .rm-switcher-list { flex: 1; overflow: auto; @@ -823,26 +781,6 @@ button { } /* ---- 对话框 ---- */ -.rm-dialog-backdrop { - position: fixed; - inset: 0; - z-index: 50; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.3); - padding: 16px; -} -.rm-dialog { - width: 100%; - max-width: 560px; - max-height: 80vh; - display: flex; - flex-direction: column; - border-radius: 12px; - background: var(--rm-bg); - box-shadow: var(--rm-shadow); -} .rm-dialog-head { display: flex; align-items: center; @@ -853,30 +791,6 @@ button { margin: 0; font-size: var(--rm-ui-font-size); } -.rm-share-form { - display: flex; - gap: 8px; - padding: 4px 20px 0; -} -.rm-share-form input, -.rm-share-form select { - height: var(--rm-control-size); - padding: 0 10px; - border: 1px solid var(--rm-divider); - border-radius: 6px; - background: var(--rm-bg); - color: var(--rm-text); - font-size: var(--rm-ui-font-size); - outline: none; -} -.rm-share-form input { - flex: 1; - min-width: 0; -} -.rm-share-form input:focus, -.rm-share-form select:focus { - border-color: var(--rm-accent); -} .rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); @@ -931,518 +845,10 @@ button { font: inherit; line-height: 1.4; } -.rm-segmented { - flex: none; - display: inline-flex; - flex-wrap: wrap; - max-width: 100%; - border: 1px solid var(--rm-divider); - border-radius: 6px; - overflow: hidden; -} -.rm-segmented button { - flex: 1 0 auto; - white-space: nowrap; - min-height: var(--rm-control-size); - padding: 4px 10px; - border: 0; - border-left: 1px solid var(--rm-divider); - background: none; - color: var(--rm-text); - font: inherit; - cursor: pointer; -} -.rm-segmented button:first-child { - border-left: 0; -} -.rm-segmented button.on { - background: var(--rm-active); - color: var(--rm-accent-strong); -} .flip { transform: rotate(180deg); } -.rm-dialog-backdrop.rm-settings-backdrop { - padding: 24px; - background: color-mix(in srgb, var(--rm-bg) 30%, transparent); - backdrop-filter: blur(4px); -} -.rm-dialog.rm-settings { - box-sizing: border-box; - flex-direction: row; - width: 100%; - max-width: 960px; - height: min(720px, 100%); - max-height: 100%; - overflow: hidden; - border: 1px solid var(--rm-divider); - background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); - backdrop-filter: blur(20px); - outline: none; -} -.rm-settings-nav { - box-sizing: border-box; - flex: none; - width: 192px; - min-height: 0; - display: flex; - flex-direction: column; - border-right: 1px solid var(--rm-divider); - background: color-mix(in srgb, var(--rm-sidebar-bg) 70%, transparent); -} -.rm-settings .rm-dialog-head { - flex: none; - box-sizing: border-box; - min-height: 52px; - gap: 8px; - padding: 8px 16px; -} -.rm-settings .rm-dialog-head h2 { - font-size: var(--rm-ui-font-size); - font-weight: 600; - min-width: 0; - outline: none; -} -.rm-settings-close-list, -.rm-settings-back { - display: none; -} -.rm-settings-search { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; - min-height: var(--rm-control-size); - margin: 0 8px 8px; - padding: 0 6px; - border: 1px solid var(--rm-divider); - border-radius: 6px; - background: var(--rm-bg); -} -.rm-settings-search > .rm-icon { - flex: none; - width: 16px; - height: 16px; - color: var(--rm-text-muted); -} -.rm-settings-search input { - box-sizing: border-box; - width: 100%; - min-width: 0; - min-height: var(--rm-control-size); - padding: 4px 0; - border: 0; - outline: 0; - background: none; - color: var(--rm-text); - font: inherit; -} -.rm-settings-search input::-webkit-search-cancel-button { - display: none; -} -.rm-settings-search:focus-within { - border-color: var(--rm-accent); -} -.rm-settings-categories { - min-height: 0; - overflow-y: auto; - padding: 0 8px 8px; -} -.rm-settings-tab { - box-sizing: border-box; - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-height: var(--rm-control-size); - margin: 2px 0; - padding: 6px 8px; - border: 0; - border-radius: 6px; - background: none; - color: var(--rm-text-muted); - font: inherit; - text-align: left; - cursor: pointer; -} -.rm-settings-tab .rm-icon { - flex: none; -} -.rm-settings-tab:hover { - background: var(--rm-hover); -} -.rm-settings-tab.on { - background: var(--rm-active); - color: var(--rm-accent-strong); -} -.rm-settings-nav-foot { - margin-top: auto; - padding: 12px 16px; - display: flex; - flex-direction: column; - gap: 4px; - font-size: var(--rm-ui-font-small); - color: var(--rm-text-muted); -} -.rm-settings-nav-foot small { - font-size: inherit; - overflow-wrap: anywhere; -} -.rm-settings-body { - flex: 1; - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; -} -.rm-settings-body > .rm-dialog-head { - border-bottom: 1px solid var(--rm-divider); -} -.rm-settings-save { - flex: none; - font-size: var(--rm-ui-font-small); - color: var(--rm-text-muted); -} -.rm-settings-save.error { - color: var(--rm-danger); -} -.rm-settings-content { - flex: 1; - min-height: 0; - min-width: 0; - overflow-y: auto; - padding: 0 24px 16px; -} -.rm-settings-page { - min-width: 0; -} -.rm-settings .rm-setting { - flex-wrap: wrap; - gap: 12px 16px; - padding: 12px 0; - border-bottom: 1px solid var(--rm-divider); - font-size: var(--rm-ui-font-size); -} -.rm-setting-info { - flex: 1 1 180px; - min-width: 0; -} -.rm-setting-info p { - margin: 4px 0 0; - color: var(--rm-text-muted); - font-size: var(--rm-ui-font-small); - line-height: var(--rm-ui-line-height); - overflow-wrap: anywhere; -} -.rm-setting-control { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: 8px; - max-width: 100%; - min-width: 0; - margin-left: auto; -} -.rm-settings .rm-setting input:not([type='checkbox'], [type='range']), -.rm-settings .rm-setting select { - box-sizing: border-box; - max-width: 260px; - min-width: 0; - min-height: var(--rm-control-size); - padding: 4px 8px; - border: 1px solid var(--rm-divider); - border-radius: 6px; - background: var(--rm-bg); - color: var(--rm-text); - font: inherit; -} -.rm-settings .rm-setting-control > input:not([type='checkbox'], [type='range']) { - width: 240px; - max-width: 100%; -} -.rm-settings .rm-setting select { - max-width: min(260px, 100%); -} -.rm-settings .rm-setting input[type='range'] { - width: 120px; - min-width: 0; - max-width: 100%; - margin: 0; - accent-color: var(--rm-accent); -} -.rm-settings input[type='checkbox'] { - accent-color: var(--rm-accent); -} -.rm-setting-control output { - min-width: 42px; - text-align: right; - font-variant-numeric: tabular-nums; -} -.rm-setting-control code { - overflow-wrap: anywhere; -} -.rm-setting-reset { - flex: none; - color: var(--rm-text-muted); -} -.rm-settings-group-title { - margin: 20px 0 0; - font-size: var(--rm-ui-font-size); - font-weight: 600; - color: var(--rm-text-muted); -} -.rm-settings-group-title:first-child { - margin-top: 16px; -} -.rm-settings-details { - margin: 12px 0; -} -.rm-settings-details > summary { - padding: 8px 0; - min-height: var(--rm-control-size); - box-sizing: border-box; - cursor: pointer; - font-size: var(--rm-ui-font-size); - color: var(--rm-text-muted); -} -.rm-settings :is(.rm-setting, .rm-settings-details, .rm-settings-form):focus { - outline: 2px solid var(--rm-accent); - outline-offset: -2px; - border-radius: 6px; -} -.rm-typography-preview { - margin: 8px 0 0; - padding: 16px; - border: 1px solid var(--rm-divider); - border-radius: 8px; - background: var(--rm-sidebar-bg); - font: var(--rm-font-size)/var(--rm-line-height) var(--rm-font-editor, var(--rm-font-text)); - overflow-wrap: anywhere; -} -.rm-typography-preview h2 { - margin-top: 0; - font-family: var(--rm-font-heading); - font-size: 1.5em; -} -.rm-typography-preview p { - margin: 0; -} -.rm-typography-preview p + p { - margin-top: 1lh; -} -.rm-typography-preview code { - font-family: var(--rm-font-mono); - font-size: 0.9em; - color: var(--rm-inline-code-text); -} -.rm-settings .rm-share-note, -.rm-settings .rm-appearance-note { - margin: 12px 0; - overflow-wrap: anywhere; -} -.rm-settings .rm-share-form { - flex-wrap: wrap; - padding: 16px 0 0; -} -.rm-settings .rm-share-form input { - flex-basis: 180px; -} -.rm-settings .rm-trash-list { - padding: 0; -} -.rm-settings .rm-trash-list li { - flex-wrap: wrap; - padding: 12px 0; - border-bottom: 1px solid var(--rm-divider); - border-radius: 0; -} -.rm-settings .rm-trash-info { - flex-basis: 180px; -} -.rm-settings .rm-token-created { - margin: 12px 0; -} -.rm-settings :is(.rm-snippet-row, .rm-plugin-row) { - padding: 12px 0; - flex-wrap: wrap; -} -.rm-settings .rm-snippet-form { - padding: 12px 0; -} -.rm-settings .rm-plugin-view { - padding: 12px 0; -} -.rm-settings .rm-settings-form { - box-sizing: border-box; - max-width: 400px; - padding: 16px 0; - border: 0; - border-radius: 0; - background: none; - box-shadow: none; -} -.rm-settings-form h3 { - margin: 0; - font-size: var(--rm-ui-font-size); -} -.rm-settings-form .rm-share-note { - margin: 0; -} -.rm-settings-unavailable { - max-width: 240px; - font-size: var(--rm-ui-font-small); - color: var(--rm-text-muted); -} -.rm-settings-result-count { - margin: 12px 0; - font-size: var(--rm-ui-font-small); - color: var(--rm-text-muted); -} -.rm-settings-result { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 12px 0; - border: 0; - border-bottom: 1px solid var(--rm-divider); - background: none; - color: var(--rm-text); - font: inherit; - text-align: left; - cursor: pointer; -} -.rm-settings-result:hover { - background: var(--rm-hover); -} -.rm-settings-result > span { - flex: 1; - min-width: 0; -} -.rm-settings-result strong { - font-weight: 500; -} -.rm-settings-result small { - display: block; - margin-top: 4px; - font-size: var(--rm-ui-font-small); - color: var(--rm-text-muted); - overflow-wrap: anywhere; -} -.rm-settings-result > .rm-icon { - flex: none; - color: var(--rm-text-muted); -} - -@media (max-width: 767px) { - .rm-dialog-backdrop.rm-settings-backdrop { - padding: 0; - } - .rm-dialog.rm-settings { - height: 100%; - max-width: none; - border: 0; - border-radius: 0; - padding-top: env(safe-area-inset-top); - padding-bottom: env(safe-area-inset-bottom); - background: var(--rm-bg); - } - .rm-settings-nav { - display: none; - width: 100%; - border-right: 0; - } - .rm-settings.show-list .rm-settings-nav { - display: flex; - } - .rm-settings.show-list .rm-settings-body { - display: none; - } - .rm-settings.show-list.searching { - flex-direction: column; - } - .rm-settings.show-list.searching .rm-settings-body { - display: flex; - } - .rm-settings.show-list.searching - :is(.rm-settings-categories, .rm-settings-nav-foot, .rm-settings-body > .rm-dialog-head) { - display: none; - } - .rm-settings.show-list:not(.searching) .rm-settings-nav { - flex: 1; - } - .rm-settings .rm-dialog-head { - padding: 8px 12px; - } - .rm-settings-close-list, - .rm-settings-back { - display: inline-flex; - } - .rm-settings-content { - padding: 0 16px 16px; - } - .rm-settings-search { - min-height: 44px; - } - .rm-settings-tab { - min-height: 44px; - } - .rm-settings-save { - max-width: 6em; - } - .rm-settings .rm-setting-info { - flex-basis: 100%; - } - .rm-settings .rm-setting-control { - width: 100%; - max-width: 100%; - } - .rm-setting-control > select { - flex: 1 1 0; - } - .rm-settings input:not([type='checkbox'], [type='radio'], [type='range']), - .rm-settings select, - .rm-settings textarea { - font-size: max(16px, var(--rm-ui-font-size)); - } -} - -.rm-trash-list { - list-style: none; - margin: 0; - padding: 0 12px 12px; - overflow: auto; -} -.rm-trash-list li { - display: flex; - align-items: center; - gap: 10px; - padding: 8px; - border-radius: 8px; - color: var(--rm-text-muted); -} -.rm-trash-list li:hover { - background: var(--rm-hover); -} -.rm-trash-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} -.rm-trash-path { - color: var(--rm-text); - font-size: var(--rm-ui-font-size); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.rm-trash-time { - font-size: var(--rm-ui-font-small); -} - /* ---- 通知 ---- */ .rm-toasts { position: fixed; @@ -1589,31 +995,17 @@ button { font-size: var(--rm-ui-font-size); } .rm-auth-card input, - .rm-share-form input, - .rm-share-form select, .rm-setting select { font-size: var(--rm-ui-font-size); } - .rm-share-form { - flex-wrap: wrap; - } .rm-search-item { padding: 10px 8px; } /* 手机上从顶部弹出,给软键盘留出空间 */ - .rm-switcher-backdrop { - padding-top: calc(8px + env(safe-area-inset-top)); - } - .rm-switcher { - max-height: 50vh; - } .rm-switcher-item { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); } - .rm-trash-list li { - flex-wrap: wrap; - } } /* 主题与 CSS 编辑 */ @@ -1680,36 +1072,6 @@ button { font-size: var(--rm-ui-font-size); } } - -.rm-plugin-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - padding: 12px 20px; - border-bottom: 1px solid var(--rm-divider); -} -.rm-plugin-row label { - display: flex; - align-items: center; - flex: 1; - min-width: 0; - gap: 8px; - overflow-wrap: anywhere; -} -.rm-plugin-row small { - display: block; - color: var(--rm-text-muted); - font-size: var(--rm-ui-font-small); -} -.rm-plugin-row p { - width: 100%; - font-size: var(--rm-ui-font-small); -} -.rm-plugin-row input { - accent-color: var(--rm-accent); - flex: none; -} .rm-plugin-view { padding: 16px; min-width: 0; @@ -1731,28 +1093,10 @@ button { color: var(--rm-accent-strong); } @media (pointer: coarse) { - .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } } - -.rm-font-input { - display: inline-flex; - align-items: center; - gap: 6px; -} -.rm-font-input input { - min-height: var(--rm-control-size); - box-sizing: border-box; - font: inherit; - width: 80px; - padding: 7px; - border: 1px solid var(--rm-divider); - border-radius: 6px; - background: var(--rm-bg); - color: var(--rm-text); -} .rm-tab-menu { right: auto; width: max-content; @@ -1959,15 +1303,6 @@ button { background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); } -.rm-dialog-backdrop { - background: rgba(19, 27, 46, 0.15); - backdrop-filter: blur(8px); -} -.rm-dialog { - border-radius: 20px; - border: 1px solid var(--rm-divider); - background: color-mix(in srgb, var(--rm-bg) 96%, transparent); -} .rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); diff --git a/web/src/common/assets/styles/element-plus.scss b/web/src/common/assets/styles/element-plus.scss index 219158d..d7830a5 100644 --- a/web/src/common/assets/styles/element-plus.scss +++ b/web/src/common/assets/styles/element-plus.scss @@ -3,9 +3,9 @@ --el-font-size-base: var(--rm-ui-font-size); --el-color-primary: var(--rm-accent); --el-bg-color: var(--rm-bg); - --el-bg-color-overlay: var(--rm-panel); + --el-bg-color-overlay: var(--rm-bg); --el-text-color-primary: var(--rm-text); - --el-border-color: var(--rm-border); + --el-border-color: var(--rm-divider); --el-border-radius-base: 8px; } .el-button { @@ -30,9 +30,6 @@ } } -.rm-settings .el-select { - width: 120px; -} .el-button + .el-button { margin-left: 0; } @@ -41,3 +38,69 @@ align-items: center; gap: inherit; } + +/* 复用 RawMark 主题,弹出层与页面使用相同的字体、色彩和密度。 */ +:root { + --el-font-size-small: var(--rm-ui-font-small); + --el-font-size-extra-small: var(--rm-ui-font-small); + --el-text-color-regular: var(--rm-text); + --el-text-color-secondary: var(--rm-text-muted); + --el-text-color-placeholder: var(--rm-text-muted); + --el-border-color-light: var(--rm-divider); + --el-border-color-lighter: var(--rm-divider); + --el-border-color-extra-light: var(--rm-divider); + --el-fill-color: var(--rm-active); + --el-fill-color-light: var(--rm-hover); + --el-fill-color-lighter: var(--rm-hover); + --el-fill-color-blank: var(--rm-bg); + --el-bg-color-overlay: var(--rm-bg); + --el-dialog-padding-primary: 20px; + --el-component-size: var(--rm-control-size); + --el-mask-color: color-mix(in srgb, var(--rm-bg) 30%, transparent); + --el-box-shadow-light: var(--rm-shadow); + --el-color-primary-light-3: color-mix(in srgb, var(--rm-accent) 70%, var(--rm-bg)); + --el-color-primary-light-5: color-mix(in srgb, var(--rm-accent) 50%, var(--rm-bg)); + --el-color-primary-light-7: color-mix(in srgb, var(--rm-accent) 30%, var(--rm-bg)); + --el-color-primary-light-8: color-mix(in srgb, var(--rm-accent) 20%, var(--rm-bg)); + --el-color-primary-light-9: color-mix(in srgb, var(--rm-accent) 10%, var(--rm-bg)); +} +.el-dialog { + max-width: calc(100vw - 24px); + color: var(--rm-text); + border: 1px solid var(--rm-divider); + border-radius: 12px; + box-shadow: var(--rm-shadow); +} +.el-dialog__body { + max-height: 72dvh; + overflow: auto; +} +.el-dialog__title { + font-size: var(--rm-ui-font-heading); +} +.el-form-item__label { + color: var(--rm-text); +} +.el-select { + min-width: 0; +} +.rm-font-picker .el-select { + width: 100%; +} +.rm-picker-dialog .rm-switcher-list { + max-height: 50dvh; + margin-top: 12px; +} +.el-message-box { + max-width: calc(100vw - 32px); +} +@media (max-width: 767px), (pointer: coarse) { + .el-input__inner, + .el-textarea__inner { + font-size: max(16px, var(--rm-ui-font-size)); + } +} + +.el-popper.is-light { + backdrop-filter: blur(20px); +} diff --git a/web/src/common/components/FontPicker/index.vue b/web/src/common/components/FontPicker/index.vue index 245d546..fe36215 100644 --- a/web/src/common/components/FontPicker/index.vue +++ b/web/src/common/components/FontPicker/index.vue @@ -1,18 +1,23 @@ - diff --git a/web/src/common/components/InlineAppearance/index.vue b/web/src/common/components/InlineAppearance/index.vue index 46c0f35..a3fa6ea 100644 --- a/web/src/common/components/InlineAppearance/index.vue +++ b/web/src/common/components/InlineAppearance/index.vue @@ -1,12 +1,17 @@ + + + + diff --git a/web/src/pages/settings/components/AppearanceSettings.vue b/web/src/pages/settings/components/AppearanceSettings.vue index 5a7a556..7073ed9 100644 --- a/web/src/pages/settings/components/AppearanceSettings.vue +++ b/web/src/pages/settings/components/AppearanceSettings.vue @@ -1,17 +1,23 @@ + + + + diff --git a/web/src/pages/settings/components/InterfaceSettings.vue b/web/src/pages/settings/components/InterfaceSettings.vue new file mode 100644 index 0000000..ef8d15a --- /dev/null +++ b/web/src/pages/settings/components/InterfaceSettings.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/web/src/pages/settings/components/MaintenancePanel.vue b/web/src/pages/settings/components/MaintenancePanel.vue index a3cbc7e..5a39452 100644 --- a/web/src/pages/settings/components/MaintenancePanel.vue +++ b/web/src/pages/settings/components/MaintenancePanel.vue @@ -1,18 +1,26 @@ + + diff --git a/web/src/pages/settings/components/SettingRow.vue b/web/src/pages/settings/components/SettingRow.vue index 2f3443c..7f17e91 100644 --- a/web/src/pages/settings/components/SettingRow.vue +++ b/web/src/pages/settings/components/SettingRow.vue @@ -1,7 +1,7 @@ + + diff --git a/web/src/pages/settings/settings-catalog.ts b/web/src/pages/settings/settings-catalog.ts index 44a70d5..c100d7b 100644 --- a/web/src/pages/settings/settings-catalog.ts +++ b/web/src/pages/settings/settings-catalog.ts @@ -1,4 +1,4 @@ -import type { IconName } from '../../common/components/Icon/index.vue' +import type { IconName } from '@@/components/Icon/index.vue' import type { SettingsSection } from '@/pinia/stores/settings' export const SETTINGS_SECTIONS: { id: SettingsSection, title: string, icon: IconName, description: string }[] = [ diff --git a/web/src/pages/workspace/apis/index.ts b/web/src/pages/workspace/apis/index.ts deleted file mode 100644 index 7b8782b..0000000 --- a/web/src/pages/workspace/apis/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { TrashItem } from '@@/apis/trash/type' -import type { TreeIcon } from '@@/apis/vault/type' -import type { ImportResult, NoteMetadata, SearchResult } from './type' -import { encodePath, requestJSON, requestRaw } from '@/http/axios' - -export const workspaceApi = { - importNotes: (dir: string, folder: string, files: File[]) => { - const body = new FormData() - body.append('dir', dir) - body.append('folder', folder) - for (const file of files) body.append('files', file) - return requestJSON('POST', '/import', { errorOwner: 'caller', body }) - }, - exportFiles: (paths: string[]) => requestRaw('POST', '/export', { errorOwner: 'caller', json: { paths } }), - metadata: () => requestJSON<{ notes: NoteMetadata[], ready: boolean }>('GET', '/metadata', { errorOwner: 'caller' }), - setTreeIcon: (path: string, icon: TreeIcon | null) => requestJSON('PUT', '/tree-icon', { errorOwner: 'caller', json: { path, icon } }), - search: async (q: string) => (await requestJSON<{ results: SearchResult[] }>('GET', `/search?q=${encodeURIComponent(q)}`, { errorOwner: 'caller' })).results, - async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise { - const headers: Record = { 'Content-Type': 'text/markdown; charset=utf-8' } - if (ifMatch === '') headers['If-None-Match'] = '*' - else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"` - const res = await requestJSON<{ rev: string }>('PUT', `/files/${encodePath(path)}`, { errorOwner: 'caller', body: content, headers, keepalive: keepalive && new Blob([content]).size < 60_000 }) - return res.rev - }, - deleteFile: (path: string) => requestJSON('DELETE', `/files/${encodePath(path)}`, { errorOwner: 'caller' }), - move: (from: string, to: string) => requestJSON('POST', '/move', { errorOwner: 'caller', json: { from, to } }), - mkdir: (path: string) => requestJSON('POST', '/folders', { errorOwner: 'caller', json: { path } }), - async upload(note: string, file: File): Promise { - const form = new FormData() - form.append('file', file, file.name || 'image') - return (await requestJSON<{ link: string }>('POST', `/assets?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', body: form })).link - }, - cleanupImages: (paths: string[]) => requestJSON<{ deleted: TrashItem[], skipped: { path: string, reason: string }[] }>('POST', '/assets/cleanup', { errorOwner: 'caller', json: { paths } }), - uploadFolderImage: async (folder: string, file: File) => { - const body = new FormData() - body.append('file', file) - return requestJSON<{ link: string }>('POST', `/assets?folder=${encodeURIComponent(folder)}`, { errorOwner: 'caller', body }) - }, - replaceImage: (path: string, file: File) => { - const body = new FormData() - body.append('file', file) - return requestJSON('PUT', `/assets/${encodePath(path)}`, { errorOwner: 'caller', body }) - }, - importImage: async (note: string, url: string): Promise => - (await requestJSON<{ link: string }>('POST', `/assets/import?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', json: { url } })).link, -} diff --git a/web/src/pages/workspace/apis/type.ts b/web/src/pages/workspace/apis/type.ts deleted file mode 100644 index 0c37b10..0000000 --- a/web/src/pages/workspace/apis/type.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface ImportResult { - paths: string[] - notes: number - assets: number - warnings: string[] -} - -export interface NoteMetadata { - path: string - rev: string - title: string - tags: string[] - links: { target: string, line: number }[] - assets: { target: string, line: number }[] - warnings: string[] -} - -export interface SearchResult { - path: string - // 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0 - line: number - snippet: string -} diff --git a/web/src/pages/workspace/batch.test.ts b/web/src/pages/workspace/batch.test.ts index f33eb55..f8108e9 100644 --- a/web/src/pages/workspace/batch.test.ts +++ b/web/src/pages/workspace/batch.test.ts @@ -1,12 +1,15 @@ +import * as filesApi from '@@/apis/files' import { toasts } from '@@/utils/notifications' // @vitest-environment happy-dom import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { pinia } from '@/pinia' import { useUserStore } from '@/pinia/stores/user' -import { workspaceApi } from './apis' + import { batchFiles, exportFiles } from './batch' import { flush, remove } from './workspace' +vi.mock('@@/utils/confirm', () => ({ confirmAction: vi.fn(async () => true) })) + vi.mock('./workspace', () => ({ move: vi.fn(), remove: vi.fn(), refreshTree: vi.fn().mockResolvedValue(undefined), flush: vi.fn(), ws: { tabs: [{ path: 'a.md', dirty: false, status: 'ready' }] } })) const user = useUserStore(pinia) beforeEach(() => { @@ -22,6 +25,7 @@ it('切换会话后批量操作停止,不以新身份继续下一项', async ( finish = resolve })) const pending = batchFiles('delete', ['a.md', 'b.md']) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) user.setToken('rms_new') finish({ ok: true }) await pending @@ -33,7 +37,7 @@ it('等待笔记保存时换会话,不再使用新身份导出旧选择', asyn vi.mocked(flush).mockImplementation(() => new Promise((resolve) => { finish = resolve })) - const request = vi.spyOn(workspaceApi, 'exportFiles') + const request = vi.spyOn(filesApi, 'exportFilesApi') const pending = exportFiles(['a.md']) user.setToken('rms_new') finish() diff --git a/web/src/pages/workspace/batch.ts b/web/src/pages/workspace/batch.ts index c176049..25ba645 100644 --- a/web/src/pages/workspace/batch.ts +++ b/web/src/pages/workspace/batch.ts @@ -1,9 +1,12 @@ +import { exportFilesApi } from '@@/apis/files' + +import { confirmAction } from '@@/utils/confirm' import { toast } from '@@/utils/notifications' +import { basename, joinPath } from '@@/utils/paths' + import { isCancel } from 'axios' -import { workspaceApi } from '@/pages/workspace/apis' import { pinia } from '@/pinia' import { useUserStore } from '@/pinia/stores/user' -import { basename, joinPath } from '../../common/utils/paths' import { minimalPaths } from './selection' import { flush, move, refreshTree, remove, ws } from './workspace' @@ -11,13 +14,13 @@ export async function batchFiles(action: 'move' | 'delete', paths: string[], des const user = useUserStore(pinia) const version = user.getSessionVersion() const subjects = minimalPaths(paths) - if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return + if (action === 'delete' && !await confirmAction(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return const failures: string[] = [] let succeeded = 0 for (const path of subjects) { try { user.assertSession(version) - const result = action === 'move' ? await move(path, joinPath(destination, basename(path))) : await remove(path) + const result = action === 'move' ? await move(path, joinPath(destination, basename(path)), 'caller') : await remove(path, 'caller') if (result.ok) succeeded++ else failures.push(`${path}:${result.error}`) } catch (cause) { @@ -34,6 +37,7 @@ export async function batchFiles(action: 'move' | 'delete', paths: string[], des toast(`刷新目录失败:${String(cause)}`) } toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? `;${failures.join(';')}` : ''}`) + return failures.length === 0 } export async function exportFiles(paths: string[]) { @@ -45,10 +49,10 @@ export async function exportFiles(paths: string[]) { await Promise.all(tabs.map(tab => flush(tab.path))) user.assertSession(version) if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出') - const response = await workspaceApi.exportFiles(subjects) - const blob = await response.blob() + const response = await exportFilesApi({ paths: subjects }) + const blob = response.data user.assertSession(version) - const disposition = response.headers.get('Content-Disposition') ?? '' + const disposition = String(response.headers.get('content-disposition') ?? '') const encoded = /filename\*=utf-8''([^;]+)/i.exec(disposition)?.[1] const plain = /filename="([^"]+)"|filename=([^;]+)/i.exec(disposition) const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? '导出.zip' diff --git a/web/src/pages/workspace/components/EditorChrome.vue b/web/src/pages/workspace/components/EditorChrome.vue index 8ef0f81..844a67d 100644 --- a/web/src/pages/workspace/components/EditorChrome.vue +++ b/web/src/pages/workspace/components/EditorChrome.vue @@ -1,6 +1,6 @@ diff --git a/web/src/pages/workspace/components/ImageDialog.vue b/web/src/pages/workspace/components/ImageDialog.vue index 54bc3b5..4c20205 100644 --- a/web/src/pages/workspace/components/ImageDialog.vue +++ b/web/src/pages/workspace/components/ImageDialog.vue @@ -1,12 +1,11 @@ -