编辑器三件套(参考 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:
@@ -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=# 任意字符串,用于加密
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+63
-48
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
@@ -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: `\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 `.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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user