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
+2642
-1983
No files matched your search
@@ -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/<resource>/index.ts。' }, { name: 'axios', importNames: ['default'], message: '复用 http/axios 的统一请求处理。' }] }],
|
||||
},
|
||||
},
|
||||
{ files: ['src/service-worker.js'], languageOptions: { globals: { __PRECACHE__: 'readonly' } } },
|
||||
)
|
||||
@@ -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 }>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)">
|
||||
<el-button v-else text size="small" :aria-label="`删除字体 ${font.name}`" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()">
|
||||
<Icon name="import" />{{ busy ? '处理中…' : '导入字体' }}
|
||||
</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。
|
||||
TTF、OTF、WOFF、WOFF2,最多 30 MB。
|
||||
</p>
|
||||
</div>
|
||||
</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)
|
||||
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
import { vaultApi } from '@@/apis/vault'
|
||||
import { readNoteFile } from '@@/utils/note-files'
|
||||
import { toasts } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
// @vitest-environment happy-dom
|
||||
@@ -7,7 +7,7 @@ import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { pinia } from '@/pinia'
|
||||
import { usePermissionStore } from '@/pinia/stores/permission'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { ApiError, requestJSON, requestRaw } from './axios'
|
||||
import { ApiError, request } from './axios'
|
||||
|
||||
const user = useUserStore(pinia)
|
||||
const permission = usePermissionStore(pinia)
|
||||
@@ -21,7 +21,7 @@ afterEach(() => {
|
||||
|
||||
it('使用 Bearer 和统一 JSON,省略 Cookie 且禁止缓存', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ path: 'a.md' }))
|
||||
expect(await requestJSON('POST', '/test', { json: { a: 1 } })).toEqual({ path: 'a.md' })
|
||||
expect(await request({ method: 'post', url: '/test', data: { a: 1 } })).toEqual({ code: 0, data: { path: 'a.md' }, message: '成功' })
|
||||
const req = fetch.mock.calls[0]![0] as Request
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer rms_test')
|
||||
expect(req.credentials).toBe('omit')
|
||||
@@ -33,11 +33,11 @@ it('401 清理当前身份及动态路由,403 保留身份,错误只提示
|
||||
permission.install(router, ['vault:read'])
|
||||
expect(router.hasRoute('Workspace')).toBe(true)
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(payload(null, 403, 40300, '权限不足')).mockResolvedValueOnce(payload(null, 401, 40100, '登录已失效'))
|
||||
await expect(requestJSON('GET', '/test')).rejects.toMatchObject({ status: 403, code: 40300 })
|
||||
await expect(request({ method: 'get', url: '/test' })).rejects.toMatchObject({ status: 403, code: 40300 })
|
||||
expect(user.token).toBe('rms_test')
|
||||
expect(router.hasRoute('Workspace')).toBe(true)
|
||||
expect(toasts.map(item => item.text)).toEqual(['权限不足'])
|
||||
await expect(requestJSON('GET', '/test')).rejects.toMatchObject({ status: 401 })
|
||||
await expect(request({ method: 'get', url: '/test' })).rejects.toMatchObject({ status: 401 })
|
||||
expect(user.token).toBe('')
|
||||
expect(router.hasRoute('Workspace')).toBe(false)
|
||||
expect(toasts.map(item => item.text)).toEqual(['权限不足', '登录已失效'])
|
||||
@@ -47,7 +47,7 @@ it.each([200, 401, 403])('旧会话的 HTTP %s 响应不得覆盖或注销新会
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const result = requestJSON('GET', '/slow').catch(error => error)
|
||||
const result = request({ method: 'get', url: '/slow' }).catch(error => error)
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
user.setToken('rms_new')
|
||||
finish(payload({ old: true }, status, status === 200 ? 0 : status * 100))
|
||||
@@ -76,17 +76,17 @@ it('身份恢复失败交给登录或路由页面展示,请求层不重复提
|
||||
})
|
||||
it('业务失败明确拒绝,保留冲突版本,由调用方显示时请求层不重复提示', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ rev: 'current' }, 412, 41200, '版本冲突'))
|
||||
await expect(requestJSON('PUT', '/file', { errorOwner: 'caller' })).rejects.toMatchObject({ status: 412, code: 41200, data: { rev: 'current' } })
|
||||
await expect(request({ method: 'put', url: '/file', errorOwner: 'caller' })).rejects.toMatchObject({ status: 412, code: 41200, data: { rev: 'current' } })
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
it('拒绝旧 JSON 格式及 HTTP 成功中的业务失败,文件与204保留原语义', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{"ok":true}')).mockResolvedValueOnce(payload(null, 200, 40900, '操作失败')).mockResolvedValueOnce(new Response('正文', { headers: { ETag: '"rev"' } })).mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
await expect(requestJSON('GET', '/old')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(requestJSON('GET', '/business')).rejects.toMatchObject({ code: 40900 })
|
||||
const raw = await requestRaw('GET', '/raw/a.md')
|
||||
expect(await raw.text()).toBe('正文')
|
||||
expect(raw.headers.get('ETag')).toBe('"rev"')
|
||||
expect(await requestJSON('POST', '/auth/logout')).toBeNull()
|
||||
await expect(request({ method: 'get', url: '/old' })).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(request({ method: 'get', url: '/business' })).rejects.toMatchObject({ code: 40900 })
|
||||
const raw = await request<string>({ method: 'get', url: '/raw/a.md', responseType: 'text', returnResponse: true })
|
||||
expect(raw.data).toBe('正文')
|
||||
expect(raw.headers.get('etag')).toBe('"rev"')
|
||||
expect(await request({ method: 'post', url: '/auth/logout' })).toBeUndefined()
|
||||
})
|
||||
it('服务端注销失败保留身份;成功才清除本地身份', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(payload(null, 500, 50000, '注销失败')).mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
@@ -102,7 +102,7 @@ it('读取笔记的原始响应解码期间切换会话,旧正文仍须拒绝'
|
||||
vi.spyOn(Response.prototype, 'text').mockImplementation(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
const pending = vaultApi.readFile('note.md').catch(error => error)
|
||||
const pending = readNoteFile('note.md').catch(error => error)
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
user.setToken('rms_new')
|
||||
finish('旧正文')
|
||||
|
||||
+28
-53
@@ -1,6 +1,6 @@
|
||||
import type { AxiosResponse, Method } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { reportError } from '@@/utils/notifications'
|
||||
import axios, { CanceledError, isAxiosError, isCancel } from 'axios'
|
||||
import axios, { AxiosHeaders, CanceledError, isAxiosError, isCancel } from 'axios'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
@@ -9,26 +9,19 @@ export class ApiError extends Error {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
interface Options {
|
||||
json?: unknown
|
||||
body?: BodyInit
|
||||
headers?: Record<string, string>
|
||||
keepalive?: boolean
|
||||
auth?: boolean
|
||||
interface RequestConfig extends Omit<AxiosRequestConfig<unknown>, 'auth'> {
|
||||
anonymous?: boolean
|
||||
errorOwner?: 'request' | 'caller'
|
||||
signal?: AbortSignal
|
||||
/** 原文和下载需要 ETag、Content-Disposition 等响应头。 */
|
||||
returnResponse?: boolean
|
||||
}
|
||||
const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCredentials: false })
|
||||
export function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
interface Envelope { code: number, data: unknown, message: string }
|
||||
function envelope(value: unknown, status: number): Envelope {
|
||||
function envelope(value: unknown, status: number): ApiResponseData<unknown> {
|
||||
if (!value || typeof value !== 'object' || !('code' in value) || typeof value.code !== 'number'
|
||||
|| !Number.isInteger(value.code) || !('data' in value) || !('message' in value) || typeof value.message !== 'string') {
|
||||
throw new ApiError(status, '服务器响应协议无效')
|
||||
}
|
||||
return value as Envelope
|
||||
return value as ApiResponseData<unknown>
|
||||
}
|
||||
function errorData(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
@@ -49,57 +42,39 @@ async function failure(error: unknown): Promise<ApiError> {
|
||||
const data = envelope(body, response.status)
|
||||
return new ApiError(response.status, data.message, errorData(data.data), data.code)
|
||||
} catch (cause) {
|
||||
return cause instanceof ApiError ? cause : new ApiError(response.status, '服务器响应协议无效')
|
||||
if (cause instanceof ApiError) return cause
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
async function execute<T>(method: string, url: string, options: Options, raw: boolean, decode: (response: AxiosResponse) => Promise<T>): Promise<T> {
|
||||
export function request<T>(config: RequestConfig & { returnResponse: true }): Promise<AxiosResponse<T, unknown, AxiosHeaders>>
|
||||
export function request<T>(config: RequestConfig): Promise<T>
|
||||
export async function request<T>(config: RequestConfig): Promise<T | AxiosResponse<T, unknown, AxiosHeaders>> {
|
||||
const user = useUserStore(pinia)
|
||||
const version = user.getSessionVersion()
|
||||
const token = options.auth === false ? '' : user.token
|
||||
const token = config.anonymous === true ? '' : user.token
|
||||
const headers = new AxiosHeaders(config.headers as ConstructorParameters<typeof AxiosHeaders>[0])
|
||||
headers.set('Authorization', token ? `Bearer ${token}` : undefined)
|
||||
try {
|
||||
const response = await instance.request({
|
||||
method: method as Method,
|
||||
url,
|
||||
headers: { 'Content-Type': options.json !== undefined ? 'application/json' : null, ...options.headers, 'Authorization': token ? `Bearer ${token}` : undefined },
|
||||
data: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
||||
responseType: raw ? 'blob' : 'text',
|
||||
signal: options.signal ? AbortSignal.any([user.getSessionSignal(), options.signal]) : user.getSessionSignal(),
|
||||
fetchOptions: { credentials: 'omit', cache: 'no-store', keepalive: options.keepalive },
|
||||
const response = await instance.request<T, AxiosResponse<T, unknown, AxiosHeaders>>({
|
||||
...config,
|
||||
headers,
|
||||
signal: config.signal ? AbortSignal.any([user.getSessionSignal(), config.signal as AbortSignal]) : user.getSessionSignal(),
|
||||
fetchOptions: { ...config.fetchOptions, credentials: 'omit', cache: 'no-store' },
|
||||
})
|
||||
user.assertSession(version)
|
||||
const result = await decode(response)
|
||||
user.assertSession(version)
|
||||
return result
|
||||
if (response.status === 204) return config.returnResponse ? response : undefined as T
|
||||
if (!config.responseType || config.responseType === 'json') {
|
||||
const result = envelope(response.data, response.status)
|
||||
if (result.code !== 0) throw new ApiError(response.status, result.message, errorData(result.data), result.code)
|
||||
}
|
||||
return config.returnResponse ? response : response.data
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) throw cause
|
||||
if (version !== user.getSessionVersion()) throw new CanceledError('会话已变化')
|
||||
const error = await failure(cause)
|
||||
user.assertSession(version)
|
||||
if (error.status === 401 && token) user.invalidate(version)
|
||||
if (options.errorOwner !== 'caller') reportError(error)
|
||||
if (config.errorOwner !== 'caller') reportError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export function requestJSON<T>(method: string, url: string, options: Options = {}): Promise<T> {
|
||||
return execute(method, url, options, false, async (response) => {
|
||||
if (response.status === 204) return null as T
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(response.data)
|
||||
} catch {
|
||||
throw new ApiError(response.status, '服务器返回了无效 JSON')
|
||||
}
|
||||
const result = envelope(parsed, response.status)
|
||||
if (result.code !== 0) throw new ApiError(response.status, result.message, errorData(result.data), result.code)
|
||||
return result.data as T
|
||||
})
|
||||
}
|
||||
export function requestRaw(method: string, url: string, options: Options = {}): Promise<Response> {
|
||||
return execute(method, url, options, true, async (response) => {
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value != null) headers.set(key, String(value))
|
||||
}
|
||||
return new Response(response.status === 204 ? null : response.data, { status: response.status, headers })
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Captcha } from '@@/apis/auth'
|
||||
import { authApi } from '@@/apis/auth'
|
||||
import type { Captcha } from '@@/apis/auth/type'
|
||||
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
import { getCaptchaApi, loginApi, setupApi } from '@@/apis/auth'
|
||||
import AppStatus from '@@/components/AppStatus/index.vue'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
@@ -13,9 +16,13 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const isSetup = computed(() => route.name === 'Setup')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const captchaCode = ref('')
|
||||
const form = ref<FormInstance>()
|
||||
const fields = reactive({ password: '', confirm: '', captchaCode: '' })
|
||||
const rules: FormRules<typeof fields> = {
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { validator: (_rule, value: string, callback) => callback(isSetup.value && value.length < 8 ? new Error('密码至少 8 位') : undefined), trigger: 'blur' }],
|
||||
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.password ? new Error('两次输入的密码不一致') : undefined), trigger: 'blur' }],
|
||||
captchaCode: [{ pattern: /^\d{6}$/, required: true, message: '请输入六位数字验证码', trigger: 'blur' }],
|
||||
}
|
||||
const captcha = ref<Captcha>()
|
||||
const captchaError = ref('')
|
||||
const error = ref('')
|
||||
@@ -26,9 +33,9 @@ async function refresh() {
|
||||
const id = ++refreshID
|
||||
refreshing.value = true
|
||||
captchaError.value = ''
|
||||
captchaCode.value = ''
|
||||
fields.captchaCode = ''
|
||||
try {
|
||||
const result = await authApi.captcha(captcha.value?.captchaId)
|
||||
const result = (await getCaptchaApi({ replaceId: captcha.value?.captchaId })).data
|
||||
if (id === refreshID) captcha.value = result
|
||||
} catch (cause) {
|
||||
if (id === refreshID && !isCancel(cause)) {
|
||||
@@ -40,16 +47,20 @@ async function refresh() {
|
||||
}
|
||||
watch(isSetup, (setup) => {
|
||||
++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh()
|
||||
form.value?.clearValidate()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => {
|
||||
++refreshID
|
||||
})
|
||||
async function submit() {
|
||||
if (busy.value || (!isSetup.value && (!captcha.value || refreshing.value))) return
|
||||
error.value = ''
|
||||
if (isSetup.value && password.value !== confirm.value) {
|
||||
error.value = '两次输入的密码不一致'; return
|
||||
}
|
||||
busy.value = true
|
||||
const owner = user.getSessionVersion()
|
||||
try {
|
||||
const result = isSetup.value ? await authApi.setup(password.value) : await authApi.login(password.value, captcha.value!.captchaId, captchaCode.value)
|
||||
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
|
||||
user.assertSession(owner)
|
||||
const result = isSetup.value ? (await setupApi({ password: fields.password })).data : (await loginApi({ password: fields.password, captchaId: captcha.value!.captchaId, captchaCode: fields.captchaCode })).data
|
||||
if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('登录响应格式错误')
|
||||
user.setToken(result.token)
|
||||
const version = user.getSessionVersion()
|
||||
@@ -70,15 +81,21 @@ async function submit() {
|
||||
<template>
|
||||
<AppStatus />
|
||||
<div class="rm-auth">
|
||||
<form class="rm-auth-card" @submit.prevent="submit">
|
||||
<el-form ref="form" class="rm-auth-card" :model="fields" :rules="rules" label-position="top" :disabled="busy" @submit.prevent="submit">
|
||||
<h1>RawMark</h1>
|
||||
<p class="rm-auth-hint">
|
||||
{{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }}
|
||||
</p>
|
||||
<label><span>密码</span><el-input v-model="password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" required autofocus show-password /></label>
|
||||
<label v-if="isSetup"><span>确认密码</span><el-input v-model="confirm" type="password" autocomplete="new-password" required show-password /></label>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="fields.password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" autofocus show-password />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isSetup" label="确认密码" prop="confirm">
|
||||
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<label><span>验证码</span><el-input v-model="captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" required placeholder="输入图片中的六位数字" /></label>
|
||||
<el-form-item label="验证码" prop="captchaCode">
|
||||
<el-input v-model="fields.captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" placeholder="输入图片中的六位数字" />
|
||||
</el-form-item>
|
||||
<div class="rm-captcha">
|
||||
<el-button :disabled="refreshing || busy" class="rm-captcha-image" aria-label="刷新验证码" @click="refresh">
|
||||
<img v-if="captcha" :src="captcha.imageDataUrl" alt="登录验证码"><span v-else>{{ refreshing ? '正在加载验证码…' : '点击重新加载验证码' }}</span>
|
||||
@@ -97,13 +114,19 @@ async function submit() {
|
||||
<el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)">
|
||||
{{ isSetup ? '完成设置' : '登录' }}
|
||||
</el-button>
|
||||
</form>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.rm-auth-card :deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.rm-auth-card :deep(.el-form-item__label) {
|
||||
color: var(--rm-text);
|
||||
}
|
||||
.rm-auth {
|
||||
font-family: 'Maple Mono Normal NL NF CN', monospace;
|
||||
font-family: var(--rm-font-text);
|
||||
}
|
||||
.rm-captcha {
|
||||
display: flex;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { ApiToken, BackupKind, BackupSummary, MaintenanceStatus, StorageUsage } from './type'
|
||||
import { requestJSON, requestRaw } from '@/http/axios'
|
||||
|
||||
export const settingsApi = {
|
||||
changePassword: (current: string, next: string) => requestJSON('POST', '/auth/password', { errorOwner: 'caller', json: { current, next } }),
|
||||
maintenanceStatus: () => requestJSON<MaintenanceStatus>('GET', '/maintenance/status', { errorOwner: 'caller' }),
|
||||
exportBackup: (kind: BackupKind) => requestRaw('POST', `/maintenance/export/${kind}`, { errorOwner: 'caller' }),
|
||||
exportNotes: () => requestRaw('POST', '/maintenance/notes/export', { errorOwner: 'caller' }),
|
||||
startBackupUpload: (kind: BackupKind, size: number) => requestJSON<{ id: string }>('POST', '/maintenance/uploads', { errorOwner: 'caller', json: { kind, size } }),
|
||||
uploadBackupChunk: (id: string, offset: number, body: Blob) => requestJSON<{ offset: number }>('PUT', `/maintenance/uploads/${id}`, { errorOwner: 'caller', body, headers: { 'Upload-Offset': String(offset) } }),
|
||||
validateBackup: (id: string) => requestJSON<BackupSummary>('POST', `/maintenance/uploads/${id}/validate`, { errorOwner: 'caller' }),
|
||||
cancelBackupUpload: (id: string) => requestJSON('DELETE', `/maintenance/uploads/${id}`, { errorOwner: 'caller' }),
|
||||
importSettings: (id: string) => requestJSON('POST', '/maintenance/settings/import', { errorOwner: 'caller', json: { id, confirmed: true } }),
|
||||
restoreBackup: (id: string) => requestJSON('POST', '/maintenance/restore', { errorOwner: 'caller', json: { id, confirmed: true } }),
|
||||
resetSettings: () => requestJSON('POST', '/maintenance/settings/reset', { errorOwner: 'caller', json: { confirmed: true } }),
|
||||
maintainIndex: (operation: 'optimize' | 'rebuild') => requestJSON('POST', `/maintenance/index/${operation}`, { errorOwner: 'caller' }),
|
||||
clearTemp: () => requestJSON<{ files: number, bytes: number, failed: string[] }>('POST', '/maintenance/temp/clear', { errorOwner: 'caller' }),
|
||||
exportLogs: () => requestRaw('POST', '/maintenance/logs/export', { errorOwner: 'caller' }),
|
||||
tokens: async () => (await requestJSON<{ tokens: ApiToken[] }>('GET', '/tokens', { errorOwner: 'caller' })).tokens,
|
||||
createToken: (name: string, scope: ApiToken['scope']) =>
|
||||
requestJSON<{ token: string, info: ApiToken }>('POST', '/tokens', { errorOwner: 'caller', json: { name, scope } }),
|
||||
deleteToken: (id: string) => requestJSON('DELETE', `/tokens/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
|
||||
storage: () => requestJSON<StorageUsage>('GET', '/storage', { errorOwner: 'caller' }),
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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 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 ApiToken {
|
||||
id: string
|
||||
name: string
|
||||
scope: 'read' | 'write'
|
||||
createdAt: number
|
||||
// 0 为从未使用
|
||||
lastUsedAt: number
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { changePasswordApi } from '@@/apis/auth'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
import TokensPanel from './TokensPanel.vue'
|
||||
|
||||
defineEmits<{ logout: [] }>()
|
||||
const user = useUserStore()
|
||||
const form = ref<FormInstance>()
|
||||
const fields = reactive({ current: '', next: '', confirm: '' })
|
||||
const rules: FormRules<typeof fields> = {
|
||||
current: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
|
||||
next: [{ required: true, min: 8, message: '新密码至少 8 位', trigger: 'blur' }],
|
||||
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.next ? new Error('两次输入的新密码不一致') : undefined), trigger: 'blur' }],
|
||||
}
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = ''
|
||||
const owner = user.getSessionVersion()
|
||||
try {
|
||||
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
|
||||
user.assertSession(owner)
|
||||
await changePasswordApi({ current: fields.current, next: fields.next })
|
||||
Object.assign(fields, { current: '', next: '', confirm: '' })
|
||||
form.value!.clearValidate()
|
||||
toast('登录密码已修改,其他设备需要重新登录')
|
||||
} catch (cause) {
|
||||
if (!isCancel(cause)) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="rm-setting-password" tabindex="-1">
|
||||
<h3 class="rm-settings-group-title">
|
||||
修改登录密码
|
||||
</h3>
|
||||
<p class="rm-share-note">
|
||||
修改后其他设备需要重新登录。
|
||||
</p>
|
||||
<el-form ref="form" class="rm-account-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="当前密码" prop="current">
|
||||
<el-input v-model="fields.current" type="password" autocomplete="current-password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="next">
|
||||
<el-input v-model="fields.next" type="password" autocomplete="new-password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" prop="confirm">
|
||||
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
|
||||
</el-form-item>
|
||||
<p v-if="error" class="rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<el-button native-type="submit" type="primary" :loading="busy">
|
||||
修改密码
|
||||
</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
<section id="rm-setting-tokens" tabindex="-1">
|
||||
<h3 class="rm-settings-group-title">
|
||||
API 令牌
|
||||
</h3><TokensPanel />
|
||||
</section>
|
||||
<SettingRow id="logout">
|
||||
<el-button @click="$emit('logout')">
|
||||
<Icon name="logout" />退出
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rm-account-form {
|
||||
max-width: 440px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { Theme } from '@@/apis/preferences/type'
|
||||
import type { CssSnippet } from '../../../common/appearance/appearance'
|
||||
import type { Settings } from '../../../pinia/stores/settings'
|
||||
import { preferencesApi } from '@@/apis/preferences'
|
||||
import type { Theme } from '@@/apis/themes/type'
|
||||
import type { CssSnippet } from '@@/appearance/appearance'
|
||||
|
||||
import type { Settings } from '@/pinia/stores/settings'
|
||||
import { getThemesApi } from '@@/apis/themes'
|
||||
import { appearancePaused, fixedThemeMode } from '@@/appearance/appearance'
|
||||
import { DISPLAY_DEFAULTS } from '@@/appearance/typography'
|
||||
import { confirmAction } from '@@/utils/confirm'
|
||||
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { appearancePaused, fixedThemeMode } from '../../../common/appearance/appearance'
|
||||
import { DISPLAY_DEFAULTS } from '../../../common/appearance/typography'
|
||||
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import { pinia } from '@/pinia'
|
||||
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const { loadSettings, reloadTheme, settings, updateSettings } = useSettingsStore(pinia)
|
||||
|
||||
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
@@ -23,7 +29,7 @@ const fixedMode = computed(() => fixedThemeMode(settings.theme))
|
||||
async function refreshThemes() {
|
||||
error.value = ''
|
||||
try {
|
||||
themes.value = await preferencesApi.themes()
|
||||
themes.value = (await getThemesApi()).data.themes
|
||||
await reloadTheme()
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络'
|
||||
@@ -75,10 +81,16 @@ async function toggle(snippet: CssSnippet) {
|
||||
}
|
||||
|
||||
async function remove(snippet: CssSnippet) {
|
||||
if (!window.confirm(`删除 CSS 片段“${snippet.name}”?`)) return
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (!await confirmAction(`删除 CSS 片段“${snippet.name}”?`)) return
|
||||
if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) {
|
||||
if (editing.value === snippet.id) editing.value = null
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
@@ -94,24 +106,16 @@ async function reset() {
|
||||
已临时停用主题和 CSS。可恢复默认外观后重新打开页面。
|
||||
</p>
|
||||
<SettingRow id="theme-select" control="rm-theme-select">
|
||||
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
|
||||
<option v-for="theme in themes" :key="theme.id" :value="theme.id">
|
||||
{{ theme.name }}
|
||||
</option>
|
||||
<option v-if="missingTheme" :value="settings.theme">
|
||||
{{ settings.theme }}(不可用)
|
||||
</option>
|
||||
</select>
|
||||
<el-select id="rm-theme-select" :model-value="settings.theme" :disabled="busy" @change="change({ theme: $event })">
|
||||
<el-option v-for="theme in themes" :key="theme.id" :value="theme.id" :label="theme.name" />
|
||||
<el-option v-if="missingTheme" :value="settings.theme" :label="`${settings.theme}(不可用)`" />
|
||||
</el-select>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="refreshThemes">
|
||||
刷新列表
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="color-mode">
|
||||
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
|
||||
<button v-for="mode in [{ id: 'light', title: '浅色' }, { id: 'dark', title: '深色' }, { id: 'system', title: '跟随系统' }]" :key="mode.id" role="radio" :aria-checked="(fixedMode ?? settings.colorMode) === mode.id" :class="{ on: (fixedMode ?? settings.colorMode) === mode.id }" :disabled="busy || !!fixedMode" @click="change({ colorMode: mode.id as Settings['colorMode'] })">
|
||||
{{ mode.title }}
|
||||
</button>
|
||||
</div>
|
||||
<el-segmented :model-value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" :options="[{ value: 'light', label: '浅色' }, { value: 'dark', label: '深色' }, { value: 'system', label: '跟随系统' }]" aria-label="明暗模式" @change="change({ colorMode: $event as Settings['colorMode'] })" />
|
||||
</SettingRow>
|
||||
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
@@ -127,7 +131,9 @@ async function reset() {
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
|
||||
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)">{{ snippet.name }}</label>
|
||||
<el-checkbox :model-value="snippet.enabled" :disabled="busy" @change="toggle(snippet)">
|
||||
{{ snippet.name }}
|
||||
</el-checkbox>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="edit(snippet)">
|
||||
编辑
|
||||
</el-button>
|
||||
@@ -135,9 +141,9 @@ async function reset() {
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
|
||||
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required></label>
|
||||
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
|
||||
<el-form v-if="editing !== null" class="rm-snippet-form" label-position="top" @submit.prevent="saveSnippet">
|
||||
<label>片段名称<el-input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
|
||||
<label>CSS<el-input v-model="draft.css" type="textarea" aria-label="CSS" :rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
|
||||
<div>
|
||||
<el-button class="rm-btn" :disabled="busy" native-type="submit">
|
||||
保存并应用
|
||||
@@ -145,7 +151,7 @@ async function reset() {
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
</el-form>
|
||||
<SettingRow id="reset-appearance">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset">
|
||||
恢复默认外观与排版
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import FontPicker from '@@/components/FontPicker/index.vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useSettingsChange } from '../composables/use-settings-change'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
const change = useSettingsChange()
|
||||
const fontInput = ref(String(settings.fontSize))
|
||||
watch(() => settings.fontSize, (value) => {
|
||||
fontInput.value = String(value)
|
||||
})
|
||||
function changeFont(value: string) {
|
||||
fontInput.value = value
|
||||
if (!value.trim()) return
|
||||
const size = Number(value)
|
||||
if (Number.isFinite(size) && size > 0 && size !== settings.fontSize) void change({ fontSize: size })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 class="rm-settings-group-title">
|
||||
正文与代码
|
||||
</h3>
|
||||
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })">
|
||||
<FontPicker id="rm-text-font" setting="textFont" />
|
||||
</SettingRow>
|
||||
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })">
|
||||
<FontPicker id="rm-code-font" setting="codeFont" />
|
||||
</SettingRow>
|
||||
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })">
|
||||
<el-input id="rm-font-size" type="number" :model-value="fontInput" @input="changeFont" @blur="fontInput = String(settings.fontSize)">
|
||||
<template #append>
|
||||
px
|
||||
</template>
|
||||
</el-input>
|
||||
</SettingRow>
|
||||
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })">
|
||||
<el-segmented :model-value="settings.contentWidth" :options="CONTENT_WIDTHS" aria-label="正文宽度" @change="change({ contentWidth: Number($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })">
|
||||
<el-slider id="rm-line-height" :min="1.2" :max="2.5" :step="0.05" :model-value="settings.lineHeight" @change="change({ lineHeight: Number($event) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output>
|
||||
</SettingRow>
|
||||
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1">
|
||||
<summary>排版预览</summary><article class="rm-typography-preview">
|
||||
<h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code>
|
||||
</article>
|
||||
</details>
|
||||
<h3 class="rm-settings-group-title">
|
||||
编辑行为
|
||||
</h3>
|
||||
<SettingRow id="selection-toolbar" control="rm-selection-toolbar">
|
||||
<el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="spellcheck" control="rm-spellcheck">
|
||||
<el-switch id="rm-spellcheck" :model-value="settings.spellcheck" @change="change({ spellcheck: Boolean($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })">
|
||||
<el-select id="rm-tab-size" :model-value="settings.tabSize" @change="change({ tabSize: $event })">
|
||||
<el-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="`${n} 列`" />
|
||||
</el-select>
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-input) {
|
||||
width: 130px;
|
||||
}
|
||||
:deep(.el-slider) {
|
||||
width: 150px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
:deep(.el-segmented) {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { UI_FONT_SIZE } from '@@/appearance/typography'
|
||||
import FontPicker from '@@/components/FontPicker/index.vue'
|
||||
import { DEFAULTS, useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useSettingsChange } from '../composables/use-settings-change'
|
||||
import AppearanceSettings from './AppearanceSettings.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
const change = useSettingsChange()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })">
|
||||
<FontPicker id="rm-ui-font" setting="uiFont" />
|
||||
</SettingRow>
|
||||
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
|
||||
<el-input-number id="rm-ui-font-size" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" :step="1" :model-value="settings.uiFontSize" @change="($event !== undefined && $event !== null) && change({ uiFontSize: $event })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })">
|
||||
<el-slider id="rm-glass-opacity" :min="0.4" :max="1" :step="0.05" :model-value="settings.glassOpacity" @change="change({ glassOpacity: Number($event) })" /><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output>
|
||||
</SettingRow>
|
||||
<AppearanceSettings />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-slider) {
|
||||
width: 150px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupKind, MaintenanceStatus } from '@/pages/settings/apis/type'
|
||||
import { authApi } from '@@/apis/auth'
|
||||
import type { BackupKind, MaintenanceStatus } from '@@/apis/maintenance/type'
|
||||
import type { AxiosHeaders, AxiosResponse } from 'axios'
|
||||
import { getAuthStateApi } from '@@/apis/auth'
|
||||
|
||||
import { cancelBackupUploadApi, clearTempApi, exportBackupApi, exportLogsApi, exportNotesApi, getMaintenanceStatusApi, importSettingsApi, maintainIndexApi, resetSettingsApi, restoreBackupApi, startBackupUploadApi, uploadBackupChunkApi, validateBackupApi } from '@@/apis/maintenance'
|
||||
import { refreshPlugins } from '@@/plugins/host'
|
||||
|
||||
import { confirmAction } from '@@/utils/confirm'
|
||||
import { isCancel } from 'axios'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
|
||||
import { pinia } from '@/pinia'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { refreshPlugins } from '../../../common/plugins/host'
|
||||
import { flushSettings, loadSettings, reloadTheme } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void> }>()
|
||||
|
||||
const emit = defineEmits<{ changed: [], importNotes: [] }>()
|
||||
|
||||
const { flushSettings, loadSettings, reloadTheme } = useSettingsStore(pinia)
|
||||
|
||||
const user = useUserStore()
|
||||
|
||||
const busy = ref(false)
|
||||
@@ -28,7 +36,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await settingsApi.maintenanceStatus(); if (mounted) last.value = status
|
||||
const status = (await getMaintenanceStatusApi()).data; if (mounted) last.value = status
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `读取维护状态失败:${String(cause)}`
|
||||
}
|
||||
@@ -45,7 +53,7 @@ async function perform(label: string, action: () => Promise<void>) {
|
||||
user.assertSession(version)
|
||||
await action()
|
||||
if (mounted) {
|
||||
emit('changed'); last.value = await settingsApi.maintenanceStatus()
|
||||
emit('changed'); last.value = (await getMaintenanceStatusApi()).data
|
||||
}
|
||||
} catch (cause) {
|
||||
if (mounted && !isCancel(cause)) error.value = String(cause)
|
||||
@@ -55,11 +63,11 @@ async function perform(label: string, action: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
async function download(response: Response, fallback: string) {
|
||||
async function download(response: AxiosResponse<Blob, unknown, AxiosHeaders>, fallback: string) {
|
||||
const version = user.getSessionVersion()
|
||||
const blob = await response.blob()
|
||||
const blob = response.data
|
||||
user.assertSession(version)
|
||||
const header = response.headers.get('Content-Disposition') ?? ''
|
||||
const header = String(response.headers.get('content-disposition') ?? '')
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
|
||||
const plain = /filename="([^"]+)"/i.exec(header)?.[1]
|
||||
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
|
||||
@@ -69,21 +77,29 @@ async function download(response: Response, fallback: string) {
|
||||
message.value = '导出文件已生成'
|
||||
}
|
||||
async function exportPackage(kind: BackupKind) {
|
||||
await perform('正在导出…', async () => download(await settingsApi.exportBackup(kind), `rawmark-${kind}.zip`))
|
||||
await perform('正在导出…', async () => download(await exportBackupApi(kind), `rawmark-${kind}.zip`))
|
||||
}
|
||||
async function exportMarkdown() {
|
||||
await perform('正在导出笔记…', async () => download(await settingsApi.exportNotes(), 'rawmark-notes.zip'))
|
||||
await perform('正在导出笔记…', async () => download(await exportNotesApi(), 'rawmark-notes.zip'))
|
||||
}
|
||||
async function exportSystemLogs() {
|
||||
await perform('正在导出日志…', async () => download(await settingsApi.exportLogs(), 'rawmark-system-logs.zip'))
|
||||
await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
|
||||
}
|
||||
async function syncPreferences() {
|
||||
await loadSettings(); await reloadTheme(); await refreshPlugins()
|
||||
}
|
||||
async function reset() {
|
||||
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
let confirmed = false
|
||||
try {
|
||||
confirmed = await confirmAction('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
if (!confirmed) return
|
||||
await perform('正在重置设置…', async () => {
|
||||
await settingsApi.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
|
||||
await resetSettingsApi(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
|
||||
})
|
||||
}
|
||||
async function restore(kind: BackupKind, event: Event) {
|
||||
@@ -93,39 +109,39 @@ async function restore(kind: BackupKind, event: Event) {
|
||||
await perform('正在上传…', async () => {
|
||||
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
|
||||
const version = user.getSessionVersion()
|
||||
const { id } = await settingsApi.startBackupUpload(kind, file.size)
|
||||
const { id } = (await startBackupUploadApi({ kind, size: file.size })).data
|
||||
let submitted = false
|
||||
try {
|
||||
let offset = 0
|
||||
while (offset < file.size) {
|
||||
if (!mounted) throw new Error('已取消上传')
|
||||
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
|
||||
const result = await settingsApi.uploadBackupChunk(id, offset, chunk)
|
||||
const result = (await uploadBackupChunkApi({ id, offset, body: chunk })).data
|
||||
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
|
||||
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
|
||||
}
|
||||
progress.value = '正在校验备份…'
|
||||
const summary = await settingsApi.validateBackup(id)
|
||||
const summary = (await validateBackupApi(id)).data
|
||||
if (!mounted) throw new Error('已取消导入')
|
||||
const description = kind === 'full'
|
||||
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
|
||||
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
|
||||
if (!confirm(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
|
||||
if (!await confirmAction(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
|
||||
await prepare()
|
||||
user.assertSession(version)
|
||||
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
||||
if (kind === 'settings') {
|
||||
await settingsApi.importSettings(id); submitted = true
|
||||
await importSettingsApi(id); submitted = true
|
||||
await syncPreferences(); message.value = '设置与资源已导入,原设置已备份'
|
||||
} else {
|
||||
await settingsApi.restoreBackup(id); submitted = true
|
||||
await restoreBackupApi(id); submitted = true
|
||||
user.invalidate(version)
|
||||
const recoveryVersion = user.getSessionVersion()
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
if (recoveryVersion !== user.getSessionVersion()) return
|
||||
try {
|
||||
if ((await authApi.state()).setUp) {
|
||||
if (((await getAuthStateApi()).data).setUp) {
|
||||
location.reload(); return
|
||||
}
|
||||
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
|
||||
@@ -135,7 +151,7 @@ async function restore(kind: BackupKind, event: Event) {
|
||||
} finally {
|
||||
if (!submitted && version === user.getSessionVersion()) {
|
||||
try {
|
||||
await settingsApi.cancelBackupUpload(id)
|
||||
await cancelBackupUploadApi(id)
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `清理上传暂存失败:${String(cause)}`
|
||||
}
|
||||
@@ -145,12 +161,12 @@ async function restore(kind: BackupKind, event: Event) {
|
||||
}
|
||||
async function index(operation: 'optimize' | 'rebuild') {
|
||||
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
|
||||
await settingsApi.maintainIndex(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
|
||||
await maintainIndexApi(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
|
||||
})
|
||||
}
|
||||
async function clearTemp() {
|
||||
await perform('正在清理临时文件…', async () => {
|
||||
const result = await settingsApi.clearTemp()
|
||||
const result = (await clearTempApi()).data
|
||||
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
|
||||
if (result.failed.length) error.value = `部分清理失败:\n${result.failed.join('\n')}`
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { InstalledPlugin } from '@@/apis/plugins/type'
|
||||
import { pluginsApi } from '@@/apis/plugins'
|
||||
import { computed, ref } from 'vue'
|
||||
import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
|
||||
|
||||
import PluginMount from '@@/components/PluginMount/index.vue'
|
||||
|
||||
import { pluginState, refreshPlugins, retryPlugin } from '@@/plugins/host'
|
||||
import { pluginsPaused, settingTabs } from '@@/plugins/registry'
|
||||
import { confirmAction } from '@@/utils/confirm'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import PluginMount from '../../../common/components/PluginMount/index.vue'
|
||||
import { pluginState, refreshPlugins, retryPlugin } from '../../../common/plugins/host'
|
||||
import { pluginsPaused, settingTabs } from '../../../common/plugins/registry'
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
@@ -14,12 +18,16 @@ const selected = ref('')
|
||||
const currentTab = computed(() => settingTabs.get(selected.value))
|
||||
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
||||
|
||||
async function perform(operation: () => Promise<unknown>) {
|
||||
async function perform(operation?: () => Promise<unknown>, confirmation?: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await operation(); await refreshPlugins()
|
||||
if (confirmation && !await confirmAction(confirmation)) return
|
||||
if (operation) await operation()
|
||||
await refreshPlugins()
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
@@ -31,18 +39,17 @@ async function install(event: Event) {
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
|
||||
await perform(() => pluginsApi.installPlugin(file))
|
||||
await perform(() => installPluginApi(file), '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')
|
||||
}
|
||||
|
||||
async function remove(plugin: InstalledPlugin) {
|
||||
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
|
||||
await perform(() => pluginsApi.uninstallPlugin(plugin.id))
|
||||
await perform(() => uninstallPluginApi(plugin.id), `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)
|
||||
}
|
||||
|
||||
function toggle(plugin: InstalledPlugin) {
|
||||
void perform(() => pluginsApi.togglePlugin(plugin.id, !plugin.enabled))
|
||||
void perform(() => togglePluginApi({ id: plugin.id, enabled: !plugin.enabled }))
|
||||
}
|
||||
onMounted(() => perform())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,7 +58,7 @@ function toggle(plugin: InstalledPlugin) {
|
||||
</p>
|
||||
<div class="rm-setting">
|
||||
<span class="rm-setting-label">已安装插件</span>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="perform()">
|
||||
刷新列表
|
||||
</el-button>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()">
|
||||
@@ -68,34 +75,51 @@ function toggle(plugin: InstalledPlugin) {
|
||||
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">
|
||||
尚未安装插件。
|
||||
</p>
|
||||
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
|
||||
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)">
|
||||
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span>
|
||||
</label>
|
||||
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">
|
||||
重试
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">
|
||||
卸载
|
||||
</el-button>
|
||||
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">
|
||||
<el-table :data="pluginState.installed" row-key="id" empty-text="尚未安装插件" :aria-busy="busy">
|
||||
<!-- @vue-generic {InstalledPlugin} -->
|
||||
<el-table-column label="插件" min-width="170">
|
||||
<template #default="{ row: plugin }">
|
||||
<strong>{{ plugin.name }}</strong><small class="rm-plugin-version">{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small><p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">
|
||||
{{ pluginState.status[plugin.id].error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- @vue-generic {InstalledPlugin} -->
|
||||
<el-table-column label="启用" width="72">
|
||||
<template #default="{ row: plugin }">
|
||||
<el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="`启用 ${plugin.name}`" @change="toggle(plugin)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- @vue-generic {InstalledPlugin} -->
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row: plugin }">
|
||||
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">
|
||||
重试
|
||||
</el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)">
|
||||
卸载
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p class="rm-appearance-note">
|
||||
若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。
|
||||
</p>
|
||||
<template v-if="settingTabs.size">
|
||||
<div class="rm-setting">
|
||||
<label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
|
||||
<select id="rm-plugin-tab" v-model="selected">
|
||||
<option value="">
|
||||
选择插件设置页
|
||||
</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">
|
||||
{{ tab.title }}
|
||||
</option>
|
||||
</select>
|
||||
<el-select id="rm-plugin-tab" v-model="selected" clearable placeholder="选择插件设置页">
|
||||
<el-option v-for="[id, tab] in settingTabs" :key="id" :value="id" :label="tab.title" />
|
||||
</el-select>
|
||||
</div>
|
||||
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rm-plugin-version {
|
||||
display: block;
|
||||
color: var(--rm-text-muted);
|
||||
font-size: var(--rm-ui-font-small);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingId } from '../settings-catalog'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { SETTING_ITEMS } from '../settings-catalog'
|
||||
|
||||
const props = defineProps<{ id: SettingId, control?: string, resettable?: boolean }>()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Command } from '../../../common/utils/commands'
|
||||
import type { Command } from '@@/utils/commands'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '@@/utils/commands'
|
||||
import { recordedKeys } from '@@/utils/keys'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '../../../common/utils/commands'
|
||||
import { recordedKeys } from '../../../common/utils/keys'
|
||||
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import { pinia } from '@/pinia'
|
||||
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
|
||||
|
||||
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
|
||||
|
||||
const commands = computed(allCommands)
|
||||
const query = ref('')
|
||||
@@ -76,7 +79,7 @@ async function reset(command: Command) {
|
||||
<template>
|
||||
<div class="rm-shortcut-settings">
|
||||
<div class="rm-shortcut-toolbar">
|
||||
<input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键"><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
|
||||
<el-input v-model="query" type="search" clearable aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
|
||||
重置全部快捷键
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageUsage } from '@/pages/settings/apis/type'
|
||||
import type { StorageUsage } from '@@/apis/storage/type'
|
||||
|
||||
import { getStorageApi } from '@@/apis/storage'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
|
||||
import MaintenancePanel from './MaintenancePanel.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
@@ -39,7 +41,7 @@ async function scan() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const next = await settingsApi.storage()
|
||||
const next = (await getStorageApi()).data
|
||||
if (mounted) usage.value = next
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `统计失败:${String(cause)}`
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiToken } from '@/pages/settings/apis/type'
|
||||
import type { ApiToken } from '@@/apis/tokens/type'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
import { createTokenApi, deleteTokenApi, getTokensApi } from '@@/apis/tokens'
|
||||
import { copyText } from '@@/utils/clipboard'
|
||||
import { confirmAction } from '@@/utils/confirm'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { copyText } from '../../../common/utils/clipboard'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
|
||||
const tokens = ref<ApiToken[] | null>(null)
|
||||
const failed = ref(false)
|
||||
const name = ref('')
|
||||
const scope = ref<ApiToken['scope']>('read')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const form = ref<FormInstance>()
|
||||
const fields = reactive({ name: '', scope: 'read' as ApiToken['scope'] })
|
||||
const rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: true, message: '请输入令牌名称', trigger: 'blur' }] }
|
||||
const user = useUserStore()
|
||||
const busy = ref(false)
|
||||
// 刚创建的令牌明文,只在本次对话框中显示
|
||||
const created = ref<string | null>(null)
|
||||
@@ -22,20 +28,28 @@ function errorText(err: unknown) {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loading.value) return
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
tokens.value = await settingsApi.tokens()
|
||||
} catch {
|
||||
failed.value = true
|
||||
tokens.value = (await getTokensApi()).data.tokens
|
||||
} catch (cause) {
|
||||
if (!isCancel(cause)) error.value = `读取 API 令牌失败:${errorText(cause)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
const owner = user.getSessionVersion()
|
||||
try {
|
||||
const res = await settingsApi.createToken(name.value.trim(), scope.value)
|
||||
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
|
||||
user.assertSession(owner)
|
||||
const res = (await createTokenApi({ name: fields.name.trim(), scope: fields.scope })).data
|
||||
tokens.value = [res.info, ...(tokens.value ?? [])]
|
||||
created.value = res.token
|
||||
name.value = ''
|
||||
fields.name = ''; form.value!.clearValidate()
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
toast(`创建失败:${errorText(err)}`)
|
||||
@@ -49,13 +63,17 @@ async function copy() {
|
||||
}
|
||||
|
||||
async function revoke(t: ApiToken) {
|
||||
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await settingsApi.deleteToken(t.id)
|
||||
if (!await confirmAction(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
|
||||
await deleteTokenApi(t.id)
|
||||
tokens.value = tokens.value!.filter(x => x.id !== t.id)
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
toast(`吊销失败:${errorText(err)}`)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,15 +82,19 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form class="rm-share-form" @submit.prevent="create">
|
||||
<el-input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
|
||||
<el-select v-model="scope" aria-label="权限">
|
||||
<el-form ref="form" class="rm-token-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="create">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="fields.name" autocomplete="off" placeholder="如:备份脚本" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限">
|
||||
<el-select v-model="fields.scope" aria-label="权限">
|
||||
<el-option value="read" label="只读" /><el-option value="write" label="读写" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
|
||||
创建
|
||||
</el-button>
|
||||
</form>
|
||||
</el-form>
|
||||
<div v-if="created" class="rm-token-created">
|
||||
<p>令牌只显示这一次,请立即保存:</p>
|
||||
<div class="rm-token-value">
|
||||
@@ -86,25 +108,59 @@ onMounted(load)
|
||||
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
|
||||
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
|
||||
</p>
|
||||
<p v-if="failed" class="rm-tree-empty">
|
||||
读取 API 令牌失败
|
||||
<p v-if="error" class="rm-auth-error" role="alert">
|
||||
{{ error }} <el-button text :disabled="loading" @click="load">
|
||||
重试
|
||||
</el-button>
|
||||
</p>
|
||||
<p v-else-if="tokens && !tokens.length" class="rm-tree-empty">
|
||||
还没有 API 令牌
|
||||
<p v-if="loading" role="status">
|
||||
读取中…
|
||||
</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<li v-for="t in tokens ?? []" :key="t.id">
|
||||
<Icon name="key" />
|
||||
<div class="rm-trash-info">
|
||||
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span>
|
||||
<span class="rm-trash-time">
|
||||
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? `${fmt.format(t.lastUsedAt)} 最近使用` : '从未使用' }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button class="rm-btn rm-btn-danger" @click="revoke(t)">
|
||||
<el-table v-else-if="tokens" :data="tokens" row-key="id" empty-text="还没有 API 令牌">
|
||||
<!-- @vue-generic {ApiToken} -->
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row: token }">
|
||||
<strong>{{ token.name }}</strong><small class="rm-token-time">{{ fmt.format(token.createdAt) }} 创建 · {{ token.lastUsedAt ? `${fmt.format(token.lastUsedAt)} 最近使用` : '从未使用' }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- @vue-generic {ApiToken} -->
|
||||
<el-table-column label="权限" width="70">
|
||||
<template #default="{ row: token }">
|
||||
{{ token.scope === 'write' ? '读写' : '只读' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- @vue-generic {ApiToken} -->
|
||||
<el-table-column label="操作" width="70">
|
||||
<template #default="{ row: token }">
|
||||
<el-button text type="danger" :disabled="busy" @click="revoke(token)">
|
||||
吊销
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rm-token-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.rm-token-form :deep(.el-form-item) {
|
||||
flex: 1 1 140px;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.rm-token-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
.rm-token-time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--rm-ui-font-small);
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { TrashItem } from '@@/apis/trash/type'
|
||||
import { trashApi } from '@@/apis/trash'
|
||||
import { clearTrashApi, getTrashApi, purgeTrashApi } from '@@/apis/trash'
|
||||
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
|
||||
import { confirmAction } from '@@/utils/confirm'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isImage } from '@@/utils/paths'
|
||||
import { isCancel } from 'axios'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { pinia } from '@/pinia'
|
||||
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { isImage } from '../../../common/utils/paths'
|
||||
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ restoreNote: (id: string) => Promise<void> }>()
|
||||
|
||||
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
|
||||
|
||||
const user = useUserStore()
|
||||
|
||||
const items = ref<TrashItem[] | null>(null)
|
||||
@@ -18,21 +25,24 @@ const error = ref('')
|
||||
const failures = ref<{ id: string, error: string }[]>([])
|
||||
const busy = ref(false)
|
||||
const saving = ref(false)
|
||||
const retentionInput = ref(String(settings.trashRetentionDays))
|
||||
watch(() => settings.trashRetentionDays, (days) => {
|
||||
retentionInput.value = String(days)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
items.value = await trashApi.trash()
|
||||
items.value = (await getTrashApi()).data.items
|
||||
error.value = ''
|
||||
} catch (cause) {
|
||||
error.value = `读取回收站失败:${String(cause)}`
|
||||
}
|
||||
}
|
||||
|
||||
async function retention(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const days = input.valueAsNumber
|
||||
async function retention(value: string) {
|
||||
const days = value.trim() ? Number(value) : NaN
|
||||
if (!Number.isSafeInteger(days) || days < 0) {
|
||||
input.value = String(settings.trashRetentionDays)
|
||||
retentionInput.value = String(settings.trashRetentionDays)
|
||||
toast('请输入非负整数天数,0 表示永不自动删除')
|
||||
return
|
||||
}
|
||||
@@ -65,15 +75,18 @@ async function doRestore(item: TrashItem) {
|
||||
|
||||
async function purge(item?: TrashItem) {
|
||||
if (busy.value) return
|
||||
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
|
||||
const version = user.getSessionVersion()
|
||||
busy.value = true
|
||||
failures.value = []
|
||||
let attempted = false
|
||||
try {
|
||||
if (!await confirmAction(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
|
||||
user.assertSession(version)
|
||||
attempted = true
|
||||
if (item) {
|
||||
await trashApi.purge(item.id)
|
||||
await purgeTrashApi(item.id)
|
||||
} else {
|
||||
const result = await trashApi.clearTrash()
|
||||
const result = (await clearTrashApi()).data
|
||||
failures.value = result.failed
|
||||
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
|
||||
}
|
||||
@@ -81,7 +94,7 @@ async function purge(item?: TrashItem) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`删除失败:${String(cause)}`)
|
||||
} finally {
|
||||
if (version === user.getSessionVersion()) await load()
|
||||
if (attempted && version === user.getSessionVersion()) await load()
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
@@ -93,7 +106,7 @@ onMounted(load)
|
||||
<template>
|
||||
<div>
|
||||
<SettingRow id="trash-retention" control="rm-trash-retention">
|
||||
<label class="rm-font-input"><input id="rm-trash-retention" type="number" min="0" step="1" :placeholder="String(DEFAULTS.trashRetentionDays)" :value="settings.trashRetentionDays" :disabled="saving" @change="retention"> 天</label>
|
||||
<el-input id="rm-trash-retention" v-model="retentionInput" type="number" min="0" step="1" :disabled="saving" aria-label="自动删除天数" @change="retention" /><span>天</span>
|
||||
</SettingRow>
|
||||
<SettingRow id="trash-clear">
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">
|
||||
@@ -116,20 +129,38 @@ onMounted(load)
|
||||
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">
|
||||
读取中…
|
||||
</p>
|
||||
<ul v-else class="rm-trash-list" :aria-busy="busy">
|
||||
<li v-for="item in items ?? []" :key="item.id">
|
||||
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
|
||||
<div class="rm-trash-info">
|
||||
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
||||
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
||||
</div>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="doRestore(item)">
|
||||
<el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" empty-text="回收站是空的">
|
||||
<!-- @vue-generic {TrashItem} -->
|
||||
<el-table-column label="项目" min-width="170">
|
||||
<template #default="{ row: item }">
|
||||
<div class="rm-trash-name">
|
||||
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" /><span>{{ item.path.replace(/\.md$/, '') }}</span>
|
||||
</div><small>{{ fmt.format(item.deletedAt) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- @vue-generic {TrashItem} -->
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row: item }">
|
||||
<el-button text :disabled="busy" @click="doRestore(item)">
|
||||
恢复
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">
|
||||
</el-button><el-button text type="danger" :disabled="busy" @click="purge(item)">
|
||||
彻底删除
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rm-trash-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
small {
|
||||
color: var(--rm-text-muted);
|
||||
font-size: var(--rm-ui-font-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Settings } from '@/pinia/stores/settings'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
|
||||
|
||||
export function useSettingsChange() {
|
||||
const store = useSettingsStore()
|
||||
return async (patch: Partial<Settings>): Promise<boolean> => {
|
||||
try {
|
||||
await store.updateSettings(patch)
|
||||
return true
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) return false
|
||||
toast(cause instanceof Error ? cause.message : '保存设置失败')
|
||||
if (cause instanceof SettingsAppearanceError) return true
|
||||
try {
|
||||
await store.loadSettings()
|
||||
} catch (reload) {
|
||||
if (!isCancel(reload)) toast(`重新读取设置失败:${String(reload)}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
+419
-199
@@ -1,163 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { Settings } from '../../pinia/stores/settings'
|
||||
|
||||
import type { SettingSearchResult } from './settings-catalog'
|
||||
import type { SettingsSection } from '@/pinia/stores/settings'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import { UI_FONT_SIZE } from '../../common/appearance/typography'
|
||||
import FontPicker from '../../common/components/FontPicker/index.vue'
|
||||
import Icon from '../../common/components/Icon/index.vue'
|
||||
import { runCommand } from '../../common/utils/commands'
|
||||
import { installApp, installPrompt } from '../../common/utils/pwa'
|
||||
import { CONTENT_WIDTHS, DEFAULTS, loadSettings, settings, SettingsAppearanceError, settingsSave, TAB_SIZES, updateSettings } from '../../pinia/stores/settings'
|
||||
import AppearanceSettings from './components/AppearanceSettings.vue'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { runCommand } from '@@/utils/commands'
|
||||
import { installApp, installPrompt } from '@@/utils/pwa'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import AccountSettings from './components/AccountSettings.vue'
|
||||
import EditorSettings from './components/EditorSettings.vue'
|
||||
import InterfaceSettings from './components/InterfaceSettings.vue'
|
||||
import PluginsPanel from './components/PluginsPanel.vue'
|
||||
import SettingRow from './components/SettingRow.vue'
|
||||
import ShortcutSettings from './components/ShortcutSettings.vue'
|
||||
import StoragePanel from './components/StoragePanel.vue'
|
||||
import TokensPanel from './components/TokensPanel.vue'
|
||||
import TrashPanel from './components/TrashPanel.vue'
|
||||
import { searchSettings, SETTINGS_SECTIONS } from './settings-catalog'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>()
|
||||
const emit = defineEmits<{ importNotes: [] }>()
|
||||
const { settingsSave } = useSettingsStore()
|
||||
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
||||
if (!buildVersion) throw new Error('缺少应用构建版本信息')
|
||||
|
||||
const model = defineModel<SettingsSection | 'index' | null>({ required: true })
|
||||
const section = computed(() => (model.value === 'index' || model.value === null ? 'appearance' : model.value))
|
||||
// 手机上一次只显示分组列表或分组内容
|
||||
const section = computed(() => model.value === 'index' || model.value === null ? 'appearance' : model.value)
|
||||
const showList = ref(model.value === 'index')
|
||||
const current = computed(() => SETTINGS_SECTIONS.find(s => s.id === section.value)!)
|
||||
const query = ref('')
|
||||
const searching = computed(() => !!query.value.trim())
|
||||
const results = computed(() => searchSettings(query.value))
|
||||
const dialog = ref<HTMLElement>()
|
||||
const content = ref<HTMLElement>()
|
||||
const heading = ref<HTMLElement>()
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
|
||||
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
|
||||
|
||||
async function open(id: SettingsSection) {
|
||||
query.value = ''
|
||||
model.value = id
|
||||
showList.value = false
|
||||
query.value = ''; model.value = id; showList.value = false
|
||||
await nextTick()
|
||||
content.value!.scrollTop = 0
|
||||
heading.value!.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
async function locate(result: SettingSearchResult) {
|
||||
await open(result.section)
|
||||
if (!result.id) return
|
||||
const target = document.getElementById(`rm-setting-${result.id}`)!
|
||||
// 设置可能位于 CSS 或排版预览的折叠区内。
|
||||
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
|
||||
if (parent instanceof HTMLDetailsElement) parent.open = true
|
||||
}
|
||||
if (target instanceof HTMLDetailsElement) target.open = true
|
||||
await nextTick()
|
||||
target.focus({ preventScroll: true })
|
||||
target.scrollIntoView({ block: 'center' })
|
||||
target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
|
||||
function back() {
|
||||
query.value = ''
|
||||
showList.value = true
|
||||
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
|
||||
query.value = ''; showList.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
model.value = null
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.isComposing) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault(); e.stopPropagation(); close()
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const controls = [...dialog.value!.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], summary, [tabindex="0"]')].filter(el => el.getClientRects().length > 0 && !el.closest('[hidden]'))
|
||||
const first = controls[0]; const last = controls.at(-1)
|
||||
if (e.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
|
||||
e.preventDefault(); last?.focus()
|
||||
} else if (!e.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) {
|
||||
e.preventDefault(); first?.focus()
|
||||
}
|
||||
}
|
||||
onMounted(() => dialog.value!.focus({ preventScroll: true }))
|
||||
onBeforeUnmount(() => {
|
||||
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
async function change(patch: Partial<Settings>) {
|
||||
try {
|
||||
await updateSettings(patch)
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
if (err instanceof SettingsAppearanceError) {
|
||||
toast(err.message); return
|
||||
}
|
||||
toast(`保存设置失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`)
|
||||
void loadSettings().catch((error) => {
|
||||
if (!isCancel(error)) toast(`重新读取设置失败:${error instanceof Error ? error.message : '网络错误'}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function changeFont(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.valueAsNumber
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
if (event.type === 'input') return
|
||||
toast('字号必须为正数')
|
||||
input.value = String(settings.fontSize)
|
||||
return
|
||||
}
|
||||
if (value !== settings.fontSize) void change({ fontSize: value })
|
||||
}
|
||||
|
||||
function changeUIFont(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.valueAsNumber
|
||||
if (!Number.isInteger(value) || value < UI_FONT_SIZE.min || value > UI_FONT_SIZE.max) {
|
||||
toast(`界面字号须为 ${UI_FONT_SIZE.min}–${UI_FONT_SIZE.max} 之间的整数`)
|
||||
input.value = String(settings.uiFontSize)
|
||||
return
|
||||
}
|
||||
if (value !== settings.uiFontSize) void change({ uiFontSize: value })
|
||||
}
|
||||
|
||||
// ---- 修改登录密码 ----
|
||||
const pw = ref({ current: '', next: '', confirm: '' })
|
||||
const pwError = ref('')
|
||||
const pwBusy = ref(false)
|
||||
|
||||
async function changePassword() {
|
||||
pwError.value = ''
|
||||
if (pw.value.next.length < 8) return void (pwError.value = '新密码至少 8 位')
|
||||
if (pw.value.next !== pw.value.confirm) return void (pwError.value = '两次输入的新密码不一致')
|
||||
pwBusy.value = true
|
||||
try {
|
||||
await settingsApi.changePassword(pw.value.current, pw.value.next)
|
||||
pw.value = { current: '', next: '', confirm: '' }
|
||||
toast('登录密码已修改,其他设备需要重新登录')
|
||||
} catch (err) {
|
||||
pwError.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||
} finally {
|
||||
pwBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close">
|
||||
<div ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey">
|
||||
<el-dialog :model-value="model !== null" class="rm-settings-modal" title="设置" :show-close="false" :close-on-click-modal="true" align-center destroy-on-close @close="close">
|
||||
<div class="rm-settings" :class="{ 'show-list': showList, searching }">
|
||||
<nav class="rm-settings-nav" aria-label="设置分类">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>设置</h2>
|
||||
@@ -165,7 +67,11 @@ async function changePassword() {
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<label class="rm-settings-search"><Icon name="search" /><input v-model="query" type="search" aria-label="搜索设置" placeholder="搜索设置"><button v-if="query" class="rm-icon-btn" aria-label="清空设置搜索" @click="query = ''"><Icon name="close" /></button></label>
|
||||
<el-input v-model="query" class="rm-settings-search" type="search" clearable aria-label="搜索设置" placeholder="搜索设置">
|
||||
<template #prefix>
|
||||
<Icon name="search" />
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="rm-settings-categories">
|
||||
<button v-for="s in SETTINGS_SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section && !searching }" :aria-current="s.id === section && !searching ? 'page' : undefined" @click="open(s.id)">
|
||||
<Icon :name="s.icon" />{{ s.title }}
|
||||
@@ -199,90 +105,9 @@ async function changePassword() {
|
||||
</div>
|
||||
<div v-show="!searching" class="rm-settings-page">
|
||||
<PluginsPanel v-if="section === 'plugins'" />
|
||||
<template v-else-if="section === 'appearance'">
|
||||
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })">
|
||||
<FontPicker id="rm-ui-font" setting="uiFont" />
|
||||
</SettingRow>
|
||||
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
|
||||
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()"> px</label>
|
||||
</SettingRow>
|
||||
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })">
|
||||
<input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })"><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output>
|
||||
</SettingRow>
|
||||
<AppearanceSettings />
|
||||
</template>
|
||||
|
||||
<template v-else-if="section === 'editor'">
|
||||
<h3 class="rm-settings-group-title">
|
||||
正文与代码
|
||||
</h3>
|
||||
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })">
|
||||
<FontPicker id="rm-text-font" setting="textFont" />
|
||||
</SettingRow>
|
||||
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })">
|
||||
<FontPicker id="rm-code-font" setting="codeFont" />
|
||||
</SettingRow>
|
||||
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })">
|
||||
<label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @input="changeFont" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()"> px</label>
|
||||
</SettingRow>
|
||||
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })">
|
||||
<div class="rm-segmented" role="radiogroup" aria-label="正文宽度">
|
||||
<button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">
|
||||
{{ w.label }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })">
|
||||
<input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })"><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output>
|
||||
</SettingRow>
|
||||
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1">
|
||||
<summary>排版预览</summary><article class="rm-typography-preview">
|
||||
<h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code>
|
||||
</article>
|
||||
</details>
|
||||
<h3 class="rm-settings-group-title">
|
||||
编辑行为
|
||||
</h3>
|
||||
<SettingRow id="selection-toolbar" control="rm-selection-toolbar">
|
||||
<el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="spellcheck" control="rm-spellcheck">
|
||||
<el-switch id="rm-spellcheck" :model-value="settings.spellcheck" @change="change({ spellcheck: Boolean($event) })" />
|
||||
</SettingRow>
|
||||
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })">
|
||||
<el-select id="rm-tab-size" :model-value="settings.tabSize" @change="change({ tabSize: $event })">
|
||||
<el-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="`${n} 列`" />
|
||||
</el-select>
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<template v-else-if="section === 'account'">
|
||||
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
|
||||
<h3>修改登录密码</h3>
|
||||
<p class="rm-share-note">
|
||||
修改后其他设备需要重新登录。
|
||||
</p>
|
||||
<label><span>当前密码</span><el-input v-model="pw.current" type="password" autocomplete="current-password" required show-password /></label>
|
||||
<label><span>新密码</span><el-input v-model="pw.next" type="password" autocomplete="new-password" required show-password /></label>
|
||||
<label><span>确认新密码</span><el-input v-model="pw.confirm" type="password" autocomplete="new-password" required show-password /></label>
|
||||
<p v-if="pwError" class="rm-auth-error" role="alert">
|
||||
{{ pwError }}
|
||||
</p>
|
||||
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="pwBusy">
|
||||
修改密码
|
||||
</el-button>
|
||||
</form>
|
||||
<section id="rm-setting-tokens" tabindex="-1">
|
||||
<h3 class="rm-settings-group-title">
|
||||
API 令牌
|
||||
</h3><TokensPanel />
|
||||
</section>
|
||||
<SettingRow id="logout">
|
||||
<el-button class="rm-btn" @click="close(), runCommand('app.logout')">
|
||||
<Icon name="logout" />退出
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
</template>
|
||||
<InterfaceSettings v-else-if="section === 'appearance'" />
|
||||
<EditorSettings v-else-if="section === 'editor'" />
|
||||
<AccountSettings v-else-if="section === 'account'" @logout="close(), runCommand('app.logout')" />
|
||||
|
||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
||||
|
||||
@@ -308,5 +133,400 @@ async function changePassword() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rm-settings {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
height: min(720px, 82dvh);
|
||||
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 :deep(.rm-dialog-head) {
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 52px;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.rm-settings :deep(.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 :deep(.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);
|
||||
}
|
||||
:deep(.rm-setting-info) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.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;
|
||||
}
|
||||
:deep(.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;
|
||||
}
|
||||
:deep(.rm-setting-control output) {
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
:deep(.rm-setting-control code) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
:deep(.rm-setting-reset) {
|
||||
flex: none;
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
:deep(.rm-settings-group-title) {
|
||||
margin: 20px 0 0;
|
||||
font-size: var(--rm-ui-font-size);
|
||||
font-weight: 600;
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
:deep(.rm-settings-group-title:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
:deep(.rm-settings-details) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
:deep(.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);
|
||||
}
|
||||
:deep(.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;
|
||||
}
|
||||
:deep(.rm-typography-preview h2) {
|
||||
margin-top: 0;
|
||||
font-family: var(--rm-font-heading);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
:deep(.rm-typography-preview p) {
|
||||
margin: 0;
|
||||
}
|
||||
:deep(.rm-typography-preview p + p) {
|
||||
margin-top: 1lh;
|
||||
}
|
||||
:deep(.rm-typography-preview code) {
|
||||
font-family: var(--rm-font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--rm-inline-code-text);
|
||||
}
|
||||
.rm-settings :deep(.rm-share-note),
|
||||
.rm-settings :deep(.rm-appearance-note) {
|
||||
margin: 12px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rm-settings :deep(.rm-token-created) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.rm-settings :deep(.rm-snippet-form) {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.rm-settings :deep(.rm-plugin-view) {
|
||||
padding: 12px 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-settings {
|
||||
height: 100dvh;
|
||||
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 :deep(.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 :deep(.rm-setting-info) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.rm-settings :deep(.rm-setting-control) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.rm-settings-modal.el-dialog) {
|
||||
width: min(960px, calc(100vw - 48px));
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
:global(.rm-settings-modal > .el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
:global(.rm-settings-modal > .el-dialog__body) {
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rm-settings {
|
||||
max-height: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.rm-settings-search {
|
||||
width: calc(100% - 16px);
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
:global(.rm-settings-modal.el-dialog) {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.rm-settings {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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<ImportResult>('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<string> {
|
||||
const headers: Record<string, string> = { '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<TrashItem>('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<string> {
|
||||
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<string> =>
|
||||
(await requestJSON<{ link: string }>('POST', `/assets/import?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', json: { url } })).link,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { runCommand } from '../../../common/utils/commands'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { runCommand } from '@@/utils/commands'
|
||||
import { toggleSourceMode, ws } from '../workspace'
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { confirmAction } from '@@/utils/confirm'
|
||||
import { basename, dirname, isImage, joinPath } from '@@/utils/paths'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { basename, dirname, isImage, joinPath } from '../../../common/utils/paths'
|
||||
import { imageUrl, move, remove, replaceImage, ws } from '../workspace'
|
||||
|
||||
const props = defineProps<{ path: string }>()
|
||||
const dialog = ref<HTMLElement>()
|
||||
const picker = ref<HTMLInputElement>()
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
@@ -27,7 +26,6 @@ watch(path, (p) => {
|
||||
watch(src, () => {
|
||||
loadError.value = false; dimensions.value = ''
|
||||
})
|
||||
onMounted(() => dialog.value?.focus())
|
||||
|
||||
function close() {
|
||||
if (!busy.value) ws.image = null
|
||||
@@ -53,7 +51,8 @@ async function rename() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await move(path.value, to)
|
||||
const result = await move(path.value, to, 'caller')
|
||||
if (!result.ok) error.value = result.error
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录'
|
||||
} finally {
|
||||
@@ -72,10 +71,10 @@ async function replace(event: Event) {
|
||||
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) {
|
||||
error.value = '请选择与原图片扩展名相同的文件'; return
|
||||
}
|
||||
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (!await confirmAction('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
|
||||
await replaceImage(path.value, file)
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试'
|
||||
@@ -85,9 +84,10 @@ async function replace(event: Event) {
|
||||
}
|
||||
|
||||
async function trash() {
|
||||
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (!await confirmAction('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
|
||||
await remove(path.value)
|
||||
} finally {
|
||||
busy.value = false
|
||||
@@ -96,14 +96,7 @@ async function trash() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
|
||||
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>图片管理</h2>
|
||||
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<el-dialog :model-value="true" title="图片管理" width="860px" class="rm-image-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="close" @close="close">
|
||||
<div class="rm-image-body">
|
||||
<p class="rm-image-path">
|
||||
{{ path }}
|
||||
@@ -129,9 +122,9 @@ async function trash() {
|
||||
<form class="rm-image-rename" @submit.prevent="rename">
|
||||
<label for="rm-image-name">图片名称</label>
|
||||
<div>
|
||||
<input id="rm-image-name" v-model="name" :disabled="busy || !entry" required>
|
||||
<el-input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
|
||||
<span>{{ extension }}</span>
|
||||
<el-button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
|
||||
<el-button native-type="submit" class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
|
||||
重命名
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -155,11 +148,10 @@ async function trash() {
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.rm-image-dialog {
|
||||
max-width: 860px;
|
||||
width: min(860px, calc(100vw - 24px));
|
||||
@@ -168,7 +160,7 @@ async function trash() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 0 20px 20px;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
font-size: var(--rm-ui-font-size);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entry } from '@@/apis/vault/type'
|
||||
import type { Entry } from '@@/apis/tree/type'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { basename, hiddenAssetPath, imageInFolder, isImage } from '@@/utils/paths'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { basename, hiddenAssetPath, imageInFolder, isImage } from '../../../common/utils/paths'
|
||||
import { metadata, refreshMetadata } from '../metadata'
|
||||
import { cleanupImages, imageUrl, openReference, uploadFolderImages, ws } from '../workspace'
|
||||
|
||||
@@ -106,21 +106,11 @@ async function clean() {
|
||||
</el-button>
|
||||
</header>
|
||||
<div class="rm-media-mobile-filters">
|
||||
<select v-model="ws.imagesAll" aria-label="资源范围">
|
||||
<option :value="false">
|
||||
当前目录
|
||||
</option><option :value="true">
|
||||
整个知识库
|
||||
</option>
|
||||
</select><select v-model="usage" aria-label="引用状态">
|
||||
<option value="all">
|
||||
全部图片
|
||||
</option><option value="used" :disabled="!certain">
|
||||
已被引用
|
||||
</option><option value="unused" :disabled="!certain">
|
||||
未被引用
|
||||
</option>
|
||||
</select><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
|
||||
<el-select v-model="ws.imagesAll" aria-label="资源范围">
|
||||
<el-option :value="false" label="当前目录" /><el-option :value="true" label="整个知识库" />
|
||||
</el-select><el-select v-model="usage" aria-label="引用状态">
|
||||
<el-option value="all" label="全部图片" /><el-option value="used" :disabled="!certain" label="已被引用" /><el-option value="unused" :disabled="!certain" label="未被引用" />
|
||||
</el-select><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
|
||||
清理孤立图片
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -138,26 +128,20 @@ async function clean() {
|
||||
</aside>
|
||||
<div class="rm-media-content">
|
||||
<div class="rm-media-toolbar">
|
||||
<label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片"></label><select v-model="format" aria-label="图片格式">
|
||||
<option value="">
|
||||
所有格式
|
||||
</option><option v-for="type in types" :key="type" :value="type">
|
||||
{{ type.toUpperCase() }}
|
||||
</option>
|
||||
</select><select v-model="sort" aria-label="图片排序">
|
||||
<option value="modified">
|
||||
最近修改
|
||||
</option><option value="name">
|
||||
名称
|
||||
</option><option value="size">
|
||||
文件大小
|
||||
</option>
|
||||
</select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list">
|
||||
<el-input v-model="query" class="rm-media-search" type="search" clearable placeholder="搜索图片名称或路径…" aria-label="搜索图片">
|
||||
<template #prefix>
|
||||
<Icon name="search" />
|
||||
</template>
|
||||
</el-input><el-select v-model="format" aria-label="图片格式">
|
||||
<el-option value="" label="所有格式" /><el-option v-for="type in types" :key="type" :value="type" :label="type.toUpperCase()" />
|
||||
</el-select><el-select v-model="sort" aria-label="图片排序">
|
||||
<el-option value="modified" label="最近修改" /><el-option value="name" label="名称" /><el-option value="size" label="文件大小" />
|
||||
</el-select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list">
|
||||
<Icon :name="list ? 'grid' : 'outline'" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="rm-media-upload">
|
||||
<label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload"><el-button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()">
|
||||
<label>上传至 <el-select v-model="uploadDir" aria-label="上传目录"><el-option value="" label="根目录 / assets" /><el-option v-for="folder in directories" :key="folder.path" :value="folder.path" :label="`${folder.path} / assets`" /></el-select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload"><el-button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()">
|
||||
<Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -198,19 +182,18 @@ async function clean() {
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)">
|
||||
<section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div><p class="rm-share-note">
|
||||
<el-dialog :model-value="cleanupCandidates !== null" :title="`清理候选 · ${cleanupCandidates?.length ?? 0} 张`" width="640px" class="rm-cleanup-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="() => { if (!busy) cleanupCandidates = null }" @close="cleanupCandidates = null">
|
||||
<p class="rm-share-note">
|
||||
服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。
|
||||
</p><ul>
|
||||
<li v-for="image in cleanupCandidates" :key="image.path">
|
||||
<label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy">{{ image.path }} <small>{{ size(image.size) }}</small></label>
|
||||
<li v-for="image in cleanupCandidates ?? []" :key="image.path">
|
||||
<el-checkbox-group v-model="cleanupSelected">
|
||||
<el-checkbox :value="image.path" :disabled="busy">
|
||||
{{ image.path }} <small>{{ size(image.size) }}</small>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</li>
|
||||
</ul><p v-if="!cleanupCandidates.length" class="rm-share-note">
|
||||
</ul><p v-if="!cleanupCandidates?.length" class="rm-share-note">
|
||||
当前范围没有可清理的孤立图片。
|
||||
</p><div class="rm-cleanup-actions">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
|
||||
@@ -219,12 +202,32 @@ async function clean() {
|
||||
移入回收站({{ cleanupSelected.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.rm-media-toolbar :deep(.el-select) {
|
||||
width: 130px;
|
||||
}
|
||||
.rm-media-upload :deep(.el-select) {
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.rm-media-mobile-filters :deep(.el-select) {
|
||||
width: 130px;
|
||||
}
|
||||
.rm-media-search.el-input {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
:deep(.el-checkbox) {
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
:deep(.el-checkbox__label) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rm-media-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImportResult } from '@/pages/workspace/apis/type'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { ImportResult } from '@@/apis/files/type'
|
||||
import { dirname } from '@@/utils/paths'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { dirname } from '../../../common/utils/paths'
|
||||
import { expanded } from '../tree'
|
||||
import { importNotes } from '../workspace'
|
||||
|
||||
@@ -17,13 +16,11 @@ const error = ref('')
|
||||
const result = ref<ImportResult | null>(null)
|
||||
const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name))
|
||||
|
||||
const dialog = ref<HTMLElement>()
|
||||
const validation = computed(() => {
|
||||
if (!zipped.value && files.value.some(f => !f.name.endsWith('.md'))) return '请选择 .md 文件,或单独选择一个 ZIP'
|
||||
if (files.value.reduce((sum, file) => sum + file.size, 0) > 100_000_000) return '导入文件总量不能超过 100 MB'
|
||||
return ''
|
||||
})
|
||||
onMounted(() => dialog.value?.focus())
|
||||
|
||||
function choose(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
@@ -64,15 +61,8 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
|
||||
<section ref="dialog" class="rm-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="导入 Markdown" :aria-busy="busy">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>导入 Markdown</h2>
|
||||
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form class="rm-import-body" @submit.prevent="submit">
|
||||
<el-dialog :model-value="true" title="导入 Markdown" width="560px" class="rm-import-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="close" @close="close">
|
||||
<el-form class="rm-import-body" label-position="top" :disabled="busy" @submit.prevent="submit">
|
||||
<p class="rm-import-target">
|
||||
导入到:{{ dir || '根目录' }}
|
||||
</p>
|
||||
@@ -81,11 +71,11 @@ async function submit() {
|
||||
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose">
|
||||
</label>
|
||||
<label v-if="zipped">新建文件夹名称
|
||||
<input v-model="folder" :disabled="busy" required @input="error = ''">
|
||||
<el-input v-model="folder" required @input="error = ''" />
|
||||
</label>
|
||||
<div v-else-if="files.length" class="rm-import-files">
|
||||
<label v-for="(file, i) in files" :key="i">{{ file.name }}
|
||||
<input v-model="names[i]" :disabled="busy" required aria-label="导入文件名称" @input="error = ''">
|
||||
<el-input v-model="names[i]" required aria-label="导入文件名称" @input="error = ''" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="rm-import-hint">
|
||||
@@ -117,17 +107,16 @@ async function submit() {
|
||||
{{ busy ? '导入中…' : '导入' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.rm-import-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 8px 20px 20px;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
font-size: var(--rm-ui-font-size);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { metadata, refreshMetadata } from '../metadata'
|
||||
import { openNote, openReference, ws } from '../workspace'
|
||||
|
||||
@@ -74,13 +74,9 @@ watch(() => ws.view, () => {
|
||||
</el-button>
|
||||
</header>
|
||||
<div class="rm-knowledge-tools">
|
||||
<label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记"></label><select v-model="tag" aria-label="筛选标签">
|
||||
<option value="">
|
||||
全部标签
|
||||
</option><option v-for="[name, count] in tags" :key="name" :value="name">
|
||||
#{{ name }} · {{ count }}
|
||||
</option>
|
||||
</select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active">仅当前笔记关联</label>
|
||||
<label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记"></label><el-select v-model="tag" aria-label="筛选标签">
|
||||
<el-option value="" label="全部标签" /><el-option v-for="[name, count] in tags" :key="name" :value="name" :label="`#${name} · ${count}`" />
|
||||
</el-select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active">仅当前笔记关联</label>
|
||||
</div>
|
||||
<p v-if="metadata.error" role="alert">
|
||||
{{ metadata.error }} <el-button class="rm-btn" @click="refreshMetadata">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '../../../common/utils/paths'
|
||||
import type { InputInstance } from 'element-plus'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '@@/utils/paths'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { batchFiles } from '../batch'
|
||||
import { fuzzyMatch, segments } from '../fuzzy'
|
||||
import { minimalPaths } from '../selection'
|
||||
@@ -11,10 +13,12 @@ import { move, ws } from '../workspace'
|
||||
const props = defineProps<{ path: string | string[] }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
const input = ref<InputInstance>()
|
||||
const list = ref<HTMLElement>()
|
||||
const query = ref('')
|
||||
const selected = ref(0)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const paths = computed(() => minimalPaths(typeof props.path === 'string' ? [props.path] : props.path))
|
||||
const title = computed(() => paths.value.length > 1 ? `${paths.value.length} 项` : noteTitle(paths.value[0]))
|
||||
|
||||
@@ -38,40 +42,53 @@ const items = computed(() => {
|
||||
watch(query, () => (selected.value = 0))
|
||||
watch(selected, i => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
|
||||
|
||||
function choose(i: number) {
|
||||
async function choose(i: number) {
|
||||
const target = items.value[i]
|
||||
if (!target) return
|
||||
if (!target || busy.value) return
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
if (paths.value.length === 1) {
|
||||
const result = await move(paths.value[0], joinPath(target.path, basename(paths.value[0])), 'caller')
|
||||
if (!result.ok) {
|
||||
error.value = result.error; return
|
||||
}
|
||||
} else if (!await batchFiles('move', paths.value, target.path)) {
|
||||
error.value = '部分项目移动失败,请核对目录后重试'; return
|
||||
}
|
||||
emit('close')
|
||||
if (paths.value.length === 1) void move(paths.value[0], joinPath(target.path, basename(paths.value[0])))
|
||||
else void batchFiles('move', paths.value, target.path)
|
||||
} catch (cause) {
|
||||
if (!isCancel(cause)) error.value = String(cause)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
function close() {
|
||||
if (!busy.value) emit('close')
|
||||
}
|
||||
function opened() {
|
||||
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const n = items.value.length
|
||||
if (e.key === 'Escape') emit('close')
|
||||
if (e.key === 'Escape') close()
|
||||
else if (e.key === 'ArrowDown' && n) selected.value = (selected.value + 1) % n
|
||||
else if (e.key === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
|
||||
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 触屏设备上不自动弹出键盘,文件夹不多时直接点选更方便
|
||||
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-switcher-backdrop" @mousedown.self="emit('close')">
|
||||
<div class="rm-switcher" role="dialog" :aria-label="`移动“${title}”`">
|
||||
<div class="rm-search-box">
|
||||
<el-dialog :model-value="true" :title="`移动“${title}”`" width="560px" class="rm-picker-dialog" :show-close="!busy" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :before-close="close" @close="close" @opened="opened">
|
||||
<el-input ref="input" v-model="query" :disabled="busy" :placeholder="`把“${title}”移动到…`" aria-label="搜索目标文件夹" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
|
||||
<template #prefix>
|
||||
<Icon name="folder" />
|
||||
<input ref="input" v-model="query" class="rm-search-input" :placeholder="`把“${title}”移动到…`" enterkeyhint="go" @keydown="onKeydown">
|
||||
<button class="rm-icon-btn" title="取消" @click="emit('close')">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<ul v-if="items.length" ref="list" class="rm-switcher-list">
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
@@ -91,6 +108,5 @@ onMounted(() => {
|
||||
<p v-else class="rm-tree-empty">
|
||||
没有可移动到的文件夹
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
|
||||
|
||||
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { availableCommands, commandKeys, formatKeys } from '../../../common/utils/commands'
|
||||
import { dirname, NOTE_EXT, noteTitle } from '../../../common/utils/paths'
|
||||
import type { InputInstance } from 'element-plus'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { availableCommands, commandKeys, formatKeys } from '@@/utils/commands'
|
||||
import { dirname, NOTE_EXT, noteTitle } from '@@/utils/paths'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { fuzzyMatch, segments } from '../fuzzy'
|
||||
import { createNoteAt, openAt, ws } from '../workspace'
|
||||
|
||||
@@ -22,7 +23,7 @@ interface Item {
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
const input = ref<InputInstance>()
|
||||
const list = ref<HTMLElement>()
|
||||
const query = ref(ws.switcher ?? '')
|
||||
const selected = ref(0)
|
||||
@@ -148,24 +149,18 @@ function onKeydown(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => input.value?.focus())
|
||||
function opened() {
|
||||
input.value?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-switcher-backdrop" @mousedown.self="cancel">
|
||||
<div class="rm-switcher" role="dialog" :aria-label="commandMode ? '命令面板' : '快速打开'">
|
||||
<div class="rm-search-box">
|
||||
<el-dialog :model-value="true" :title="commandMode ? '命令面板' : '快速打开'" width="560px" class="rm-picker-dialog" @close="cancel" @opened="opened">
|
||||
<el-input ref="input" v-model="query" :placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'" :aria-label="commandMode ? '搜索命令' : '搜索笔记'" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
|
||||
<template #prefix>
|
||||
<Icon :name="commandMode ? 'command' : 'search'" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
class="rm-search-input"
|
||||
:placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'"
|
||||
enterkeyhint="go"
|
||||
@keydown="onKeydown"
|
||||
@blur="close"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent>
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
@@ -187,6 +182,5 @@ onMounted(() => input.value?.focus())
|
||||
<p v-else class="rm-tree-empty">
|
||||
{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SearchResult } from '@/pages/workspace/apis/type'
|
||||
import type { SearchResult } from '@@/apis/search/type'
|
||||
|
||||
import { searchApi } from '@@/apis/search'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
|
||||
import { dirname, noteTitle } from '@@/utils/paths'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { workspaceApi } from '@/pages/workspace/apis'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { dirname, noteTitle } from '../../../common/utils/paths'
|
||||
import { openAt, ws } from '../workspace'
|
||||
|
||||
const SEARCH_DELAY = 150
|
||||
@@ -30,7 +32,7 @@ async function run(q: string) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await workspaceApi.search(q)
|
||||
const r = (await searchApi({ q })).data.results
|
||||
if (id !== seq) return
|
||||
results.value = r
|
||||
selected.value = 0
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { InlineStyles } from '@@/markdown/inline-styles'
|
||||
import type { EditorSelection } from '@codemirror/state'
|
||||
import type { InlineStyles } from '../../../common/markdown/inline-styles'
|
||||
import type { FormatCommand } from '../editor/format'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import InlineAppearance from '@@/components/InlineAppearance/index.vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import InlineAppearance from '../../../common/components/InlineAppearance/index.vue'
|
||||
import { settings } from '../../../pinia/stores/settings'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { insertLink } from '../editor/format'
|
||||
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from '../editor/inline-styles'
|
||||
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
|
||||
|
||||
const { settings } = useSettingsStore(pinia)
|
||||
|
||||
const root = ref<HTMLElement>()
|
||||
const floating = ref<{ left: number, top: number, maxHeight: number, maxWidth: number } | null>(null)
|
||||
const appearanceOpen = ref(false)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import PluginMount from '@@/components/PluginMount/index.vue'
|
||||
import { sidebarViews } from '@@/plugins/registry'
|
||||
import { commandTitle } from '@@/utils/commands'
|
||||
import { basename } from '@@/utils/paths'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import PluginMount from '../../../common/components/PluginMount/index.vue'
|
||||
import { sidebarViews } from '../../../common/plugins/registry'
|
||||
import { commandTitle } from '../../../common/utils/commands'
|
||||
import { basename } from '../../../common/utils/paths'
|
||||
import { dropDirectory, tree, treeIconTarget } from '../tree'
|
||||
import { createFolder, createNote, move, openSearch, ws } from '../workspace'
|
||||
import SearchPanel from './SearchPanel.vue'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Tab } from '../workspace'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { commandTitle } from '@@/utils/commands'
|
||||
import { noteTitle } from '@@/utils/paths'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { commandTitle } from '../../../common/utils/commands'
|
||||
import { noteTitle } from '../../../common/utils/paths'
|
||||
import { closeTab, createNote, ws } from '../workspace'
|
||||
|
||||
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeIcon } from '@@/apis/vault/type'
|
||||
import type { TreeIcon } from '@@/apis/tree/type'
|
||||
import type { IconName } from '@@/utils/icons'
|
||||
import type { Gemoji } from 'gemoji'
|
||||
import type { IconName } from '../../../common/utils/icons'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { iconPaths } from '@@/utils/icons'
|
||||
import { basename } from '@@/utils/paths'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { iconPaths } from '../../../common/utils/icons'
|
||||
import { basename } from '../../../common/utils/paths'
|
||||
import { treeIconTarget } from '../tree'
|
||||
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from '../tree-icon-catalog'
|
||||
import { saveTreeIcon } from '../workspace'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeNode } from '../tree'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { basename, dirname, inAssetDir, isImage, joinPath } from '@@/utils/paths'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon/index.vue'
|
||||
import { basename, dirname, inAssetDir, isImage, joinPath } from '../../../common/utils/paths'
|
||||
import { batchFiles, exportFiles } from '../batch'
|
||||
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget } from '../tree'
|
||||
import { createFolder, createNote, move, openNote, renameTarget, ws } from '../workspace'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { watchFontLoading } from '../../../common/appearance/font-loading'
|
||||
import { watchFontLoading } from '@@/appearance/font-loading'
|
||||
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
|
||||
import { createEditorState, createEditorView } from '../editor'
|
||||
import sample from './sample.md?raw'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import { codeRenderers, notePath, pluginRefresh } from '@@/plugins/registry'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView } from '@codemirror/view'
|
||||
import { codeRenderers, notePath, pluginRefresh } from '../../../common/plugins/registry'
|
||||
import { codeContentRange, fenceLanguage } from './codeblocks'
|
||||
import { editorFocused, setEditorFocus } from './focus'
|
||||
import { CodeRendererWidget, MathWidget } from './widgets'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TokenRun } from '@@/highlight/client'
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import type { TokenRun } from '../../../common/highlight/client'
|
||||
import { cachedTokens, requestTokens } from '@@/highlight/client'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { StateEffect, StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import { cachedTokens, requestTokens } from '../../../common/highlight/client'
|
||||
|
||||
interface CodeBlock {
|
||||
// 代码内容(不含围栏行)在文档中的范围
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import type { KeyBinding, ViewUpdate } from '@codemirror/view'
|
||||
import type { MarkdownParser } from '@lezer/markdown'
|
||||
import { typoraExtensions } from '@@/markdown/extensions'
|
||||
import { editorExtensions, markdownSyntax, reportEditorFailure } from '@@/plugins/registry'
|
||||
import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
|
||||
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { Language } from '@codemirror/language'
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state'
|
||||
import { drawSelection, EditorView, keymap } from '@codemirror/view'
|
||||
import { typoraExtensions } from '../../../common/markdown/extensions'
|
||||
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../../../common/plugins/registry'
|
||||
import { renderedBlocks } from './blocks'
|
||||
import { codeBlockHighlight } from './codeblocks'
|
||||
import { editorFocus } from './focus'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TransactionSpec } from '@codemirror/state'
|
||||
import type { FormatCommand } from './format'
|
||||
import { renderMarkdown } from '@@/markdown/html'
|
||||
import { redo, undo } from '@codemirror/commands'
|
||||
import { EditorSelection, EditorState, Transaction } from '@codemirror/state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMarkdown } from '../../../common/markdown/html'
|
||||
import { toggleBold } from './format'
|
||||
import { createEditorState } from './index'
|
||||
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inline-styles'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { InlineStyles } from '@@/markdown/inline-styles'
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import type { InlineStyles } from '../../../common/markdown/inline-styles'
|
||||
import type { FormatCommand } from './format'
|
||||
import { inlineStyleText, stylesFromTag } from '@@/markdown/inline-styles'
|
||||
import { ensureSyntaxTree, language } from '@codemirror/language'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
|
||||
|
||||
interface Wrapper { marks: { from: number, to: number }[] }
|
||||
interface Segment {
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import type { TocEntry } from './widgets'
|
||||
import { inlineStyleText, stylesFromTag } from '@@/markdown/inline-styles'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Facet, StateEffect } from '@codemirror/state'
|
||||
import { Decoration, ViewPlugin } from '@codemirror/view'
|
||||
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
|
||||
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
|
||||
import {
|
||||
BulletWidget,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Command, KeyBinding } from '@codemirror/view'
|
||||
import { typoraKeys } from '@@/utils/typora-keys'
|
||||
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
|
||||
import { typoraKeys } from '../../../common/utils/typora-keys'
|
||||
import { selectCodeBlock } from './codeblocks'
|
||||
import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format'
|
||||
import { toggleSelectionMark, toggleUnderline } from './inline-styles'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import type { DecorationSet } from '@codemirror/view'
|
||||
import type { SyntaxNode } from '@lezer/common'
|
||||
import { renderInlineMarkdown } from '@@/markdown/html'
|
||||
import { hydrate } from '@@/markdown/render'
|
||||
import { isolateHistory, redo, undo } from '@codemirror/commands'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { StateField } from '@codemirror/state'
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
|
||||
import { renderInlineMarkdown } from '../../../common/markdown/html'
|
||||
import { hydrate } from '../../../common/markdown/render'
|
||||
import { editorFocused, setEditorFocus } from './focus'
|
||||
|
||||
type Align = '' | 'left' | 'center' | 'right'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { OwnedRenderer } from '../../../common/plugins/registry'
|
||||
import type { OwnedRenderer } from '@@/plugins/registry'
|
||||
import { emojiFor, renderMath } from '@@/markdown/render'
|
||||
import { mountContent } from '@@/plugins/mount'
|
||||
import { copyText } from '@@/utils/clipboard'
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { emojiFor, renderMath } from '../../../common/markdown/render'
|
||||
import { mountContent } from '../../../common/plugins/mount'
|
||||
import { copyText } from '../../../common/utils/clipboard'
|
||||
|
||||
export class BulletWidget extends WidgetType {
|
||||
eq() {
|
||||
|
||||
@@ -6,12 +6,14 @@ import { CanceledError } from 'axios'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { pinia } from '@/pinia'
|
||||
import * as preferences from '@/pinia/stores/settings'
|
||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import Workspace from './index.vue'
|
||||
import { flushAll, readyToReload } from './workspace'
|
||||
|
||||
const preferences = useSettingsStore(pinia)
|
||||
|
||||
vi.mock('./workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./workspace')>()
|
||||
return { ...actual, flushAll: vi.fn(), readyToReload: vi.fn(), startWorkspace: vi.fn(), stopWorkspace: vi.fn() }
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import AppStatus from '@@/components/AppStatus/index.vue'
|
||||
import Icon from '@@/components/Icon/index.vue'
|
||||
import { registerCommand } from '@@/utils/commands'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { noteTitle } from '@@/utils/paths'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import SettingsDialog from '@/pages/settings/index.vue'
|
||||
import { pinia } from '@/pinia'
|
||||
import { SettingsAppearanceError, SIDEBAR_WIDTH, useSettingsStore } from '@/pinia/stores/settings'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Icon from '../../common/components/Icon/index.vue'
|
||||
import { registerCommand } from '../../common/utils/commands'
|
||||
import { noteTitle } from '../../common/utils/paths'
|
||||
import { settings, SettingsAppearanceError, SIDEBAR_WIDTH, updateSettings } from '../../pinia/stores/settings'
|
||||
import SettingsDialog from '../settings/index.vue'
|
||||
import EditorChrome from './components/EditorChrome.vue'
|
||||
import EditorPane from './components/EditorPane.vue'
|
||||
import ImageDialog from './components/ImageDialog.vue'
|
||||
import ImageGallery from './components/ImageGallery.vue'
|
||||
|
||||
import ImageGallery from './components/ImageGallery.vue'
|
||||
import ImportDialog from './components/ImportDialog.vue'
|
||||
import KnowledgePanel from './components/KnowledgePanel.vue'
|
||||
import MoveDialog from './components/MoveDialog.vue'
|
||||
@@ -27,6 +28,8 @@ import Sidebar from './components/Sidebar.vue'
|
||||
import TabBar from './components/TabBar.vue'
|
||||
import { flushAll, readyToReload, restore, startWorkspace, stopWorkspace, ws } from './workspace'
|
||||
|
||||
const { settings, updateSettings } = useSettingsStore(pinia)
|
||||
|
||||
async function prepareReload() {
|
||||
await flushAll(); return readyToReload()
|
||||
}
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user