feat(settings): organize categories and include optional font storage

This commit is contained in:
eezd committed 2026-10-25 13:22:16 +08:00
1 parent 1408982782
commit 88492fdcde
15 files changed
+124 -49

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd ./cmd COPY cmd ./cmd
COPY internal ./internal COPY internal ./internal
COPY web/embed.go ./web/ COPY web/embed.go web/font_storage.go ./web/
COPY --from=web /src/web/dist ./web/dist COPY --from=web /src/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data
+2 -2
View File
@@ -42,11 +42,11 @@ docker run -d --name rawmark \
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。 - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
- 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「应用 → 存储空间」按需统计数据目录与外置笔记库,不跟随内部符号链接;附件与回收站包含在笔记库总量中。 - 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「存储空间」进入即统计数据目录、外置笔记库及内置可选字体(不含默认 Maple),支持刷新和字体明细;不跟随内部符号链接,附件与回收站包含在笔记库总量中。
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。 - 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。 - 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
- 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 - 自动化:在设置「账户与安全 → API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
+5
View File
@@ -85,6 +85,11 @@ func run(addr string) error {
srv.ThemesDir = filepath.Join(dataDir, "themes") srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.PluginsDir = filepath.Join(dataDir, "plugins") srv.PluginsDir = filepath.Join(dataDir, "plugins")
srv.FontsDir = filepath.Join(dataDir, "fonts") srv.FontsDir = filepath.Join(dataDir, "fonts")
fontsSize, err := web.FontStorage()
if err != nil {
return fmt.Errorf("读取内置字体统计失败: %w", err)
}
srv.BuiltinFontsSize = fontsSize
trashDone := make(chan struct{}) trashDone := make(chan struct{})
go func() { go func() {
defer close(trashDone) defer close(trashDone)
+1
View File
@@ -43,6 +43,7 @@ type Server struct {
PluginsDir string PluginsDir string
FontsDir string FontsDir string
pluginsMu sync.Mutex pluginsMu sync.Mutex
BuiltinFontsSize int64
} }
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server { func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
+11 -1
View File
@@ -2469,7 +2469,9 @@
"categories", "categories",
"assetsSize", "assetsSize",
"trashSize", "trashSize",
"calculatedAt" "calculatedAt",
"builtinFontsSize",
"customFontsSize"
], ],
"properties": { "properties": {
"totalSize": { "totalSize": {
@@ -2514,6 +2516,14 @@
} }
} }
} }
},
"builtinFontsSize": {
"type": "integer",
"minimum": 0
},
"customFontsSize": {
"type": "integer",
"minimum": 0
} }
} }
} }
+6
View File
@@ -21,6 +21,8 @@ type storageUsage struct {
Categories []storageCategory `json:"categories"` Categories []storageCategory `json:"categories"`
AssetsSize int64 `json:"assetsSize"` AssetsSize int64 `json:"assetsSize"`
TrashSize int64 `json:"trashSize"` TrashSize int64 `json:"trashSize"`
BuiltinFontsSize int64 `json:"builtinFontsSize"`
CustomFontsSize int64 `json:"customFontsSize"`
CalculatedAt int64 `json:"calculatedAt"` CalculatedAt int64 `json:"calculatedAt"`
} }
@@ -30,6 +32,10 @@ func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
internalError(w, r, err) internalError(w, r, err)
return return
} }
usage.BuiltinFontsSize = s.BuiltinFontsSize
usage.CustomFontsSize = usage.Categories[2].Size
usage.Categories[2].Size += s.BuiltinFontsSize
usage.TotalSize += s.BuiltinFontsSize
writeJSON(w, http.StatusOK, usage) writeJSON(w, http.StatusOK, usage)
} }
+31
View File
@@ -2,9 +2,12 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"rawmark/internal/vault"
"testing" "testing"
) )
@@ -79,3 +82,31 @@ func TestStorageDoesNotReturnPartialSuccess(t *testing.T) {
t.Fatalf("请求取消应停止扫描: %v", err) t.Fatalf("请求取消应停止扫描: %v", err)
} }
} }
func TestStorageIncludesOptionalAndImportedFontBytes(t *testing.T) {
root := t.TempDir()
os.MkdirAll(filepath.Join(root, "vault"), 0700)
os.MkdirAll(filepath.Join(root, "fonts/custom"), 0700)
os.WriteFile(filepath.Join(root, "fonts/custom/font.woff2"), make([]byte, 123), 0600)
v, err := vault.Open(filepath.Join(root, "vault"))
if err != nil {
t.Fatal(err)
}
s := &Server{DataDir: root, vault: v, BuiltinFontsSize: 456}
recorder := httptest.NewRecorder()
s.getStorage(recorder, httptest.NewRequest("GET", "/", nil))
var usage storageUsage
if err = json.Unmarshal(recorder.Body.Bytes(), &usage); err != nil {
t.Fatal(err)
}
if usage.BuiltinFontsSize != 456 || usage.CustomFontsSize != 123 || usage.Categories[2].Size != 579 {
t.Fatalf("字体统计错误: %+v", usage)
}
var sum int64
for _, category := range usage.Categories {
sum += category.Size
}
if sum != usage.TotalSize {
t.Fatal("分类与总量不一致")
}
}
+21
View File
@@ -0,0 +1,21 @@
package web
import (
"encoding/json"
"io/fs"
)
// FontStorage 读取构建工具统计的内置可选字体大小,不包含默认 Maple。
func FontStorage() (int64, error) {
data, err := fs.ReadFile(Dist(), "font-storage.json")
if err != nil {
return 0, err
}
var sizes struct {
OptionalFontsSize int64 `json:"optionalFontsSize"`
}
if err := json.Unmarshal(data, &sizes); err != nil {
return 0, err
}
return sizes.OptionalFontsSize, nil
}
+3 -2
View File
@@ -199,13 +199,14 @@ async function changePassword() {
<p v-if="pwError" class="rm-auth-error" role="alert">{{ pwError }}</p> <p v-if="pwError" class="rm-auth-error" role="alert">{{ pwError }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button> <button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
</form> </form>
<section id="rm-setting-tokens" tabindex="-1"><h3 class="rm-settings-group-title">API 令牌</h3><TokensPanel /></section>
<SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow> <SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow>
</template> </template>
<ShortcutSettings v-else-if="section === 'shortcuts'" /> <ShortcutSettings v-else-if="section === 'shortcuts'" />
<TokensPanel v-else-if="section === 'tokens'" />
<TrashPanel v-else-if="section === 'trash'" /> <TrashPanel v-else-if="section === 'trash'" />
<StoragePanel v-else-if="section === 'application'" /> <StoragePanel v-else-if="section === 'storage'" />
<template v-else-if="section === 'about'"> <template v-else-if="section === 'about'">
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p> <p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow> <SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
+9 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, type StorageUsage } from './api' import { api, type StorageUsage } from './api'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
@@ -38,12 +38,14 @@ async function scan() {
if (mounted) error.value = '统计失败:' + String(cause) if (mounted) error.value = '统计失败:' + String(cause)
} finally { if (mounted) busy.value = false } } finally { if (mounted) busy.value = false }
} }
onMounted(scan)
</script> </script>
<template> <template>
<div> <div>
<SettingRow id="storage"><button class="rm-btn" :disabled="busy" @click="scan">{{ busy ? '统计中…' : usage ? '刷新' : '查看' }}</button></SettingRow> <h3 class="rm-settings-group-title">空间占用</h3>
<p class="rm-share-note">统计普通文件实际字节;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。</p> <SettingRow id="storage"><button class="rm-btn" :disabled="busy" @click="scan">{{ busy ? '统计中…' : '刷新' }}</button></SettingRow>
<p class="rm-share-note">统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。</p>
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p> <p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
<div v-if="usage" class="rm-storage" :aria-busy="busy"> <div v-if="usage" class="rm-storage" :aria-busy="busy">
<div class="rm-storage-summary"> <div class="rm-storage-summary">
@@ -56,6 +58,10 @@ async function scan() {
<dl class="rm-storage-list"> <dl class="rm-storage-list">
<template v-for="segment in segments" :key="segment.id"> <template v-for="segment in segments" :key="segment.id">
<div><dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small></dd></div> <div><dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small></dd></div>
<template v-if="segment.id === 'fonts'">
<div class="rm-storage-sub"><dt>内置可选字体</dt><dd>{{ size(usage.builtinFontsSize) }}</dd></div>
<div class="rm-storage-sub"><dt>导入字体</dt><dd>{{ size(usage.customFontsSize) }}</dd></div>
</template>
<template v-if="segment.id === 'vault'"> <template v-if="segment.id === 'vault'">
<div class="rm-storage-sub"><dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div> <div class="rm-storage-sub"><dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
<div class="rm-storage-sub"><dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div> <div class="rm-storage-sub"><dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
+2
View File
@@ -60,6 +60,8 @@ export interface TrashItem {
export interface StorageUsage { export interface StorageUsage {
totalSize: number totalSize: number
categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[] categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[]
builtinFontsSize: number
customFontsSize: number
assetsSize: number assetsSize: number
trashSize: number trashSize: number
calculatedAt: number calculatedAt: number
+16 -33
View File
@@ -45,6 +45,15 @@ async function search(selector: string, query: string) {
} }
describe('设置查找与重置', () => { describe('设置查找与重置', () => {
it('分类按约定排序,令牌归入账户与安全', async () => {
ws.settings = 'account'
vi.spyOn(api, 'tokens').mockResolvedValue([])
mount(SettingsDialog)
expect([...host!.querySelectorAll('.rm-settings-tab')].map(el => el.textContent)).toEqual(['外观','编辑器','快捷键','插件','存储空间','回收站','账户与安全','关于'])
await vi.waitFor(() => expect(host!.querySelector('#rm-setting-tokens')).not.toBeNull())
expect(searchSettings('API 令牌')).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'tokens', section: 'account' })]))
})
it('编辑器字号在输入时立即生效,无需回车或失焦', async () => { it('编辑器字号在输入时立即生效,无需回车或失焦', async () => {
ws.settings = 'editor' ws.settings = 'editor'
mount(SettingsDialog) mount(SettingsDialog)
@@ -150,14 +159,14 @@ describe('设置查找与重置', () => {
expect(allCommands().map(command => command.id)).not.toContain('app.shares') expect(allCommands().map(command => command.id)).not.toContain('app.shares')
ws.settings = 'appearance' ws.settings = 'appearance'
mount(SettingsDialog) mount(SettingsDialog)
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(9) expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(8)
expect(host!.textContent).not.toContain('分享') expect(host!.textContent).not.toContain('分享')
}) })
it('按名称、关键词及多词匹配,只索引静态说明', () => { it('按名称、关键词及多词匹配,只索引静态说明', () => {
expect(searchSettings(' ')).toEqual([]) expect(searchSettings(' ')).toEqual([])
expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })]) expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })])
expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'tokens', title: 'API 令牌' }), expect.objectContaining({ id: 'openapi' })])) expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'account', title: 'API 令牌' }), expect.objectContaining({ id: 'openapi' })]))
settings.cssSnippets = [{ id: 'private', name: 'private-snippet-name', enabled: true, css: ':root { --private: 1 }' }] settings.cssSnippets = [{ id: 'private', name: 'private-snippet-name', enabled: true, css: ':root { --private: 1 }' }]
expect(searchSettings('private')).toEqual([]) expect(searchSettings('private')).toEqual([])
}) })
@@ -223,33 +232,6 @@ describe('设置同步顺序', () => {
expect(localStorage.getItem('rawmark.settings')).toBe(cache) expect(localStorage.getItem('rawmark.settings')).toBe(cache)
}) })
it.each(['settings', 'fonts'] as const)('旧%s响应晚到时,不恢复已删除字体或覆盖新设置与缓存', async (delayed) => {
const font = { id: 'custom-' + 'c'.repeat(64), name: '已删除字体', format: 'woff2' as const }
const oldSettings = { uiFont: font.id, textFont: font.id, codeFont: font.id, fontSize: 18 }
let release!: () => void
const pending = new Promise<void>(resolve => { release = resolve })
vi.mocked(api.settings).mockImplementationOnce(async () => {
if (delayed === 'settings') await pending
return oldSettings
})
vi.mocked(api.fonts).mockImplementationOnce(async () => {
if (delayed === 'fonts') await pending
return [font]
})
const oldLoad = loadSettings()
vi.mocked(api.settings).mockResolvedValue({ uiFont: 'maple', textFont: 'maple', codeFont: 'maple', fontSize: 24 })
await loadSettings()
const cache = localStorage.getItem('rawmark.settings')
release()
await oldLoad
expect(settings).toMatchObject({ uiFont: 'maple', textFont: 'maple', codeFont: 'maple', fontSize: 24 })
expect(customFonts.value).toEqual([])
expect(document.querySelector('#rm-custom-fonts')!.textContent).not.toContain(font.id)
expect(document.documentElement.style.getPropertyValue('--rm-font-text')).not.toContain(font.id)
expect(document.documentElement.style.getPropertyValue('--rm-font-size')).toBe('24px')
expect(localStorage.getItem('rawmark.settings')).toBe(cache)
})
it.each(['settings', 'fonts'] as const)('本地保存完成后,迟到的旧%s响应不覆盖界面、缓存或清理期限', async delayed => { it.each(['settings', 'fonts'] as const)('本地保存完成后,迟到的旧%s响应不覆盖界面、缓存或清理期限', async delayed => {
let release!: () => void let release!: () => void
const pending = new Promise<void>(resolve => { release = resolve }) const pending = new Promise<void>(resolve => { release = resolve })
@@ -424,13 +406,13 @@ describe('快捷键查找', () => {
}) })
describe('应用存储与回收站', () => { describe('应用存储与回收站', () => {
const usage = { totalSize: 1024, categories: [{ id: 'vault' as const, size: 1024 }], assetsSize: 512, trashSize: 256, calculatedAt: 1700000000000 } const usage = { totalSize: 1024, categories: [{ id: 'vault' as const, size: 1024 }], builtinFontsSize: 0, customFontsSize: 0, assetsSize: 512, trashSize: 256, calculatedAt: 1700000000000 }
it('按需统计,防重复请求,刷新失败保留上次结果并展示错误', async () => { it('进入页面自动统计,防重复请求,刷新失败保留上次结果并展示错误', async () => {
let resolve!: (value: typeof usage) => void let resolve!: (value: typeof usage) => void
const storage = vi.spyOn(api, 'storage').mockImplementationOnce(() => new Promise(done => { resolve = done })) const storage = vi.spyOn(api, 'storage').mockImplementationOnce(() => new Promise(done => { resolve = done }))
ws.settings = 'application' ws.settings = 'storage'
mount(SettingsDialog) mount(SettingsDialog)
expect(storage).not.toHaveBeenCalled() expect(storage).toHaveBeenCalledTimes(1)
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-storage button')! const button = host!.querySelector<HTMLButtonElement>('#rm-setting-storage button')!
button.click() button.click()
await nextTick() await nextTick()
@@ -445,6 +427,7 @@ describe('应用存储与回收站', () => {
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误')) await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误'))
expect(host!.textContent).toContain('1 KiB') expect(host!.textContent).toContain('1 KiB')
}) })
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => { it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() } const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
vi.spyOn(api, 'trash').mockResolvedValue([item]) vi.spyOn(api, 'trash').mockResolvedValue([item])
+5 -5
View File
@@ -2,19 +2,18 @@ import type { IconName } from './Icon.vue'
import type { SettingsSection } from './workspace' import type { SettingsSection } from './workspace'
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
{ id: 'application', title: '应用', icon: 'grid', description: '查看存储空间及各类文件占用' },
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' }, { id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' }, { id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' }, { id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' }, { id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
{ id: 'tokens', title: 'API 令牌', icon: 'key', description: '创建或撤销 REST API 访问令牌' }, { id: 'storage', title: '存储空间', icon: 'grid', description: '笔记库、数据库、字体、主题与插件占用' },
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' }, { id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
{ id: 'account', title: '账户与安全', icon: 'user', description: '修改登录密码与退出登录' }, { id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' },
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' }, { id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
] ]
export const SETTING_ITEMS = { export const SETTING_ITEMS = {
'storage': { section: 'application', title: '存储空间', description: '按需统计笔记库、数据库、字体、主题、插件及其他文件的大小;包含外置笔记库。', keywords: '容量 占用 大小 附件 图片 回收站' }, 'storage': { section: 'storage', title: '空间占用', description: '自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。', keywords: '容量 占用 大小 附件 图片 回收站' },
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' }, 'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' }, 'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' }, 'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
@@ -34,11 +33,12 @@ export const SETTING_ITEMS = {
'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' }, 'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' },
'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' }, 'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' },
'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' }, 'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' },
'tokens': { section: 'account', title: 'API 令牌', description: '创建或撤销 REST API 访问令牌。', keywords: 'API REST 接口 自动化 安全' },
'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' }, 'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' },
'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' }, 'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' },
'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' }, 'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' },
'version': { section: 'about', title: '构建版本', description: '当前页面加载的应用版本。', keywords: 'RawMark 关于' }, 'version': { section: 'about', title: '构建版本', description: '当前页面加载的应用版本。', keywords: 'RawMark 关于' },
'openapi': { section: 'about', title: 'API 文档', description: '在「API 令牌」创建令牌后,通过 REST API 读取或写入笔记。', keywords: 'OpenAPI 接口 自动化' }, 'openapi': { section: 'about', title: 'API 文档', description: '在「账户与安全 → API 令牌」创建令牌后,通过 REST API 读取或写入笔记。', keywords: 'OpenAPI 接口 自动化' },
} satisfies Record<string, { section: SettingsSection; title: string; description: string; keywords: string }> } satisfies Record<string, { section: SettingsSection; title: string; description: string; keywords: string }>
export type SettingId = keyof typeof SETTING_ITEMS export type SettingId = keyof typeof SETTING_ITEMS
+2 -2
View File
@@ -36,7 +36,7 @@ export interface Tab {
conflictRev?: string conflictRev?: string
} }
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about' | 'application' export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'trash' | 'plugins' | 'about' | 'storage'
export interface Toast { export interface Toast {
id: number id: number
@@ -1104,7 +1104,7 @@ function registerBuiltinCommands() {
{ id: 'view.focus', title: '开启/关闭专注写作', when: hasNote, run: () => (ws.focusMode = !ws.focusMode) }, { id: 'view.focus', title: '开启/关闭专注写作', when: hasNote, run: () => (ws.focusMode = !ws.focusMode) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') }, { id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } }, { id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } },
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') }, { id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'account') },
]) { ]) {
registerCommand({ ...cmd, keys: typoraKeys(cmd.id) }) registerCommand({ ...cmd, keys: typoraKeys(cmd.id) })
} }
+9
View File
@@ -11,6 +11,13 @@ export default defineConfig({
apply: 'build', apply: 'build',
enforce: 'post', enforce: 'post',
generateBundle(_options, bundle) { generateBundle(_options, bundle) {
// 只统计应用内可选字体;Maple 与 KaTeX 不在 fonts.css 内。
const fontCSS = readFileSync(new URL('./src/themes/fonts.css', import.meta.url), 'utf8')
const fontPaths = new Set([...fontCSS.matchAll(/url\(['"]?(\.?\/?fonts\/[^)'"]+)/g)].map(match => match[1]!))
const size = [...fontPaths].reduce((total, path) => total + readFileSync(new URL('./src/themes/' + path, import.meta.url)).byteLength, 0)
const manifest = JSON.stringify({ optionalFontsSize: size })
this.emitFile({ type: 'asset', fileName: 'font-storage.json', source: manifest })
// 缓存列表由产物生成,覆盖懒加载资源,旧页面更新后仍能加载其依赖。 // 缓存列表由产物生成,覆盖懒加载资源,旧页面更新后仍能加载其依赖。
const files = Object.keys(bundle).filter((name) => name === 'index.html' || name.startsWith('assets/')).sort() const files = Object.keys(bundle).filter((name) => name === 'index.html' || name.startsWith('assets/')).sort()
const version = createHash('sha256') const version = createHash('sha256')
@@ -22,6 +29,8 @@ export default defineConfig({
version.update(readFileSync(new URL('./public/' + name, import.meta.url))) version.update(readFileSync(new URL('./public/' + name, import.meta.url)))
files.push(name) files.push(name)
} }
version.update(manifest)
files.push('font-storage.json')
const buildVersion = version.digest('hex').slice(0, 16) const buildVersion = version.digest('hex').slice(0, 16)
const entry = bundle['index.html'] const entry = bundle['index.html']
if (!entry || entry.type !== 'asset' || typeof entry.source !== 'string') throw new Error('缺少应用入口 HTML,无法写入构建版本') if (!entry || entry.type !== 'asset' || typeof entry.source !== 'string') throw new Error('缺少应用入口 HTML,无法写入构建版本')