refactor(web): align resource APIs and forms with the starter conventions

Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors.

Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
This commit is contained in:
eezd committed 2026-10-26 11:53:37 +08:00
1 parent cef0298c34
commit 6b537c03e0
114 files changed
+2876 -2217

No files matched your search

+7
View File
@@ -43,5 +43,12 @@ export default antfu(
'unicorn/no-instanceof-builtins': 'off', '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' } } }, { files: ['src/service-worker.js'], languageOptions: { globals: { __PRECACHE__: 'readonly' } } },
) )
+62
View File
@@ -0,0 +1,62 @@
import type * as Assets from './type'
import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios'
/** 上传笔记图片。 */
export function uploadImageApi(data: Assets.UploadImageRequestData) {
const body = new FormData()
body.append('file', data.file, data.file.name || 'image')
return request<Assets.ImageResponseData>({
url: 'assets',
method: 'post',
params: { note: data.note },
data: body,
errorOwner: 'caller',
})
}
/** 上传目录图片。 */
export function uploadFolderImageApi(data: Assets.FolderImageRequestData) {
const body = new FormData()
body.append('file', data.file)
return request<Assets.ImageResponseData>({
url: 'assets',
method: 'post',
params: { folder: data.folder },
data: body,
errorOwner: 'caller',
})
}
/** 替换原路径图片。 */
export function replaceImageApi(data: Assets.ReplaceImageRequestData) {
const body = new FormData()
body.append('file', data.file)
return request<Assets.ReplaceImageResponseData>({
url: `assets/${encodePath(data.path)}`,
method: 'put',
data: body,
errorOwner: 'caller',
})
}
/** 下载并导入远程图片。 */
export function importImageApi(data: Assets.ImportImageRequestData) {
return request<Assets.ImageResponseData>({
url: 'assets/import',
method: 'post',
params: { note: data.note },
data: { url: data.url },
errorOwner: 'caller',
})
}
/** 核对引用后清理图片。 */
export function cleanupImagesApi(data: Assets.CleanupImagesRequestData) {
return request<Assets.CleanupImagesResponseData>({
url: 'assets/cleanup',
method: 'post',
data,
errorOwner: 'caller',
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { TrashItem } from '../trash/type'
export interface UploadImageRequestData { note: string, file: File }
export interface FolderImageRequestData { folder: string, file: File }
export interface ReplaceImageRequestData { path: string, file: File }
export interface ImportImageRequestData { note: string, url: string }
export interface CleanupImagesRequestData { paths: string[] }
export type ImageResponseData = ApiResponseData<{ link: string }>
export type ReplaceImageResponseData = ApiResponseData<{ ok: boolean }>
export type CleanupImagesResponseData = ApiResponseData<{ deleted: TrashItem[], skipped: { path: string, reason: string }[] }>
+80 -14
View File
@@ -1,16 +1,82 @@
import { requestJSON } from '@/http/axios' import type * as Auth from './type'
import { request } from '@/http/axios'
export interface LoginResult { token: string, expiresAt: string } /** 查询初始化状态。 */
export interface Identity { permissions: string[], expiresAt: string } export function getAuthStateApi() {
export interface Captcha { captchaId: string, imageDataUrl: string, expiresAt: string } return request<Auth.StateResponseData>({
export interface ResourceTicket { url: string, expiresAt: string } url: 'auth/state',
export type TicketRequest = { kind: 'images' | 'fonts' | 'events' } | { kind: 'plugin', pluginId: string, revision: string } method: 'get',
export const authApi = { anonymous: true,
state: () => requestJSON<{ setUp: boolean }>('GET', '/auth/state', { auth: false, errorOwner: 'caller' }), 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'), export function getCaptchaApi(data: Auth.CaptchaRequestData) {
ticket: (data: TicketRequest) => requestJSON<ResourceTicket>('POST', '/auth/tickets', { json: data }), 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',
})
} }
+15
View File
@@ -0,0 +1,15 @@
export interface LoginRequestData { password: string, captchaId: string, captchaCode: string }
export interface SetupRequestData { password: string }
export interface CaptchaRequestData { replaceId?: string }
export interface PasswordRequestData { current: string, next: string }
export interface LoginResult { token: string, expiresAt: string }
export interface Identity { permissions: string[], expiresAt: string }
export interface Captcha { captchaId: string, imageDataUrl: string, expiresAt: string }
export interface ResourceTicket { url: string, expiresAt: string }
export type TicketRequestData = { kind: 'images' | 'fonts' | 'events' } | { kind: 'plugin', pluginId: string, revision: string }
export type StateResponseData = ApiResponseData<{ setUp: boolean }>
export type LoginResponseData = ApiResponseData<LoginResult>
export type IdentityResponseData = ApiResponseData<Identity>
export type CaptchaResponseData = ApiResponseData<Captcha>
export type TicketResponseData = ApiResponseData<ResourceTicket>
export type PasswordResponseData = ApiResponseData<{ ok: boolean }>
@@ -1,14 +1,16 @@
import { authApi } from '@@/apis/auth' import * as authApi from '@@/apis/auth'
import { connectEventsApi } from '@@/apis/events'
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest' import { afterEach, expect, it, vi } from 'vitest'
import { connectEvents } from './events' import { apiSuccess } from '../../../../tests/api-response'
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals() vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals()
}) })
it('首次连接和重连均补同步,停止后不再连接', async () => { 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() vi.useFakeTimers()
class Socket { class Socket {
static CLOSED = 3 static CLOSED = 3
@@ -26,7 +28,7 @@ it('首次连接和重连均补同步,停止后不再连接', async () => {
} }
vi.stubGlobal('WebSocket', Socket) vi.stubGlobal('WebSocket', Socket)
let synchronized = 0 let synchronized = 0
const stop = connectEvents({ onEvents: () => {}, onResync: () => { const stop = connectEventsApi({ onEvents: () => {}, onResync: () => {
synchronized++ synchronized++
} }) } })
await vi.waitFor(() => expect(Socket.instances).toHaveLength(1)) await vi.waitFor(() => expect(Socket.instances).toHaveLength(1))
@@ -40,5 +42,5 @@ it('首次连接和重连均补同步,停止后不再连接', async () => {
Socket.instances[1].onclose!() Socket.instances[1].onclose!()
vi.advanceTimersByTime(30000) vi.advanceTimersByTime(30000)
expect(Socket.instances).toHaveLength(2) 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 { isCancel } from 'axios'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
export interface VaultEvent { export function connectEventsApi(handlers: EventHandlers): () => void {
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 {
const user = useUserStore(pinia) const user = useUserStore(pinia)
const version = user.getSessionVersion() const version = user.getSessionVersion()
let socket: WebSocket | undefined let socket: WebSocket | undefined
@@ -29,7 +25,7 @@ export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void,
if (stopped || connecting) return if (stopped || connecting) return
connecting = true connecting = true
try { try {
const ticket = await authApi.ticket({ kind: 'events' }) const ticket = (await createResourceTicketApi({ kind: 'events' })).data
user.assertSession(version) user.assertSession(version)
if (stopped) return if (stopped) return
const url = new URL(ticket.url, location.origin) const url = new URL(ticket.url, location.origin)
+8
View File
@@ -0,0 +1,8 @@
export interface VaultEvent {
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
path?: string
from?: string
rev?: string
}
export interface EventHandlers { onEvents: (events: VaultEvent[]) => void, onResync: () => void }
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { getCaptchaApi, loginApi } from '@@/apis/auth'
import { importFontApi } from '@@/apis/fonts'
import { searchApi } from '@@/apis/search'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { apiSuccess } from '../../../../tests/api-response'
import { exportFilesApi, importNotesApi, writeFileApi } from './index'
const user = useUserStore(pinia)
beforeEach(() => user.setToken('rms_api_fixture'))
afterEach(() => {
vi.restoreAllMocks(); user.setToken('')
})
const json = (data: unknown) => new Response(JSON.stringify(apiSuccess(data)), { headers: { 'Content-Type': 'application/json' } })
it('aPI 返回完整 JSON 响应,验证码和登录使用真实请求体且不发送现有身份', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(json({ captchaId: 'challenge', imageDataUrl: 'data:image/png;base64,test', expiresAt: '2030-01-01' })).mockResolvedValueOnce(json({ token: 'rms_new', expiresAt: '2030-01-01' }))
const captcha = await getCaptchaApi({ replaceId: 'old' })
expect(captcha.code).toBe(0); expect(captcha.data.captchaId).toBe('challenge')
const fields = { password: 'fixture-password', captchaId: captcha.data.captchaId, captchaCode: '123456' }
expect((await loginApi(fields)).data.token).toBe('rms_new')
const requests = fetch.mock.calls.map(call => call[0] as Request)
expect(requests.every(request => !request.headers.has('Authorization'))).toBe(true)
expect(await requests[0]!.json()).toEqual({ replaceId: 'old' })
expect(await requests[1]!.json()).toEqual(fields)
})
it('gET 使用 params 编码查询,保留 Bearer 和完整搜索响应', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({ results: [] }))
expect(await searchApi({ q: '笔记 & 示例' })).toEqual(apiSuccess({ results: [] }))
const request = fetch.mock.calls[0]![0] as Request
expect(new URL(request.url).searchParams.get('q')).toBe('笔记 & 示例')
expect(request.headers.get('Authorization')).toBe('Bearer rms_api_fixture')
})
it('字体与笔记使用真实 multipart 文件,不被 JSON 请求头覆盖', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(json({ id: 'font' })).mockResolvedValueOnce(json({ notes: 1, assets: 0, paths: ['fixture.md'], warnings: [] }))
await importFontApi(new File(['font'], 'fixture.woff2'))
await importNotesApi({ dir: 'folder', folder: '', files: [new File(['# 正文'], 'fixture.md')] })
for (const [input] of fetch.mock.calls) expect((input as Request).headers.get('Content-Type')).toMatch(/^multipart\/form-data; boundary=/)
const body = await (fetch.mock.calls[1]![0] as Request).formData()
expect(body.get('dir')).toBe('folder')
expect(body.get('files')).toMatchObject({ name: 'fixture.md' })
})
it('版本写入保留 Markdown 原文、条件请求头与小文件卸载保存', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => json({ rev: 'next' }))
expect((await writeFileApi({ path: '目录/a.md', content: '# 原文', ifMatch: 'old', keepalive: true })).data.rev).toBe('next')
const request = fetch.mock.calls[0]![0] as Request
expect(request.url).toContain(encodeURIComponent('目录'))
expect(request.headers.get('If-Match')).toBe('"old"')
expect(request.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
expect(fetch.mock.calls[0]![1]).toMatchObject({ keepalive: true })
expect(await request.text()).toBe('# 原文')
await writeFileApi({ path: 'new.md', content: '', ifMatch: '' })
expect((fetch.mock.calls[1]![0] as Request).headers.get('If-None-Match')).toBe('*')
})
it('导出直接返回 Blob 与下载响应头,不模拟 Fetch Response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ZIP', { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="fixture.zip"' } }))
const response = await exportFilesApi({ paths: ['fixture.md'] })
expect(response.data).toBeInstanceOf(Blob)
expect(await response.data.text()).toBe('ZIP')
expect(response.headers.get('Content-Disposition')).toBe('attachment; filename="fixture.zip"')
})
+95
View File
@@ -0,0 +1,95 @@
import type * as Files from './type'
import { encodePath } from '@@/utils/paths'
import { request } from '@/http/axios'
/** 读取 Markdown 原文与版本。 */
export function getFileApi(path: string) {
return request<string>({
url: `files/${encodePath(path)}`,
method: 'get',
responseType: 'text',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 按版本写入 Markdown。 */
export function writeFileApi(data: Files.WriteFileRequestData) {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
if (data.ifMatch === '') headers['If-None-Match'] = '*'
else if (data.ifMatch !== undefined) headers['If-Match'] = `"${data.ifMatch}"`
return request<Files.WriteFileResponseData>({
url: `files/${encodePath(data.path)}`,
method: 'put',
data: data.content,
headers,
fetchOptions: { keepalive: !!data.keepalive && new Blob([data.content]).size < 60_000 },
errorOwner: 'caller',
})
}
/** 移入回收站。 */
export function deleteFileApi(path: string) {
return request<Files.DeleteFileResponseData>({
url: `files/${encodePath(path)}`,
method: 'delete',
errorOwner: 'caller',
})
}
/** 创建文件夹。 */
export function createFolderApi(data: Files.FileRequestData) {
return request<Files.FileOperationResponseData>({
url: 'folders',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 移动文件或目录。 */
export function moveFileApi(data: Files.MoveRequestData) {
return request<Files.FileOperationResponseData>({
url: 'move',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 导入 Markdown 或 ZIP。 */
export function importNotesApi(data: Files.ImportRequestData) {
const body = new FormData()
body.append('dir', data.dir)
body.append('folder', data.folder)
for (const file of data.files) body.append('files', file)
return request<Files.ImportResponseData>({
url: 'import',
method: 'post',
data: body,
errorOwner: 'caller',
})
}
/** 导出选中内容为 Markdown ZIP。 */
export function exportFilesApi(data: Files.ExportRequestData) {
return request<Blob>({
url: 'export',
method: 'post',
data,
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 下载原始文件。 */
export function getRawFileApi(path: string) {
return request<Blob>({
url: `raw/${encodePath(path)}`,
method: 'get',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
+12
View File
@@ -0,0 +1,12 @@
import type { TrashItem } from '../trash/type'
export interface FileRequestData { path: string }
export interface WriteFileRequestData { path: string, content: string, ifMatch?: string, keepalive?: boolean }
export interface MoveRequestData { from: string, to: string }
export interface ImportRequestData { dir: string, folder: string, files: File[] }
export interface ExportRequestData { paths: string[] }
export interface ImportResult { paths: string[], notes: number, assets: number, warnings: string[] }
export type WriteFileResponseData = ApiResponseData<{ rev: string }>
export type DeleteFileResponseData = ApiResponseData<TrashItem>
export type FileOperationResponseData = ApiResponseData<{ ok: boolean }>
export type ImportResponseData = ApiResponseData<ImportResult>
+32
View File
@@ -0,0 +1,32 @@
import type * as Fonts from './type'
import { request } from '@/http/axios'
/** 读取导入字体目录。 */
export function getFontsApi() {
return request<Fonts.FontsResponseData>({
url: 'fonts',
method: 'get',
errorOwner: 'caller',
})
}
/** 导入字体。 */
export function importFontApi(file: File) {
const data = new FormData()
data.append('file', file)
return request<Fonts.ImportFontResponseData>({
url: 'fonts',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 删除导入字体。 */
export function deleteFontApi(id: string) {
return request<Fonts.DeleteFontResponseData>({
url: `fonts/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
+4
View File
@@ -0,0 +1,4 @@
export interface CustomFont { id: string, name: string, format: 'ttf' | 'otf' | 'woff' | 'woff2' }
export type FontsResponseData = ApiResponseData<{ fonts: CustomFont[] }>
export type ImportFontResponseData = ApiResponseData<CustomFont>
export type DeleteFontResponseData = ApiResponseData<{ ok: boolean }>
+131
View File
@@ -0,0 +1,131 @@
import type * as Maintenance from './type'
import { request } from '@/http/axios'
/** 读取维护状态。 */
export function getMaintenanceStatusApi() {
return request<Maintenance.MaintenanceResponseData>({
url: 'maintenance/status',
method: 'get',
errorOwner: 'caller',
})
}
/** 导出实例或设置备份。 */
export function exportBackupApi(kind: Maintenance.BackupKind) {
return request<Blob>({
url: `maintenance/export/${kind}`,
method: 'post',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 导出全部 Markdown 笔记。 */
export function exportNotesApi() {
return request<Blob>({
url: 'maintenance/notes/export',
method: 'post',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 导出系统日志。 */
export function exportLogsApi() {
return request<Blob>({
url: 'maintenance/logs/export',
method: 'post',
responseType: 'blob',
returnResponse: true,
errorOwner: 'caller',
})
}
/** 创建备份上传。 */
export function startBackupUploadApi(data: Maintenance.BackupUploadRequestData) {
return request<Maintenance.BackupUploadResponseData>({
url: 'maintenance/uploads',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 顺序上传备份分块。 */
export function uploadBackupChunkApi(data: Maintenance.BackupChunkRequestData) {
return request<Maintenance.BackupChunkResponseData>({
url: `maintenance/uploads/${encodeURIComponent(data.id)}`,
method: 'put',
data: data.body,
headers: { 'Upload-Offset': String(data.offset) },
errorOwner: 'caller',
})
}
/** 校验完整备份。 */
export function validateBackupApi(id: string) {
return request<Maintenance.BackupSummaryResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}/validate`,
method: 'post',
errorOwner: 'caller',
})
}
/** 取消备份上传并清理暂存。 */
export function cancelBackupUploadApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/uploads/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
/** 导入已确认的设置备份。 */
export function importSettingsApi(id: string) {
return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/import',
method: 'post',
data: { id, confirmed: true },
errorOwner: 'caller',
})
}
/** 恢复已确认的实例备份。 */
export function restoreBackupApi(id: string) {
return request<Maintenance.RestoreBackupResponseData>({
url: 'maintenance/restore',
method: 'post',
data: { id, confirmed: true },
errorOwner: 'caller',
})
}
/** 备份后重置设置。 */
export function resetSettingsApi() {
return request<Maintenance.MaintenanceOperationResponseData>({
url: 'maintenance/settings/reset',
method: 'post',
data: { confirmed: true },
errorOwner: 'caller',
})
}
/** 优化或重建派生索引。 */
export function maintainIndexApi(operation: 'optimize' | 'rebuild') {
return request<Maintenance.MaintenanceOperationResponseData>({
url: `maintenance/index/${operation}`,
method: 'post',
errorOwner: 'caller',
})
}
/** 清理非活动临时文件。 */
export function clearTempApi() {
return request<Maintenance.ClearTempResponseData>({
url: 'maintenance/temp/clear',
method: 'post',
errorOwner: 'caller',
})
}
+12
View File
@@ -0,0 +1,12 @@
export interface MaintenanceStatus { busy: boolean, operation: string, error: string, finishedAt: number, backup?: string }
export interface BackupSummary { kind: 'full' | 'settings', createdAt: number, entries: number, size: number }
export type BackupKind = 'full' | 'settings'
export interface BackupUploadRequestData { kind: BackupKind, size: number }
export interface BackupChunkRequestData { id: string, offset: number, body: Blob }
export type MaintenanceResponseData = ApiResponseData<MaintenanceStatus>
export type BackupUploadResponseData = ApiResponseData<{ id: string }>
export type BackupChunkResponseData = ApiResponseData<{ offset: number }>
export type BackupSummaryResponseData = ApiResponseData<BackupSummary>
export type MaintenanceOperationResponseData = ApiResponseData<{ ok: boolean }>
export type RestoreBackupResponseData = ApiResponseData<{ restarting: boolean }>
export type ClearTempResponseData = ApiResponseData<{ files: number, bytes: number, failed: string[] }>
+11
View File
@@ -0,0 +1,11 @@
import type * as Metadata from './type'
import { request } from '@/http/axios'
/** 读取笔记元数据索引。 */
export function getMetadataApi() {
return request<Metadata.MetadataResponseData>({
url: 'metadata',
method: 'get',
errorOwner: 'caller',
})
}
+2
View File
@@ -0,0 +1,2 @@
export interface NoteMetadata { path: string, rev: string, title: string, tags: string[], links: { target: string, line: number }[], assets: { target: string, line: number }[], warnings: string[] }
export type MetadataResponseData = ApiResponseData<{ notes: NoteMetadata[], ready: boolean }>
+59 -13
View File
@@ -1,15 +1,61 @@
import type { InstalledPlugin } from './type' import type * as Plugins from './type'
import { requestJSON } from '@/http/axios' import { request } from '@/http/axios'
export const pluginsApi = { /** 读取已安装插件。 */
plugins: async () => (await requestJSON<{ plugins: InstalledPlugin[] }>('GET', '/plugins', { errorOwner: 'caller' })).plugins, export function getPluginsApi() {
installPlugin: (file: File) => { return request<Plugins.PluginsResponseData>({
const body = new FormData() url: 'plugins',
body.append('file', file) method: 'get',
return requestJSON<InstalledPlugin>('POST', '/plugins', { errorOwner: 'caller', body }) errorOwner: 'caller',
}, })
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' }), /** 安装插件 ZIP。 */
savePluginData: (id: string, data: unknown) => requestJSON('PUT', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller', json: { data } }), export function installPluginApi(file: File) {
const data = new FormData()
data.append('file', file)
return request<Plugins.PluginResponseData>({
url: 'plugins',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 启用或停用插件。 */
export function togglePluginApi(data: Plugins.TogglePluginRequestData) {
return request<Plugins.PluginResponseData>({
url: `plugins/${encodeURIComponent(data.id)}`,
method: 'patch',
data: { enabled: data.enabled },
errorOwner: 'caller',
})
}
/** 卸载插件并保留插件数据。 */
export function uninstallPluginApi(id: string) {
return request<Plugins.PluginOperationResponseData>({
url: `plugins/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
/** 读取插件存储。 */
export function getPluginDataApi(id: string) {
return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(id)}/data`,
method: 'get',
errorOwner: 'caller',
})
}
/** 保存插件存储。 */
export function savePluginDataApi(data: Plugins.PluginDataRequestData) {
return request<Plugins.PluginDataResponseData>({
url: `plugins/${encodeURIComponent(data.id)}/data`,
method: 'put',
data: { data: data.data },
errorOwner: 'caller',
})
} }
+7
View File
@@ -6,3 +6,10 @@ export interface InstalledPlugin {
revision: string revision: string
enabled: boolean enabled: boolean
} }
export interface TogglePluginRequestData { id: string, enabled: boolean }
export interface PluginDataRequestData { id: string, data: unknown }
export type PluginsResponseData = ApiResponseData<{ plugins: InstalledPlugin[] }>
export type PluginResponseData = ApiResponseData<InstalledPlugin>
export type PluginDataResponseData = ApiResponseData<{ data: unknown }>
export type PluginOperationResponseData = ApiResponseData<{ ok: boolean }>
-16
View File
@@ -1,16 +0,0 @@
import type { CustomFont, Theme } from './type'
import { requestJSON } from '@/http/axios'
export const preferencesApi = {
settings: () => requestJSON<Record<string, unknown>>('GET', '/settings', { errorOwner: 'caller' }),
fonts: async () => (await requestJSON<{ fonts: CustomFont[] }>('GET', '/fonts', { errorOwner: 'caller' })).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<CustomFont>('POST', '/fonts', { errorOwner: 'caller', body })
},
deleteFont: (id: string) => requestJSON('DELETE', `/fonts/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
themes: async () => (await requestJSON<{ themes: Theme[] }>('GET', '/themes', { errorOwner: 'caller' })).themes,
theme: (id: string) => requestJSON<Theme & { css: string }>('GET', `/themes/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
patchSettings: (patch: Record<string, unknown>) => requestJSON<Record<string, unknown>>('PATCH', '/settings', { errorOwner: 'caller', json: patch }),
}
-11
View File
@@ -1,11 +0,0 @@
export interface Theme {
id: string
name: string
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
+12
View File
@@ -0,0 +1,12 @@
import type * as Search from './type'
import { request } from '@/http/axios'
/** 搜索笔记。 */
export function searchApi(params: Search.SearchRequestData) {
return request<Search.SearchResponseData>({
url: 'search',
method: 'get',
params,
errorOwner: 'caller',
})
}
+8
View File
@@ -0,0 +1,8 @@
export interface SearchRequestData { q: string }
export interface SearchResult {
path: string
/** 第一处匹配行,从1开始;只有路径匹配时为0。 */
line: number
snippet: string
}
export type SearchResponseData = ApiResponseData<{ results: SearchResult[] }>
+21
View File
@@ -0,0 +1,21 @@
import type * as Settings from './type'
import { request } from '@/http/axios'
/** 读取设置。 */
export function getSettingsApi() {
return request<Settings.SettingsResponseData>({
url: 'settings',
method: 'get',
errorOwner: 'caller',
})
}
/** 更新设置。 */
export function patchSettingsApi(data: Settings.SettingsData) {
return request<Settings.SettingsResponseData>({
url: 'settings',
method: 'patch',
data,
errorOwner: 'caller',
})
}
+3
View File
@@ -0,0 +1,3 @@
/** 保留插件写入的未知设置,应用端按已知字段校验。 */
export type SettingsData = Record<string, unknown>
export type SettingsResponseData = ApiResponseData<SettingsData>
+11
View File
@@ -0,0 +1,11 @@
import type * as Storage from './type'
import { request } from '@/http/axios'
/** 统计实际存储占用。 */
export function getStorageApi() {
return request<Storage.StorageResponseData>({
url: 'storage',
method: 'get',
errorOwner: 'caller',
})
}
+2
View File
@@ -0,0 +1,2 @@
export interface StorageUsage { totalSize: number, categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other', size: number }[], builtinFontsSize: number, customFontsSize: number, assetsSize: number, trashSize: number, calculatedAt: number }
export type StorageResponseData = ApiResponseData<StorageUsage>
+20
View File
@@ -0,0 +1,20 @@
import type * as Themes from './type'
import { request } from '@/http/axios'
/** 读取可选主题。 */
export function getThemesApi() {
return request<Themes.ThemesResponseData>({
url: 'themes',
method: 'get',
errorOwner: 'caller',
})
}
/** 读取主题与样式。 */
export function getThemeApi(id: string) {
return request<Themes.ThemeResponseData>({
url: `themes/${encodeURIComponent(id)}`,
method: 'get',
errorOwner: 'caller',
})
}
+3
View File
@@ -0,0 +1,3 @@
export interface Theme { id: string, name: string, version: string }
export type ThemesResponseData = ApiResponseData<{ themes: Theme[] }>
export type ThemeResponseData = ApiResponseData<Theme & { css: string }>
+30
View File
@@ -0,0 +1,30 @@
import type * as Tokens from './type'
import { request } from '@/http/axios'
/** 读取 API 令牌列表。 */
export function getTokensApi() {
return request<Tokens.TokensResponseData>({
url: 'tokens',
method: 'get',
errorOwner: 'caller',
})
}
/** 创建 API 令牌。 */
export function createTokenApi(data: Tokens.CreateTokenRequestData) {
return request<Tokens.CreateTokenResponseData>({
url: 'tokens',
method: 'post',
data,
errorOwner: 'caller',
})
}
/** 撤销 API 令牌。 */
export function deleteTokenApi(id: string) {
return request<Tokens.DeleteTokenResponseData>({
url: `tokens/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
}
+5
View File
@@ -0,0 +1,5 @@
export interface ApiToken { id: string, name: string, scope: 'read' | 'write', createdAt: number, lastUsedAt: number }
export interface CreateTokenRequestData { name: string, scope: ApiToken['scope'] }
export type TokensResponseData = ApiResponseData<{ tokens: ApiToken[] }>
export type CreateTokenResponseData = ApiResponseData<{ token: string, info: ApiToken }>
export type DeleteTokenResponseData = ApiResponseData<{ ok: boolean }>
+36 -7
View File
@@ -1,9 +1,38 @@
import type { TrashItem, TrashPurgeResult } from './type' import type * as Trash from './type'
import { requestJSON } from '@/http/axios' import { request } from '@/http/axios'
export const trashApi = { /** 读取回收站。 */
clearTrash: () => requestJSON<TrashPurgeResult>('DELETE', '/trash', { errorOwner: 'caller' }), export function getTrashApi() {
trash: async () => (await requestJSON<{ items: TrashItem[] }>('GET', '/trash', { errorOwner: 'caller' })).items, return request<Trash.TrashResponseData>({
restore: (id: string) => requestJSON<TrashItem>('POST', `/trash/${id}/restore`, { errorOwner: 'caller' }), url: 'trash',
purge: (id: string) => requestJSON('DELETE', `/trash/${id}`, { errorOwner: 'caller' }), method: 'get',
errorOwner: 'caller',
})
}
/** 恢复回收站条目。 */
export function restoreTrashApi(id: string) {
return request<Trash.RestoreTrashResponseData>({
url: `trash/${encodeURIComponent(id)}/restore`,
method: 'post',
errorOwner: 'caller',
})
}
/** 清空回收站并返回逐项结果。 */
export function clearTrashApi() {
return request<Trash.ClearTrashResponseData>({
url: 'trash',
method: 'delete',
errorOwner: 'caller',
})
}
/** 永久删除回收站条目。 */
export function purgeTrashApi(id: string) {
return request<Trash.PurgeTrashResponseData>({
url: `trash/${encodeURIComponent(id)}`,
method: 'delete',
errorOwner: 'caller',
})
} }
+5
View File
@@ -9,3 +9,8 @@ export interface TrashPurgeResult {
deleted: string[] deleted: string[]
failed: { id: string, error: string }[] failed: { id: string, error: string }[]
} }
export type TrashResponseData = ApiResponseData<{ items: TrashItem[] }>
export type RestoreTrashResponseData = ApiResponseData<TrashItem>
export type ClearTrashResponseData = ApiResponseData<TrashPurgeResult>
export type PurgeTrashResponseData = ApiResponseData<{ ok: boolean }>
+21
View File
@@ -0,0 +1,21 @@
import type * as Tree from './type'
import { request } from '@/http/axios'
/** 读取目录树。 */
export function getTreeApi() {
return request<Tree.TreeResponseData>({
url: 'tree',
method: 'get',
errorOwner: 'caller',
})
}
/** 设置或重置目录项图标。 */
export function setTreeIconApi(data: Tree.TreeIconRequestData) {
return request<Tree.TreeIconResponseData>({
url: 'tree-icon',
method: 'put',
data,
errorOwner: 'caller',
})
}
@@ -9,3 +9,7 @@ export interface Entry {
mtime: number mtime: number
icon?: TreeIcon icon?: TreeIcon
} }
export interface TreeIconRequestData { path: string, icon: TreeIcon | null }
export type TreeResponseData = ApiResponseData<{ entries: Entry[] }>
export type TreeIconResponseData = ApiResponseData<{ ok: boolean }>
-16
View File
@@ -1,16 +0,0 @@
import type { Entry } from './type'
import { encodePath, requestJSON, requestRaw } from '@/http/axios'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
export const vaultApi = {
tree: async () => (await requestJSON<{ entries: Entry[] }>('GET', '/tree', { errorOwner: 'caller' })).entries,
async readFile(path: string): Promise<{ content: string, rev: string }> {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
const res = await requestRaw('GET', `/files/${encodePath(path)}`, { errorOwner: 'caller' })
const content = await res.text()
user.assertSession(version)
return { content, rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
},
}
+1 -1
View File
@@ -20,7 +20,7 @@ describe('导入字体资源边界', () => {
}) })
}) })
vi.mock('@@/apis/auth/resources', () => ({ vi.mock('@@/utils/resource-tickets', () => ({
prepareResources: async () => {}, prepareResources: async () => {},
imageResourceRoot: () => '/api/v1/resources/test-images/images/', imageResourceRoot: () => '/api/v1/resources/test-images/images/',
fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/', fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/',
+5 -4
View File
@@ -1,6 +1,7 @@
import type { CustomFont } from '@@/apis/preferences/type' import type { CustomFont } from '@@/apis/fonts/type'
import { fontResourceRoot, prepareResources } from '@@/apis/auth/resources' import { getFontsApi } from '@@/apis/fonts'
import { preferencesApi } from '@@/apis/preferences' import { fontResourceRoot, prepareResources } from '@@/utils/resource-tickets'
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
export const customFonts = shallowRef<CustomFont[]>([]) export const customFonts = shallowRef<CustomFont[]>([])
@@ -23,5 +24,5 @@ export function parseFontCatalog(value: unknown): CustomFont[] {
export async function loadFonts() { export async function loadFonts() {
await prepareResources() await prepareResources()
applyFontCatalog(parseFontCatalog(await preferencesApi.fonts())) applyFontCatalog(parseFontCatalog((await getFontsApi()).data.fonts))
} }
-665
View File
@@ -148,27 +148,6 @@ button {
color: var(--rm-text-muted); color: var(--rm-text-muted);
line-height: 1.6; 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 { .rm-auth-card .rm-btn {
height: var(--rm-control-size); height: var(--rm-control-size);
justify-content: center; justify-content: center;
@@ -537,27 +516,6 @@ button {
border-radius: 2px; 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 { .rm-switcher-list {
flex: 1; flex: 1;
overflow: auto; 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 { .rm-dialog-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -853,30 +791,6 @@ button {
margin: 0; margin: 0;
font-size: var(--rm-ui-font-size); 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 { .rm-share-note {
margin: 10px 20px 4px; margin: 10px 20px 4px;
font-size: var(--rm-ui-font-small); font-size: var(--rm-ui-font-small);
@@ -931,518 +845,10 @@ button {
font: inherit; font: inherit;
line-height: 1.4; 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 { .flip {
transform: rotate(180deg); 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 { .rm-toasts {
position: fixed; position: fixed;
@@ -1589,31 +995,17 @@ button {
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
} }
.rm-auth-card input, .rm-auth-card input,
.rm-share-form input,
.rm-share-form select,
.rm-setting select { .rm-setting select {
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
} }
.rm-share-form {
flex-wrap: wrap;
}
.rm-search-item { .rm-search-item {
padding: 10px 8px; padding: 10px 8px;
} }
/* 手机上从顶部弹出,给软键盘留出空间 */ /* 手机上从顶部弹出,给软键盘留出空间 */
.rm-switcher-backdrop {
padding-top: calc(8px + env(safe-area-inset-top));
}
.rm-switcher {
max-height: 50vh;
}
.rm-switcher-item { .rm-switcher-item {
height: var(--rm-control-size); height: var(--rm-control-size);
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
} }
.rm-trash-list li {
flex-wrap: wrap;
}
} }
/* 主题与 CSS 编辑 */ /* 主题与 CSS 编辑 */
@@ -1680,36 +1072,6 @@ button {
font-size: var(--rm-ui-font-size); 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 { .rm-plugin-view {
padding: 16px; padding: 16px;
min-width: 0; min-width: 0;
@@ -1731,28 +1093,10 @@ button {
color: var(--rm-accent-strong); color: var(--rm-accent-strong);
} }
@media (pointer: coarse) { @media (pointer: coarse) {
.rm-plugin-row .rm-btn,
.rm-plugin-tabs .rm-btn { .rm-plugin-tabs .rm-btn {
min-height: var(--rm-control-size); 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 { .rm-tab-menu {
right: auto; right: auto;
width: max-content; width: max-content;
@@ -1959,15 +1303,6 @@ button {
background: color-mix(in srgb, var(--rm-bg) 75%, transparent); background: color-mix(in srgb, var(--rm-bg) 75%, transparent);
box-shadow: var(--rm-shadow); 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 { .rm-menu {
background: color-mix(in srgb, var(--rm-bg) 92%, transparent); background: color-mix(in srgb, var(--rm-bg) 92%, transparent);
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
+68 -5
View File
@@ -3,9 +3,9 @@
--el-font-size-base: var(--rm-ui-font-size); --el-font-size-base: var(--rm-ui-font-size);
--el-color-primary: var(--rm-accent); --el-color-primary: var(--rm-accent);
--el-bg-color: var(--rm-bg); --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-text-color-primary: var(--rm-text);
--el-border-color: var(--rm-border); --el-border-color: var(--rm-divider);
--el-border-radius-base: 8px; --el-border-radius-base: 8px;
} }
.el-button { .el-button {
@@ -30,9 +30,6 @@
} }
} }
.rm-settings .el-select {
width: 120px;
}
.el-button + .el-button { .el-button + .el-button {
margin-left: 0; margin-left: 0;
} }
@@ -41,3 +38,69 @@
align-items: center; align-items: center;
gap: inherit; 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);
}
+40 -96
View File
@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Settings } from '../../../pinia/stores/settings' import type { Settings } from '@/pinia/stores/settings'
import { preferencesApi } from '@@/apis/preferences'
import { deleteFontApi, importFontApi } from '@@/apis/fonts'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { pinia } from '@/pinia'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
import { customFonts, loadFonts } from '../../appearance/fonts' import { customFonts, loadFonts } from '../../appearance/fonts'
import { FONT_PRESETS } from '../../appearance/typography' import { FONT_PRESETS } from '../../appearance/typography'
import Icon from '../Icon/index.vue' import Icon from '../Icon/index.vue'
const props = defineProps<{ id: string, setting: 'uiFont' | 'textFont' | 'codeFont' }>() const props = defineProps<{ id: string, setting: 'uiFont' | 'textFont' | 'codeFont' }>()
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
const user = useUserStore() const user = useUserStore()
const expanded = ref(false)
const busy = ref(false) const busy = ref(false)
const error = ref('') const error = ref('')
const fileInput = ref<HTMLInputElement>() 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 })), ...Object.entries(FONT_PRESETS).filter(([id]) => !settings.hiddenFonts.includes(id)).map(([id, font]) => ({ id, name: font.label })),
...customFonts.value, ...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) { async function choose(id: string) {
error.value = '' error.value = ''
busy.value = true busy.value = true
try { try {
if (await save({ [props.setting]: id })) expanded.value = false await save({ [props.setting]: id })
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -69,11 +73,10 @@ async function importFont(event: Event) {
throw new Error('无法解析字体文件,请选择有效的字体。', { cause }) throw new Error('无法解析字体文件,请选择有效的字体。', { cause })
} }
user.assertSession(version) user.assertSession(version)
const font = await preferencesApi.importFont(file) const font = (await importFontApi(file)).data
await loadFonts() await loadFonts()
user.assertSession(version) user.assertSession(version)
if (!await save({ [props.setting]: font.id })) return if (!await save({ [props.setting]: font.id })) return
expanded.value = false
toast('字体已导入') toast('字体已导入')
} catch (cause) { } catch (cause) {
if (isCancel(cause)) return if (isCancel(cause)) return
@@ -94,7 +97,7 @@ async function remove(id: string) {
} }
if (!await save(patch)) return if (!await save(patch)) return
} else { } else {
await preferencesApi.deleteFont(id) await deleteFontApi(id)
await loadSettings() await loadSettings()
} }
toast('字体已删除,使用它的位置已恢复 Maple') toast('字体已删除,使用它的位置已恢复 Maple')
@@ -105,35 +108,28 @@ async function remove(id: string) {
busy.value = false busy.value = false
} }
} }
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && expanded.value) {
event.stopPropagation(); expanded.value = false
}
}
</script> </script>
<template> <template>
<div class="rm-font-picker" @keydown="onKey"> <div class="rm-font-picker">
<el-button :id="id" class="rm-btn rm-font-trigger" :aria-expanded="expanded" :aria-controls="`${id}-options`" :disabled="busy" @click="expanded = !expanded"> <el-select :id="id" :model-value="settings[setting]" :disabled="busy" :loading="busy" filterable popper-class="rm-font-options" @change="choose">
<span>{{ selected.name }}</span><Icon name="chevron" /> <el-option v-for="font in fonts" :key="font.id" :label="font.name" :value="font.id">
</el-button> <div class="rm-font-option">
<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)">
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small> <span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small>
</button> <el-button v-else text size="small" :aria-label="`删除字体 ${font.name}`" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
<button v-if="font.id !== 'maple'" class="rm-icon-btn" :aria-label="`删除字体 ${font.name}`" title="删除字体" :disabled="busy" @click="remove(font.id)"> <Icon name="trash" />
<Icon name="trash" /> </el-button>
</button> </div>
</div> </el-option>
<el-button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()"> <template #footer>
<Icon name="import" />{{ busy ? '处理中…' : '导入字体' }} <el-button class="rm-font-import" :disabled="busy" @click="fileInput!.click()">
</el-button> <Icon name="import" />导入字体
<p class="rm-share-note"> </el-button>
支持 TTF、OTF、WOFF、WOFF2,单个文件不超过 30 MB。 <p class="rm-share-note">
</p> TTF、OTF、WOFF、WOFF2,最多 30 MB。
</div> </p>
</template>
</el-select>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="`导入字体 ${id}`" @change="importFont"> <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"> <p v-if="error" class="rm-auth-error" role="alert">
{{ error }} {{ error }}
@@ -141,83 +137,31 @@ function onKey(event: KeyboardEvent) {
</div> </div>
</template> </template>
<style scoped> <style scoped lang="scss">
.rm-font-picker { .rm-font-picker {
position: relative;
width: 260px; width: 260px;
max-width: 100%; max-width: 100%;
min-width: 0; 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 { .rm-font-option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--rm-space-1); gap: 8px;
} }
.rm-font-choice { .rm-font-option > span {
display: flex;
align-items: center;
flex: 1; flex: 1;
min-width: 0; overflow: hidden;
padding: 6px 8px; text-overflow: ellipsis;
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;
} }
.rm-font-choice span { .rm-font-option small {
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;
color: var(--rm-text-muted); color: var(--rm-text-muted);
white-space: nowrap;
} }
.rm-font-import { .rm-font-import {
width: 100%; width: 100%;
margin-top: var(--rm-space-1);
} }
.rm-font-options p { .rm-share-note {
margin: var(--rm-space-2); margin: 8px 0 0;
} font-size: var(--rm-ui-font-small);
@media (pointer: coarse) { color: var(--rm-text-muted);
.rm-font-choice {
min-height: 44px;
}
} }
</style> </style>
@@ -1,12 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InlineStyles } from '../../markdown/inline-styles' import type { InlineStyles } from '../../markdown/inline-styles'
import { computed, onBeforeUnmount, ref, watch } from 'vue' 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 { fixedThemeMode } from '../../appearance/appearance'
import { HOLLOW, INLINE_FONTS, SHADOW } from '../../markdown/inline-styles' import { HOLLOW, INLINE_FONTS, SHADOW } from '../../markdown/inline-styles'
const props = defineProps<{ styles: InlineStyles, baseSize: number }>() const props = defineProps<{ styles: InlineStyles, baseSize: number }>()
const emit = defineEmits<{ change: [patch: InlineStyles], clear: [] }>() const emit = defineEmits<{ change: [patch: InlineStyles], clear: [] }>()
const { settings } = useSettingsStore(pinia)
const system = matchMedia('(prefers-color-scheme: dark)') const system = matchMedia('(prefers-color-scheme: dark)')
const systemDark = ref(system.matches) const systemDark = ref(system.matches)
function onSystem() { function onSystem() {
+9 -4
View File
@@ -1,11 +1,16 @@
import type { PluginAPI } from '@@/plugin-api' import type { PluginAPI } from '@@/plugin-api'
import { pluginsApi } from '@@/apis/plugins' import * as pluginsApi from '@@/apis/plugins'
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest' 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 { allCommands, commandKeys } from '../utils/commands'
import { pluginState, startPlugins, stopPlugins } from './host' import { pluginState, startPlugins, stopPlugins } from './host'
const { settings } = useSettingsStore(pinia)
const { cause } = vi.hoisted(() => ({ cause: new Error('插件命令的原始原因') })) const { cause } = vi.hoisted(() => ({ cause: new Error('插件命令的原始原因') }))
vi.mock('/api/v1/resources/test-plugin/plugin/plugins/fault/assets/rev/main.js', () => ({ 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() { default: (sdk: PluginAPI) => sdk.addCommand({ id: 'fail', title: '失败命令', keys: ['Mod+B'], run() {
@@ -17,7 +22,7 @@ afterEach(() => {
}) })
it('命令失败保留原始异常并清理注册,正常插件列表读取不受影响', async () => { 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[] = [] const notices: string[] = []
startPlugins({ notice: message => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) }) startPlugins({ notice: message => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active')) await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
@@ -33,7 +38,7 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不
expect(notices.some(message => message.includes('命令失败'))).toBe(true) expect(notices.some(message => message.includes('命令失败'))).toBe(true)
}) })
vi.mock('@@/apis/auth/resources', () => ({ vi.mock('@@/utils/resource-tickets', () => ({
prepareResources: async () => {}, prepareResources: async () => {},
imageResourceRoot: () => '/api/v1/resources/test-images/images/', imageResourceRoot: () => '/api/v1/resources/test-images/images/',
fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/', fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/',
+10 -8
View File
@@ -1,8 +1,10 @@
import type { InstalledPlugin } from '@@/apis/plugins/type' import type { InstalledPlugin } from '@@/apis/plugins/type'
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api' import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api'
import { pluginResourceURL } from '@@/apis/auth/resources' import { getPluginDataApi, getPluginsApi, savePluginDataApi } from '@@/apis/plugins'
import { pluginsApi } from '@@/apis/plugins' import { getTreeApi } from '@@/apis/tree'
import { vaultApi } from '@@/apis/vault' import { readNoteFile } from '@@/utils/note-files'
import { pluginResourceURL } from '@@/utils/resource-tickets'
import { reactive } from 'vue' import { reactive } from 'vue'
import { allCommands, registerCommand } from '../utils/commands' import { allCommands, registerCommand } from '../utils/commands'
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry' import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
@@ -144,10 +146,10 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
}, },
vault: { vault: {
list: () => { list: () => {
check(); return vaultApi.tree() check(); return getTreeApi().then(({ data }) => data.entries)
}, },
read: (path) => { read: (path) => {
check(); return vaultApi.readFile(path) check(); return readNoteFile(path)
}, },
write: (path, content, options) => { write: (path, content, options) => {
check(); return adapter.writeNote(path, content, options) check(); return adapter.writeNote(path, content, options)
@@ -177,10 +179,10 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
}, },
data: { data: {
load: async () => { load: async () => {
check(); return (await pluginsApi.pluginData(id)).data as JSONValue check(); return ((await getPluginDataApi(id)).data).data as JSONValue
}, },
save: async (data) => { save: async (data) => {
check(); await pluginsApi.savePluginData(id, data) check(); await savePluginDataApi({ id, data })
}, },
}, },
notice: (message) => { notice: (message) => {
@@ -257,7 +259,7 @@ export async function refreshPlugins() {
const currentSession = session const currentSession = session
const sequence = ++request const sequence = ++request
try { try {
const installed = await pluginsApi.plugins() const installed = (await getPluginsApi()).data.plugins
if (!running || currentSession !== session || sequence !== request) return if (!running || currentSession !== session || sequence !== request) return
pluginState.installed = installed pluginState.installed = installed
pluginState.error = '' pluginState.error = ''
+4 -1
View File
@@ -1,8 +1,11 @@
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令 // 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
import { shallowReactive } from 'vue' 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' import { formatKeys, matchKeys, physicalKeys } from './keys'
const { settings } = useSettingsStore(pinia)
export { formatKeys, matchKeys } from './keys' export { formatKeys, matchKeys } from './keys'
export interface Command { export interface Command {
+30
View File
@@ -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)
})
+16
View File
@@ -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
}
}
+6
View File
@@ -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, '') }
}
+3 -1
View File
@@ -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' export const NOTE_EXT = '.md'
@@ -1,6 +1,6 @@
import { createResourceTicketApi } from '@@/apis/auth'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { authApi } from './index'
let version = -1 let version = -1
let imageRoot = '' let imageRoot = ''
@@ -14,10 +14,10 @@ export async function prepareResources() {
} }
if (imageRoot && fontRoot) return if (imageRoot && fontRoot) return
if (!pending) { 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) user.assertSession(current)
imageRoot = images.url imageRoot = images.data.url
fontRoot = fonts.url fontRoot = fonts.data.url
}) })
pending = request pending = request
void request.finally(() => { void request.finally(() => {
@@ -36,5 +36,5 @@ export function fontResourceRoot(): string {
return fontRoot return fontRoot
} }
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> { 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`
} }
+1 -1
View File
@@ -23,7 +23,7 @@ describe('typora 默认快捷键', () => {
try { try {
const { typoraKeys } = await import('./typora-keys') const { typoraKeys } = await import('./typora-keys')
const { physicalKeys, matchKeys, recordedKeys } = await import('./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 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)) const keys = ids.flatMap(id => typoraKeys(id).map(physicalKeys))
expect(new Set(keys).size).toBe(keys.length) expect(new Set(keys).size).toBe(keys.length)
+14 -14
View File
@@ -1,4 +1,4 @@
import { vaultApi } from '@@/apis/vault' import { readNoteFile } from '@@/utils/note-files'
import { toasts } from '@@/utils/notifications' import { toasts } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
// @vitest-environment happy-dom // @vitest-environment happy-dom
@@ -7,7 +7,7 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { usePermissionStore } from '@/pinia/stores/permission' import { usePermissionStore } from '@/pinia/stores/permission'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { ApiError, requestJSON, requestRaw } from './axios' import { ApiError, request } from './axios'
const user = useUserStore(pinia) const user = useUserStore(pinia)
const permission = usePermissionStore(pinia) const permission = usePermissionStore(pinia)
@@ -21,7 +21,7 @@ afterEach(() => {
it('使用 Bearer 和统一 JSON,省略 Cookie 且禁止缓存', async () => { it('使用 Bearer 和统一 JSON,省略 Cookie 且禁止缓存', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ path: 'a.md' })) 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 const req = fetch.mock.calls[0]![0] as Request
expect(req.headers.get('Authorization')).toBe('Bearer rms_test') expect(req.headers.get('Authorization')).toBe('Bearer rms_test')
expect(req.credentials).toBe('omit') expect(req.credentials).toBe('omit')
@@ -33,11 +33,11 @@ it('401 清理当前身份及动态路由,403 保留身份,错误只提示
permission.install(router, ['vault:read']) permission.install(router, ['vault:read'])
expect(router.hasRoute('Workspace')).toBe(true) expect(router.hasRoute('Workspace')).toBe(true)
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(payload(null, 403, 40300, '权限不足')).mockResolvedValueOnce(payload(null, 401, 40100, '登录已失效')) 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(user.token).toBe('rms_test')
expect(router.hasRoute('Workspace')).toBe(true) expect(router.hasRoute('Workspace')).toBe(true)
expect(toasts.map(item => item.text)).toEqual(['权限不足']) 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(user.token).toBe('')
expect(router.hasRoute('Workspace')).toBe(false) expect(router.hasRoute('Workspace')).toBe(false)
expect(toasts.map(item => item.text)).toEqual(['权限不足', '登录已失效']) 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) => { vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => {
finish = 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')) await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
user.setToken('rms_new') user.setToken('rms_new')
finish(payload({ old: true }, status, status === 200 ? 0 : status * 100)) finish(payload({ old: true }, status, status === 200 ? 0 : status * 100))
@@ -76,17 +76,17 @@ it('身份恢复失败交给登录或路由页面展示,请求层不重复提
}) })
it('业务失败明确拒绝,保留冲突版本,由调用方显示时请求层不重复提示', async () => { it('业务失败明确拒绝,保留冲突版本,由调用方显示时请求层不重复提示', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ rev: 'current' }, 412, 41200, '版本冲突')) 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) expect(toasts).toHaveLength(0)
}) })
it('拒绝旧 JSON 格式及 HTTP 成功中的业务失败,文件与204保留原语义', async () => { 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 })) 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(request({ method: 'get', url: '/old' })).rejects.toBeInstanceOf(ApiError)
await expect(requestJSON('GET', '/business')).rejects.toMatchObject({ code: 40900 }) await expect(request({ method: 'get', url: '/business' })).rejects.toMatchObject({ code: 40900 })
const raw = await requestRaw('GET', '/raw/a.md') const raw = await request<string>({ method: 'get', url: '/raw/a.md', responseType: 'text', returnResponse: true })
expect(await raw.text()).toBe('正文') expect(raw.data).toBe('正文')
expect(raw.headers.get('ETag')).toBe('"rev"') expect(raw.headers.get('etag')).toBe('"rev"')
expect(await requestJSON('POST', '/auth/logout')).toBeNull() expect(await request({ method: 'post', url: '/auth/logout' })).toBeUndefined()
}) })
it('服务端注销失败保留身份;成功才清除本地身份', async () => { it('服务端注销失败保留身份;成功才清除本地身份', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(payload(null, 500, 50000, '注销失败')).mockResolvedValueOnce(new Response(null, { status: 204 })) 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) => { vi.spyOn(Response.prototype, 'text').mockImplementation(() => new Promise((resolve) => {
finish = 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')) await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
user.setToken('rms_new') user.setToken('rms_new')
finish('旧正文') finish('旧正文')
+28 -53
View File
@@ -1,6 +1,6 @@
import type { AxiosResponse, Method } from 'axios' import type { AxiosRequestConfig, AxiosResponse } from 'axios'
import { reportError } from '@@/utils/notifications' 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 { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
@@ -9,26 +9,19 @@ export class ApiError extends Error {
super(message) super(message)
} }
} }
interface Options { interface RequestConfig extends Omit<AxiosRequestConfig<unknown>, 'auth'> {
json?: unknown anonymous?: boolean
body?: BodyInit
headers?: Record<string, string>
keepalive?: boolean
auth?: boolean
errorOwner?: 'request' | 'caller' errorOwner?: 'request' | 'caller'
signal?: AbortSignal /** 原文和下载需要 ETag、Content-Disposition 等响应头。 */
returnResponse?: boolean
} }
const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCredentials: false }) const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCredentials: false })
export function encodePath(path: string): string { function envelope(value: unknown, status: number): ApiResponseData<unknown> {
return path.split('/').map(encodeURIComponent).join('/')
}
interface Envelope { code: number, data: unknown, message: string }
function envelope(value: unknown, status: number): Envelope {
if (!value || typeof value !== 'object' || !('code' in value) || typeof value.code !== 'number' 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') { || !Number.isInteger(value.code) || !('data' in value) || !('message' in value) || typeof value.message !== 'string') {
throw new ApiError(status, '服务器响应协议无效') throw new ApiError(status, '服务器响应协议无效')
} }
return value as Envelope return value as ApiResponseData<unknown>
} }
function errorData(value: unknown): Record<string, unknown> { function errorData(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as 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) const data = envelope(body, response.status)
return new ApiError(response.status, data.message, errorData(data.data), data.code) return new ApiError(response.status, data.message, errorData(data.data), data.code)
} catch (cause) { } 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 user = useUserStore(pinia)
const version = user.getSessionVersion() 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 { try {
const response = await instance.request({ const response = await instance.request<T, AxiosResponse<T, unknown, AxiosHeaders>>({
method: method as Method, ...config,
url, headers,
headers: { 'Content-Type': options.json !== undefined ? 'application/json' : null, ...options.headers, 'Authorization': token ? `Bearer ${token}` : undefined }, signal: config.signal ? AbortSignal.any([user.getSessionSignal(), config.signal as AbortSignal]) : user.getSessionSignal(),
data: options.json !== undefined ? JSON.stringify(options.json) : options.body, fetchOptions: { ...config.fetchOptions, credentials: 'omit', cache: 'no-store' },
responseType: raw ? 'blob' : 'text',
signal: options.signal ? AbortSignal.any([user.getSessionSignal(), options.signal]) : user.getSessionSignal(),
fetchOptions: { credentials: 'omit', cache: 'no-store', keepalive: options.keepalive },
}) })
user.assertSession(version) user.assertSession(version)
const result = await decode(response) if (response.status === 204) return config.returnResponse ? response : undefined as T
user.assertSession(version) if (!config.responseType || config.responseType === 'json') {
return result 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) { } catch (cause) {
if (isCancel(cause)) throw cause if (isCancel(cause)) throw cause
if (version !== user.getSessionVersion()) throw new CanceledError('会话已变化') if (version !== user.getSessionVersion()) throw new CanceledError('会话已变化')
const error = await failure(cause) const error = await failure(cause)
user.assertSession(version) user.assertSession(version)
if (error.status === 401 && token) user.invalidate(version) if (error.status === 401 && token) user.invalidate(version)
if (options.errorOwner !== 'caller') reportError(error) if (config.errorOwner !== 'caller') reportError(error)
throw 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 })
})
}
+42 -19
View File
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Captcha } from '@@/apis/auth' import type { Captcha } from '@@/apis/auth/type'
import { authApi } from '@@/apis/auth'
import type { FormInstance, FormRules } from 'element-plus'
import { getCaptchaApi, loginApi, setupApi } from '@@/apis/auth'
import AppStatus from '@@/components/AppStatus/index.vue' import AppStatus from '@@/components/AppStatus/index.vue'
import { isCancel } from 'axios' 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 { useRoute, useRouter } from 'vue-router'
import { ApiError } from '@/http/axios' import { ApiError } from '@/http/axios'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
@@ -13,9 +16,13 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const user = useUserStore() const user = useUserStore()
const isSetup = computed(() => route.name === 'Setup') const isSetup = computed(() => route.name === 'Setup')
const password = ref('') const form = ref<FormInstance>()
const confirm = ref('') const fields = reactive({ password: '', confirm: '', captchaCode: '' })
const captchaCode = ref('') 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 captcha = ref<Captcha>()
const captchaError = ref('') const captchaError = ref('')
const error = ref('') const error = ref('')
@@ -26,9 +33,9 @@ async function refresh() {
const id = ++refreshID const id = ++refreshID
refreshing.value = true refreshing.value = true
captchaError.value = '' captchaError.value = ''
captchaCode.value = '' fields.captchaCode = ''
try { try {
const result = await authApi.captcha(captcha.value?.captchaId) const result = (await getCaptchaApi({ replaceId: captcha.value?.captchaId })).data
if (id === refreshID) captcha.value = result if (id === refreshID) captcha.value = result
} catch (cause) { } catch (cause) {
if (id === refreshID && !isCancel(cause)) { if (id === refreshID && !isCancel(cause)) {
@@ -40,16 +47,20 @@ async function refresh() {
} }
watch(isSetup, (setup) => { watch(isSetup, (setup) => {
++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh() ++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh()
form.value?.clearValidate()
}, { immediate: true }) }, { immediate: true })
onBeforeUnmount(() => {
++refreshID
})
async function submit() { async function submit() {
if (busy.value || (!isSetup.value && (!captcha.value || refreshing.value))) return if (busy.value || (!isSetup.value && (!captcha.value || refreshing.value))) return
error.value = '' error.value = ''
if (isSetup.value && password.value !== confirm.value) {
error.value = '两次输入的密码不一致'; return
}
busy.value = true busy.value = true
const owner = user.getSessionVersion()
try { 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('登录响应格式错误') if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('登录响应格式错误')
user.setToken(result.token) user.setToken(result.token)
const version = user.getSessionVersion() const version = user.getSessionVersion()
@@ -70,15 +81,21 @@ async function submit() {
<template> <template>
<AppStatus /> <AppStatus />
<div class="rm-auth"> <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> <h1>RawMark</h1>
<p class="rm-auth-hint"> <p class="rm-auth-hint">
{{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }} {{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }}
</p> </p>
<label><span>密码</span><el-input v-model="password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" required autofocus show-password /></label> <el-form-item label="密码" prop="password">
<label v-if="isSetup"><span>确认密码</span><el-input v-model="confirm" type="password" autocomplete="new-password" required show-password /></label> <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> <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"> <div class="rm-captcha">
<el-button :disabled="refreshing || busy" class="rm-captcha-image" aria-label="刷新验证码" @click="refresh"> <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> <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)"> <el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)">
{{ isSetup ? '完成设置' : '登录' }} {{ isSetup ? '完成设置' : '登录' }}
</el-button> </el-button>
</form> </el-form>
</div> </div>
</template> </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 { .rm-auth {
font-family: 'Maple Mono Normal NL NF CN', monospace; font-family: var(--rm-font-text);
} }
.rm-captcha { .rm-captcha {
display: flex; display: flex;
-24
View File
@@ -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' }),
}
-24
View File
@@ -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"> <script setup lang="ts">
import type { Theme } from '@@/apis/preferences/type' import type { Theme } from '@@/apis/themes/type'
import type { CssSnippet } from '../../../common/appearance/appearance' import type { CssSnippet } from '@@/appearance/appearance'
import type { Settings } from '../../../pinia/stores/settings'
import { preferencesApi } from '@@/apis/preferences' 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 { toast } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ApiError } from '@/http/axios' import { ApiError } from '@/http/axios'
import { appearancePaused, fixedThemeMode } from '../../../common/appearance/appearance' import { pinia } from '@/pinia'
import { DISPLAY_DEFAULTS } from '../../../common/appearance/typography' import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
const { loadSettings, reloadTheme, settings, updateSettings } = useSettingsStore(pinia)
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }]) const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
const error = ref('') const error = ref('')
const busy = ref(false) const busy = ref(false)
@@ -23,7 +29,7 @@ const fixedMode = computed(() => fixedThemeMode(settings.theme))
async function refreshThemes() { async function refreshThemes() {
error.value = '' error.value = ''
try { try {
themes.value = await preferencesApi.themes() themes.value = (await getThemesApi()).data.themes
await reloadTheme() await reloadTheme()
} catch (err) { } catch (err) {
error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络' error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络'
@@ -75,9 +81,15 @@ async function toggle(snippet: CssSnippet) {
} }
async function remove(snippet: CssSnippet) { async function remove(snippet: CssSnippet) {
if (!window.confirm(`删除 CSS 片段“${snippet.name}”?`)) return if (busy.value) return
if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) { busy.value = true
if (editing.value === snippet.id) editing.value = null 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
} }
} }
@@ -94,24 +106,16 @@ async function reset() {
已临时停用主题和 CSS。可恢复默认外观后重新打开页面。 已临时停用主题和 CSS。可恢复默认外观后重新打开页面。
</p> </p>
<SettingRow id="theme-select" control="rm-theme-select"> <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 })"> <el-select id="rm-theme-select" :model-value="settings.theme" :disabled="busy" @change="change({ theme: $event })">
<option v-for="theme in themes" :key="theme.id" :value="theme.id"> <el-option v-for="theme in themes" :key="theme.id" :value="theme.id" :label="theme.name" />
{{ theme.name }} <el-option v-if="missingTheme" :value="settings.theme" :label="`${settings.theme}(不可用)`" />
</option> </el-select>
<option v-if="missingTheme" :value="settings.theme">
{{ settings.theme }}(不可用)
</option>
</select>
<el-button class="rm-btn" :disabled="busy" @click="refreshThemes"> <el-button class="rm-btn" :disabled="busy" @click="refreshThemes">
刷新列表 刷新列表
</el-button> </el-button>
</SettingRow> </SettingRow>
<SettingRow id="color-mode"> <SettingRow id="color-mode">
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式"> <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'] })" />
<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>
</SettingRow> </SettingRow>
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert"> <p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">
{{ error }} {{ error }}
@@ -127,7 +131,9 @@ async function reset() {
</el-button> </el-button>
</SettingRow> </SettingRow>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row"> <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 class="rm-btn" :disabled="busy" @click="edit(snippet)">
编辑 编辑
</el-button> </el-button>
@@ -135,9 +141,9 @@ async function reset() {
删除 删除
</el-button> </el-button>
</div> </div>
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet"> <el-form v-if="editing !== null" class="rm-snippet-form" label-position="top" @submit.prevent="saveSnippet">
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required></label> <label>片段名称<el-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> <label>CSS<el-input v-model="draft.css" type="textarea" aria-label="CSS" :rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div> <div>
<el-button class="rm-btn" :disabled="busy" native-type="submit"> <el-button class="rm-btn" :disabled="busy" native-type="submit">
保存并应用 保存并应用
@@ -145,7 +151,7 @@ async function reset() {
取消 取消
</el-button> </el-button>
</div> </div>
</form> </el-form>
<SettingRow id="reset-appearance"> <SettingRow id="reset-appearance">
<el-button class="rm-btn" :disabled="busy" @click="reset"> <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"> <script setup lang="ts">
import type { BackupKind, MaintenanceStatus } from '@/pages/settings/apis/type' import type { BackupKind, MaintenanceStatus } from '@@/apis/maintenance/type'
import { authApi } from '@@/apis/auth' 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 { isCancel } from 'axios'
import { onBeforeUnmount, onMounted, ref } from 'vue' 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 { useUserStore } from '@/pinia/stores/user'
import { refreshPlugins } from '../../../common/plugins/host'
import { flushSettings, loadSettings, reloadTheme } from '../../../pinia/stores/settings'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
const props = defineProps<{ prepareMaintenance: () => Promise<void> }>() const props = defineProps<{ prepareMaintenance: () => Promise<void> }>()
const emit = defineEmits<{ changed: [], importNotes: [] }>() const emit = defineEmits<{ changed: [], importNotes: [] }>()
const { flushSettings, loadSettings, reloadTheme } = useSettingsStore(pinia)
const user = useUserStore() const user = useUserStore()
const busy = ref(false) const busy = ref(false)
@@ -28,7 +36,7 @@ onBeforeUnmount(() => {
}) })
onMounted(async () => { onMounted(async () => {
try { try {
const status = await settingsApi.maintenanceStatus(); if (mounted) last.value = status const status = (await getMaintenanceStatusApi()).data; if (mounted) last.value = status
} catch (cause) { } catch (cause) {
if (mounted) error.value = `读取维护状态失败:${String(cause)}` if (mounted) error.value = `读取维护状态失败:${String(cause)}`
} }
@@ -45,7 +53,7 @@ async function perform(label: string, action: () => Promise<void>) {
user.assertSession(version) user.assertSession(version)
await action() await action()
if (mounted) { if (mounted) {
emit('changed'); last.value = await settingsApi.maintenanceStatus() emit('changed'); last.value = (await getMaintenanceStatusApi()).data
} }
} catch (cause) { } catch (cause) {
if (mounted && !isCancel(cause)) error.value = String(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 version = user.getSessionVersion()
const blob = await response.blob() const blob = response.data
user.assertSession(version) 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 encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
const plain = /filename="([^"]+)"/i.exec(header)?.[1] const plain = /filename="([^"]+)"/i.exec(header)?.[1]
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
@@ -69,21 +77,29 @@ async function download(response: Response, fallback: string) {
message.value = '导出文件已生成' message.value = '导出文件已生成'
} }
async function exportPackage(kind: BackupKind) { 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() { 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() { 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() { async function syncPreferences() {
await loadSettings(); await reloadTheme(); await refreshPlugins() await loadSettings(); await reloadTheme(); await refreshPlugins()
} }
async function reset() { 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 perform('正在重置设置…', async () => {
await settingsApi.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份' await resetSettingsApi(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
}) })
} }
async function restore(kind: BackupKind, event: Event) { async function restore(kind: BackupKind, event: Event) {
@@ -93,39 +109,39 @@ async function restore(kind: BackupKind, event: Event) {
await perform('正在上传…', async () => { await perform('正在上传…', async () => {
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间') if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
const version = user.getSessionVersion() const version = user.getSessionVersion()
const { id } = await settingsApi.startBackupUpload(kind, file.size) const { id } = (await startBackupUploadApi({ kind, size: file.size })).data
let submitted = false let submitted = false
try { try {
let offset = 0 let offset = 0
while (offset < file.size) { while (offset < file.size) {
if (!mounted) throw new Error('已取消上传') if (!mounted) throw new Error('已取消上传')
const chunk = file.slice(offset, offset + 8 * 1024 ** 2) 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('服务端返回的上传进度不一致') if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%` offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
} }
progress.value = '正在校验备份…' progress.value = '正在校验备份…'
const summary = await settingsApi.validateBackup(id) const summary = (await validateBackupApi(id)).data
if (!mounted) throw new Error('已取消导入') if (!mounted) throw new Error('已取消导入')
const description = kind === 'full' const description = kind === 'full'
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。' ? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。' : '将替换全部偏好和插件配置,合并字体、主题和插件,同 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() await prepare()
user.assertSession(version) user.assertSession(version)
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…' progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
if (kind === 'settings') { if (kind === 'settings') {
await settingsApi.importSettings(id); submitted = true await importSettingsApi(id); submitted = true
await syncPreferences(); message.value = '设置与资源已导入,原设置已备份' await syncPreferences(); message.value = '设置与资源已导入,原设置已备份'
} else { } else {
await settingsApi.restoreBackup(id); submitted = true await restoreBackupApi(id); submitted = true
user.invalidate(version) user.invalidate(version)
const recoveryVersion = user.getSessionVersion() const recoveryVersion = user.getSessionVersion()
for (let attempt = 0; attempt < 30; attempt++) { for (let attempt = 0; attempt < 30; attempt++) {
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
if (recoveryVersion !== user.getSessionVersion()) return if (recoveryVersion !== user.getSessionVersion()) return
try { try {
if ((await authApi.state()).setUp) { if (((await getAuthStateApi()).data).setUp) {
location.reload(); return location.reload(); return
} }
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ } } catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
@@ -135,7 +151,7 @@ async function restore(kind: BackupKind, event: Event) {
} finally { } finally {
if (!submitted && version === user.getSessionVersion()) { if (!submitted && version === user.getSessionVersion()) {
try { try {
await settingsApi.cancelBackupUpload(id) await cancelBackupUploadApi(id)
} catch (cause) { } catch (cause) {
if (mounted) error.value = `清理上传暂存失败:${String(cause)}` if (mounted) error.value = `清理上传暂存失败:${String(cause)}`
} }
@@ -145,12 +161,12 @@ async function restore(kind: BackupKind, event: Event) {
} }
async function index(operation: 'optimize' | 'rebuild') { async function index(operation: 'optimize' | 'rebuild') {
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => { await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
await settingsApi.maintainIndex(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建' await maintainIndexApi(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
}) })
} }
async function clearTemp() { async function clearTemp() {
await perform('正在清理临时文件…', async () => { await perform('正在清理临时文件…', async () => {
const result = await settingsApi.clearTemp() const result = (await clearTempApi()).data
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB` message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
if (result.failed.length) error.value = `部分清理失败:\n${result.failed.join('\n')}` if (result.failed.length) error.value = `部分清理失败:\n${result.failed.join('\n')}`
}) })
@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InstalledPlugin } from '@@/apis/plugins/type' import type { InstalledPlugin } from '@@/apis/plugins/type'
import { pluginsApi } from '@@/apis/plugins' import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
import { computed, ref } from 'vue'
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 { 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 busy = ref(false)
const error = ref('') const error = ref('')
@@ -14,12 +18,16 @@ const selected = ref('')
const currentTab = computed(() => settingTabs.get(selected.value)) const currentTab = computed(() => settingTabs.get(selected.value))
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' } 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 busy.value = true
error.value = '' error.value = ''
try { try {
await operation(); await refreshPlugins() if (confirmation && !await confirmAction(confirmation)) return
if (operation) await operation()
await refreshPlugins()
} catch (err) { } catch (err) {
if (isCancel(err)) return
error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试' error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试'
} finally { } finally {
busy.value = false busy.value = false
@@ -31,18 +39,17 @@ async function install(event: Event) {
const file = input.files?.[0] const file = input.files?.[0]
input.value = '' input.value = ''
if (!file) return if (!file) return
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return await perform(() => installPluginApi(file), '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')
await perform(() => pluginsApi.installPlugin(file))
} }
async function remove(plugin: InstalledPlugin) { async function remove(plugin: InstalledPlugin) {
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return await perform(() => uninstallPluginApi(plugin.id), `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)
await perform(() => pluginsApi.uninstallPlugin(plugin.id))
} }
function toggle(plugin: InstalledPlugin) { function toggle(plugin: InstalledPlugin) {
void perform(() => pluginsApi.togglePlugin(plugin.id, !plugin.enabled)) void perform(() => togglePluginApi({ id: plugin.id, enabled: !plugin.enabled }))
} }
onMounted(() => perform())
</script> </script>
<template> <template>
@@ -51,7 +58,7 @@ function toggle(plugin: InstalledPlugin) {
</p> </p>
<div class="rm-setting"> <div class="rm-setting">
<span class="rm-setting-label">已安装插件</span> <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>
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()"> <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 v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">
尚未安装插件。 尚未安装插件。
</p> </p>
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row"> <el-table :data="pluginState.installed" row-key="id" empty-text="尚未安装插件" :aria-busy="busy">
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)"> <!-- @vue-generic {InstalledPlugin} -->
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span> <el-table-column label="插件" min-width="170">
</label> <template #default="{ row: plugin }">
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)"> <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 }}
</el-button> </p>
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)"> </template>
卸载 </el-table-column>
</el-button> <!-- @vue-generic {InstalledPlugin} -->
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error"> <el-table-column label="启用" width="72">
{{ pluginState.status[plugin.id].error }} <template #default="{ row: plugin }">
</p> <el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="`启用 ${plugin.name}`" @change="toggle(plugin)" />
</div> </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"> <p class="rm-appearance-note">
若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。 若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。
</p> </p>
<template v-if="settingTabs.size"> <template v-if="settingTabs.size">
<div class="rm-setting"> <div class="rm-setting">
<label for="rm-plugin-tab" class="rm-setting-label">插件设置</label> <label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
<select id="rm-plugin-tab" v-model="selected"> <el-select id="rm-plugin-tab" v-model="selected" clearable placeholder="选择插件设置页">
<option value=""> <el-option v-for="[id, tab] in settingTabs" :key="id" :value="id" :label="tab.title" />
选择插件设置页 </el-select>
</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">
{{ tab.title }}
</option>
</select>
</div> </div>
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" /> <PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
</template> </template>
</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"> <script setup lang="ts">
import type { SettingId } from '../settings-catalog' import type { SettingId } from '../settings-catalog'
import Icon from '@@/components/Icon/index.vue'
import { computed } from 'vue' import { computed } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { SETTING_ITEMS } from '../settings-catalog' import { SETTING_ITEMS } from '../settings-catalog'
const props = defineProps<{ id: SettingId, control?: string, resettable?: boolean }>() const props = defineProps<{ id: SettingId, control?: string, resettable?: boolean }>()
@@ -1,11 +1,14 @@
<script setup lang="ts"> <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 { toast } from '@@/utils/notifications'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue' import { pinia } from '@/pinia'
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '../../../common/utils/commands' import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import { recordedKeys } from '../../../common/utils/keys'
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings' const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
const commands = computed(allCommands) const commands = computed(allCommands)
const query = ref('') const query = ref('')
@@ -76,7 +79,7 @@ async function reset(command: Command) {
<template> <template>
<div class="rm-shortcut-settings"> <div class="rm-shortcut-settings">
<div class="rm-shortcut-toolbar"> <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> </el-button>
</div> </div>
@@ -1,7 +1,9 @@
<script setup lang="ts"> <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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { settingsApi } from '@/pages/settings/apis'
import MaintenancePanel from './MaintenancePanel.vue' import MaintenancePanel from './MaintenancePanel.vue'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
@@ -39,7 +41,7 @@ async function scan() {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
const next = await settingsApi.storage() const next = (await getStorageApi()).data
if (mounted) usage.value = next if (mounted) usage.value = next
} catch (cause) { } catch (cause) {
if (mounted) error.value = `统计失败:${String(cause)}` if (mounted) error.value = `统计失败:${String(cause)}`
@@ -1,17 +1,23 @@
<script setup lang="ts"> <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 { toast } from '@@/utils/notifications'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { onMounted, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ApiError } from '@/http/axios' import { ApiError } from '@/http/axios'
import { settingsApi } from '@/pages/settings/apis' import { useUserStore } from '@/pinia/stores/user'
import Icon from '../../../common/components/Icon/index.vue'
import { copyText } from '../../../common/utils/clipboard'
const tokens = ref<ApiToken[] | null>(null) const tokens = ref<ApiToken[] | null>(null)
const failed = ref(false) const error = ref('')
const name = ref('') const loading = ref(false)
const scope = ref<ApiToken['scope']>('read') 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 busy = ref(false)
// 刚创建的令牌明文,只在本次对话框中显示 // 刚创建的令牌明文,只在本次对话框中显示
const created = ref<string | null>(null) const created = ref<string | null>(null)
@@ -22,20 +28,28 @@ function errorText(err: unknown) {
} }
async function load() { async function load() {
if (loading.value) return
loading.value = true; error.value = ''
try { try {
tokens.value = await settingsApi.tokens() tokens.value = (await getTokensApi()).data.tokens
} catch { } catch (cause) {
failed.value = true if (!isCancel(cause)) error.value = `读取 API 令牌失败:${errorText(cause)}`
} finally {
loading.value = false
} }
} }
async function create() { async function create() {
if (busy.value) return
busy.value = true busy.value = true
const owner = user.getSessionVersion()
try { 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 ?? [])] tokens.value = [res.info, ...(tokens.value ?? [])]
created.value = res.token created.value = res.token
name.value = '' fields.name = ''; form.value!.clearValidate()
} catch (err) { } catch (err) {
if (isCancel(err)) return if (isCancel(err)) return
toast(`创建失败:${errorText(err)}`) toast(`创建失败:${errorText(err)}`)
@@ -49,13 +63,17 @@ async function copy() {
} }
async function revoke(t: ApiToken) { async function revoke(t: ApiToken) {
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return if (busy.value) return
busy.value = true
try { 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) tokens.value = tokens.value!.filter(x => x.id !== t.id)
} catch (err) { } catch (err) {
if (isCancel(err)) return if (isCancel(err)) return
toast(`吊销失败:${errorText(err)}`) toast(`吊销失败:${errorText(err)}`)
} finally {
busy.value = false
} }
} }
@@ -64,15 +82,19 @@ onMounted(load)
<template> <template>
<div> <div>
<form class="rm-share-form" @submit.prevent="create"> <el-form ref="form" class="rm-token-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="create">
<el-input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" /> <el-form-item label="名称" prop="name">
<el-select v-model="scope" aria-label="权限"> <el-input v-model="fields.name" autocomplete="off" placeholder="如:备份脚本" maxlength="100" />
<el-option value="read" label="只读" /><el-option value="write" label="读写" /> </el-form-item>
</el-select> <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 class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
创建 创建
</el-button> </el-button>
</form> </el-form>
<div v-if="created" class="rm-token-created"> <div v-if="created" class="rm-token-created">
<p>令牌只显示这一次,请立即保存:</p> <p>令牌只显示这一次,请立即保存:</p>
<div class="rm-token-value"> <div class="rm-token-value">
@@ -86,25 +108,59 @@ onMounted(load)
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见 调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。 <a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
</p> </p>
<p v-if="failed" class="rm-tree-empty"> <p v-if="error" class="rm-auth-error" role="alert">
读取 API 令牌失败 {{ error }} <el-button text :disabled="loading" @click="load">
重试
</el-button>
</p> </p>
<p v-else-if="tokens && !tokens.length" class="rm-tree-empty"> <p v-if="loading" role="status">
还没有 API 令牌 读取中…
</p> </p>
<ul v-else class="rm-trash-list"> <el-table v-else-if="tokens" :data="tokens" row-key="id" empty-text="还没有 API 令牌">
<li v-for="t in tokens ?? []" :key="t.id"> <!-- @vue-generic {ApiToken} -->
<Icon name="key" /> <el-table-column label="名称" min-width="140">
<div class="rm-trash-info"> <template #default="{ row: token }">
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span> <strong>{{ token.name }}</strong><small class="rm-token-time">{{ fmt.format(token.createdAt) }} 创建 · {{ token.lastUsedAt ? `${fmt.format(token.lastUsedAt)} 最近使用` : '从未使用' }}</small>
<span class="rm-trash-time"> </template>
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? `${fmt.format(t.lastUsedAt)} 最近使用` : '从未使用' }} </el-table-column>
</span> <!-- @vue-generic {ApiToken} -->
</div> <el-table-column label="权限" width="70">
<el-button class="rm-btn rm-btn-danger" @click="revoke(t)"> <template #default="{ row: token }">
吊销 {{ token.scope === 'write' ? '读写' : '只读' }}
</el-button> </template>
</li> </el-table-column>
</ul> <!-- @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>
</template>
</el-table-column>
</el-table>
</div> </div>
</template> </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"> <script setup lang="ts">
import type { TrashItem } from '@@/apis/trash/type' 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 { toast } from '@@/utils/notifications'
import { isImage } from '@@/utils/paths'
import { isCancel } from 'axios' 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 { 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' import SettingRow from './SettingRow.vue'
const props = defineProps<{ restoreNote: (id: string) => Promise<void> }>() const props = defineProps<{ restoreNote: (id: string) => Promise<void> }>()
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
const user = useUserStore() const user = useUserStore()
const items = ref<TrashItem[] | null>(null) const items = ref<TrashItem[] | null>(null)
@@ -18,21 +25,24 @@ const error = ref('')
const failures = ref<{ id: string, error: string }[]>([]) const failures = ref<{ id: string, error: string }[]>([])
const busy = ref(false) const busy = ref(false)
const saving = ref(false) const saving = ref(false)
const retentionInput = ref(String(settings.trashRetentionDays))
watch(() => settings.trashRetentionDays, (days) => {
retentionInput.value = String(days)
})
async function load() { async function load() {
try { try {
items.value = await trashApi.trash() items.value = (await getTrashApi()).data.items
error.value = '' error.value = ''
} catch (cause) { } catch (cause) {
error.value = `读取回收站失败:${String(cause)}` error.value = `读取回收站失败:${String(cause)}`
} }
} }
async function retention(event: Event) { async function retention(value: string) {
const input = event.target as HTMLInputElement const days = value.trim() ? Number(value) : NaN
const days = input.valueAsNumber
if (!Number.isSafeInteger(days) || days < 0) { if (!Number.isSafeInteger(days) || days < 0) {
input.value = String(settings.trashRetentionDays) retentionInput.value = String(settings.trashRetentionDays)
toast('请输入非负整数天数,0 表示永不自动删除') toast('请输入非负整数天数,0 表示永不自动删除')
return return
} }
@@ -65,15 +75,18 @@ async function doRestore(item: TrashItem) {
async function purge(item?: TrashItem) { async function purge(item?: TrashItem) {
if (busy.value) return if (busy.value) return
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
const version = user.getSessionVersion() const version = user.getSessionVersion()
busy.value = true busy.value = true
failures.value = [] failures.value = []
let attempted = false
try { try {
if (!await confirmAction(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
user.assertSession(version)
attempted = true
if (item) { if (item) {
await trashApi.purge(item.id) await purgeTrashApi(item.id)
} else { } else {
const result = await trashApi.clearTrash() const result = (await clearTrashApi()).data
failures.value = result.failed failures.value = result.failed
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`) toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
} }
@@ -81,7 +94,7 @@ async function purge(item?: TrashItem) {
if (isCancel(cause)) return if (isCancel(cause)) return
toast(`删除失败:${String(cause)}`) toast(`删除失败:${String(cause)}`)
} finally { } finally {
if (version === user.getSessionVersion()) await load() if (attempted && version === user.getSessionVersion()) await load()
busy.value = false busy.value = false
} }
} }
@@ -93,7 +106,7 @@ onMounted(load)
<template> <template>
<div> <div>
<SettingRow id="trash-retention" control="rm-trash-retention"> <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>
<SettingRow id="trash-clear"> <SettingRow id="trash-clear">
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()"> <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 v-else-if="items === null && !error" class="rm-tree-empty" role="status">
读取中… 读取中…
</p> </p>
<ul v-else class="rm-trash-list" :aria-busy="busy"> <el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" empty-text="回收站是空的">
<li v-for="item in items ?? []" :key="item.id"> <!-- @vue-generic {TrashItem} -->
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" /> <el-table-column label="项目" min-width="170">
<div class="rm-trash-info"> <template #default="{ row: item }">
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span> <div class="rm-trash-name">
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span> <Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" /><span>{{ item.path.replace(/\.md$/, '') }}</span>
</div> </div><small>{{ fmt.format(item.deletedAt) }}</small>
<el-button class="rm-btn" :disabled="busy" @click="doRestore(item)"> </template>
恢复 </el-table-column>
</el-button> <!-- @vue-generic {TrashItem} -->
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)"> <el-table-column label="操作" width="160">
彻底删除 <template #default="{ row: item }">
</el-button> <el-button text :disabled="busy" @click="doRestore(item)">
</li> 恢复
</ul> </el-button><el-button text type="danger" :disabled="busy" @click="purge(item)">
彻底删除
</el-button>
</template>
</el-table-column>
</el-table>
</div> </div>
</template> </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
View File
@@ -1,163 +1,65 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Settings } from '../../pinia/stores/settings'
import type { SettingSearchResult } from './settings-catalog' import type { SettingSearchResult } from './settings-catalog'
import type { SettingsSection } from '@/pinia/stores/settings' import type { SettingsSection } from '@/pinia/stores/settings'
import { toast } from '@@/utils/notifications' import Icon from '@@/components/Icon/index.vue'
import { isCancel } from 'axios' import { runCommand } from '@@/utils/commands'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { installApp, installPrompt } from '@@/utils/pwa'
import { ApiError } from '@/http/axios' import { computed, nextTick, ref } from 'vue'
import { settingsApi } from '@/pages/settings/apis' import { useSettingsStore } from '@/pinia/stores/settings'
import { UI_FONT_SIZE } from '../../common/appearance/typography' import AccountSettings from './components/AccountSettings.vue'
import FontPicker from '../../common/components/FontPicker/index.vue' import EditorSettings from './components/EditorSettings.vue'
import Icon from '../../common/components/Icon/index.vue' import InterfaceSettings from './components/InterfaceSettings.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 PluginsPanel from './components/PluginsPanel.vue' import PluginsPanel from './components/PluginsPanel.vue'
import SettingRow from './components/SettingRow.vue' import SettingRow from './components/SettingRow.vue'
import ShortcutSettings from './components/ShortcutSettings.vue' import ShortcutSettings from './components/ShortcutSettings.vue'
import StoragePanel from './components/StoragePanel.vue' import StoragePanel from './components/StoragePanel.vue'
import TokensPanel from './components/TokensPanel.vue'
import TrashPanel from './components/TrashPanel.vue' import TrashPanel from './components/TrashPanel.vue'
import { searchSettings, SETTINGS_SECTIONS } from './settings-catalog' import { searchSettings, SETTINGS_SECTIONS } from './settings-catalog'
const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>() const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>()
const emit = defineEmits<{ importNotes: [] }>() const emit = defineEmits<{ importNotes: [] }>()
const { settingsSave } = useSettingsStore()
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
if (!buildVersion) throw new Error('缺少应用构建版本信息') if (!buildVersion) throw new Error('缺少应用构建版本信息')
const model = defineModel<SettingsSection | 'index' | null>({ required: true }) 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 showList = ref(model.value === 'index')
const current = computed(() => SETTINGS_SECTIONS.find(s => s.id === section.value)!) const current = computed(() => SETTINGS_SECTIONS.find(s => s.id === section.value)!)
const query = ref('') const query = ref('')
const searching = computed(() => !!query.value.trim()) const searching = computed(() => !!query.value.trim())
const results = computed(() => searchSettings(query.value)) const results = computed(() => searchSettings(query.value))
const dialog = ref<HTMLElement>()
const content = ref<HTMLElement>() const content = ref<HTMLElement>()
const heading = 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 preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存') const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
async function open(id: SettingsSection) { async function open(id: SettingsSection) {
query.value = '' query.value = ''; model.value = id; showList.value = false
model.value = id
showList.value = false
await nextTick() await nextTick()
content.value!.scrollTop = 0 content.value!.scrollTop = 0
heading.value!.focus({ preventScroll: true }) heading.value!.focus({ preventScroll: true })
} }
async function locate(result: SettingSearchResult) { async function locate(result: SettingSearchResult) {
await open(result.section) await open(result.section)
if (!result.id) return if (!result.id) return
const target = document.getElementById(`rm-setting-${result.id}`)! const target = document.getElementById(`rm-setting-${result.id}`)!
// 设置可能位于 CSS 或排版预览的折叠区内。
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) { for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
if (parent instanceof HTMLDetailsElement) parent.open = true if (parent instanceof HTMLDetailsElement) parent.open = true
} }
if (target instanceof HTMLDetailsElement) target.open = true if (target instanceof HTMLDetailsElement) target.open = true
await nextTick() await nextTick()
target.focus({ preventScroll: true }) target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'center' })
target.scrollIntoView({ block: 'center' })
} }
function back() { function back() {
query.value = '' query.value = ''; showList.value = true
showList.value = true
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
} }
function close() { function close() {
model.value = null 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> </script>
<template> <template>
<div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close"> <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 ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey"> <div class="rm-settings" :class="{ 'show-list': showList, searching }">
<nav class="rm-settings-nav" aria-label="设置分类"> <nav class="rm-settings-nav" aria-label="设置分类">
<div class="rm-dialog-head"> <div class="rm-dialog-head">
<h2>设置</h2> <h2>设置</h2>
@@ -165,7 +67,11 @@ async function changePassword() {
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </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"> <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)"> <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 }} <Icon :name="s.icon" />{{ s.title }}
@@ -199,90 +105,9 @@ async function changePassword() {
</div> </div>
<div v-show="!searching" class="rm-settings-page"> <div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" /> <PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'"> <InterfaceSettings v-else-if="section === 'appearance'" />
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })"> <EditorSettings v-else-if="section === 'editor'" />
<FontPicker id="rm-ui-font" setting="uiFont" /> <AccountSettings v-else-if="section === 'account'" @logout="close(), runCommand('app.logout')" />
</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>
<ShortcutSettings v-else-if="section === 'shortcuts'" /> <ShortcutSettings v-else-if="section === 'shortcuts'" />
@@ -308,5 +133,400 @@ async function changePassword() {
</div> </div>
</section> </section>
</div> </div>
</div> </el-dialog>
</template> </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 -1
View File
@@ -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' import type { SettingsSection } from '@/pinia/stores/settings'
export const SETTINGS_SECTIONS: { id: SettingsSection, title: string, icon: IconName, description: string }[] = [ export const SETTINGS_SECTIONS: { id: SettingsSection, title: string, icon: IconName, description: string }[] = [
-46
View File
@@ -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,
}
-23
View File
@@ -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
}
+6 -2
View File
@@ -1,12 +1,15 @@
import * as filesApi from '@@/apis/files'
import { toasts } from '@@/utils/notifications' import { toasts } from '@@/utils/notifications'
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { workspaceApi } from './apis'
import { batchFiles, exportFiles } from './batch' import { batchFiles, exportFiles } from './batch'
import { flush, remove } from './workspace' 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' }] } })) 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) const user = useUserStore(pinia)
beforeEach(() => { beforeEach(() => {
@@ -22,6 +25,7 @@ it('切换会话后批量操作停止,不以新身份继续下一项', async (
finish = resolve finish = resolve
})) }))
const pending = batchFiles('delete', ['a.md', 'b.md']) const pending = batchFiles('delete', ['a.md', 'b.md'])
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
user.setToken('rms_new') user.setToken('rms_new')
finish({ ok: true }) finish({ ok: true })
await pending await pending
@@ -33,7 +37,7 @@ it('等待笔记保存时换会话,不再使用新身份导出旧选择', asyn
vi.mocked(flush).mockImplementation(() => new Promise((resolve) => { vi.mocked(flush).mockImplementation(() => new Promise((resolve) => {
finish = resolve finish = resolve
})) }))
const request = vi.spyOn(workspaceApi, 'exportFiles') const request = vi.spyOn(filesApi, 'exportFilesApi')
const pending = exportFiles(['a.md']) const pending = exportFiles(['a.md'])
user.setToken('rms_new') user.setToken('rms_new')
finish() finish()
+11 -7
View File
@@ -1,9 +1,12 @@
import { exportFilesApi } from '@@/apis/files'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications' import { toast } from '@@/utils/notifications'
import { basename, joinPath } from '@@/utils/paths'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { workspaceApi } from '@/pages/workspace/apis'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import { basename, joinPath } from '../../common/utils/paths'
import { minimalPaths } from './selection' import { minimalPaths } from './selection'
import { flush, move, refreshTree, remove, ws } from './workspace' 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 user = useUserStore(pinia)
const version = user.getSessionVersion() const version = user.getSessionVersion()
const subjects = minimalPaths(paths) const subjects = minimalPaths(paths)
if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return if (action === 'delete' && !await confirmAction(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
const failures: string[] = [] const failures: string[] = []
let succeeded = 0 let succeeded = 0
for (const path of subjects) { for (const path of subjects) {
try { try {
user.assertSession(version) 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++ if (result.ok) succeeded++
else failures.push(`${path}:${result.error}`) else failures.push(`${path}:${result.error}`)
} catch (cause) { } catch (cause) {
@@ -34,6 +37,7 @@ export async function batchFiles(action: 'move' | 'delete', paths: string[], des
toast(`刷新目录失败:${String(cause)}`) toast(`刷新目录失败:${String(cause)}`)
} }
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? `;${failures.join(';')}` : ''}`) toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? `;${failures.join(';')}` : ''}`)
return failures.length === 0
} }
export async function exportFiles(paths: string[]) { 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))) await Promise.all(tabs.map(tab => flush(tab.path)))
user.assertSession(version) user.assertSession(version)
if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出') if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
const response = await workspaceApi.exportFiles(subjects) const response = await exportFilesApi({ paths: subjects })
const blob = await response.blob() const blob = response.data
user.assertSession(version) 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 encoded = /filename\*=utf-8''([^;]+)/i.exec(disposition)?.[1]
const plain = /filename="([^"]+)"|filename=([^;]+)/i.exec(disposition) const plain = /filename="([^"]+)"|filename=([^;]+)/i.exec(disposition)
const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? '导出.zip' const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? '导出.zip'
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from '../../../common/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { runCommand } from '../../../common/utils/commands' import { runCommand } from '@@/utils/commands'
import { toggleSourceMode, ws } from '../workspace' import { toggleSourceMode, ws } from '../workspace'
</script> </script>
@@ -1,12 +1,11 @@
<script setup lang="ts"> <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 { 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' import { imageUrl, move, remove, replaceImage, ws } from '../workspace'
const props = defineProps<{ path: string }>() const props = defineProps<{ path: string }>()
const dialog = ref<HTMLElement>()
const picker = ref<HTMLInputElement>() const picker = ref<HTMLInputElement>()
const busy = ref(false) const busy = ref(false)
const error = ref('') const error = ref('')
@@ -27,7 +26,6 @@ watch(path, (p) => {
watch(src, () => { watch(src, () => {
loadError.value = false; dimensions.value = '' loadError.value = false; dimensions.value = ''
}) })
onMounted(() => dialog.value?.focus())
function close() { function close() {
if (!busy.value) ws.image = null if (!busy.value) ws.image = null
@@ -53,7 +51,8 @@ async function rename() {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
await move(path.value, to) const result = await move(path.value, to, 'caller')
if (!result.ok) error.value = result.error
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录' error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录'
} finally { } finally {
@@ -72,10 +71,10 @@ async function replace(event: Event) {
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) { if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) {
error.value = '请选择与原图片扩展名相同的文件'; return error.value = '请选择与原图片扩展名相同的文件'; return
} }
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
if (!await confirmAction('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
await replaceImage(path.value, file) await replaceImage(path.value, file)
} catch (err) { } catch (err) {
error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试' error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试'
@@ -85,9 +84,10 @@ async function replace(event: Event) {
} }
async function trash() { async function trash() {
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return if (busy.value) return
busy.value = true busy.value = true
try { try {
if (!await confirmAction('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
await remove(path.value) await remove(path.value)
} finally { } finally {
busy.value = false busy.value = false
@@ -96,70 +96,62 @@ async function trash() {
</script> </script>
<template> <template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close"> <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">
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy"> <div class="rm-image-body">
<div class="rm-dialog-head"> <p class="rm-image-path">
<h2>图片管理</h2> {{ path }}
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"> </p>
<Icon name="close" /> <div class="rm-image-preview">
</button> <p v-if="!entry">
图片已不存在,请刷新目录
</p>
<p v-else-if="loadError" role="alert">
图片无法预览,文件可能损坏或浏览器不支持此格式。
</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true">
</div> </div>
<div class="rm-image-body"> <div class="rm-image-navigation">
<p class="rm-image-path"> <el-button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">
{{ path }} 上一张
</p> </el-button>
<div class="rm-image-preview"> <span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<p v-if="!entry"> <el-button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">
图片已不存在,请刷新目录 下一张
</p> </el-button>
<p v-else-if="loadError" role="alert">
图片无法预览,文件可能损坏或浏览器不支持此格式。
</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true">
</div>
<div class="rm-image-navigation">
<el-button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">
上一张
</el-button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<el-button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">
下一张
</el-button>
</div>
<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>
<span>{{ extension }}</span>
<el-button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
重命名
</el-button>
</div>
</form>
<p class="rm-image-hint">
改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace">
<el-button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">
移动到…
</el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">
移入回收站
</el-button>
<el-button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">
{{ busy ? '处理中…' : '替换图片' }}
</el-button>
</div>
</div> </div>
</section> <form class="rm-image-rename" @submit.prevent="rename">
</div> <label for="rm-image-name">图片名称</label>
<div>
<el-input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
<span>{{ extension }}</span>
<el-button native-type="submit" class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
重命名
</el-button>
</div>
</form>
<p class="rm-image-hint">
改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace">
<el-button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">
移动到…
</el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">
移入回收站
</el-button>
<el-button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">
{{ busy ? '处理中…' : '替换图片' }}
</el-button>
</div>
</div>
</el-dialog>
</template> </template>
<style scoped> <style scoped lang="scss">
.rm-image-dialog { .rm-image-dialog {
max-width: 860px; max-width: 860px;
width: min(860px, calc(100vw - 24px)); width: min(860px, calc(100vw - 24px));
@@ -168,7 +160,7 @@ async function trash() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
padding: 0 20px 20px; padding: 0;
overflow-y: auto; overflow-y: auto;
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
} }
@@ -1,9 +1,9 @@
<script setup lang="ts"> <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 { toast } from '@@/utils/notifications'
import { basename, hiddenAssetPath, imageInFolder, isImage } from '@@/utils/paths'
import { computed, onMounted, ref } from 'vue' 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 { metadata, refreshMetadata } from '../metadata'
import { cleanupImages, imageUrl, openReference, uploadFolderImages, ws } from '../workspace' import { cleanupImages, imageUrl, openReference, uploadFolderImages, ws } from '../workspace'
@@ -106,21 +106,11 @@ async function clean() {
</el-button> </el-button>
</header> </header>
<div class="rm-media-mobile-filters"> <div class="rm-media-mobile-filters">
<select v-model="ws.imagesAll" aria-label="资源范围"> <el-select v-model="ws.imagesAll" aria-label="资源范围">
<option :value="false"> <el-option :value="false" label="当前目录" /><el-option :value="true" label="整个知识库" />
当前目录 </el-select><el-select v-model="usage" aria-label="引用状态">
</option><option :value="true"> <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">
</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-button> </el-button>
</div> </div>
@@ -138,26 +128,20 @@ async function clean() {
</aside> </aside>
<div class="rm-media-content"> <div class="rm-media-content">
<div class="rm-media-toolbar"> <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="图片格式"> <el-input v-model="query" class="rm-media-search" type="search" clearable placeholder="搜索图片名称或路径…" aria-label="搜索图片">
<option value=""> <template #prefix>
所有格式 <Icon name="search" />
</option><option v-for="type in types" :key="type" :value="type"> </template>
{{ type.toUpperCase() }} </el-input><el-select v-model="format" aria-label="图片格式">
</option> <el-option value="" label="所有格式" /><el-option v-for="type in types" :key="type" :value="type" :label="type.toUpperCase()" />
</select><select v-model="sort" aria-label="图片排序"> </el-select><el-select v-model="sort" aria-label="图片排序">
<option value="modified"> <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">
</option><option value="name">
名称
</option><option value="size">
文件大小
</option>
</select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list">
<Icon :name="list ? 'grid' : 'outline'" /> <Icon :name="list ? 'grid' : 'outline'" />
</button> </button>
</div> </div>
<div class="rm-media-upload"> <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 ? '处理中…' : '上传图片' }} <Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}
</el-button> </el-button>
</div> </div>
@@ -198,33 +182,52 @@ async function clean() {
</p> </p>
</aside> </aside>
</div> </div>
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"> <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">
<section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"> <p class="rm-share-note">
<div class="rm-dialog-head"> 服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。
<h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"> </p><ul>
<Icon name="close" /> <li v-for="image in cleanupCandidates ?? []" :key="image.path">
</button> <el-checkbox-group v-model="cleanupSelected">
</div><p class="rm-share-note"> <el-checkbox :value="image.path" :disabled="busy">
服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。 {{ image.path }} <small>{{ size(image.size) }}</small>
</p><ul> </el-checkbox>
<li v-for="image in cleanupCandidates" :key="image.path"> </el-checkbox-group>
<label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy">{{ image.path }} <small>{{ size(image.size) }}</small></label> </li>
</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">
</p><div class="rm-cleanup-actions"> <el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null"> 取消
取消 </el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">
</el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean"> 移入回收站({{ cleanupSelected.length }})
移入回收站({{ cleanupSelected.length }}) </el-button>
</el-button> </div>
</div> </el-dialog>
</section>
</div>
</section> </section>
</template> </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 { .rm-media-library {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImportResult } from '@/pages/workspace/apis/type' import type { ImportResult } from '@@/apis/files/type'
import { computed, onMounted, ref } from 'vue' import { dirname } from '@@/utils/paths'
import { computed, ref } from 'vue'
import { ApiError } from '@/http/axios' import { ApiError } from '@/http/axios'
import Icon from '../../../common/components/Icon/index.vue'
import { dirname } from '../../../common/utils/paths'
import { expanded } from '../tree' import { expanded } from '../tree'
import { importNotes } from '../workspace' import { importNotes } from '../workspace'
@@ -17,13 +16,11 @@ const error = ref('')
const result = ref<ImportResult | null>(null) const result = ref<ImportResult | null>(null)
const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name)) const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name))
const dialog = ref<HTMLElement>()
const validation = computed(() => { const validation = computed(() => {
if (!zipped.value && files.value.some(f => !f.name.endsWith('.md'))) return '请选择 .md 文件,或单独选择一个 ZIP' 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' if (files.value.reduce((sum, file) => sum + file.size, 0) > 100_000_000) return '导入文件总量不能超过 100 MB'
return '' return ''
}) })
onMounted(() => dialog.value?.focus())
function choose(event: Event) { function choose(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -64,70 +61,62 @@ async function submit() {
</script> </script>
<template> <template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close"> <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">
<section ref="dialog" class="rm-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="导入 Markdown" :aria-busy="busy"> <el-form class="rm-import-body" label-position="top" :disabled="busy" @submit.prevent="submit">
<div class="rm-dialog-head"> <p class="rm-import-target">
<h2>导入 Markdown</h2> 导入到:{{ dir || '根目录' }}
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"> </p>
<Icon name="close" /> <template v-if="!result">
</button> <label>选择 Markdown 文件或一个 ZIP
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose">
</label>
<label v-if="zipped">新建文件夹名称
<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 }}
<el-input v-model="names[i]" required aria-label="导入文件名称" @input="error = ''" />
</label>
</div>
<p class="rm-import-hint">
总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。
</p>
</template>
<div v-if="result" role="status">
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
<details v-if="result.warnings.length" class="rm-import-warnings">
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
<ul>
<li v-for="warning in result.warnings" :key="warning">
{{ warning }}
</li>
</ul>
</details>
</div> </div>
<form class="rm-import-body" @submit.prevent="submit"> <p v-if="error || validation" class="rm-auth-error" role="alert">
<p class="rm-import-target"> {{ error || validation }}
导入到:{{ dir || '根目录' }} </p>
</p> <p v-if="busy" role="status">
<template v-if="!result"> 正在上传并整理,请勿关闭页面…
<label>选择 Markdown 文件或一个 ZIP </p>
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose"> <div class="rm-import-actions">
</label> <el-button native-type="button" class="rm-btn" :disabled="busy" @click="close">
<label v-if="zipped">新建文件夹名称 {{ result ? '完成' : '取消' }}
<input v-model="folder" :disabled="busy" required @input="error = ''"> </el-button>
</label> <el-button v-if="!result" native-type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">
<div v-else-if="files.length" class="rm-import-files"> {{ busy ? '导入中…' : '导入' }}
<label v-for="(file, i) in files" :key="i">{{ file.name }} </el-button>
<input v-model="names[i]" :disabled="busy" required aria-label="导入文件名称" @input="error = ''"> </div>
</label> </el-form>
</div> </el-dialog>
<p class="rm-import-hint">
总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。
</p>
</template>
<div v-if="result" role="status">
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
<details v-if="result.warnings.length" class="rm-import-warnings">
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
<ul>
<li v-for="warning in result.warnings" :key="warning">
{{ warning }}
</li>
</ul>
</details>
</div>
<p v-if="error || validation" class="rm-auth-error" role="alert">
{{ error || validation }}
</p>
<p v-if="busy" role="status">
正在上传并整理,请勿关闭页面…
</p>
<div class="rm-import-actions">
<el-button native-type="button" class="rm-btn" :disabled="busy" @click="close">
{{ result ? '完成' : '取消' }}
</el-button>
<el-button v-if="!result" native-type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">
{{ busy ? '导入中…' : '导入' }}
</el-button>
</div>
</form>
</section>
</div>
</template> </template>
<style scoped> <style scoped lang="scss">
.rm-import-body { .rm-import-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding: 8px 20px 20px; padding: 4px 0;
overflow-y: auto; overflow-y: auto;
font-size: var(--rm-ui-font-size); font-size: var(--rm-ui-font-size);
} }
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from '@@/components/Icon/index.vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { metadata, refreshMetadata } from '../metadata' import { metadata, refreshMetadata } from '../metadata'
import { openNote, openReference, ws } from '../workspace' import { openNote, openReference, ws } from '../workspace'
@@ -74,13 +74,9 @@ watch(() => ws.view, () => {
</el-button> </el-button>
</header> </header>
<div class="rm-knowledge-tools"> <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="筛选标签"> <label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记"></label><el-select v-model="tag" aria-label="筛选标签">
<option value=""> <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>
</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>
</div> </div>
<p v-if="metadata.error" role="alert"> <p v-if="metadata.error" role="alert">
{{ metadata.error }} <el-button class="rm-btn" @click="refreshMetadata"> {{ metadata.error }} <el-button class="rm-btn" @click="refreshMetadata">
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import type { InputInstance } from 'element-plus'
import Icon from '../../../common/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '../../../common/utils/paths' import { basename, dirname, inAssetDir, joinPath, noteTitle } from '@@/utils/paths'
import { isCancel } from 'axios'
import { computed, ref, watch } from 'vue'
import { batchFiles } from '../batch' import { batchFiles } from '../batch'
import { fuzzyMatch, segments } from '../fuzzy' import { fuzzyMatch, segments } from '../fuzzy'
import { minimalPaths } from '../selection' import { minimalPaths } from '../selection'
@@ -11,10 +13,12 @@ import { move, ws } from '../workspace'
const props = defineProps<{ path: string | string[] }>() const props = defineProps<{ path: string | string[] }>()
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
const input = ref<HTMLInputElement>() const input = ref<InputInstance>()
const list = ref<HTMLElement>() const list = ref<HTMLElement>()
const query = ref('') const query = ref('')
const selected = ref(0) const selected = ref(0)
const busy = ref(false)
const error = ref('')
const paths = computed(() => minimalPaths(typeof props.path === 'string' ? [props.path] : props.path)) 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])) const title = computed(() => paths.value.length > 1 ? `${paths.value.length} 项` : noteTitle(paths.value[0]))
@@ -38,59 +42,71 @@ const items = computed(() => {
watch(query, () => (selected.value = 0)) watch(query, () => (selected.value = 0))
watch(selected, i => list.value?.children[i]?.scrollIntoView({ block: 'nearest' })) watch(selected, i => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
function choose(i: number) { async function choose(i: number) {
const target = items.value[i] const target = items.value[i]
if (!target) return if (!target || busy.value) return
emit('close') busy.value = true; error.value = ''
if (paths.value.length === 1) void move(paths.value[0], joinPath(target.path, basename(paths.value[0]))) try {
else void batchFiles('move', paths.value, target.path) 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')
} 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) { function onKeydown(e: KeyboardEvent) {
const n = items.value.length 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 === '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 === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value) else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
else return else return
e.preventDefault() e.preventDefault()
} }
onMounted(() => {
// 触屏设备上不自动弹出键盘,文件夹不多时直接点选更方便
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
})
</script> </script>
<template> <template>
<div class="rm-switcher-backdrop" @mousedown.self="emit('close')"> <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">
<div class="rm-switcher" role="dialog" :aria-label="`移动“${title}”`"> <el-input ref="input" v-model="query" :disabled="busy" :placeholder="`把“${title}”移动到…`" aria-label="搜索目标文件夹" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
<div class="rm-search-box"> <template #prefix>
<Icon name="folder" /> <Icon name="folder" />
<input ref="input" v-model="query" class="rm-search-input" :placeholder="`把“${title}”移动到…`" enterkeyhint="go" @keydown="onKeydown"> </template>
<button class="rm-icon-btn" title="取消" @click="emit('close')"> </el-input>
<Icon name="close" /> <p v-if="error" role="alert" class="rm-auth-error">
</button> {{ error }}
</div> </p>
<ul v-if="items.length" ref="list" class="rm-switcher-list"> <ul v-if="items.length" ref="list" class="rm-switcher-list">
<li <li
v-for="(item, i) in items" v-for="(item, i) in items"
:key="item.path" :key="item.path"
class="rm-switcher-item" class="rm-switcher-item"
:class="{ active: i === selected }" :class="{ active: i === selected }"
@click="choose(i)" @click="choose(i)"
@mousemove="selected = i" @mousemove="selected = i"
> >
<Icon name="folder" /> <Icon name="folder" />
<span class="rm-switcher-title"> <span class="rm-switcher-title">
<template v-if="item.path === ''">根目录</template> <template v-if="item.path === ''">根目录</template>
<span v-for="(s, j) in segments(item.path, item.hits)" v-else :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span> <span v-for="(s, j) in segments(item.path, item.hits)" v-else :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span> </span>
</li> </li>
</ul> </ul>
<p v-else class="rm-tree-empty"> <p v-else class="rm-tree-empty">
没有可移动到的文件夹 没有可移动到的文件夹
</p> </p>
</div> </el-dialog>
</div>
</template> </template>
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { EditorState } from '@codemirror/state' import type { EditorState } from '@codemirror/state'
import Icon from '@@/components/Icon/index.vue'
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace' import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节 // 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import type { InputInstance } from 'element-plus'
import Icon from '../../../common/components/Icon/index.vue' import Icon from '@@/components/Icon/index.vue'
import { availableCommands, commandKeys, formatKeys } from '../../../common/utils/commands' import { availableCommands, commandKeys, formatKeys } from '@@/utils/commands'
import { dirname, NOTE_EXT, noteTitle } from '../../../common/utils/paths' import { dirname, NOTE_EXT, noteTitle } from '@@/utils/paths'
import { computed, ref, watch } from 'vue'
import { fuzzyMatch, segments } from '../fuzzy' import { fuzzyMatch, segments } from '../fuzzy'
import { createNoteAt, openAt, ws } from '../workspace' import { createNoteAt, openAt, ws } from '../workspace'
@@ -22,7 +23,7 @@ interface Item {
run: () => void run: () => void
} }
const input = ref<HTMLInputElement>() const input = ref<InputInstance>()
const list = ref<HTMLElement>() const list = ref<HTMLElement>()
const query = ref(ws.switcher ?? '') const query = ref(ws.switcher ?? '')
const selected = ref(0) const selected = ref(0)
@@ -148,45 +149,38 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault() e.preventDefault()
} }
onMounted(() => input.value?.focus()) function opened() {
input.value?.focus()
}
</script> </script>
<template> <template>
<div class="rm-switcher-backdrop" @mousedown.self="cancel"> <el-dialog :model-value="true" :title="commandMode ? '命令面板' : '快速打开'" width="560px" class="rm-picker-dialog" @close="cancel" @opened="opened">
<div class="rm-switcher" role="dialog" :aria-label="commandMode ? '命令面板' : '快速打开'"> <el-input ref="input" v-model="query" :placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'" :aria-label="commandMode ? '搜索命令' : '搜索笔记'" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
<div class="rm-search-box"> <template #prefix>
<Icon :name="commandMode ? 'command' : 'search'" /> <Icon :name="commandMode ? 'command' : 'search'" />
<input </template>
ref="input" </el-input>
v-model="query" <ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent>
class="rm-search-input" <li
:placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'" v-for="(item, i) in items"
enterkeyhint="go" :key="item.key"
@keydown="onKeydown" class="rm-switcher-item"
@blur="close" :class="{ active: i === selected }"
> @click="choose(i)"
</div> @mousemove="selected = i"
<ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent> >
<li <Icon :name="item.icon" />
v-for="(item, i) in items" <span class="rm-switcher-title">
:key="item.key" <span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
class="rm-switcher-item" </span>
:class="{ active: i === selected }" <span v-if="item.detail" class="rm-switcher-dir" :class="{ 'rm-switcher-keys': item.icon === 'command' }">
@click="choose(i)" <span v-for="(s, j) in segments(item.detail, item.detailHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
@mousemove="selected = i" </span>
> </li>
<Icon :name="item.icon" /> </ul>
<span class="rm-switcher-title"> <p v-else class="rm-tree-empty">
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span> {{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
</span> </p>
<span v-if="item.detail" class="rm-switcher-dir" :class="{ 'rm-switcher-keys': item.icon === 'command' }"> </el-dialog>
<span v-for="(s, j) in segments(item.detail, item.detailHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
</li>
</ul>
<p v-else class="rm-tree-empty">
{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
</p>
</div>
</div>
</template> </template>
@@ -1,10 +1,12 @@
<script setup lang="ts"> <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 { toast } from '@@/utils/notifications'
import { dirname, noteTitle } from '@@/utils/paths'
import { computed, onMounted, ref, watch } from 'vue' 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' import { openAt, ws } from '../workspace'
const SEARCH_DELAY = 150 const SEARCH_DELAY = 150
@@ -30,7 +32,7 @@ async function run(q: string) {
return return
} }
try { try {
const r = await workspaceApi.search(q) const r = (await searchApi({ q })).data.results
if (id !== seq) return if (id !== seq) return
results.value = r results.value = r
selected.value = 0 selected.value = 0
@@ -1,15 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InlineStyles } from '@@/markdown/inline-styles'
import type { EditorSelection } from '@codemirror/state' import type { EditorSelection } from '@codemirror/state'
import type { InlineStyles } from '../../../common/markdown/inline-styles'
import type { FormatCommand } from '../editor/format' 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 { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue' import { pinia } from '@/pinia'
import InlineAppearance from '../../../common/components/InlineAppearance/index.vue' import { useSettingsStore } from '@/pinia/stores/settings'
import { settings } from '../../../pinia/stores/settings'
import { insertLink } from '../editor/format' import { insertLink } from '../editor/format'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from '../editor/inline-styles' import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from '../editor/inline-styles'
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace' import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
const { settings } = useSettingsStore(pinia)
const root = ref<HTMLElement>() const root = ref<HTMLElement>()
const floating = ref<{ left: number, top: number, maxHeight: number, maxWidth: number } | null>(null) const floating = ref<{ left: number, top: number, maxHeight: number, maxWidth: number } | null>(null)
const appearanceOpen = ref(false) const appearanceOpen = ref(false)
@@ -1,10 +1,10 @@
<script setup lang="ts"> <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 { 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 { dropDirectory, tree, treeIconTarget } from '../tree'
import { createFolder, createNote, move, openSearch, ws } from '../workspace' import { createFolder, createNote, move, openSearch, ws } from '../workspace'
import SearchPanel from './SearchPanel.vue' import SearchPanel from './SearchPanel.vue'
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Tab } from '../workspace' 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 { 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' import { closeTab, createNote, ws } from '../workspace'
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面 // 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
@@ -1,11 +1,11 @@
<script setup lang="ts"> <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 { 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 { 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 { treeIconTarget } from '../tree'
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from '../tree-icon-catalog' import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from '../tree-icon-catalog'
import { saveTreeIcon } from '../workspace' import { saveTreeIcon } from '../workspace'
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TreeNode } from '../tree' 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 { 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 { batchFiles, exportFiles } from '../batch'
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget } from '../tree' import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget } from '../tree'
import { createFolder, createNote, move, openNote, renameTarget, ws } from '../workspace' import { createFolder, createNote, move, openNote, renameTarget, ws } from '../workspace'
+1 -1
View File
@@ -1,4 +1,4 @@
import { watchFontLoading } from '../../../common/appearance/font-loading' import { watchFontLoading } from '@@/appearance/font-loading'
// 编辑器演示页(无需登录),用于性能与交互的自动化测试 // 编辑器演示页(无需登录),用于性能与交互的自动化测试
import { createEditorState, createEditorView } from '../editor' import { createEditorState, createEditorView } from '../editor'
import sample from './sample.md?raw' import sample from './sample.md?raw'
+1 -1
View File
@@ -1,10 +1,10 @@
import type { EditorState, Range } from '@codemirror/state' import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view' import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common' import type { SyntaxNode } from '@lezer/common'
import { codeRenderers, notePath, pluginRefresh } from '@@/plugins/registry'
import { syntaxTree } from '@codemirror/language' import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state' import { StateField } from '@codemirror/state'
import { Decoration, EditorView } from '@codemirror/view' import { Decoration, EditorView } from '@codemirror/view'
import { codeRenderers, notePath, pluginRefresh } from '../../../common/plugins/registry'
import { codeContentRange, fenceLanguage } from './codeblocks' import { codeContentRange, fenceLanguage } from './codeblocks'
import { editorFocused, setEditorFocus } from './focus' import { editorFocused, setEditorFocus } from './focus'
import { CodeRendererWidget, MathWidget } from './widgets' import { CodeRendererWidget, MathWidget } from './widgets'
+2 -2
View File
@@ -1,11 +1,11 @@
import type { TokenRun } from '@@/highlight/client'
import type { EditorState, Range } from '@codemirror/state' import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet, ViewUpdate } from '@codemirror/view' import type { DecorationSet, ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common' 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 { syntaxTree } from '@codemirror/language'
import { StateEffect, StateField } from '@codemirror/state' import { StateEffect, StateField } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view' import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
import { cachedTokens, requestTokens } from '../../../common/highlight/client'
interface CodeBlock { interface CodeBlock {
// 代码内容(不含围栏行)在文档中的范围 // 代码内容(不含围栏行)在文档中的范围
+2 -2
View File
@@ -1,13 +1,13 @@
import type { Extension } from '@codemirror/state' import type { Extension } from '@codemirror/state'
import type { KeyBinding, ViewUpdate } from '@codemirror/view' import type { KeyBinding, ViewUpdate } from '@codemirror/view'
import type { MarkdownParser } from '@lezer/markdown' 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 { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown' import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
import { Language } from '@codemirror/language' import { Language } from '@codemirror/language'
import { Compartment, EditorState, Prec } from '@codemirror/state' import { Compartment, EditorState, Prec } from '@codemirror/state'
import { drawSelection, EditorView, keymap } from '@codemirror/view' 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 { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks' import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus' import { editorFocus } from './focus'
@@ -1,9 +1,9 @@
import type { TransactionSpec } from '@codemirror/state' import type { TransactionSpec } from '@codemirror/state'
import type { FormatCommand } from './format' import type { FormatCommand } from './format'
import { renderMarkdown } from '@@/markdown/html'
import { redo, undo } from '@codemirror/commands' import { redo, undo } from '@codemirror/commands'
import { EditorSelection, EditorState, Transaction } from '@codemirror/state' import { EditorSelection, EditorState, Transaction } from '@codemirror/state'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '../../../common/markdown/html'
import { toggleBold } from './format' import { toggleBold } from './format'
import { createEditorState } from './index' import { createEditorState } from './index'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inline-styles' 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 { EditorState } from '@codemirror/state'
import type { SyntaxNode } from '@lezer/common' import type { SyntaxNode } from '@lezer/common'
import type { InlineStyles } from '../../../common/markdown/inline-styles'
import type { FormatCommand } from './format' import type { FormatCommand } from './format'
import { inlineStyleText, stylesFromTag } from '@@/markdown/inline-styles'
import { ensureSyntaxTree, language } from '@codemirror/language' import { ensureSyntaxTree, language } from '@codemirror/language'
import { EditorSelection } from '@codemirror/state' import { EditorSelection } from '@codemirror/state'
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
interface Wrapper { marks: { from: number, to: number }[] } interface Wrapper { marks: { from: number, to: number }[] }
interface Segment { interface Segment {
@@ -2,10 +2,10 @@ import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet, EditorView, ViewUpdate } from '@codemirror/view' import type { DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common' import type { SyntaxNode } from '@lezer/common'
import type { TocEntry } from './widgets' import type { TocEntry } from './widgets'
import { inlineStyleText, stylesFromTag } from '@@/markdown/inline-styles'
import { syntaxTree } from '@codemirror/language' import { syntaxTree } from '@codemirror/language'
import { Facet, StateEffect } from '@codemirror/state' import { Facet, StateEffect } from '@codemirror/state'
import { Decoration, ViewPlugin } from '@codemirror/view' import { Decoration, ViewPlugin } from '@codemirror/view'
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks' import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
import { import {
BulletWidget, BulletWidget,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Command, KeyBinding } from '@codemirror/view' import type { Command, KeyBinding } from '@codemirror/view'
import { typoraKeys } from '@@/utils/typora-keys'
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands' import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
import { typoraKeys } from '../../../common/utils/typora-keys'
import { selectCodeBlock } from './codeblocks' import { selectCodeBlock } from './codeblocks'
import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format' import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format'
import { toggleSelectionMark, toggleUnderline } from './inline-styles' import { toggleSelectionMark, toggleUnderline } from './inline-styles'
+2 -2
View File
@@ -1,12 +1,12 @@
import type { EditorState, Range } from '@codemirror/state' import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view' import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common' import type { SyntaxNode } from '@lezer/common'
import { renderInlineMarkdown } from '@@/markdown/html'
import { hydrate } from '@@/markdown/render'
import { isolateHistory, redo, undo } from '@codemirror/commands' import { isolateHistory, redo, undo } from '@codemirror/commands'
import { syntaxTree } from '@codemirror/language' import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state' import { StateField } from '@codemirror/state'
import { Decoration, EditorView, WidgetType } from '@codemirror/view' import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { renderInlineMarkdown } from '../../../common/markdown/html'
import { hydrate } from '../../../common/markdown/render'
import { editorFocused, setEditorFocus } from './focus' import { editorFocused, setEditorFocus } from './focus'
type Align = '' | 'left' | 'center' | 'right' type Align = '' | 'left' | 'center' | 'right'
+4 -4
View File
@@ -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 { 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 { export class BulletWidget extends WidgetType {
eq() { eq() {
+3 -1
View File
@@ -6,12 +6,14 @@ import { CanceledError } from 'axios'
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from 'vue-router'
import { pinia } from '@/pinia' import { pinia } from '@/pinia'
import * as preferences from '@/pinia/stores/settings' import { useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user' import { useUserStore } from '@/pinia/stores/user'
import Sidebar from './components/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import Workspace from './index.vue' import Workspace from './index.vue'
import { flushAll, readyToReload } from './workspace' import { flushAll, readyToReload } from './workspace'
const preferences = useSettingsStore(pinia)
vi.mock('./workspace', async (importOriginal) => { vi.mock('./workspace', async (importOriginal) => {
const actual = await importOriginal<typeof import('./workspace')>() const actual = await importOriginal<typeof import('./workspace')>()
return { ...actual, flushAll: vi.fn(), readyToReload: vi.fn(), startWorkspace: vi.fn(), stopWorkspace: vi.fn() } return { ...actual, flushAll: vi.fn(), readyToReload: vi.fn(), startWorkspace: vi.fn(), stopWorkspace: vi.fn() }
+9 -6
View File
@@ -1,21 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import AppStatus from '@@/components/AppStatus/index.vue' 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 { toast } from '@@/utils/notifications'
import { noteTitle } from '@@/utils/paths'
import { isCancel } from 'axios' import { isCancel } from 'axios'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ApiError } from '@/http/axios' 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 { 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 EditorChrome from './components/EditorChrome.vue'
import EditorPane from './components/EditorPane.vue' import EditorPane from './components/EditorPane.vue'
import ImageDialog from './components/ImageDialog.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 ImportDialog from './components/ImportDialog.vue'
import KnowledgePanel from './components/KnowledgePanel.vue' import KnowledgePanel from './components/KnowledgePanel.vue'
import MoveDialog from './components/MoveDialog.vue' import MoveDialog from './components/MoveDialog.vue'
@@ -27,6 +28,8 @@ import Sidebar from './components/Sidebar.vue'
import TabBar from './components/TabBar.vue' import TabBar from './components/TabBar.vue'
import { flushAll, readyToReload, restore, startWorkspace, stopWorkspace, ws } from './workspace' import { flushAll, readyToReload, restore, startWorkspace, stopWorkspace, ws } from './workspace'
const { settings, updateSettings } = useSettingsStore(pinia)
async function prepareReload() { async function prepareReload() {
await flushAll(); return readyToReload() 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