refactor(web): align resource APIs and forms with the starter conventions
Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors. Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
This commit is contained in:
1 parent
cef0298c34
commit
6b537c03e0
114 files changed
+2876
-2217
No files matched your search
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }[] }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"')
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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[] }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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[] }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** 保留插件写入的未知设置,应用端按已知字段校验。 */
|
||||
export type SettingsData = Record<string, unknown>
|
||||
export type SettingsResponseData = ApiResponseData<SettingsData>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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 }>
|
||||
@@ -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 }>
|
||||
@@ -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, '') }
|
||||
},
|
||||
}
|
||||
@@ -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/',
|
||||
|
||||
@@ -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<CustomFont[]>([])
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { Settings } from '../../../pinia/stores/settings'
|
||||
import { preferencesApi } from '@@/apis/preferences'
|
||||
import type { Settings } from '@/pinia/stores/settings'
|
||||
|
||||
import { deleteFontApi, importFontApi } from '@@/apis/fonts'
|
||||
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, ref } from 'vue'
|
||||
import { pinia } from '@/pinia'
|
||||
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import { customFonts, loadFonts } from '../../appearance/fonts'
|
||||
import { FONT_PRESETS } from '../../appearance/typography'
|
||||
import Icon from '../Icon/index.vue'
|
||||
|
||||
const props = defineProps<{ id: string, setting: 'uiFont' | 'textFont' | 'codeFont' }>()
|
||||
|
||||
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
|
||||
|
||||
const user = useUserStore()
|
||||
const expanded = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
@@ -20,13 +25,12 @@ const fonts = computed(() => [
|
||||
...Object.entries(FONT_PRESETS).filter(([id]) => !settings.hiddenFonts.includes(id)).map(([id, font]) => ({ id, name: font.label })),
|
||||
...customFonts.value,
|
||||
])
|
||||
const selected = computed(() => fonts.value.find(font => font.id === settings[props.setting]) ?? { id: 'maple', name: FONT_PRESETS.maple.label })
|
||||
|
||||
async function choose(id: string) {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
if (await save({ [props.setting]: id })) expanded.value = false
|
||||
await save({ [props.setting]: id })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@@ -69,11 +73,10 @@ async function importFont(event: Event) {
|
||||
throw new Error('无法解析字体文件,请选择有效的字体。', { cause })
|
||||
}
|
||||
user.assertSession(version)
|
||||
const font = await preferencesApi.importFont(file)
|
||||
const font = (await importFontApi(file)).data
|
||||
await loadFonts()
|
||||
user.assertSession(version)
|
||||
if (!await save({ [props.setting]: font.id })) return
|
||||
expanded.value = false
|
||||
toast('字体已导入')
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) return
|
||||
@@ -94,7 +97,7 @@ async function remove(id: string) {
|
||||
}
|
||||
if (!await save(patch)) return
|
||||
} else {
|
||||
await preferencesApi.deleteFont(id)
|
||||
await deleteFontApi(id)
|
||||
await loadSettings()
|
||||
}
|
||||
toast('字体已删除,使用它的位置已恢复 Maple')
|
||||
@@ -105,35 +108,28 @@ async function remove(id: string) {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && expanded.value) {
|
||||
event.stopPropagation(); expanded.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-font-picker" @keydown="onKey">
|
||||
<el-button :id="id" class="rm-btn rm-font-trigger" :aria-expanded="expanded" :aria-controls="`${id}-options`" :disabled="busy" @click="expanded = !expanded">
|
||||
<span>{{ selected.name }}</span><Icon name="chevron" />
|
||||
</el-button>
|
||||
<div v-if="expanded" :id="`${id}-options`" class="rm-font-options">
|
||||
<div v-for="font in fonts" :key="font.id" class="rm-font-option">
|
||||
<button class="rm-font-choice" :class="{ on: font.id === settings[setting] }" :aria-pressed="font.id === settings[setting]" :disabled="busy" @click="choose(font.id)">
|
||||
<div class="rm-font-picker">
|
||||
<el-select :id="id" :model-value="settings[setting]" :disabled="busy" :loading="busy" filterable popper-class="rm-font-options" @change="choose">
|
||||
<el-option v-for="font in fonts" :key="font.id" :label="font.name" :value="font.id">
|
||||
<div class="rm-font-option">
|
||||
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small>
|
||||
</button>
|
||||
<button v-if="font.id !== 'maple'" class="rm-icon-btn" :aria-label="`删除字体 ${font.name}`" title="删除字体" :disabled="busy" @click="remove(font.id)">
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<el-button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()">
|
||||
<Icon name="import" />{{ busy ? '处理中…' : '导入字体' }}
|
||||
</el-button>
|
||||
<p class="rm-share-note">
|
||||
支持 TTF、OTF、WOFF、WOFF2,单个文件不超过 30 MB。
|
||||
</p>
|
||||
</div>
|
||||
<el-button v-else text size="small" :aria-label="`删除字体 ${font.name}`" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
|
||||
<Icon name="trash" />
|
||||
</el-button>
|
||||
</div>
|
||||
</el-option>
|
||||
<template #footer>
|
||||
<el-button class="rm-font-import" :disabled="busy" @click="fileInput!.click()">
|
||||
<Icon name="import" />导入字体
|
||||
</el-button>
|
||||
<p class="rm-share-note">
|
||||
TTF、OTF、WOFF、WOFF2,最多 30 MB。
|
||||
</p>
|
||||
</template>
|
||||
</el-select>
|
||||
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="`导入字体 ${id}`" @change="importFont">
|
||||
<p v-if="error" class="rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
@@ -141,83 +137,31 @@ function onKey(event: KeyboardEvent) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.rm-font-picker {
|
||||
position: relative;
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.rm-font-trigger {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
.rm-font-trigger span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rm-font-options {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--rm-divider);
|
||||
border-radius: 8px;
|
||||
padding: var(--rm-space-1);
|
||||
max-height: min(320px, 45dvh);
|
||||
overflow: auto;
|
||||
background: var(--rm-bg);
|
||||
box-shadow: var(--rm-shadow);
|
||||
}
|
||||
.rm-font-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rm-space-1);
|
||||
gap: 8px;
|
||||
}
|
||||
.rm-font-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.rm-font-option > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
min-height: var(--rm-control-size);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rm-font-choice span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rm-font-choice:hover,
|
||||
.rm-font-choice.on {
|
||||
background: var(--rm-hover);
|
||||
}
|
||||
.rm-font-choice small {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
.rm-font-option small {
|
||||
color: var(--rm-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rm-font-import {
|
||||
width: 100%;
|
||||
margin-top: var(--rm-space-1);
|
||||
}
|
||||
.rm-font-options p {
|
||||
margin: var(--rm-space-2);
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.rm-font-choice {
|
||||
min-height: 44px;
|
||||
}
|
||||
.rm-share-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: var(--rm-ui-font-small);
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { InlineStyles } from '../../markdown/inline-styles'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { settings } from '../../../pinia/stores/settings'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { fixedThemeMode } from '../../appearance/appearance'
|
||||
import { HOLLOW, INLINE_FONTS, SHADOW } from '../../markdown/inline-styles'
|
||||
|
||||
const props = defineProps<{ styles: InlineStyles, baseSize: number }>()
|
||||
|
||||
const emit = defineEmits<{ change: [patch: InlineStyles], clear: [] }>()
|
||||
|
||||
const { settings } = useSettingsStore(pinia)
|
||||
|
||||
const system = matchMedia('(prefers-color-scheme: dark)')
|
||||
const systemDark = ref(system.matches)
|
||||
function onSystem() {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { PluginAPI } from '@@/plugin-api'
|
||||
import { pluginsApi } from '@@/apis/plugins'
|
||||
import * as pluginsApi from '@@/apis/plugins'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { settings } from '../../pinia/stores/settings'
|
||||
|
||||
import { pinia } from '@/pinia'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { apiSuccess } from '../../../tests/api-response'
|
||||
import { allCommands, commandKeys } from '../utils/commands'
|
||||
import { pluginState, startPlugins, stopPlugins } from './host'
|
||||
|
||||
const { settings } = useSettingsStore(pinia)
|
||||
|
||||
const { cause } = vi.hoisted(() => ({ cause: new Error('插件命令的原始原因') }))
|
||||
vi.mock('/api/v1/resources/test-plugin/plugin/plugins/fault/assets/rev/main.js', () => ({
|
||||
default: (sdk: PluginAPI) => sdk.addCommand({ id: 'fail', title: '失败命令', keys: ['Mod+B'], run() {
|
||||
@@ -17,7 +22,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
it('命令失败保留原始异常并清理注册,正常插件列表读取不受影响', async () => {
|
||||
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([{ id: 'fault', name: '故障插件', version: '1', apiVersion: 1, revision: 'rev', enabled: true }])
|
||||
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [{ id: 'fault', name: '故障插件', version: '1', apiVersion: 1, revision: 'rev', enabled: true }] }))
|
||||
const notices: string[] = []
|
||||
startPlugins({ notice: message => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
|
||||
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
|
||||
@@ -33,7 +38,7 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不
|
||||
expect(notices.some(message => message.includes('命令失败'))).toBe(true)
|
||||
})
|
||||
|
||||
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/',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { InstalledPlugin } from '@@/apis/plugins/type'
|
||||
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api'
|
||||
import { pluginResourceURL } from '@@/apis/auth/resources'
|
||||
import { pluginsApi } from '@@/apis/plugins'
|
||||
import { vaultApi } from '@@/apis/vault'
|
||||
import { getPluginDataApi, getPluginsApi, savePluginDataApi } from '@@/apis/plugins'
|
||||
import { getTreeApi } from '@@/apis/tree'
|
||||
import { readNoteFile } from '@@/utils/note-files'
|
||||
import { pluginResourceURL } from '@@/utils/resource-tickets'
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { allCommands, registerCommand } from '../utils/commands'
|
||||
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
|
||||
@@ -144,10 +146,10 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
|
||||
},
|
||||
vault: {
|
||||
list: () => {
|
||||
check(); return vaultApi.tree()
|
||||
check(); return getTreeApi().then(({ data }) => data.entries)
|
||||
},
|
||||
read: (path) => {
|
||||
check(); return vaultApi.readFile(path)
|
||||
check(); return readNoteFile(path)
|
||||
},
|
||||
write: (path, content, options) => {
|
||||
check(); return adapter.writeNote(path, content, options)
|
||||
@@ -177,10 +179,10 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
|
||||
},
|
||||
data: {
|
||||
load: async () => {
|
||||
check(); return (await pluginsApi.pluginData(id)).data as JSONValue
|
||||
check(); return ((await getPluginDataApi(id)).data).data as JSONValue
|
||||
},
|
||||
save: async (data) => {
|
||||
check(); await pluginsApi.savePluginData(id, data)
|
||||
check(); await savePluginDataApi({ id, data })
|
||||
},
|
||||
},
|
||||
notice: (message) => {
|
||||
@@ -257,7 +259,7 @@ export async function refreshPlugins() {
|
||||
const currentSession = session
|
||||
const sequence = ++request
|
||||
try {
|
||||
const installed = await pluginsApi.plugins()
|
||||
const installed = (await getPluginsApi()).data.plugins
|
||||
if (!running || currentSession !== session || sequence !== request) return
|
||||
pluginState.installed = installed
|
||||
pluginState.error = ''
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
|
||||
import { shallowReactive } from 'vue'
|
||||
import { settings } from '../../pinia/stores/settings'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { formatKeys, matchKeys, physicalKeys } from './keys'
|
||||
|
||||
const { settings } = useSettingsStore(pinia)
|
||||
|
||||
export { formatKeys, matchKeys } from './keys'
|
||||
|
||||
export interface Command {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { confirmAction } from './confirm'
|
||||
|
||||
const user = useUserStore(pinia)
|
||||
beforeEach(() => user.setToken('rms_old'))
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks(); user.setToken('')
|
||||
})
|
||||
it.each(['cancel', 'close'])('用户 %s 时明确取消,不执行危险操作', async (action) => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue(action)
|
||||
expect(await confirmAction('删除?')).toBe(false)
|
||||
})
|
||||
it('确认期间切换身份后,旧操作不使用新身份执行', async () => {
|
||||
let finish!: (action: Awaited<ReturnType<typeof ElMessageBox.confirm>>) => void
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const pending = confirmAction('删除?')
|
||||
user.setToken('rms_new'); finish('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
expect(await pending).toBe(false)
|
||||
})
|
||||
it('非取消错误保留原因并拒绝,不能吞成用户取消', async () => {
|
||||
const cause = new Error('确认框加载失败')
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue(cause)
|
||||
await expect(confirmAction('删除?')).rejects.toBe(cause)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
/** 确认等待期间换会话时,旧操作视为取消。 */
|
||||
export async function confirmAction(message: string, title = '确认操作'): Promise<boolean> {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', distinguishCancelAndClose: true })
|
||||
return version === user.getSessionVersion()
|
||||
} catch (cause) {
|
||||
if (cause === 'cancel' || cause === 'close') return false
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { getFileApi } from '@@/apis/files'
|
||||
|
||||
export async function readNoteFile(path: string): Promise<{ content: string, rev: string }> {
|
||||
const response = await getFileApi(path)
|
||||
return { content: response.data, rev: String(response.headers.get('etag') ?? '').replace(/"/g, '') }
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { encodePath } from '@/http/axios'
|
||||
export function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
export const NOTE_EXT = '.md'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createResourceTicketApi } from '@@/apis/auth'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { authApi } from './index'
|
||||
|
||||
let version = -1
|
||||
let imageRoot = ''
|
||||
@@ -14,10 +14,10 @@ export async function prepareResources() {
|
||||
}
|
||||
if (imageRoot && fontRoot) return
|
||||
if (!pending) {
|
||||
const request = Promise.all([authApi.ticket({ kind: 'images' }), authApi.ticket({ kind: 'fonts' })]).then(([images, fonts]) => {
|
||||
const request = Promise.all([createResourceTicketApi({ kind: 'images' }), createResourceTicketApi({ kind: 'fonts' })]).then(([images, fonts]) => {
|
||||
user.assertSession(current)
|
||||
imageRoot = images.url
|
||||
fontRoot = fonts.url
|
||||
imageRoot = images.data.url
|
||||
fontRoot = fonts.data.url
|
||||
})
|
||||
pending = request
|
||||
void request.finally(() => {
|
||||
@@ -36,5 +36,5 @@ export function fontResourceRoot(): string {
|
||||
return fontRoot
|
||||
}
|
||||
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {
|
||||
return `${(await authApi.ticket({ kind: 'plugin', pluginId, revision })).url}main.js`
|
||||
return `${(await createResourceTicketApi({ kind: 'plugin', pluginId, revision })).data.url}main.js`
|
||||
}
|
||||
@@ -23,7 +23,7 @@ describe('typora 默认快捷键', () => {
|
||||
try {
|
||||
const { typoraKeys } = await import('./typora-keys')
|
||||
const { physicalKeys, matchKeys, recordedKeys } = await import('./keys')
|
||||
const { editorCommands } = await import('../../pages/workspace/editor/shortcuts')
|
||||
const { editorCommands } = await import('@/pages/workspace/editor/shortcuts')
|
||||
const ids = [...editorCommands.map(c => c.id), 'note.new', 'note.save', 'tab.close', 'app.settings', 'view.sidebar', 'view.source', 'view.focus', 'app.quickOpen', 'view.outline', 'note.insertImage']
|
||||
const keys = ids.flatMap(id => typoraKeys(id).map(physicalKeys))
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
|
||||
Reference in new issue
Block a user