diff --git a/README.md b/README.md
index e6d0f6b..e4c91ae 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ docker run -d --name rawmark \
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。
- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
-- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
+- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面、正文与代码字体可分别选择,各字体下拉均支持导入 TTF/OTF/WOFF/WOFF2(单个不超过30 MB)及删除;Maple为不可删除的默认字体,删除正在使用的字体会恢复Maple,导入字体保存在服务器数据目录的fonts/并跨设备共用。界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go
index 262d730..727a441 100644
--- a/cmd/rawmark/main.go
+++ b/cmd/rawmark/main.go
@@ -83,6 +83,7 @@ func run(addr string) error {
srv := api.New(v, am, st, hub, idx)
srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.PluginsDir = filepath.Join(dataDir, "plugins")
+ srv.FontsDir = filepath.Join(dataDir, "fonts")
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(web.Dist()),
diff --git a/internal/api/api.go b/internal/api/api.go
index 0e3053c..de907ca 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -40,6 +40,7 @@ type Server struct {
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
ThemesDir string
PluginsDir string
+ FontsDir string
pluginsMu sync.Mutex
}
@@ -98,6 +99,10 @@ func (s *Server) routes() []route {
{"GET /api/v1/settings", authed, s.getSettings},
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
+ {"GET /api/v1/fonts", sessionOnly, s.listFonts},
+ {"POST /api/v1/fonts", sessionOnly, s.importFont},
+ {"GET /api/v1/fonts/{id}/file", sessionOnly, s.fontFile},
+ {"DELETE /api/v1/fonts/{id}", sessionOnly, s.deleteFont},
{"GET /api/v1/plugins", sessionOnly, s.listPlugins},
{"POST /api/v1/plugins", sessionOnly, s.installPlugin},
{"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin},
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index 0823d3c..7d9218f 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -130,6 +130,7 @@ func newFixture(t *testing.T) *fixture {
server := New(v, am, st, hub, idx)
server.ThemesDir = filepath.Join(dir, "themes")
server.PluginsDir = filepath.Join(dir, "plugins")
+ server.FontsDir = filepath.Join(dir, "fonts")
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
t.Cleanup(srv.Close)
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
diff --git a/internal/api/fonts.go b/internal/api/fonts.go
new file mode 100644
index 0000000..7e1b839
--- /dev/null
+++ b/internal/api/fonts.go
@@ -0,0 +1,254 @@
+package api
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "rawmark/internal/events"
+)
+
+const maxFontBytes = 30 << 20
+
+var fontIDPattern = regexp.MustCompile(`^custom-[a-f0-9]{64}$`)
+
+type fontInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Format string `json:"format"`
+}
+
+func (s *Server) openFont(id string) (*os.Root, fontInfo, error) {
+ var info fontInfo
+ if !fontIDPattern.MatchString(id) || s.FontsDir == "" {
+ return nil, info, fs.ErrNotExist
+ }
+ root, err := os.OpenRoot(s.FontsDir)
+ if err != nil {
+ return nil, info, err
+ }
+ defer root.Close()
+ dir, err := root.OpenRoot(id)
+ if err != nil {
+ return nil, info, err
+ }
+ data, err := dir.ReadFile("manifest.json")
+ if err == nil {
+ err = json.Unmarshal(data, &info)
+ }
+ if err == nil && (info.ID != id || info.Name == "" || !validFontFormat(info.Format)) {
+ err = fmt.Errorf("字体安装信息无效")
+ }
+ if err != nil {
+ dir.Close()
+ return nil, info, err
+ }
+ return dir, info, nil
+}
+
+func validFontFormat(format string) bool {
+ return format == "ttf" || format == "otf" || format == "woff" || format == "woff2"
+}
+
+func (s *Server) listFonts(w http.ResponseWriter, r *http.Request) {
+ s.settingsMu.Lock()
+ defer s.settingsMu.Unlock()
+ fonts := []fontInfo{}
+ if s.FontsDir != "" {
+ entries, err := os.ReadDir(s.FontsDir)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ internalError(w, r, err)
+ return
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() || !fontIDPattern.MatchString(entry.Name()) {
+ continue
+ }
+ dir, info, err := s.openFont(entry.Name())
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ dir.Close()
+ fonts = append(fonts, info)
+ }
+ }
+ writeJSON(w, 200, map[string]any{"fonts": fonts})
+}
+
+func (s *Server) importFont(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxFontBytes+(1<<20))
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ writeError(w, 400, "无法读取字体文件,单个文件上限 30 MB")
+ return
+ }
+ defer r.MultipartForm.RemoveAll()
+ f, header, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, 400, "请选择字体文件")
+ return
+ }
+ defer f.Close()
+ format := strings.TrimPrefix(strings.ToLower(filepath.Ext(header.Filename)), ".")
+ data, err := io.ReadAll(io.LimitReader(f, maxFontBytes+1))
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ if len(data) > maxFontBytes {
+ writeError(w, 413, "字体文件超过 30 MB")
+ return
+ }
+ magic := map[string]string{"ttf": "\x00\x01\x00\x00", "otf": "OTTO", "woff": "wOFF", "woff2": "wOF2"}
+ if !validFontFormat(format) || len(data) < 12 || string(data[:4]) != magic[format] {
+ writeError(w, 400, "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件")
+ return
+ }
+ if s.FontsDir == "" {
+ writeError(w, 503, "字体目录未配置")
+ return
+ }
+ hash := sha256.Sum256(data)
+ id := "custom-" + hex.EncodeToString(hash[:])
+ info := fontInfo{id, strings.TrimSuffix(filepath.Base(header.Filename), filepath.Ext(header.Filename)), format}
+ if strings.TrimSpace(info.Name) == "" || len(info.Name) > 200 || strings.ContainsAny(info.Name, "\x00\r\n") {
+ writeError(w, 400, "字体文件名无效或过长")
+ return
+ }
+ s.settingsMu.Lock()
+ defer s.settingsMu.Unlock()
+ if dir, existing, err := s.openFont(id); err == nil {
+ dir.Close()
+ writeJSON(w, 200, existing)
+ return
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ internalError(w, r, err)
+ return
+ }
+ if err := os.MkdirAll(s.FontsDir, 0o755); err != nil {
+ internalError(w, r, err)
+ return
+ }
+ stage, err := os.MkdirTemp(s.FontsDir, ".import-")
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ defer os.RemoveAll(stage)
+ manifest, err := json.Marshal(info)
+ if err == nil {
+ err = os.WriteFile(filepath.Join(stage, "manifest.json"), manifest, 0o600)
+ }
+ if err == nil {
+ err = os.WriteFile(filepath.Join(stage, "font."+format), data, 0o600)
+ }
+ if err == nil {
+ err = os.Rename(stage, filepath.Join(s.FontsDir, id))
+ }
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ s.hub.Broadcast(events.Event{Type: "settings"})
+ writeJSON(w, 201, info)
+}
+
+func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
+ s.settingsMu.Lock()
+ dir, info, err := s.openFont(r.PathValue("id"))
+ if errors.Is(err, fs.ErrNotExist) {
+ s.settingsMu.Unlock()
+ writeError(w, 404, "字体不存在")
+ return
+ }
+ if err != nil {
+ s.settingsMu.Unlock()
+ internalError(w, r, err)
+ return
+ }
+ defer dir.Close()
+ f, err := dir.Open("font." + info.Format)
+ s.settingsMu.Unlock()
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ defer f.Close()
+ stat, err := f.Stat()
+ if err != nil || !stat.Mode().IsRegular() {
+ if err == nil {
+ err = fmt.Errorf("字体必须为普通文件")
+ }
+ internalError(w, r, err)
+ return
+ }
+ w.Header().Set("Content-Type", "font/"+info.Format)
+ http.ServeContent(w, r, "font."+info.Format, stat.ModTime(), f)
+}
+
+func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if id == "maple" {
+ writeError(w, 400, "Maple 是默认字体,不可删除")
+ return
+ }
+ s.settingsMu.Lock()
+ defer s.settingsMu.Unlock()
+ dir, _, err := s.openFont(id)
+ if errors.Is(err, fs.ErrNotExist) {
+ writeError(w, 404, "字体不存在")
+ return
+ }
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ dir.Close()
+ settings, err := s.readSettings()
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ for _, key := range []string{"uiFont", "textFont", "codeFont"} {
+ var selected string
+ if json.Unmarshal(settings[key], &selected) == nil && selected == id {
+ settings[key] = json.RawMessage(`"maple"`)
+ }
+ }
+ data, err := json.Marshal(settings)
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ root, err := os.OpenRoot(s.FontsDir)
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ defer root.Close()
+ // 先撤下资源;设置写失败时恢复,避免留下指向已删除字体的设置。
+ removed := ".remove-" + id
+ if err := root.Rename(id, removed); err != nil {
+ internalError(w, r, err)
+ return
+ }
+ if err := s.store.SetSetting(settingsKey, string(data)); err != nil {
+ internalError(w, r, errors.Join(err, root.Rename(removed, id)))
+ return
+ }
+ s.hub.Broadcast(events.Event{Type: "settings"})
+ if err := root.RemoveAll(removed); err != nil {
+ internalError(w, r, fmt.Errorf("字体已停用,但文件清理失败:%w", err))
+ return
+ }
+ writeJSON(w, 200, map[string]bool{"ok": true})
+}
diff --git a/internal/api/fonts_test.go b/internal/api/fonts_test.go
new file mode 100644
index 0000000..0f64d99
--- /dev/null
+++ b/internal/api/fonts_test.go
@@ -0,0 +1,173 @@
+package api
+
+import (
+ "bytes"
+ "database/sql"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func uploadTestFont(t *testing.T, f *fixture, client *http.Client, name string, data []byte) *http.Response {
+ t.Helper()
+ var body bytes.Buffer
+ form := multipart.NewWriter(&body)
+ file, err := form.CreateFormFile("file", name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.Write(data); err != nil {
+ t.Fatal(err)
+ }
+ if err := form.Close(); err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest("POST", f.url+"/api/v1/fonts", &body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", form.FormDataContentType())
+ res, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res
+}
+
+func testFontData(t *testing.T) []byte {
+ t.Helper()
+ data, err := os.ReadFile("../../web/src/themes/fonts/maple/regular-00.woff2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+}
+
+func TestFontImportReuseReadAndDelete(t *testing.T) {
+ f := newFixture(t)
+ data := testFontData(t)
+ info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "示例字体.woff2", data))
+ if !fontIDPattern.MatchString(info.ID) || info.Name != "示例字体" || info.Format != "woff2" {
+ t.Fatalf("字体信息错误:%+v", info)
+ }
+ again := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "renamed.woff2", data))
+ if again != info {
+ t.Fatalf("相同内容未复用原字体:%+v", again)
+ }
+ list := decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil))
+ if len(list.Fonts) != 1 || list.Fonts[0] != info {
+ t.Fatalf("列表不符:%+v", list)
+ }
+ res := f.do(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil)
+ got, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil || !bytes.Equal(got, data) || res.Header.Get("Content-Type") != "font/woff2" {
+ t.Fatal("字体文件不一致", err)
+ }
+ f.do(f.admin, "PATCH", "/api/v1/settings", `{"uiFont":"`+info.ID+`","textFont":"`+info.ID+`","codeFont":"`+info.ID+`","plugin.data":{"keep":true}}`, nil).Body.Close()
+ f.do(f.admin, "DELETE", "/api/v1/fonts/"+info.ID, "", nil).Body.Close()
+ settings := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
+ for _, key := range []string{"uiFont", "textFont", "codeFont"} {
+ if settings[key] != "maple" {
+ t.Fatalf("%s 未恢复默认:%v", key, settings[key])
+ }
+ }
+ if settings["plugin.data"] == nil {
+ t.Fatal("无关设置被删除")
+ }
+ if code := f.status(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil); code != 404 {
+ t.Fatal("已删除字体仍可读取", code)
+ }
+ list = decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil))
+ if len(list.Fonts) != 0 {
+ t.Fatal("已删除字体仍在列表")
+ }
+}
+
+func TestFontBoundaries(t *testing.T) {
+ f := newFixture(t)
+ guest := newClient()
+ for _, endpoint := range []string{"/api/v1/fonts", "/api/v1/fonts/maple/file"} {
+ if code := f.status(guest, "GET", endpoint, "", nil); code != 401 {
+ t.Fatal("匿名字体访问未拒绝", code)
+ }
+ }
+ res := uploadTestFont(t, f, guest, "font.woff2", testFontData(t))
+ res.Body.Close()
+ if res.StatusCode != 401 {
+ t.Fatal("匿名上传未拒绝", res.StatusCode)
+ }
+ if code := f.status(f.admin, "DELETE", "/api/v1/fonts/maple", "", nil); code != 400 {
+ t.Fatal("Maple不应可删除", code)
+ }
+ for _, tc := range []struct {
+ name string
+ data []byte
+ }{
+ {"font.exe", testFontData(t)}, {"font.ttf", testFontData(t)}, {".woff2", testFontData(t)}, {"font.woff2", []byte("invalid")},
+ } {
+ res := uploadTestFont(t, f, f.admin, tc.name, tc.data)
+ res.Body.Close()
+ if res.StatusCode != 400 {
+ t.Fatalf("非法字体 %s 未拒绝:%d", tc.name, res.StatusCode)
+ }
+ }
+ res = uploadTestFont(t, f, f.admin, "large.woff2", bytes.Repeat([]byte{'x'}, maxFontBytes+1))
+ res.Body.Close()
+ if res.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatal("超大字体未拒绝", res.StatusCode)
+ }
+ token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"fonts","scope":"write"}`, nil))
+ for _, tc := range []struct{ method, path string }{{"GET", "/api/v1/fonts"}, {"DELETE", "/api/v1/fonts/maple"}} {
+ if code := f.status(guest, tc.method, tc.path, "", map[string]string{"Authorization": "Bearer " + token.Token}); code != 403 {
+ t.Fatal("令牌不应管理字体", code)
+ }
+ }
+ info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "font.woff2", testFontData(t)))
+ dir := filepath.Join(filepath.Dir(f.root), "fonts", info.ID)
+ if err := os.Remove(filepath.Join(dir, "font.woff2")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(filepath.Dir(f.root), "rawmark.db"), filepath.Join(dir, "font.woff2")); err != nil {
+ t.Fatal(err)
+ }
+ if code := f.status(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil); code != 500 {
+ t.Fatal("字体不应读取目录外的数据", code)
+ }
+}
+
+func TestFontDeleteRollsBackWhenSettingsWriteFails(t *testing.T) {
+ f := newFixture(t)
+ info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "font.woff2", testFontData(t)))
+ f.do(f.admin, "PATCH", "/api/v1/settings", `{"uiFont":"`+info.ID+`"}`, nil).Body.Close()
+ db, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TRIGGER reject_font_settings BEFORE UPDATE ON settings BEGIN SELECT RAISE(ABORT, 'test write failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+ if code := f.status(f.admin, "DELETE", "/api/v1/fonts/"+info.ID, "", nil); code != 500 {
+ t.Fatal("设置失败应报告错误", code)
+ }
+ res := f.do(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil)
+ res.Body.Close()
+ settings := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
+ if settings["uiFont"] != info.ID {
+ t.Fatal("失败后设置未保留")
+ }
+ entries, err := os.ReadDir(filepath.Join(filepath.Dir(f.root), "fonts"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), ".remove-") {
+ t.Fatal("失败后字体未恢复")
+ }
+ }
+}
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index 5baaff3..f5690f8 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -2022,6 +2022,139 @@
}
}
}
+ },
+ "/fonts": {
+ "get": {
+ "summary": "列出已导入字体",
+ "tags": [
+ "设置"
+ ],
+ "security": [
+ {
+ "cookie": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功"
+ },
+ "401": {
+ "description": "未登录"
+ },
+ "403": {
+ "description": "只接受登录会话"
+ }
+ }
+ },
+ "post": {
+ "summary": "导入字体,按内容去重(TTF/OTF/WOFF/WOFF2,上限30 MB)",
+ "tags": [
+ "设置"
+ ],
+ "security": [
+ {
+ "cookie": []
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "成功"
+ },
+ "401": {
+ "description": "未登录"
+ },
+ "403": {
+ "description": "只接受登录会话"
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "file"
+ ],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/fonts/{id}": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "delete": {
+ "summary": "删除导入字体,将使用它的界面、正文和代码字体恢复Maple;Maple不可删除",
+ "tags": [
+ "设置"
+ ],
+ "security": [
+ {
+ "cookie": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功"
+ },
+ "401": {
+ "description": "未登录"
+ },
+ "403": {
+ "description": "只接受登录会话"
+ }
+ }
+ }
+ },
+ "/fonts/{id}/file": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "get": {
+ "summary": "读取导入字体文件",
+ "tags": [
+ "设置"
+ ],
+ "security": [
+ {
+ "cookie": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功"
+ },
+ "401": {
+ "description": "未登录"
+ },
+ "403": {
+ "description": "只接受登录会话"
+ }
+ }
+ }
}
},
"components": {
diff --git a/web/src/app/FontPicker.vue b/web/src/app/FontPicker.vue
new file mode 100644
index 0000000..cfdc74b
--- /dev/null
+++ b/web/src/app/FontPicker.vue
@@ -0,0 +1,113 @@
+
+
+
+ {{ error }}