编辑器三件套(参考 xLog):CodeMirror 源码模式 + 外链图片一键转存 + 上传进度/前置校验

- md 页签从裸 textarea 升级 CodeMirror 6:markdown 语法高亮、行内历史,粘贴/拖图直传;
  markdown 仍是唯一真相源,转存/切换用 syncCM 全量灌回,工具栏选区辅助改为面向 CM
- POST /api/admin/files/import:外链抓取复用 linkmeta SSRF 防护拨号与跳转限制,
  二进制传输放宽 30s;类型按内容嗅探反查白名单,与手动上传共用去重/落盘(persistFile 抽取共享)
- 上传走 XHR 进度回调 + 编辑器 toast 栈:类型/大小前置校验(与后端白名单一致)、逐文件进度
- persistFile 去重路径补 URL 解析——此前命中去重返回的行没有 URL,转存替换会把正文图链清空(已修复受损数据)
- hub nil 安全(测试环境未装配时不 panic)
This commit is contained in:
Sakurasan
2026-09-28 23:42:24 +08:00
parent b93fe9847c
commit f7a2e83727
9 changed files with 553 additions and 99 deletions
+2
View File
@@ -18,3 +18,5 @@ ONE_TELEGRAM_BOT_TOKEN=
# 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名) # 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名)
ONE_GOOGLE_CLIENT_ID= ONE_GOOGLE_CLIENT_ID=
ONE_GOOGLE_CLIENT_SECRET= ONE_GOOGLE_CLIENT_SECRET=
ONE_SECRET=# 任意字符串,用于加密
+109 -3
View File
@@ -13,7 +13,9 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -23,6 +25,7 @@ import (
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/httpx" "oneblog/internal/httpx"
"oneblog/internal/hub" "oneblog/internal/hub"
"oneblog/internal/linkmeta"
"oneblog/internal/model" "oneblog/internal/model"
"oneblog/internal/storage" "oneblog/internal/storage"
"oneblog/internal/store" "oneblog/internal/store"
@@ -79,6 +82,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects)) mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects))
mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID)) mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID))
mux.HandleFunc("/api/admin/files", a.guard(a.files)) mux.HandleFunc("/api/admin/files", a.guard(a.files))
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID)) mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments)) mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
@@ -727,6 +731,12 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
} }
sum := hex.EncodeToString(hasher.Sum(nil)) sum := hex.EncodeToString(hasher.Sum(nil))
return a.persistFile(ctx, fh.Filename, ext, mime, tmp.Name(), size, sum)
}
// persistFile 落库共享段:内容哈希做 key(同年月分目录)、去重复用、
// Put 对象存储、建行。storeOne(本地上传)与 importOne(外链转存)共用。
func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string, size int64, sum string) (model.File, error) {
key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext) key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key—— // S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
// 数据库必须记录同样的完整 key,直链才不会 404 // 数据库必须记录同样的完整 key,直链才不会 404
@@ -734,12 +744,15 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
key = p + "/" + key key = p + "/" + key
} }
// 内容去重:同一份内容只存一份,复用已有行 // 内容去重:同一份内容只存一份,复用已有行。
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
if exist, err := a.Store.GetFileByKey(key); err == nil { if exist, err := a.Store.GetFileByKey(key); err == nil {
exist.URL = storage.FileURL(exist.Store, exist.Key, a.Cfg.UploadsPublicBase)
return exist, nil return exist, nil
} }
f, err := os.Open(tmp.Name()) f, err := os.Open(tmpPath)
if err != nil { if err != nil {
return model.File{}, err return model.File{}, err
} }
@@ -750,7 +763,7 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
created, err := a.Store.CreateFile(model.File{ created, err := a.Store.CreateFile(model.File{
Key: key, Key: key,
Name: fh.Filename, Name: name,
Mime: mime, Mime: mime,
Size: size, Size: size,
SHA256: sum, SHA256: sum,
@@ -763,6 +776,99 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
return created, nil return created, nil
} }
// mimeToExt 是 allowFileExt 的反向映射:外链转存时内容嗅探出 mime,
// 反推扩展名(URL 本身可能不带后缀或后缀不可信)。
var mimeToExt = func() map[string]string {
m := make(map[string]string, len(allowFileExt))
for ext, mime := range allowFileExt {
m[mime] = ext
}
return m
}()
// importFiles 外链转存:POST /api/admin/files/import {"urls": [...]}。
// 站主把别处的图片贴进正文后一键搬进自己的存储——与手动上传同一套
// 白名单、内容嗅探与内容去重;抓取走 linkmeta 的 SSRF 防护拨号。
func (a *API) importFiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
return
}
var in struct {
URLs []string `json:"urls"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if len(in.URLs) == 0 || len(in.URLs) > 20 {
httpx.BadRequest(w, "urls 需要1-20个")
return
}
// files 带上 source(原址),前端按它做正文替换
type imported struct {
Source string `json:"source"`
File model.File `json:"file"`
}
files := make([]imported, 0, len(in.URLs))
errs := map[string]string{}
for _, u := range in.URLs {
f, err := a.importOne(r.Context(), u)
if err != nil {
if bu, ok := err.(badUpload); ok {
errs[u] = string(bu)
} else {
errs[u] = err.Error()
}
continue
}
files = append(files, imported{Source: u, File: f})
}
httpx.OK(w, map[string]any{"files": files, "errors": errs})
}
func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error) {
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
data, ct, err := linkmeta.FetchBytes(cctx, rawURL, maxFileUpload)
if err != nil {
return model.File{}, fmt.Errorf("抓取失败:%w", err)
}
if len(data) == 0 {
return model.File{}, badUpload("空内容")
}
// 类型必须落在本站白名单里:嗅探优先(不信响应头,更不信 URL 后缀)
detected := strings.SplitN(http.DetectContentType(data[:512]), ";", 2)[0]
ext, ok := mimeToExt[detected]
if !ok {
return model.File{}, badUpload("不支持的类型 " + detected)
}
_ = ct
// 落临时文件:persistFile / S3 PutObject 都要确定的文件与长度
tmp, err := os.CreateTemp("", "one-import-*")
if err != nil {
return model.File{}, err
}
defer os.Remove(tmp.Name())
size, err := tmp.Write(data)
if err != nil {
tmp.Close()
return model.File{}, err
}
if err := tmp.Close(); err != nil {
return model.File{}, err
}
sum := sha256.Sum256(data)
// 名字取 URL 路径末段(仅用于文件管理页展示,不参与存储路径)
name := rawURL
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
if base := path.Base(u.Path); base != "" && base != "/" && base != "." {
name = base
}
}
return a.persistFile(ctx, name, ext, detected, tmp.Name(), int64(size), hex.EncodeToString(sum[:]))
}
func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/")) id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/"))
if err != nil { if err != nil {
+8
View File
@@ -18,6 +18,11 @@ func New() *Hub {
// Subscribe 订阅某主题;返回信号 channel 和退订函数。 // Subscribe 订阅某主题;返回信号 channel 和退订函数。
// channel 容量 1:订阅者处理不过来时新信号直接丢弃(合并刷新)。 // channel 容量 1:订阅者处理不过来时新信号直接丢弃(合并刷新)。
func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) { func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) {
// nil hub(测试/未装配场景)静默降级:永远收不到信号的通道
if h == nil {
ch := make(chan struct{})
return ch, func() {}
}
ch := make(chan struct{}, 1) ch := make(chan struct{}, 1)
h.mu.Lock() h.mu.Lock()
if h.subs[topic] == nil { if h.subs[topic] == nil {
@@ -38,6 +43,9 @@ func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) {
// Broadcast 唤醒某主题的全部订阅者;积压的订阅者不阻塞。 // Broadcast 唤醒某主题的全部订阅者;积压的订阅者不阻塞。
func (h *Hub) Broadcast(topic int64) { func (h *Hub) Broadcast(topic int64) {
if h == nil {
return
}
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
for ch := range h.subs[topic] { for ch := range h.subs[topic] {
+73 -19
View File
@@ -75,25 +75,7 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
return nil, errors.New("linkmeta: empty host") return nil, errors.New("linkmeta: empty host")
} }
dial := f.Dial client := f.client()
if dial == nil {
dial = safeDial
}
timeout := f.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
client := &http.Client{
Transport: &http.Transport{
DialContext: dial,
TLSHandshakeTimeout: 3 * time.Second,
// 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
ForceAttemptHTTP2: false,
},
Timeout: timeout,
CheckRedirect: limitRedirects,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -125,6 +107,78 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
return card, nil return card, nil
} }
// client 组装带 SSRF 防护的 HTTP 客户端(Fetch 与 FetchBytes 共用)
func (f *Fetcher) client() *http.Client {
dial := f.Dial
if dial == nil {
dial = safeDial
}
timeout := f.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return &http.Client{
Transport: &http.Transport{
DialContext: dial,
TLSHandshakeTimeout: 3 * time.Second,
// 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
ForceAttemptHTTP2: false,
},
Timeout: timeout,
CheckRedirect: limitRedirects,
}
}
// FetchBytes 抓二进制内容(外链图片转存用):与 Fetch 共用同一套
// SSRF 防护与跳转限制,字节数有 maxBytes 硬上限。
// 返回内容与 Content-Type(响应头缺失时用内容嗅探兜底)。
func FetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
return (&Fetcher{}).fetchBytes(ctx, rawURL, maxBytes)
}
func (f *Fetcher) fetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, "", fmt.Errorf("linkmeta: bad url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
}
if u.Host == "" {
return nil, "", errors.New("linkmeta: empty host")
}
client := f.client()
// 二进制传输放宽时限:大图慢链路 5 秒的元信息默认值不够用
if f.Timeout <= 0 {
client.Timeout = 30 * time.Second
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("linkmeta: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("linkmeta: status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("linkmeta: read: %w", err)
}
if int64(len(data)) > maxBytes {
return nil, "", fmt.Errorf("linkmeta: exceeds %d bytes", maxBytes)
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(data)
}
return data, ct, nil
}
func limitRedirects(req *http.Request, via []*http.Request) error { func limitRedirects(req *http.Request, via []*http.Request) error {
if len(via) > maxRedirects { if len(via) > maxRedirects {
return fmt.Errorf("linkmeta: too many redirects") return fmt.Errorf("linkmeta: too many redirects")
+5
View File
@@ -10,6 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@milkdown/crepe": "^7.22.1", "@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"vue": "^3.4.0", "vue": "^3.4.0",
+63 -48
View File
@@ -8,6 +8,21 @@ importers:
.: .:
dependencies: dependencies:
'@codemirror/commands':
specifier: ^6.11.1
version: 6.11.1
'@codemirror/lang-markdown':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
'@codemirror/state':
specifier: ^6.7.6
version: 6.7.6
'@codemirror/view':
specifier: ^6.43.13
version: 6.43.13
'@milkdown/crepe': '@milkdown/crepe':
specifier: ^7.22.1 specifier: ^7.22.1
version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3) version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
@@ -140,14 +155,14 @@ packages:
'@codemirror/search@6.7.2': '@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
'@codemirror/state@6.7.5': '@codemirror/state@6.7.6':
resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/theme-one-dark@6.1.3': '@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.43.12': '@codemirror/view@6.43.13':
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -1118,15 +1133,15 @@ snapshots:
'@codemirror/autocomplete@6.20.3': '@codemirror/autocomplete@6.20.3':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@codemirror/commands@6.11.1': '@codemirror/commands@6.11.1':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4': '@codemirror/lang-angular@0.1.4':
@@ -1147,7 +1162,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/css': 1.3.6 '@lezer/css': 1.3.6
@@ -1155,7 +1170,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/go': 1.0.1 '@lezer/go': 1.0.1
@@ -1165,8 +1180,8 @@ snapshots:
'@codemirror/lang-css': 6.3.1 '@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5 '@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/css': 1.3.6 '@lezer/css': 1.3.6
'@lezer/html': 1.3.13 '@lezer/html': 1.3.13
@@ -1181,8 +1196,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7 '@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5 '@lezer/javascript': 1.5.5
@@ -1191,8 +1206,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1215,8 +1230,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1226,8 +1241,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2 '@lezer/markdown': 1.7.2
@@ -1235,7 +1250,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/php': 1.0.6 '@lezer/php': 1.0.6
@@ -1243,7 +1258,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/python': 1.1.19 '@lezer/python': 1.1.19
@@ -1256,7 +1271,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/lang-css': 6.3.1 '@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/sass': 1.1.0 '@lezer/sass': 1.1.0
@@ -1264,7 +1279,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1289,8 +1304,8 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/xml': 1.0.6 '@lezer/xml': 1.0.6
@@ -1298,7 +1313,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1332,8 +1347,8 @@ snapshots:
'@codemirror/language@6.12.4': '@codemirror/language@6.12.4':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1345,30 +1360,30 @@ snapshots:
'@codemirror/lint@6.9.7': '@codemirror/lint@6.9.7':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
crelt: 1.0.7 crelt: 1.0.7
'@codemirror/search@6.7.2': '@codemirror/search@6.7.2':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
crelt: 1.0.7 crelt: 1.0.7
'@codemirror/state@6.7.5': '@codemirror/state@6.7.6':
dependencies: dependencies:
'@marijn/find-cluster-break': 1.0.4 '@marijn/find-cluster-break': 1.0.4
'@codemirror/theme-one-dark@6.1.3': '@codemirror/theme-one-dark@6.1.3':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@codemirror/view@6.43.12': '@codemirror/view@6.43.13':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
crelt: 1.0.7 crelt: 1.0.7
style-mod: 4.1.4 style-mod: 4.1.4
w3c-keyname: 2.2.8 w3c-keyname: 2.2.8
@@ -1550,11 +1565,11 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {} '@marijn/find-cluster-break@1.0.4': {}
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)': '@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@floating-ui/dom': 1.8.0 '@floating-ui/dom': 1.8.0
'@milkdown/core': 7.22.1 '@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1 '@milkdown/ctx': 7.22.1
@@ -1594,10 +1609,10 @@ snapshots:
'@codemirror/commands': 6.11.1 '@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/language-data': 6.5.2 '@codemirror/language-data': 6.5.2
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/theme-one-dark': 6.1.3 '@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3) '@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@types/lodash-es': 4.17.12 '@types/lodash-es': 4.17.12
clsx: 2.1.1 clsx: 2.1.1
codemirror: 6.0.2 codemirror: 6.0.2
@@ -1621,9 +1636,9 @@ snapshots:
'@milkdown/exception@7.22.1': {} '@milkdown/exception@7.22.1': {}
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)': '@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies: dependencies:
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3) '@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@milkdown/core': 7.22.1 '@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1 '@milkdown/ctx': 7.22.1
'@milkdown/exception': 7.22.1 '@milkdown/exception': 7.22.1
@@ -2036,8 +2051,8 @@ snapshots:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7 '@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2 '@codemirror/search': 6.7.2
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
commander@15.0.0: {} commander@15.0.0: {}
+239 -24
View File
@@ -2,9 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { site } from '../site'
import { Crepe, CrepeFeature } from '@milkdown/crepe' import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
import { EditorView as CMView, keymap } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { markdown as markdownLang } from '@codemirror/lang-markdown'
import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -184,6 +189,10 @@ onBeforeUnmount(() => {
crepe.destroy() crepe.destroy()
crepe = null crepe = null
} }
if (cmView) {
cmView.destroy()
cmView = null
}
}) })
// ---------- mode switching ---------- // ---------- mode switching ----------
@@ -195,6 +204,10 @@ async function switchMode(next) {
crepe.destroy() crepe.destroy()
crepe = null crepe = null
} }
// CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
await nextTick()
mountCM()
syncCM()
} else { } else {
// MD → WYSIWYG: spin up crepe with the current markdown // MD → WYSIWYG: spin up crepe with the current markdown
await nextTick() await nextTick()
@@ -399,6 +412,76 @@ function clearCover() {
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ---------- // ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null) const mdPane = ref(null)
// ---------- CodeMirror 源码模式 ----------
// md 页签不是裸 textarea:语法高亮 + 行内历史(⌘Z),粘贴/拖图片直接上传。
// markdown 仍是唯一真相源——CM 的改动写回 form.content_md,
// 外部改动(转存替换、wysiwyg 切回)用 syncCM 全量灌回。
let cmView = null
const cmTheme = CMView.theme({
'&': { color: 'var(--admin-ink)', backgroundColor: 'transparent', fontSize: '13.5px' },
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '1.75', padding: '12px 0 40vh' },
'.cm-scroller': { overflow: 'auto', maxHeight: '70vh' },
'.cm-line': { padding: '0 2px' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--admin-accent)' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 18%, transparent) !important' },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 6%, transparent)' },
'.cm-heading, .cm-header': { color: 'var(--admin-accent)', fontWeight: '600' },
'.cm-link, .cm-url': { color: 'var(--admin-accent)', textDecoration: 'underline' },
'.cm-emphasis': { fontStyle: 'italic' },
'.cm-strong': { fontWeight: '700' },
'.cm-code, .cm-monospace': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: 'var(--admin-muted)' }
})
function handleEditorFiles(files, view) {
const list = [...files].filter((f) => f.type.startsWith('image/'))
if (!list.length) return false
view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' }
})
;[...list].forEach((f) => uploadOne(f, { insertImage: true }))
return true
}
function mountCM() {
if (cmView || !mdPane.value) return
cmView = new CMView({
parent: mdPane.value,
state: EditorState.create({
doc: form.content_md,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
markdownLang(),
cmTheme,
CMView.updateListener.of((u) => {
if (u.docChanged) form.content_md = u.state.doc.toString()
}),
CMView.domEventHandlers({
paste: (e, view) => handleEditorFiles(e.clipboardData?.files, view),
drop: (e, view) => {
if (e.dataTransfer?.files?.length) {
e.preventDefault()
return handleEditorFiles(e.dataTransfer.files, view)
}
return false
}
})
]
})
})
}
// 用 form.content_md 的当前值整段灌回(进入 md 页签、转存替换后调用)
function syncCM() {
if (!cmView) return
const cur = cmView.state.doc.toString()
if (cur === form.content_md) return
cmView.dispatch({
changes: { from: 0, to: cur.length, insert: form.content_md }
})
}
const showLinkInput = ref(false) const showLinkInput = ref(false)
const showImageInput = ref(false) const showImageInput = ref(false)
const linkText = ref('') const linkText = ref('')
@@ -407,22 +490,31 @@ const imageUrl = ref('')
const imageAlt = ref('') const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) { function insertAtCursor(text, selStart, selEnd) {
if (cmView) {
if (selStart == null) {
const s = cmView.state.selection.main
selStart = s.from
selEnd = s.to
}
cmView.dispatch({
changes: { from: selStart, to: selEnd, insert: text },
selection: { anchor: selStart + text.length }
})
cmView.focus()
return
}
const before = form.content_md.slice(0, selStart) const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd) const after = form.content_md.slice(selEnd)
form.content_md = before + text + after form.content_md = before + text + after
nextTick(() => {
if (!mdPane.value) return
const newPos = selStart + text.length
mdPane.value.focus()
mdPane.value.setSelectionRange(newPos, newPos)
})
} }
function withSelection(fn) { function withSelection(fn) {
const el = mdPane.value if (cmView) {
const start = el ? el.selectionStart : form.content_md.length const sel = cmView.state.selection.main
const end = el ? el.selectionEnd : form.content_md.length fn(sel.from, sel.to)
fn(start, end) return
}
fn(form.content_md.length, form.content_md.length)
} }
function tbBold() { function tbBold() {
@@ -533,24 +625,142 @@ function commitImage() {
showImageInput.value = false showImageInput.value = false
} }
// ---------- toast ----------
// 编辑器内的轻提示:上传进度、转存结果、校验失败。自动消失,错误多留一会儿。
const toasts = reactive([])
let toastSeq = 0
function toast(text, type = 'info', ttl = 2600) {
const id = ++toastSeq
toasts.push({ id, text, type })
setTimeout(() => {
const i = toasts.findIndex((t) => t.id === id)
if (i >= 0) toasts.splice(i, 1)
}, ttl)
return id
}
function toastUpdate(id, text, type = 'info') {
const t = toasts.find((x) => x.id === id)
if (t) {
t.text = text
t.type = type
}
}
function toastDone(id, text) {
toastUpdate(id, text, 'ok')
const idx = toasts.findIndex((t) => t.id === id)
if (idx >= 0) {
const t = toasts[idx]
setTimeout(() => {
const i = toasts.indexOf(t)
if (i >= 0) toasts.splice(i, 1)
}, 1800)
}
}
// ---------- 上传(封面 / 编辑器插图共用) ---------- // ---------- 上传(封面 / 编辑器插图共用) ----------
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。 // 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false) const uploadingImage = ref(false)
async function uploadOne(file) { // 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
const fd = new FormData() const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
fd.append('file', file) const MAX_UPLOAD_MB = 50
function validateFile(file) {
const ext = (file.name.split('.').pop() || '').toLowerCase()
if (!ALLOW_UPLOAD_EXT.includes(ext)) {
return `不支持的类型 .${ext}(允许:${ALLOW_UPLOAD_EXT.join('/')})`
}
if (file.size > MAX_UPLOAD_MB * 1024 * 1024) {
return `${file.name} 超过 ${MAX_UPLOAD_MB}MB 上限`
}
return ''
}
// opts.cmView 传了就把结果以 markdown 图片插到该编辑器光标处(md 页签粘贴/拖拽)
async function uploadOne(file, opts = {}) {
const bad = validateFile(file)
if (bad) {
toast(bad, 'err')
throw new Error(bad)
}
const tid = toast(`上传 ${file.name} 0%`, 'info', 60000)
try { try {
const data = await adminApi.uploadFiles(fd) const data = await adminApi.uploadFile(file, (p) =>
toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
)
const f = Array.isArray(data) ? data[0] : null const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败') if (!f || !f.url) throw new Error('上传失败')
toastDone(tid, `${file.name} 上传完成`)
if (opts.cmView) {
const v = opts.cmView
const pos = v.state.selection.main.from
v.dispatch({ changes: { from: pos, insert: `![](${f.url})\n` } })
}
return f.url return f.url
} catch (e) { } catch (e) {
toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
error.value = e.message || '上传失败' error.value = e.message || '上传失败'
throw e throw e
} }
} }
// ---------- 外链图片转存 ----------
// xLog 的 Cloud:正文里的外链 <img> 一键抓回自己的存储(后端走 SSRF
// 防护拨号 + 同一套白名单/去重),成功后原地替换 markdown 里的 URL。
const importing = ref(false)
function externalImages(md) {
const own = [site.uploads_public_base, location.origin].filter(Boolean)
const out = []
const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g
let m
while ((m = re.exec(md))) {
const u = m[1]
if (!own.some((b) => u.startsWith(b)) && !u.startsWith('/uploads/')) out.push(u)
}
return [...new Set(out)]
}
async function importExternal() {
if (importing.value) return
const urls = externalImages(form.content_md)
if (!urls.length) {
toast('正文里没有外链图片', 'info')
return
}
importing.value = true
const tid = toast(`转存外链图片 0/${urls.length}…`, 'info', 120000)
try {
const data = await adminApi.importFiles(urls)
// 后端按原址回:source -> 转存后的文件(同内容去重时可能是已有行)
const ok = new Map((data.files || []).map((e) => [e.source, e.file]))
const errs = data.errors || {}
let done = 0
let changed = false
form.content_md = form.content_md.replace(
/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
(whole, alt, u) => {
if (!ok.has(u)) return whole
done++
toastUpdate(tid, `转存外链图片 ${done}/${urls.length}…`)
changed = true
return `![${alt}](${ok.get(u).url})`
}
)
if (changed) syncCM()
const fail = Object.keys(errs).length
if (fail) {
toastUpdate(tid, `转存完成:${done} 成功,${fail} 失败(${Object.values(errs)[0]})`, fail ? 'err' : 'ok')
} else {
toastDone(tid, `转存完成:${done} 张已入自己的存储`)
}
} catch (e) {
toastUpdate(tid, '转存失败:' + (e.message || ''), 'err')
} finally {
importing.value = false
}
}
const coverInput = ref(null) const coverInput = ref(null)
async function onCoverPick(e) { async function onCoverPick(e) {
const file = e.target.files && e.target.files[0] const file = e.target.files && e.target.files[0]
@@ -693,6 +903,14 @@ const tbGroups = computed(() => [
<div class="editor-mode"> <div class="editor-mode">
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button> <button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button> <button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
<button
class="btn"
:disabled="importing"
title="把正文里的外链图片抓回自己的存储"
@click="importExternal"
>
{{ importing ? '转存中…' : '⇲ 转存外链图' }}
</button>
</div> </div>
</div> </div>
@@ -775,16 +993,13 @@ const tbGroups = computed(() => [
<!-- editor area --> <!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div> <div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<textarea <!-- md 页签:CodeMirror 6(语法高亮/历史/粘贴拖拽上传),markdown 唯一真相源 -->
v-show="mode === 'md'" <div v-show="mode === 'md'" ref="mdPane" class="md-pane" aria-label="Markdown 正文"></div>
ref="mdPane"
class="md-pane" <!-- 轻提示栈 -->
v-model="form.content_md" <div class="toast-stack" aria-live="polite">
placeholder="直接写 Markdown…" <div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
aria-label="Markdown 正文" </div>
@paste="onMdPaste"
@drop="onMdDrop"
></textarea>
<div class="editor-stats"> <div class="editor-stats">
<span><strong>{{ cjkChars }}</strong> 汉字</span> <span><strong>{{ cjkChars }}</strong> 汉字</span>
+29
View File
@@ -96,6 +96,35 @@ export const adminApi = {
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }), deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。 // 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。 // 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
uploadFile: (file, onProgress) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', base + '/api/admin/files')
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
}
xhr.onload = () => {
if (xhr.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
reject(new Error('未登录或登录已过期'))
return
}
let data = {}
try {
data = JSON.parse(xhr.responseText)
} catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data)
else reject(new Error(data.error || `上传失败(${xhr.status})`))
}
xhr.onerror = () => reject(new Error('网络错误,上传中止'))
const fd = new FormData()
fd.append('file', file)
xhr.send(fd)
}),
// 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors }
importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }),
uploadFiles: async (formData) => { uploadFiles: async (formData) => {
const res = await fetch(base + '/api/admin/files', { const res = await fetch(base + '/api/admin/files', {
method: 'POST', method: 'POST',
+25 -5
View File
@@ -835,17 +835,37 @@ html.topbar-hidden {--topbar-h: 0px;}
0%, 100% {opacity: 1;} 0%, 100% {opacity: 1;}
50% {opacity: 0.3;} 50% {opacity: 0.3;}
} }
/* md 页签容器现在是 CodeMirror 挂载点(原为 textarea) */
.md-pane {width: 100%; .md-pane {width: 100%;
min-height: 460px; min-height: 460px;
border: 1px solid var(--admin-line); border: 1px solid var(--admin-line);
border-radius: 3px; border-radius: 3px;
background: var(--admin-card); background: var(--admin-card);
padding: 18px 22px; padding: 6px 14px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
color: var(--admin-ink); color: var(--admin-ink);
resize: vertical; overflow: hidden;}
.md-pane:focus-within {border-color: var(--admin-accent);}
/* 轻提示栈:上传进度 / 转存结果 / 校验失败 */
.toast-stack {position: fixed;
right: 18px;
bottom: 18px;
z-index: 90;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;}
.toast {padding: 9px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--admin-ink);
background: var(--admin-card);
border: 1px solid var(--admin-line);
box-shadow: 0 6px 24px rgba(0,0,0,.12);
overflow-wrap: anywhere;}
.toast.ok {border-color: color-mix(in srgb, var(--admin-accent) 55%, transparent);}
.toast.err {border-color: #c26a55;
color: #b4553f;
outline: none;} outline: none;}
.md-pane:focus {border-color: var(--admin-accent);} .md-pane:focus {border-color: var(--admin-accent);}
.toolbar {display: flex; .toolbar {display: flex;