Compare commits
19
Commits
a74299d716
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf14897aa | ||
|
|
8ff9878e91 | ||
|
|
1a43199ca0 | ||
|
|
84f88cb473 | ||
|
|
c6eb0bbe5b | ||
|
|
165f87d086 | ||
|
|
2674bea223 | ||
|
|
ce07f4a347 | ||
|
|
c2d5e9db89 | ||
|
|
3eb0aab788 | ||
|
|
f7669badf2 | ||
|
|
6e6196d68b | ||
|
|
23771f0661 | ||
|
|
44a24736aa | ||
|
|
55d898cd42 | ||
|
|
ab96426489 | ||
|
|
c84a383e5c | ||
|
|
f7a2e83727 | ||
|
|
b93fe9847c |
@@ -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))
|
||||||
@@ -227,6 +231,7 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
in.Status = store.NormalizeStatus(in.Status)
|
in.Status = store.NormalizeStatus(in.Status)
|
||||||
attachLinkCard(r.Context(), &in, nil)
|
attachLinkCard(r.Context(), &in, nil)
|
||||||
|
autoMeta(&in, nil)
|
||||||
p, err := a.Store.Create(in)
|
p, err := a.Store.Create(in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.ServerError(w, err)
|
httpx.ServerError(w, err)
|
||||||
@@ -266,12 +271,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.Status != "" {
|
if in.Status != "" {
|
||||||
in.Status = store.NormalizeStatus(in.Status)
|
in.Status = store.NormalizeStatus(in.Status)
|
||||||
}
|
}
|
||||||
// 多读一次当前行只为拿到已有卡片:链接没变就不必再打远端
|
// 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动
|
||||||
|
// 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住
|
||||||
var curCard *model.LinkCard
|
var curCard *model.LinkCard
|
||||||
|
var curPost *model.Post
|
||||||
if cur, err := a.Store.Get(id); err == nil {
|
if cur, err := a.Store.Get(id); err == nil {
|
||||||
curCard = cur.LinkCard
|
curCard = cur.LinkCard
|
||||||
|
curPost = &cur
|
||||||
}
|
}
|
||||||
attachLinkCard(r.Context(), &in, curCard)
|
attachLinkCard(r.Context(), &in, curCard)
|
||||||
|
autoMeta(&in, curPost)
|
||||||
p, err := a.Store.Update(id, in)
|
p, err := a.Store.Update(id, in)
|
||||||
writeOne(w, p, err)
|
writeOne(w, p, err)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
@@ -727,6 +736,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 +749,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 +768,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 +781,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 {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 长文的自动摘要与自动封面:站主没填这两项时从正文派生——
|
||||||
|
// 摘要取正文开头的一段纯文本,封面取正文第一张图。
|
||||||
|
// 更新路径只在「新值与旧值都为空」时才生成,站主主动清空的要保得住。
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oneblog/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autoSummaryLen = 110
|
||||||
|
|
||||||
|
var (
|
||||||
|
mdImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)[^)]*\)`)
|
||||||
|
mdCodeFence = regexp.MustCompile("(?s)```.*?```")
|
||||||
|
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||||
|
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+.*$`) // 标题行整行不进摘要(与文章标题重复)
|
||||||
|
mdNoiseRe = regexp.MustCompile(`(?m)^([>\s*-]+|\d+\.\s)+`)
|
||||||
|
mdEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "*", "", "_", "")
|
||||||
|
wsRe = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// autoMeta 按需填充 in.Summary / in.CoverURL。
|
||||||
|
// cur 为更新前的旧文章(新建时传 nil)。
|
||||||
|
func autoMeta(in *model.PostInput, cur *model.Post) {
|
||||||
|
if in.Kind != model.KindLong || strings.TrimSpace(in.ContentMd) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Summary) == "" && (cur == nil || strings.TrimSpace(cur.Summary) == "") {
|
||||||
|
in.Summary = summarizeMarkdown(in.ContentMd, autoSummaryLen)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.CoverURL) == "" && (cur == nil || strings.TrimSpace(cur.CoverURL) == "") {
|
||||||
|
in.CoverURL = firstImage(in.ContentMd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeMarkdown 剥掉 markdown 语法后取正文开头一段纯文本
|
||||||
|
func summarizeMarkdown(md string, maxRunes int) string {
|
||||||
|
s := mdCodeFence.ReplaceAllString(md, " ") // 代码块整段不进摘要
|
||||||
|
s = mdHeadingRe.ReplaceAllString(s, " ")
|
||||||
|
s = mdImageRe.ReplaceAllString(s, " ")
|
||||||
|
s = mdLinkRe.ReplaceAllString(s, "$1")
|
||||||
|
s = mdNoiseRe.ReplaceAllString(s, " ")
|
||||||
|
s = mdEmphasis.Replace(s)
|
||||||
|
s = strings.ReplaceAll(s, "`", "")
|
||||||
|
s = wsRe.ReplaceAllString(s, " ")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > maxRunes {
|
||||||
|
return string(runes[:maxRunes]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstImage 取正文第一张图片的地址(markdown 图;http/站内路径均可)
|
||||||
|
func firstImage(md string) string {
|
||||||
|
m := mdImageRe.FindStringSubmatch(md)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u := m[1]
|
||||||
|
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/uploads/") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oneblog/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSummarizeMarkdown(t *testing.T) {
|
||||||
|
md := "## 标题\n\n这是**正文**的第一段,含[链接](https://x.com)与图片:\n\n\n\n```go\ncode ignored\n```\n\n后续内容"
|
||||||
|
got := summarizeMarkdown(md, 110)
|
||||||
|
for _, bad := range []string{"#", "**", "![]", "```", "code ignored"} {
|
||||||
|
if strings.Contains(got, bad) {
|
||||||
|
t.Errorf("summary 含语法残留 %q: %q", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "链接") || !strings.HasPrefix(got, "这是") {
|
||||||
|
t.Errorf("summary 应以正文开头并保留链接文字: %q", got)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("长", 200)
|
||||||
|
if got := summarizeMarkdown(long, 110); len([]rune(got)) != 111 || !strings.HasSuffix(got, "…") {
|
||||||
|
t.Errorf("超长应截到 110+省略号, got %d runes", len([]rune(got)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstImage(t *testing.T) {
|
||||||
|
md := "前言\n\n\n\n"
|
||||||
|
if got := firstImage(md); got != "https://a.com/1.png" {
|
||||||
|
t.Errorf("firstImage = %q", got)
|
||||||
|
}
|
||||||
|
if got := firstImage("没有图片"); got != "" {
|
||||||
|
t.Errorf("无图应返回空, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoMeta(t *testing.T) {
|
||||||
|
in := model.PostInput{Kind: model.KindLong, ContentMd: "# T\n\n正文内容 "}
|
||||||
|
autoMeta(&in, nil)
|
||||||
|
if in.Summary == "" || in.CoverURL != "https://a.com/x.png" {
|
||||||
|
t.Errorf("空字段应自动填充: summary=%q cover=%q", in.Summary, in.CoverURL)
|
||||||
|
}
|
||||||
|
// 站主已填的不能覆盖
|
||||||
|
in2 := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "手写的", CoverURL: ""}
|
||||||
|
autoMeta(&in2, nil)
|
||||||
|
if in2.Summary != "手写的" {
|
||||||
|
t.Errorf("手写摘要不应被覆盖: %q", in2.Summary)
|
||||||
|
}
|
||||||
|
// 更新路径:新值与旧值都空才补——主动清空的保得住
|
||||||
|
cleared := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "", CoverURL: ""}
|
||||||
|
cur := model.Post{Summary: "旧摘要", CoverURL: "旧封面"}
|
||||||
|
autoMeta(&cleared, &cur)
|
||||||
|
if cleared.Summary != "" || cleared.CoverURL != "" {
|
||||||
|
t.Errorf("主动清空不应复活: summary=%q cover=%q", cleared.Summary, cleared.CoverURL)
|
||||||
|
}
|
||||||
|
// 新旧都空 → 生成
|
||||||
|
never := model.PostInput{Kind: model.KindLong, ContentMd: "正文 "}
|
||||||
|
autoMeta(&never, &model.Post{})
|
||||||
|
if never.CoverURL != "/uploads/a.png" {
|
||||||
|
t.Errorf("新旧都空应取首图: %q", never.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"oneblog/internal/hub"
|
"oneblog/internal/hub"
|
||||||
"oneblog/internal/model"
|
"oneblog/internal/model"
|
||||||
"oneblog/internal/ratelimit"
|
"oneblog/internal/ratelimit"
|
||||||
|
"oneblog/internal/render"
|
||||||
"oneblog/internal/storage"
|
"oneblog/internal/storage"
|
||||||
"oneblog/internal/store"
|
"oneblog/internal/store"
|
||||||
"oneblog/internal/thumbs"
|
"oneblog/internal/thumbs"
|
||||||
@@ -128,6 +129,12 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
httpx.ServerError(w, err)
|
httpx.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 时间线上短文直接铺正文:出口处做盘古之白(库里存的是原始渲染结果)
|
||||||
|
for i := range page.Items {
|
||||||
|
if page.Items[i].Kind == model.KindShort {
|
||||||
|
page.Items[i].ContentHTML = render.PanguHTML(page.Items[i].ContentHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
httpx.OK(w, page)
|
httpx.OK(w, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +163,8 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
nb = nil
|
nb = nil
|
||||||
}
|
}
|
||||||
|
// 详情正文出口做盘古之白(存库不动,改的是渲染层)
|
||||||
|
p.ContentHTML = render.PanguHTML(p.ContentHTML)
|
||||||
httpx.OK(w, struct {
|
httpx.OK(w, struct {
|
||||||
model.Post
|
model.Post
|
||||||
Neighbors []model.Post `json:"neighbors"`
|
Neighbors []model.Post `json:"neighbors"`
|
||||||
@@ -255,7 +264,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
desc := p.Summary
|
desc := p.Summary
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
desc = trimRunes(stripTags(p.ContentHTML), 160)
|
desc = trimRunes(stripTags(render.PanguHTML(p.ContentHTML)), 160)
|
||||||
}
|
}
|
||||||
item := rssItem{
|
item := rssItem{
|
||||||
Title: title,
|
Title: title,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// 盘古之白:在 CJK 与拉丁字母/数字之间补一个空格,中文混排的阅读观感
|
||||||
|
// 差别很大(「把MVP搬进仓库」→「把 MVP 搬进仓库」)。
|
||||||
|
// 在 HTML 层做:标签原样保留,<code>/<pre>/<script>/<style> 内的文本
|
||||||
|
// 一律不动(代码语义容不得我们加空格),其余文本段做补空格。
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
|
// code 上下文里的文本不处理
|
||||||
|
var codeOpenRe = regexp.MustCompile(`(?i)<(code|pre|script|style|kbd|samp)\b[^>]*>`)
|
||||||
|
var codeCloseRe = regexp.MustCompile(`(?i)</(code|pre|script|style|kbd|samp)>`)
|
||||||
|
|
||||||
|
// CJK 统一表意区 + 扩展A/兼容 + 日文假名已在 2E80-9FFF 覆盖,
|
||||||
|
// 再补全角标点区(FF00-FFEF)与 CJK 标点(3000-303F)
|
||||||
|
var cjkRe = regexp.MustCompile(`[\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}]`)
|
||||||
|
var latinTailRe = regexp.MustCompile(`([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])([A-Za-z0-9_$@])`)
|
||||||
|
var latinHeadRe = regexp.MustCompile(`([A-Za-z0-9+%,.;:!?%)\]])([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])`)
|
||||||
|
|
||||||
|
func isCJK(r rune) bool {
|
||||||
|
return cjkRe.MatchString(string(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// panguText 对纯文本做补空格。已经隔着空格的不动(正则天然要求相邻)。
|
||||||
|
func panguText(s string) string {
|
||||||
|
if !hasCJKAndLatin(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
prev := ""
|
||||||
|
for prev != s {
|
||||||
|
prev = s
|
||||||
|
s = latinTailRe.ReplaceAllString(s, "$1 $2")
|
||||||
|
s = latinHeadRe.ReplaceAllString(s, "$1 $2")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCJKAndLatin(s string) bool {
|
||||||
|
var cjk, latin bool
|
||||||
|
for _, r := range s {
|
||||||
|
if isCJK(r) {
|
||||||
|
cjk = true
|
||||||
|
} else if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||||
|
latin = true
|
||||||
|
}
|
||||||
|
if cjk && latin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanguHTML 给渲染出的正文 HTML 做盘古之白。流式扫描:标签原样输出,
|
||||||
|
// 普通文本段补空格,代码上下文里的文本跳过。
|
||||||
|
func PanguHTML(html string) string {
|
||||||
|
if !strings.Contains(html, "<") {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
inCode := false
|
||||||
|
for {
|
||||||
|
loc := tagRe.FindStringIndex(html)
|
||||||
|
if loc == nil {
|
||||||
|
rest := html
|
||||||
|
if !inCode {
|
||||||
|
rest = panguText(rest)
|
||||||
|
}
|
||||||
|
b.WriteString(rest)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
head, tag := html[:loc[0]], html[loc[0]:loc[1]]
|
||||||
|
if !inCode {
|
||||||
|
head = panguText(head)
|
||||||
|
}
|
||||||
|
b.WriteString(head)
|
||||||
|
b.WriteString(tag)
|
||||||
|
if !inCode {
|
||||||
|
if m := codeOpenRe.FindStringSubmatch(tag); m != nil {
|
||||||
|
// 自闭合或行内 code 直接开着也按进/出配对处理(</code> 总会出现)
|
||||||
|
inCode = true
|
||||||
|
}
|
||||||
|
} else if codeCloseRe.MatchString(tag) {
|
||||||
|
inCode = false
|
||||||
|
}
|
||||||
|
html = html[loc[1]:]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPanguText(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"把MVP搬进仓库": "把 MVP 搬进仓库",
|
||||||
|
"今天写了3个小时代码": "今天写了 3 个小时代码",
|
||||||
|
"使用Go和Vue3开发": "使用 Go 和 Vue3 开发",
|
||||||
|
"纯中文没有混排": "纯中文没有混排",
|
||||||
|
"已经隔了空格的 Go 语言": "已经隔了空格的 Go 语言",
|
||||||
|
"数字100在前": "数字 100 在前",
|
||||||
|
"英文后接标点。followed": "英文后接标点。 followed",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := panguText(in); got != want {
|
||||||
|
t.Errorf("panguText(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPanguHTML(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
// 普通文本:处理
|
||||||
|
"<p>把MVP搬进仓库</p>": "<p>把 MVP 搬进仓库</p>",
|
||||||
|
// 行内 code:不动
|
||||||
|
"<p>运行<code>go build ./...</code>即可</p>": "<p>运行<code>go build ./...</code>即可</p>",
|
||||||
|
// pre 代码块:整个内部不动
|
||||||
|
"<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>": "<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>",
|
||||||
|
// 属性里不是文本:不动(src 含字母数字混合不受影响)
|
||||||
|
`<img alt="测试图1" src="a1.png">`: `<img alt="测试图1" src="a1.png">`,
|
||||||
|
// 嵌套:code 结束后恢复处理
|
||||||
|
"<p>先<code>跑起来</code>再看结果100%</p>": "<p>先<code>跑起来</code>再看结果 100%</p>",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := PanguHTML(in); got != want {
|
||||||
|
t.Errorf("PanguHTML(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import TopBar from './components/TopBar.vue'
|
import TopBar from './components/TopBar.vue'
|
||||||
import ThemeSwitcher from './components/ThemeSwitcher.vue'
|
import ThemeSwitcher from './components/ThemeSwitcher.vue'
|
||||||
@@ -9,6 +9,31 @@ import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
|
|||||||
import { site, setAdminScope } from './site'
|
import { site, setAdminScope } from './site'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
|
||||||
|
function onCopyClick(e) {
|
||||||
|
const btn = e.target.closest?.('.pre-copy')
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.pre-wrap')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(pre.innerText.replace(/\n$/, ''))
|
||||||
|
.then(() => {
|
||||||
|
btn.textContent = '已复制'
|
||||||
|
btn.classList.add('done')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = '复制'
|
||||||
|
btn.classList.remove('done')
|
||||||
|
}, 1600)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.textContent = '复制失败'
|
||||||
|
setTimeout(() => (btn.textContent = '复制'), 1600)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('click', onCopyClick))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', onCopyClick))
|
||||||
|
|
||||||
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||||
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
|
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
|
||||||
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
|
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import ToastStack from './ToastStack.vue'
|
||||||
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
|
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
|
||||||
import { checkAuth } from './auth'
|
import { checkAuth } from './auth'
|
||||||
import { adminApi, session } from '../api'
|
import { adminApi, session } from '../api'
|
||||||
@@ -96,6 +97,7 @@ watch(() => route.path, () => loadCounts())
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="ready" class="admin-shell">
|
<div v-if="ready" class="admin-shell">
|
||||||
|
<ToastStack />
|
||||||
<aside class="admin-side">
|
<aside class="admin-side">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
|
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { adminApi } from '../api'
|
import { adminApi } from '../api'
|
||||||
import { relativeDate, stripTags } from '../utils'
|
import { relativeDate, stripTags } from '../utils'
|
||||||
@@ -46,12 +47,14 @@ function setStatus(v) {
|
|||||||
|
|
||||||
async function approve(id) {
|
async function approve(id) {
|
||||||
await adminApi.approveComment(id)
|
await adminApi.approveComment(id)
|
||||||
|
toastOk('已通过,前台可见')
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
async function remove(id) {
|
||||||
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
|
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
|
||||||
await adminApi.deleteComment(id)
|
await adminApi.deleteComment(id)
|
||||||
|
toastOk('已删除')
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ async function banToggle(c) {
|
|||||||
const banned = !c.user?.banned
|
const banned = !c.user?.banned
|
||||||
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
|
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
|
||||||
await adminApi.setReaderBanned(c.user?.id, banned)
|
await adminApi.setReaderBanned(c.user?.id, banned)
|
||||||
|
toastOk(banned ? '已禁言' : '已解除禁言')
|
||||||
c.user = { ...(c.user || {}), banned }
|
c.user = { ...(c.user || {}), banned }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { toast, toastOk, toastErr, toastUpdate, toastDone } from './toast'
|
||||||
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 +190,10 @@ onBeforeUnmount(() => {
|
|||||||
crepe.destroy()
|
crepe.destroy()
|
||||||
crepe = null
|
crepe = null
|
||||||
}
|
}
|
||||||
|
if (cmView) {
|
||||||
|
cmView.destroy()
|
||||||
|
cmView = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- mode switching ----------
|
// ---------- mode switching ----------
|
||||||
@@ -195,6 +205,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()
|
||||||
@@ -267,6 +281,9 @@ async function autosave() {
|
|||||||
function applySaved(saved) {
|
function applySaved(saved) {
|
||||||
form.slug = saved.slug
|
form.slug = saved.slug
|
||||||
form.status = saved.status
|
form.status = saved.status
|
||||||
|
// 自动摘要/自动封面在服务端补齐的,回写表单让站主看见(清空仍会保留为空)
|
||||||
|
if (typeof saved.summary === 'string') form.summary = saved.summary
|
||||||
|
if (typeof saved.cover_url === 'string') form.cover_url = saved.cover_url
|
||||||
publishedLocal.value = isoToLocal(saved.published_at)
|
publishedLocal.value = isoToLocal(saved.published_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +320,7 @@ function buildPayload() {
|
|||||||
async function save(status) {
|
async function save(status) {
|
||||||
if (status) form.status = status
|
if (status) form.status = status
|
||||||
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
|
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
|
||||||
error.value = '先写点什么再保存'
|
toastErr('先写点什么再保存')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -317,12 +334,17 @@ async function save(status) {
|
|||||||
const created = await adminApi.createPost(payload)
|
const created = await adminApi.createPost(payload)
|
||||||
localStorage.removeItem(DRAFT_KEY)
|
localStorage.removeItem(DRAFT_KEY)
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
|
toastOk('已创建')
|
||||||
router.replace(`/admin/${created.id}`)
|
router.replace(`/admin/${created.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
// 手动保存(按钮/⌘S/发布)给回音;自动保存静默,不打扰
|
||||||
|
const label = status === 'published' ? '已发布' : status === 'draft' ? '已转回草稿' : '已保存'
|
||||||
|
toastOk(label + ' · ' + savedAt.value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toastErr(e.message || '保存失败')
|
||||||
error.value = e.message || '保存失败'
|
error.value = e.message || '保存失败'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -399,6 +421,82 @@ 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([
|
||||||
|
{ key: 'Mod-b', run: () => { tbBold(); return true } },
|
||||||
|
{ key: 'Mod-i', run: () => { tbItalic(); return true } },
|
||||||
|
{ key: 'Mod-e', run: () => { tbCode(); return true } },
|
||||||
|
{ key: 'Mod-k', run: () => { openLink(); return true } }
|
||||||
|
]),
|
||||||
|
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 +505,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() {
|
||||||
@@ -507,6 +614,15 @@ function tbTask() {
|
|||||||
function tbHr() {
|
function tbHr() {
|
||||||
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
|
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
|
||||||
}
|
}
|
||||||
|
function tbTable() {
|
||||||
|
withSelection((s, e) =>
|
||||||
|
insertAtCursor(
|
||||||
|
'\n| 列 A | 列 B |\n| --- | --- |\n| 内容 | 内容 |\n| 内容 | 内容 |\n',
|
||||||
|
s,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function openLink() {
|
function openLink() {
|
||||||
showLinkInput.value = true
|
showLinkInput.value = true
|
||||||
@@ -534,23 +650,110 @@ function commitImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 上传(封面 / 编辑器插图共用) ----------
|
// ---------- 上传(封面 / 编辑器插图共用) ----------
|
||||||
|
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
|
||||||
// 后台「高级 → 自定义 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]
|
||||||
@@ -618,15 +821,29 @@ function insertDate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 工具栏按钮的统一定义(用作 v-for 渲染)
|
// 工具栏按钮的统一定义(用作 v-for 渲染)
|
||||||
|
// 快捷键的展示形态(xLog 同款):Mod 在 Mac 上显示 ⌘,其余显示 Ctrl
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform || '')
|
||||||
|
function keyDisplay(shortcut) {
|
||||||
|
if (!shortcut) return ''
|
||||||
|
return '(' + shortcut
|
||||||
|
.replace('Mod', isMac ? '⌘' : 'Ctrl')
|
||||||
|
.replace(/-([a-z])$/i, (_, c) => c.toUpperCase()) + ')'
|
||||||
|
}
|
||||||
|
|
||||||
const tbGroups = computed(() => [
|
const tbGroups = computed(() => [
|
||||||
{
|
{
|
||||||
label: '格式',
|
label: '格式',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' },
|
{ key: 'b', title: '加粗', shortcut: 'Mod-b', run: tbBold, show: mode.value === 'md',
|
||||||
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' },
|
icon: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z', fill: true },
|
||||||
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' },
|
{ key: 'i', title: '斜体', shortcut: 'Mod-i', run: tbItalic, show: mode.value === 'md',
|
||||||
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' },
|
icon: 'M19 4h-9M14 20H5M15 4L9 20' },
|
||||||
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' }
|
{ key: 's', title: '删除线', run: tbStrike, show: mode.value === 'md',
|
||||||
|
icon: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16' },
|
||||||
|
{ key: 'code', title: '行内代码', shortcut: 'Mod-e', run: tbCode, show: mode.value === 'md',
|
||||||
|
icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
|
||||||
|
{ key: 'cb', title: '代码块', run: tbCodeBlock, show: mode.value === 'md',
|
||||||
|
icon: 'M9 10l-2 2.5L9 15M15 10l2 2.5-2 2.5M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -634,19 +851,29 @@ const tbGroups = computed(() => [
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
|
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
|
||||||
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
|
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
|
||||||
{ key: 'q', label: '"', title: '引用', run: tbQuote, show: mode.value === 'md' },
|
{ key: 'q', title: '引用', run: tbQuote, show: mode.value === 'md',
|
||||||
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, show: mode.value === 'md' },
|
icon: 'M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1zM15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1z', fill: true },
|
||||||
{ key: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' },
|
{ key: 'ul', title: '无序列表', run: tbUl, show: mode.value === 'md',
|
||||||
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' },
|
icon: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
|
||||||
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' }
|
{ key: 'ol', title: '有序列表', run: tbOl, show: mode.value === 'md',
|
||||||
|
icon: 'M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1' },
|
||||||
|
{ key: 'task', title: '任务列表', run: tbTask, show: mode.value === 'md',
|
||||||
|
icon: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
|
||||||
|
{ key: 'hr', title: '分隔线', run: tbHr, show: mode.value === 'md',
|
||||||
|
icon: 'M5 12h14' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '插入',
|
label: '插入',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' },
|
{ key: 'link', title: '链接', shortcut: 'Mod-k', run: openLink, show: mode.value === 'md',
|
||||||
{ key: 'img', label: '🖼', title: '图片', run: openImage, show: mode.value === 'md' },
|
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' },
|
||||||
{ key: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' }
|
{ key: 'img', title: '图片', run: openImage, show: mode.value === 'md',
|
||||||
|
icon: 'M21 15l-5-5L5 21M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 8.5h.01' },
|
||||||
|
{ key: 'table', title: '表格', run: tbTable, show: mode.value === 'md',
|
||||||
|
icon: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 10h18M10 21V10' },
|
||||||
|
{ key: 'date', title: '插入今天日期', run: insertDate, show: mode.value === 'md',
|
||||||
|
icon: 'M16 2v4M8 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
@@ -693,6 +920,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>
|
||||||
|
|
||||||
@@ -712,11 +947,18 @@ const tbGroups = computed(() => [
|
|||||||
:key="it.key"
|
:key="it.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="tb-btn"
|
class="tb-btn"
|
||||||
:class="{ italic: it.italic, mono: it.mono }"
|
:title="it.title + keyDisplay(it.shortcut)"
|
||||||
:title="it.title"
|
|
||||||
:aria-label="it.title"
|
:aria-label="it.title"
|
||||||
@click="it.run()"
|
@click="it.run()"
|
||||||
>{{ it.label }}</button>
|
>
|
||||||
|
<svg v-if="it.icon" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
:fill="it.fill ? 'currentColor' : 'none'"
|
||||||
|
:stroke="it.fill ? 'none' : 'currentColor'" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path :d="it.icon" />
|
||||||
|
</svg>
|
||||||
|
<span v-else class="tb-text">{{ it.label }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -775,16 +1017,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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { adminApi } from '../api'
|
import { adminApi } from '../api'
|
||||||
import { relativeDate } from '../utils'
|
import { relativeDate } from '../utils'
|
||||||
@@ -55,8 +56,10 @@ async function upload(files) {
|
|||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
for (const f of files) fd.append('file', f)
|
for (const f of files) fd.append('file', f)
|
||||||
await adminApi.uploadFiles(fd)
|
await adminApi.uploadFiles(fd)
|
||||||
|
toastOk(`已上传 ${files.length} 个文件`)
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toastErr(e.message || '上传失败')
|
||||||
uploadError.value = e.message || '上传失败'
|
uploadError.value = e.message || '上传失败'
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
@@ -75,6 +78,7 @@ function onDrop(e) {
|
|||||||
async function copyUrl(f) {
|
async function copyUrl(f) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(f.url)
|
await navigator.clipboard.writeText(f.url)
|
||||||
|
toastOk('链接已复制')
|
||||||
copiedId.value = f.id
|
copiedId.value = f.id
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (copiedId.value === f.id) copiedId.value = 0
|
if (copiedId.value === f.id) copiedId.value = 0
|
||||||
@@ -89,10 +93,12 @@ async function remove(f) {
|
|||||||
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
|
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
|
||||||
try {
|
try {
|
||||||
await adminApi.deleteFile(f.id)
|
await adminApi.deleteFile(f.id)
|
||||||
|
toastOk(`已删除「${f.name}」`)
|
||||||
// 当前页删空时退一页,其余直接重载
|
// 当前页删空时退一页,其余直接重载
|
||||||
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
|
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
|
||||||
else load()
|
else load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toastErr(e.message || '删除失败')
|
||||||
error.value = e.message || '删除失败'
|
error.value = e.message || '删除失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
|
import ToastStack from './ToastStack.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { adminApi, session } from '../api'
|
import { adminApi, session } from '../api'
|
||||||
import { site } from '../site'
|
import { site } from '../site'
|
||||||
@@ -17,8 +19,10 @@ async function submit() {
|
|||||||
const data = await adminApi.login(username.value, password.value)
|
const data = await adminApi.login(username.value, password.value)
|
||||||
session.user = username.value
|
session.user = username.value
|
||||||
void data
|
void data
|
||||||
|
toastOk('登录成功')
|
||||||
router.push('/admin')
|
router.push('/admin')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toastErr('用户名或密码不对')
|
||||||
error.value = '用户名或密码不对'
|
error.value = '用户名或密码不对'
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
@@ -27,6 +31,7 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ToastStack />
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<form class="card" @submit.prevent="submit">
|
<form class="card" @submit.prevent="submit">
|
||||||
<p class="eyebrow">后台</p>
|
<p class="eyebrow">后台</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { adminApi } from '../api'
|
import { adminApi } from '../api'
|
||||||
import { formatDateShort, relativeDate } from '../utils'
|
import { formatDateShort, relativeDate } from '../utils'
|
||||||
@@ -125,7 +126,7 @@ async function bulk(action) {
|
|||||||
await adminApi.bulkPosts([...selected.value], action)
|
await adminApi.bulkPosts([...selected.value], action)
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '操作失败')
|
toastErr(e.message || '操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ async function remove(id, title) {
|
|||||||
await adminApi.deletePost(id)
|
await adminApi.deletePost(id)
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '删除失败')
|
toastErr(e.message || '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
import { adminApi } from '../api'
|
import { adminApi } from '../api'
|
||||||
|
|
||||||
const projects = ref([])
|
const projects = ref([])
|
||||||
@@ -40,7 +41,7 @@ onMounted(load)
|
|||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
if (!form.value.title.trim()) {
|
if (!form.value.title.trim()) {
|
||||||
alert('请填写作品名称')
|
toastErr('请填写作品名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +57,7 @@ async function create() {
|
|||||||
form.value = blank()
|
form.value = blank()
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '创建失败')
|
toastErr(e.message || '创建失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ function cancelEdit() {
|
|||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
if (!draft.value.title.trim()) {
|
if (!draft.value.title.trim()) {
|
||||||
alert('请填写作品名称')
|
toastErr('请填写作品名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -97,7 +98,7 @@ async function saveEdit() {
|
|||||||
editing.value = null
|
editing.value = null
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '保存失败')
|
toastErr(e.message || '保存失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
|
|||||||
await adminApi.updateProject(p.id, { status: next })
|
await adminApi.updateProject(p.id, { status: next })
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '更新失败')
|
toastErr(e.message || '更新失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ async function remove(p) {
|
|||||||
await adminApi.deleteProject(p.id)
|
await adminApi.deleteProject(p.id)
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '删除失败')
|
toastErr(e.message || '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
import { adminApi } from '../api'
|
import { adminApi } from '../api'
|
||||||
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
||||||
import { SECTIONS } from '../ui/sections'
|
import { SECTIONS } from '../ui/sections'
|
||||||
@@ -35,14 +36,11 @@ function textToSocial(text) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
|
// 皮肤已精简:亮端固定纸感,暗端固定墨色(2026 重设计)。下面只做展示。
|
||||||
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
|
|
||||||
const lightSkins = [
|
const lightSkins = [
|
||||||
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' },
|
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }
|
||||||
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
|
|
||||||
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
|
|
||||||
]
|
]
|
||||||
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
|
const darkSkin = { id: 'ink', label: '墨色', bg: '#191817', fg: '#d9b97c' }
|
||||||
|
|
||||||
// ---------- 界面与自定义(第二套 UI) ----------
|
// ---------- 界面与自定义(第二套 UI) ----------
|
||||||
|
|
||||||
@@ -186,7 +184,9 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
toastOk('设置已保存')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
toastErr('保存失败:' + (e.message || ''))
|
||||||
error.value = e.message || '保存失败'
|
error.value = e.message || '保存失败'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -241,19 +241,12 @@ async function save() {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>亮色皮肤</label>
|
<label>亮色皮肤</label>
|
||||||
<div class="theme-swatches">
|
<div class="theme-swatches">
|
||||||
<div
|
<div class="sw on" :style="{ background: lightSkins[0].bg, borderColor: lightSkins[0].fg }" :title="lightSkins[0].label + '(固定)'">
|
||||||
v-for="sk in lightSkins"
|
<span :style="{ background: lightSkins[0].fg }">{{ lightSkins[0].label }}</span>
|
||||||
:key="sk.id"
|
|
||||||
class="sw"
|
|
||||||
:class="{ on: settings.light_skin_id === sk.id }"
|
|
||||||
:style="{ background: sk.bg, borderColor: settings.light_skin_id === sk.id ? sk.fg : 'var(--admin-line)' }"
|
|
||||||
@click="settings.light_skin_id = sk.id"
|
|
||||||
>
|
|
||||||
<span :style="{ background: sk.fg }">{{ sk.label }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
|
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
|
||||||
访客在前台选择「自动」且系统偏好浅色时使用。
|
亮端固定为纸感。访客在前台选择「自动」且系统偏好浅色时使用。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { toastOk, toastErr } from './toast'
|
||||||
import { adminApi } from '../api'
|
import { adminApi } from '../api'
|
||||||
|
|
||||||
const tags = ref([])
|
const tags = ref([])
|
||||||
@@ -30,7 +31,7 @@ async function create() {
|
|||||||
newColor.value = ''
|
newColor.value = ''
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '创建失败')
|
toastErr(e.message || '创建失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ async function update(t) {
|
|||||||
await adminApi.updateTag(t.id, { name: t.name, color: t.color })
|
await adminApi.updateTag(t.id, { name: t.name, color: t.color })
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '更新失败')
|
toastErr(e.message || '更新失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ async function remove(t) {
|
|||||||
await adminApi.deleteTag(t.id)
|
await adminApi.deleteTag(t.id)
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '删除失败')
|
toastErr(e.message || '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ function cancelMerge() {
|
|||||||
async function doMerge(fromTag) {
|
async function doMerge(fromTag) {
|
||||||
const toId = Number(mergeInto.value)
|
const toId = Number(mergeInto.value)
|
||||||
if (!toId || toId === fromTag.id) {
|
if (!toId || toId === fromTag.id) {
|
||||||
alert('选一个不同的目标标签')
|
toastErr('选一个不同的目标标签')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
|
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
|
||||||
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
|
|||||||
cancelMerge()
|
cancelMerge()
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || '合并失败')
|
toastErr(e.message || '合并失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
// 后台共享 toast 的渲染层:挂在 AdminLayout(所有管理页)与 LoginView。
|
||||||
|
// 样式在全局 styles.css(.toast-stack / .toast),与编辑器共用一份。
|
||||||
|
import { toasts } from './toast'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-stack" aria-live="polite">
|
||||||
|
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
// 后台共享的轻提示:所有视图的操作反馈(成功/失败/进度)从这里发,
|
||||||
|
// ToastStack.vue 负责渲染(挂在 AdminLayout 与 LoginView)。
|
||||||
|
// EditorView 的上传/转存也走它——原先那套局部实现已并入。
|
||||||
|
export const toasts = reactive([])
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
export function toast(text, type = 'info', ttl = 2600) {
|
||||||
|
const id = ++seq
|
||||||
|
toasts.push({ id, text, type })
|
||||||
|
setTimeout(() => {
|
||||||
|
const i = toasts.findIndex((t) => t.id === id)
|
||||||
|
if (i >= 0) toasts.splice(i, 1)
|
||||||
|
}, ttl)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toastOk(text) {
|
||||||
|
return toast(text, 'ok', 2200)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toastErr(text) {
|
||||||
|
return toast(text, 'err', 4200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新一条已存在的 toast(上传进度用)
|
||||||
|
export function toastUpdate(id, text, type = 'info') {
|
||||||
|
const t = toasts.find((x) => x.id === id)
|
||||||
|
if (t) {
|
||||||
|
t.text = text
|
||||||
|
t.type = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记成功并很快消失(进度条收尾)
|
||||||
|
export 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)
|
||||||
|
}, 1600)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -2,34 +2,70 @@
|
|||||||
import { onBeforeUnmount, watch } from 'vue'
|
import { onBeforeUnmount, watch } from 'vue'
|
||||||
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
|
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
|
||||||
|
|
||||||
// 图片预览层:App.vue 全局挂一次。铺满全屏的暗底 + 居中大图,
|
// 图片预览层(手机重做版):App.vue 全局挂一次。
|
||||||
// 多图时左右箭头 / 方向键切换,角标显示第几张;Esc / 点空白收起。
|
//
|
||||||
|
// 手机上出过两次事故,这次的实现原则:
|
||||||
|
// 1. 不碰 body 的 overflow——之前用 inline overflow 锁背景,把样式表的
|
||||||
|
// overflow-x: clip 覆盖成 hidden,横向从「裁剪」变「可滚动容器」,
|
||||||
|
// 整页被拖宽。现在滚动封锁靠遮罩自己的 @wheel/@touchmove.prevent:
|
||||||
|
// 事件不落到页面里,背景自然滚不动,滚动位置也天然保留。
|
||||||
|
// 2. 图片尺寸全部用百分比(max-width/height: 100%),不引入 vw/vh/dvh——
|
||||||
|
// 这些单位在全面屏 URL 栏收展时各有各的坑,遮罩本身就是视口大小。
|
||||||
|
// 3. 手势全归遮罩:touch-action: none + touchend 位移判滑动翻页,
|
||||||
|
// 双击缩放/长按菜单/下拉刷新这些浏览器手势都不会来捣乱。
|
||||||
|
// 4. 桌面能力不变:Esc / 方向键 / 点空白关闭 / 悬停箭头。
|
||||||
|
|
||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
if (e.key === 'Escape') closeLightbox()
|
if (e.key === 'Escape') closeLightbox()
|
||||||
if (e.key === 'ArrowLeft') stepLightbox(-1)
|
if (e.key === 'ArrowLeft') stepLightbox(-1)
|
||||||
if (e.key === 'ArrowRight') stepLightbox(1)
|
if (e.key === 'ArrowRight') stepLightbox(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上
|
||||||
|
// (z-index 更高也拦不住)——开预览时整个藏掉
|
||||||
watch(
|
watch(
|
||||||
() => st.open,
|
() => st.open,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) window.addEventListener('keydown', onKey)
|
if (v) window.addEventListener('keydown', onKey)
|
||||||
else window.removeEventListener('keydown', onKey)
|
else window.removeEventListener('keydown', onKey)
|
||||||
|
document.body.classList.toggle('lb-open', v)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
document.body.classList.remove('lb-open')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 滑动翻页:横向位移超 44px 且明显大于纵向才算一次滑动
|
||||||
|
let touchX = 0
|
||||||
|
let touchY = 0
|
||||||
|
function onTouchStart(e) {
|
||||||
|
touchX = e.touches[0].clientX
|
||||||
|
touchY = e.touches[0].clientY
|
||||||
|
}
|
||||||
|
function onTouchEnd(e) {
|
||||||
|
if (st.list.length < 2) return
|
||||||
|
const dx = e.changedTouches[0].clientX - touchX
|
||||||
|
const dy = e.changedTouches[0].clientY - touchY
|
||||||
|
if (Math.abs(dx) > 44 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||||
|
stepLightbox(dx < 0 ? 1 : -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="lb">
|
<Transition name="lb" :duration="220">
|
||||||
<div v-if="st.open" class="lb-overlay" @click="closeLightbox">
|
<div
|
||||||
<img
|
v-if="st.open"
|
||||||
class="lb-img"
|
class="lb-overlay"
|
||||||
:src="st.list[st.index]"
|
@click="closeLightbox"
|
||||||
alt=""
|
@touchstart.passive="onTouchStart"
|
||||||
@click.stop
|
@touchend.passive="onTouchEnd"
|
||||||
/>
|
@wheel.prevent
|
||||||
|
@touchmove.prevent
|
||||||
|
>
|
||||||
|
<img class="lb-img" :src="st.list[st.index]" alt="" @click.stop />
|
||||||
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
|
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
|
||||||
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
|
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
|
||||||
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
|
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
|
||||||
@@ -44,19 +80,23 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 130;
|
z-index: 130;
|
||||||
background: rgba(12, 11, 10, 0.88);
|
background: rgba(12, 11, 10, 0.92);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* 遮罩全权处理手势:杜绝双击缩放/长按菜单/下拉刷新来添乱 */
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 百分比尺寸:遮罩即视口,图片永远不会超出屏幕半像素 */
|
||||||
.lb-img {
|
.lb-img {
|
||||||
max-width: min(1200px, 92vw);
|
max-width: 100%;
|
||||||
max-height: 88vh;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lb-nav {
|
.lb-nav {
|
||||||
@@ -123,29 +163,36 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
/* 手机:滑动翻页为主,箭头贴边缩小;位置全走安全区 */
|
||||||
|
@media (pointer: coarse) {
|
||||||
.lb-img {
|
.lb-img {
|
||||||
max-width: 96vw;
|
border-radius: 0;
|
||||||
max-height: 74vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lb-nav {
|
.lb-nav {
|
||||||
width: 38px;
|
width: 40px;
|
||||||
height: 38px;
|
height: 40px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lb-nav.prev {
|
.lb-nav.prev {
|
||||||
left: 10px;
|
left: calc(6px + env(safe-area-inset-left));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lb-nav.next {
|
.lb-nav.next {
|
||||||
right: 10px;
|
right: calc(6px + env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lb-close {
|
.lb-close {
|
||||||
top: 10px;
|
top: calc(10px + env(safe-area-inset-top));
|
||||||
right: 10px;
|
right: calc(10px + env(safe-area-inset-right));
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-count {
|
||||||
|
bottom: calc(14px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { publicApi } from '../api'
|
import { publicApi } from '../api'
|
||||||
import { site } from '../site'
|
import { site } from '../site'
|
||||||
import { socialIcon } from '../socialIcons'
|
import { socialIcon } from '../socialIcons'
|
||||||
@@ -8,6 +8,35 @@ import { relativeDate, stripTags, thumbURL } from '../utils'
|
|||||||
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
|
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
|
||||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||||
|
|
||||||
|
// 文章页目录(PostView 传入 headings;空数组时不渲染这张卡)。
|
||||||
|
// 高亮跟随滚动:视口上方最近的那个标题算「当前」。
|
||||||
|
const props = defineProps({
|
||||||
|
toc: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
const activeId = ref('')
|
||||||
|
let tocObserver = null
|
||||||
|
|
||||||
|
function watchToc() {
|
||||||
|
if (tocObserver) tocObserver.disconnect()
|
||||||
|
if (!props.toc.length) return
|
||||||
|
tocObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) activeId.value = e.target.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '0px 0px -65% 0px' }
|
||||||
|
)
|
||||||
|
nextTick(() => {
|
||||||
|
for (const t of props.toc) {
|
||||||
|
const el = document.getElementById(t.id)
|
||||||
|
if (el) tocObserver.observe(el)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watch(() => props.toc, watchToc, { deep: false })
|
||||||
|
onBeforeUnmount(() => tocObserver && tocObserver.disconnect())
|
||||||
|
|
||||||
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
|
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
|
||||||
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
|
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
|
||||||
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
|
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
|
||||||
@@ -62,6 +91,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside v-if="!isVivid" class="rail-right">
|
<aside v-if="!isVivid" class="rail-right">
|
||||||
|
<section v-if="toc.length" class="card toc-card">
|
||||||
|
<p class="eyebrow">目录</p>
|
||||||
|
<ul class="toc-list">
|
||||||
|
<li v-for="t in toc" :key="t.id" :class="{ h3: t.level >= 3, on: activeId === t.id }">
|
||||||
|
<a :href="'#' + t.id" :class="{ on: activeId === t.id }">{{ t.text }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p class="eyebrow">关于这里</p>
|
<p class="eyebrow">关于这里</p>
|
||||||
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
|
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
|
||||||
@@ -108,15 +146,6 @@ onMounted(async () => {
|
|||||||
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
|
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
|
||||||
所有宽度都不缺位。 -->
|
所有宽度都不缺位。 -->
|
||||||
<footer class="rail-foot">
|
<footer class="rail-foot">
|
||||||
<nav class="foot-links" aria-label="页脚导航">
|
|
||||||
<template v-for="(l, i) in links" :key="l.to">
|
|
||||||
<span v-if="i" class="dot">·</span>
|
|
||||||
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
|
|
||||||
</template>
|
|
||||||
<span class="dot">·</span>
|
|
||||||
<a href="/rss.xml">RSS</a>
|
|
||||||
</nav>
|
|
||||||
<!-- 后台填的社交账号:单独一排 -->
|
|
||||||
<div v-if="social.length" class="social-row">
|
<div v-if="social.length" class="social-row">
|
||||||
<a
|
<a
|
||||||
v-for="s in social"
|
v-for="s in social"
|
||||||
@@ -132,6 +161,14 @@ onMounted(async () => {
|
|||||||
<span v-else>{{ s.label }}</span>
|
<span v-else>{{ s.label }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="foot-links" aria-label="页脚导航">
|
||||||
|
<template v-for="(l, i) in links" :key="l.to">
|
||||||
|
<span v-if="i" class="dot">·</span>
|
||||||
|
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
|
||||||
|
</template>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<a href="/rss.xml">RSS</a>
|
||||||
|
</nav>
|
||||||
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
|
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
|
||||||
<div class="foot-meta">
|
<div class="foot-meta">
|
||||||
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
|
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
|
||||||
@@ -175,6 +212,24 @@ onMounted(async () => {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文章目录:当前小节高亮,层级缩进 */
|
||||||
|
.toc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.toc-list li {margin: 2px 0;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
padding-left: 8px;}
|
||||||
|
.toc-list li.h3 {padding-left: 20px;
|
||||||
|
font-size: 12.5px;}
|
||||||
|
.toc-list li.on {border-left-color: var(--accent);}
|
||||||
|
.toc-list a {color: var(--muted);
|
||||||
|
text-decoration: none;}
|
||||||
|
.toc-list li.on a, .toc-list a:hover {color: var(--accent);}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
@@ -268,12 +323,13 @@ onMounted(async () => {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图标 + 版权同排:源码等入口是单色小图标,hover 才亮成强调色 */
|
/* 社交按钮:页脚第一排居中 */
|
||||||
.social-row {
|
.social-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
margin-top: 2px;
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.foot-meta {
|
.foot-meta {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { reactive } from 'vue'
|
|||||||
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
|
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
|
||||||
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
|
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
|
||||||
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
|
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
|
||||||
// 打开时锁 body 滚动(和菜单 sheet 同一套做法)。
|
//
|
||||||
|
// 注意:这里绝不能碰 body 的 inline overflow——样式表的 overflow-x: clip
|
||||||
|
// 会被整个 overflow: hidden 覆盖,横向从「裁剪」变「可滚动容器」,
|
||||||
|
// 手机上整页能被拖宽(真实事故)。背景滚动由 ImgLightbox 遮罩自己的
|
||||||
|
// @wheel/@touchmove.prevent 拦截,滚动位置天然保留。
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
list: [],
|
list: [],
|
||||||
index: 0,
|
index: 0,
|
||||||
@@ -17,12 +21,10 @@ export function openLightbox(list, index = 0) {
|
|||||||
state.list = list
|
state.list = list
|
||||||
state.index = Math.max(0, Math.min(index, list.length - 1))
|
state.index = Math.max(0, Math.min(index, list.length - 1))
|
||||||
state.open = true
|
state.open = true
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeLightbox() {
|
export function closeLightbox() {
|
||||||
state.open = false
|
state.open = false
|
||||||
document.body.style.overflow = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stepLightbox(delta) {
|
export function stepLightbox(delta) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { setThumbBase } from './utils'
|
|||||||
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
|
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
|
||||||
const DARK_SKIN = 'ink'
|
const DARK_SKIN = 'ink'
|
||||||
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
|
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
|
||||||
const LIGHT_SKINS = ['paper', 'sage', 'rose']
|
const LIGHT_SKINS = ['paper']
|
||||||
|
|
||||||
// 客户端显示模式 —— 持久化在 localStorage
|
// 客户端显示模式 —— 持久化在 localStorage
|
||||||
const MODE_KEY = 'one.themeMode'
|
const MODE_KEY = 'one.themeMode'
|
||||||
@@ -53,7 +53,7 @@ function systemPrefersDark() {
|
|||||||
|
|
||||||
// 模式 → 实际 data-theme 值
|
// 模式 → 实际 data-theme 值
|
||||||
//
|
//
|
||||||
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
|
// light / auto 亮端 → 固定纸感(paper);皮肤选择已精简,暗端固定墨色
|
||||||
// dark → ink(强制暗色)
|
// dark → ink(强制暗色)
|
||||||
//
|
//
|
||||||
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
|
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
|
||||||
@@ -101,7 +101,7 @@ export const site = reactive({
|
|||||||
loaded: false
|
loaded: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// data-theme → 前台皮肤。classic: paper/sage/rose/ink;vivid: vivid/vivid-dark
|
// data-theme → 前台皮肤。classic: paper/ink(亮端固定纸感);vivid: vivid/vivid-dark
|
||||||
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
|
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
|
||||||
// data-ui → 整站 UI 版本(classic / vivid)
|
// data-ui → 整站 UI 版本(classic / vivid)
|
||||||
//
|
//
|
||||||
@@ -129,7 +129,7 @@ function applyTheme(animate = false) {
|
|||||||
setTimeout(() => el.classList.remove('theme-anim'), 300)
|
setTimeout(() => el.classList.remove('theme-anim'), 300)
|
||||||
}
|
}
|
||||||
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
|
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
|
||||||
// vivid 自带亮/暗两套调色板,不复用 paper/sage/rose —— 否则两套 token 会互相打架。
|
// vivid 自带亮/暗两套调色板,不复用 classic 的纸感/墨色 —— 否则两套 token 会互相打架。
|
||||||
const vivid = !adminScope && site.ui_id === 'vivid'
|
const vivid = !adminScope && site.ui_id === 'vivid'
|
||||||
const theme = vivid
|
const theme = vivid
|
||||||
? (isDark ? 'vivid-dark' : 'vivid')
|
? (isDark ? 'vivid-dark' : 'vivid')
|
||||||
|
|||||||
+137
-72
@@ -31,56 +31,34 @@
|
|||||||
--col-main: 640px;
|
--col-main: 640px;
|
||||||
--col-right: 264px;
|
--col-right: 264px;
|
||||||
--gutter: 28px;}
|
--gutter: 28px;}
|
||||||
:root[data-theme='ink'] {--paper: #1f1d1a;
|
:root[data-theme='ink'] {/* 墨:暖炭底、暖白字、鎏金点缀。比旧版更中性(褪掉黄褐相),
|
||||||
--card: #25221e;
|
层次靠三档灰阶拉开;强调金提亮提纯,暗底上对比更足 */
|
||||||
--paper-sunken: #2b2722;
|
--paper: #191817;
|
||||||
--ink: #ece5d2;
|
--card: #201e1c;
|
||||||
--ink-soft: #c3b58f;
|
--paper-sunken: #262420;
|
||||||
--muted: #8c8270;
|
--ink: #e7e3da;
|
||||||
--faint: #6a6155;
|
--ink-soft: #b8b2a6;
|
||||||
--line: #3a352e;
|
--muted: #867f73;
|
||||||
--line-soft: #2f2b25;
|
--faint: #5f594f;
|
||||||
--accent: #c3a972;
|
--line: #34302b;
|
||||||
--accent-soft: rgba(195, 169, 114, 0.14);
|
--line-soft: #292623;
|
||||||
--accent-line: rgba(195, 169, 114, 0.4);}
|
--accent: #d9b97c;
|
||||||
:root[data-theme='sage'] {--paper: #eef0e6;
|
--accent-soft: rgba(217, 185, 124, 0.12);
|
||||||
--card: #f6f7ef;
|
--accent-line: rgba(217, 185, 124, 0.4);}
|
||||||
--paper-sunken: #e2e6d6;
|
:root[data-admin-theme='dark'] {--admin-bg: #191817;
|
||||||
--ink: #2a3127;
|
|
||||||
--ink-soft: #475042;
|
|
||||||
--muted: #6f7a68;
|
|
||||||
--faint: #94a08d;
|
|
||||||
--line: #d6dcc9;
|
|
||||||
--line-soft: #e6e9d9;
|
|
||||||
--accent: #5f7b5c;
|
|
||||||
--accent-soft: rgba(95, 123, 92, 0.12);
|
|
||||||
--accent-line: rgba(95, 123, 92, 0.38);}
|
|
||||||
:root[data-theme='rose'] {--paper: #f7eee6;
|
|
||||||
--card: #fdf5ee;
|
|
||||||
--paper-sunken: #ecdfd3;
|
|
||||||
--ink: #3a2922;
|
|
||||||
--ink-soft: #5e463d;
|
|
||||||
--muted: #9a7c6f;
|
|
||||||
--faint: #b89c91;
|
|
||||||
--line: #ead9cb;
|
|
||||||
--line-soft: #f0e2d5;
|
|
||||||
--accent: #a55a4a;
|
|
||||||
--accent-soft: rgba(165, 90, 74, 0.1);
|
|
||||||
--accent-line: rgba(165, 90, 74, 0.38);}
|
|
||||||
:root[data-admin-theme='dark'] {--admin-bg: #1a1815;
|
|
||||||
--admin-paper: var(--admin-bg);
|
--admin-paper: var(--admin-bg);
|
||||||
--admin-card: #25221e;
|
--admin-card: #201e1c;
|
||||||
--admin-paper-sunken: #2f2b25;
|
--admin-paper-sunken: #262420;
|
||||||
--admin-ink: #ece5d2;
|
--admin-ink: #e7e3da;
|
||||||
--admin-ink-soft: #c3b58f;
|
--admin-ink-soft: #b8b2a6;
|
||||||
--admin-muted: #9a8f7e;
|
--admin-muted: #948c7f;
|
||||||
--admin-faint: #6e6557;
|
--admin-faint: #635c51;
|
||||||
--admin-line: #3a352e;
|
--admin-line: #34302b;
|
||||||
--admin-line-soft: #2f2b25;
|
--admin-line-soft: #292623;
|
||||||
--admin-accent: #c3a972;
|
--admin-accent: #d9b97c;
|
||||||
--admin-accent-soft: rgba(195, 169, 114, 0.16);
|
--admin-accent-soft: rgba(217, 185, 124, 0.16);
|
||||||
--admin-accent-line: rgba(195, 169, 114, 0.42);
|
--admin-accent-line: rgba(217, 185, 124, 0.42);
|
||||||
--admin-on-accent: #1a1815;
|
--admin-on-accent: #191817;
|
||||||
--admin-danger: #d68d75;
|
--admin-danger: #d68d75;
|
||||||
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
||||||
--admin-overlay: rgba(0, 0, 0, 0.6);}
|
--admin-overlay: rgba(0, 0, 0, 0.6);}
|
||||||
@@ -230,7 +208,10 @@ html.topbar-hidden {--topbar-h: 0px;}
|
|||||||
min-width: 0;}
|
min-width: 0;}
|
||||||
.prose {font-size: 16.5px;
|
.prose {font-size: 16.5px;
|
||||||
line-height: var(--lh);
|
line-height: var(--lh);
|
||||||
color: var(--ink);}
|
color: var(--ink);
|
||||||
|
/* anywhere 而非 break-word:长 URL/长 token 要参与 min-content 计算,
|
||||||
|
否则时间线正文里的裸链接会把列撑破(真机上浏览器缩放适配后整页缩小+右侧留白) */
|
||||||
|
overflow-wrap: anywhere;}
|
||||||
.prose > :first-child {margin-top: 22px;}
|
.prose > :first-child {margin-top: 22px;}
|
||||||
.prose p {margin: 0 0 1.1em;}
|
.prose p {margin: 0 0 1.1em;}
|
||||||
.prose > p:first-of-type::first-letter {float: left;
|
.prose > p:first-of-type::first-letter {float: left;
|
||||||
@@ -254,6 +235,17 @@ html.topbar-hidden {--topbar-h: 0px;}
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: var(--accent);}
|
background: var(--accent);}
|
||||||
.prose h4 {font-size: 16.5px;}
|
.prose h4 {font-size: 16.5px;}
|
||||||
|
/* 悬停浮现的标题锚点(enhanceProse 注入,指向 heading id) */
|
||||||
|
.prose .h-anchor {opacity: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72em;
|
||||||
|
color: var(--faint);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 0;
|
||||||
|
transition: opacity .2s;}
|
||||||
|
.prose h2:hover .h-anchor, .prose h3:hover .h-anchor, .prose h4:hover .h-anchor {opacity: 1;}
|
||||||
|
.prose .h-anchor:hover {color: var(--accent);}
|
||||||
.prose blockquote {margin: 1.4em 0;
|
.prose blockquote {margin: 1.4em 0;
|
||||||
padding: 2px 0 2px 18px;
|
padding: 2px 0 2px 18px;
|
||||||
border-left: 2px solid var(--accent-line);
|
border-left: 2px solid var(--accent-line);
|
||||||
@@ -273,12 +265,32 @@ html.topbar-hidden {--topbar-h: 0px;}
|
|||||||
background: var(--paper-sunken);
|
background: var(--paper-sunken);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
border-radius: 3px;}
|
border-radius: 3px;}
|
||||||
|
.prose .pre-wrap {position: relative;}
|
||||||
|
.pre-copy {position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: inherit;
|
||||||
|
background: color-mix(in srgb, var(--ink) 14%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .15s, transform .08s;}
|
||||||
|
.pre-copy:hover {opacity: 1;}
|
||||||
|
.pre-wrap:hover .pre-copy {opacity: 0.85;}
|
||||||
|
.pre-copy:active {transform: translateY(2px);}
|
||||||
|
.pre-copy.done {opacity: 1;
|
||||||
|
color: var(--accent);}
|
||||||
.prose pre {background: var(--paper-sunken);
|
.prose pre {background: var(--paper-sunken);
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
padding: 14px 16px;
|
padding: 16px 18px;
|
||||||
overflow-x: auto;
|
overflow: auto;
|
||||||
line-height: 1.7;}
|
line-height: 1.7;
|
||||||
|
max-height: 30.5em;}
|
||||||
.prose pre code {background: none;
|
.prose pre code {background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 13.5px;}
|
font-size: 13.5px;}
|
||||||
@@ -289,14 +301,22 @@ html.topbar-hidden {--topbar-h: 0px;}
|
|||||||
background: var(--line);
|
background: var(--line);
|
||||||
margin: 2em 0;}
|
margin: 2em 0;}
|
||||||
.prose table {width: 100%;
|
.prose table {width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
margin: 1.4em 0;
|
border-spacing: 0;
|
||||||
font-size: 15px;}
|
margin: 1.6em 0;
|
||||||
.prose th, .prose td {border: 1px solid var(--line);
|
font-size: 14.5px;
|
||||||
padding: 7px 10px;
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;}
|
||||||
|
.prose th, .prose td {border: 0;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
padding: 11px 16px;
|
||||||
text-align: left;}
|
text-align: left;}
|
||||||
|
.prose tr > * + * {border-left: 1px solid var(--line-soft);}
|
||||||
|
.prose tbody tr:last-child td {border-bottom: 0;}
|
||||||
.prose th {background: var(--paper-sunken);
|
.prose th {background: var(--paper-sunken);
|
||||||
font-weight: 600;}
|
font-weight: 600;}
|
||||||
|
.prose tbody tr:nth-child(even) {background: color-mix(in srgb, var(--paper-sunken) 55%, transparent);}
|
||||||
.prose-short {font-size: 20px;
|
.prose-short {font-size: 20px;
|
||||||
line-height: 1.9;}
|
line-height: 1.9;}
|
||||||
.prose-short > p:first-of-type::first-letter {float: none;
|
.prose-short > p:first-of-type::first-letter {float: none;
|
||||||
@@ -835,17 +855,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;
|
||||||
@@ -868,12 +908,13 @@ html.topbar-hidden {--topbar-h: 0px;}
|
|||||||
color: var(--admin-muted);
|
color: var(--admin-muted);
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
text-transform: uppercase;}
|
text-transform: uppercase;}
|
||||||
.tb-btn {width: 28px;
|
/* xLog 式工具栏按钮:方形圆角、线性图标、悬停出底框 */
|
||||||
height: 28px;
|
.tb-btn {width: 32px;
|
||||||
|
height: 32px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--admin-ink-soft);
|
color: var(--admin-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -886,9 +927,12 @@ html.topbar-hidden {--topbar-h: 0px;}
|
|||||||
border-color: var(--admin-line);
|
border-color: var(--admin-line);
|
||||||
color: var(--admin-ink);}
|
color: var(--admin-ink);}
|
||||||
}
|
}
|
||||||
.tb-btn.italic {font-style: italic;}
|
.tb-btn svg {width: 17px;
|
||||||
.tb-btn.mono {font-family: var(--mono);
|
height: 17px;}
|
||||||
font-size: 11.5px;}
|
.tb-text {font-family: var(--mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: inherit;}
|
||||||
.tb-popover {display: flex;
|
.tb-popover {display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -1383,4 +1427,25 @@ a.cm-name-a:hover {color: var(--accent);}
|
|||||||
.cm-link {padding: 8px 2px;}
|
.cm-link {padding: 8px 2px;}
|
||||||
.cm-more-r {padding: 10px 0;}
|
.cm-more-r {padding: 10px 0;}
|
||||||
.cm-input {font-size: 16px;}
|
.cm-input {font-size: 16px;}
|
||||||
}
|
}
|
||||||
|
/* 编辑器图片缩放把手:Milkdown 图片块 hover 底部出现拖拽条(上下拖调高度,
|
||||||
|
比例存进 markdown 的 alt)。默认半透明让访客知道它在那儿。 */
|
||||||
|
.admin-shell .image-resize-handle {opacity: 0.45;}
|
||||||
|
.admin-shell .image-wrapper:hover .image-resize-handle {opacity: 1;}
|
||||||
|
|
||||||
|
/* lightbox 打开时藏掉吸顶/固定栏:一是全屏观感,二是绕开真机上
|
||||||
|
backdrop-filter 元素合成层盖过遮罩的 Chromium 绘制问题 */
|
||||||
|
body.lb-open .sticky-head,
|
||||||
|
body.lb-open .topbar,
|
||||||
|
body.lb-open .reading-bar {visibility: hidden;}
|
||||||
|
|
||||||
|
/* classic 文章页阅读进度条:视口顶部细线,vivid 的同款能力 */
|
||||||
|
.reading-bar {position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
width: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
z-index: 60;
|
||||||
|
transition: width .1s linear;
|
||||||
|
pointer-events: none;}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { sanitizeHtml } from '../../utils'
|
import { applyImageRatio, sanitizeHtml } from '../../utils'
|
||||||
import { openLightbox } from '../../lightbox'
|
import { openLightbox } from '../../lightbox'
|
||||||
import CommentSection from '../../components/comments/CommentSection.vue'
|
import CommentSection from '../../components/comments/CommentSection.vue'
|
||||||
import LinkCard from '../../components/LinkCard.vue'
|
import LinkCard from '../../components/LinkCard.vue'
|
||||||
@@ -168,7 +168,7 @@ function onTocClick(e) {
|
|||||||
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
|
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
|
||||||
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
|
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
|
||||||
|
|
||||||
<div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" />
|
<div class="prose" :class="{ 'prose-note': isShort }" v-html="applyImageRatio(sanitizeHtml(props.contentHtml))" />
|
||||||
|
|
||||||
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
|
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { site } from '../../site'
|
import { site } from '../../site'
|
||||||
|
import { socialIcon } from '../../socialIcons'
|
||||||
|
|
||||||
|
// 社交按钮与 classic 页脚同源:后台「社交链接」+ 固定源码仓库入口
|
||||||
|
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
|
||||||
|
const social = (site.social_links || []).concat([{ label: '源码', url: PROJECT_REPO }])
|
||||||
|
.filter((s) => s && s.url)
|
||||||
|
|
||||||
// 余白页脚。对应设计稿的 <footer class="foot">。
|
// 余白页脚。对应设计稿的 <footer class="foot">。
|
||||||
//
|
//
|
||||||
@@ -21,6 +27,20 @@ const links = [
|
|||||||
<template>
|
<template>
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
<div class="quote">What’s left unsaid holds the most weight.</div>
|
<div class="quote">What’s left unsaid holds the most weight.</div>
|
||||||
|
<div v-if="social.length" class="foot-social">
|
||||||
|
<a
|
||||||
|
v-for="s in social"
|
||||||
|
:key="s.label + s.url"
|
||||||
|
:href="s.url"
|
||||||
|
:title="s.label"
|
||||||
|
:aria-label="s.label"
|
||||||
|
:target="s.url.startsWith('http') ? '_blank' : undefined"
|
||||||
|
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
|
||||||
|
>
|
||||||
|
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
|
||||||
|
<span v-else>{{ s.label }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></div>
|
<div>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></div>
|
||||||
<div class="foot-nav">
|
<div class="foot-nav">
|
||||||
<template v-for="(l, i) in links" :key="l.to">
|
<template v-for="(l, i) in links" :key="l.to">
|
||||||
|
|||||||
@@ -96,8 +96,12 @@ html[data-ui='vivid'] .prose code {font-family:var(--mono);font-size:.88em;
|
|||||||
padding:2px 7px;border-radius:6px;}
|
padding:2px 7px;border-radius:6px;}
|
||||||
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
|
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
|
||||||
color:#e8e8f0;padding:18px 20px;border-radius:12px;
|
color:#e8e8f0;padding:18px 20px;border-radius:12px;
|
||||||
overflow-x:auto;font-size:13.5px;line-height:1.7;
|
overflow:auto;font-size:13.5px;line-height:1.7;
|
||||||
box-shadow:var(--shadow-lg);}
|
box-shadow:var(--shadow-lg);
|
||||||
|
max-height:30.5em;}
|
||||||
|
/* 复制按钮(vivid 配色:浅字浮在深底上) */
|
||||||
|
html[data-ui='vivid'] .pre-copy {color:#e8e8f0;
|
||||||
|
background:rgba(255,255,255,.14);}
|
||||||
/* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
|
/* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
|
||||||
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
|
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
|
||||||
background:color-mix(in srgb,var(--paper) 86%,var(--accent));
|
background:color-mix(in srgb,var(--paper) 86%,var(--accent));
|
||||||
@@ -215,3 +219,17 @@ html[data-ui='vivid'] .sr-only {position: absolute;
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;}
|
border: 0;}
|
||||||
html[data-ui='vivid'] .toc a:active { color: var(--accent); }
|
html[data-ui='vivid'] .toc a:active { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- 表格(xLog 式:圆角容器 + 斑马纹,替代满格线) ---- */
|
||||||
|
html[data-ui='vivid'] .prose table {width:100%;
|
||||||
|
border-collapse:separate;border-spacing:0;
|
||||||
|
margin:1.8em 0;font-size:14px;
|
||||||
|
border:1px solid var(--line);border-radius:12px;overflow:hidden;}
|
||||||
|
html[data-ui='vivid'] .prose th, html[data-ui='vivid'] .prose td {border:0;
|
||||||
|
border-bottom:1px solid var(--line-soft);
|
||||||
|
padding:12px 18px;text-align:left;}
|
||||||
|
html[data-ui='vivid'] .prose tr > * + * {border-left:1px solid var(--line-soft);}
|
||||||
|
html[data-ui='vivid'] .prose tbody tr:last-child td {border-bottom:0;}
|
||||||
|
html[data-ui='vivid'] .prose th {background:var(--accent-soft);font-weight:600;}
|
||||||
|
html[data-ui='vivid'] .prose tbody tr:nth-child(even) {background:var(--card);}
|
||||||
|
/* 标题锚点 + 复制按钮沿用 classic 的 .h-anchor/.pre-copy 基础样式 */
|
||||||
|
|||||||
@@ -169,6 +169,15 @@ html[data-ui='vivid'] .foot {max-width:1100px;margin:40px auto 0;padding:28px 28
|
|||||||
border-top:1px solid var(--line);
|
border-top:1px solid var(--line);
|
||||||
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;}
|
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;}
|
||||||
html[data-ui='vivid'] .foot a {color:var(--accent);font-weight:600;transition:opacity .25s}
|
html[data-ui='vivid'] .foot a {color:var(--accent);font-weight:600;transition:opacity .25s}
|
||||||
|
/* 页脚社交排:图标居中一行,与 classic 页脚同步 */
|
||||||
|
html[data-ui='vivid'] .foot-social {display:flex;justify-content:center;align-items:center;
|
||||||
|
gap:18px;margin:14px 0 18px;}
|
||||||
|
html[data-ui='vivid'] .foot-social a {color:var(--soft);font-weight:400;
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
|
width:30px;height:30px;border-radius:8px;transition:color .25s,background .25s;}
|
||||||
|
html[data-ui='vivid'] .foot-social a:hover {color:var(--accent);background:var(--accent-soft);}
|
||||||
|
html[data-ui='vivid'] .foot-social svg {width:17px;height:17px;fill:currentColor;}
|
||||||
|
html[data-ui='vivid'] .foot-social span {font-size:12px;}
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
html[data-ui='vivid'] .foot a:hover {opacity:.75}
|
html[data-ui='vivid'] .foot a:hover {opacity:.75}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -3,7 +3,7 @@ import DOMPurify from 'dompurify'
|
|||||||
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
|
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
|
||||||
// 所有 v-html 出口必须经过它。
|
// 所有 v-html 出口必须经过它。
|
||||||
export function sanitizeHtml(html) {
|
export function sanitizeHtml(html) {
|
||||||
return DOMPurify.sanitize(html || '')
|
return enhanceProse(DOMPurify.sanitize(html || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 缩略图 ----------
|
// ---------- 缩略图 ----------
|
||||||
@@ -28,6 +28,42 @@ export function thumbURL(u, w = 480) {
|
|||||||
return '/uploads/thumb/' + key + '?w=' + w
|
return '/uploads/thumb/' + key + '?w=' + w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- xLog 式排版增强 ----------
|
||||||
|
// 对消毒后的正文 HTML 做三件展示层增强(markdown/库里内容不动):
|
||||||
|
// 1) pre 包一层容器并注入悬停复制按钮;
|
||||||
|
// 2) 带 id 的标题(withHeadingIds 生成)尾部加 # 锚点;
|
||||||
|
// 3) (盘古之白在后端渲染出口做,见 backend/internal/render/pangu.go)
|
||||||
|
export function enhanceProse(html) {
|
||||||
|
if (!html) return html
|
||||||
|
// 复制按钮:包在 .pre-wrap 里,按钮悬浮于 pre 右上
|
||||||
|
html = html
|
||||||
|
.replace(/<pre\b/g, '<div class="pre-wrap"><button type="button" class="pre-copy" aria-label="复制代码">复制</button><pre')
|
||||||
|
.replace(/<\/pre>/g, '</pre></div>')
|
||||||
|
// 标题锚点:h2-h4 且有 id 的,末尾补一个 # 链接(悬停浮现)
|
||||||
|
html = html.replace(
|
||||||
|
/<h([2-4])([^>]*\bid="([^"]+)"[^>]*)>([\s\S]*?)<\/h\1>/g,
|
||||||
|
(m, lvl, attrs, id, inner) =>
|
||||||
|
`<h${lvl}${attrs}>${inner}<a class="h-anchor" href="#${id}" aria-label="标题链接">#</a></h${lvl}>`
|
||||||
|
)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器拖拽缩放的比例展示:Milkdown 图片块把高度比例写进 markdown 的 alt
|
||||||
|
// (),渲染出的 <img alt="0.6"> 在这里还原成对应的宽度并居中。
|
||||||
|
// 纯数字 alt 才生效(>=1 的当 100% 处理),正常图片的描述性 alt 不受影响。
|
||||||
|
export function applyImageRatio(html) {
|
||||||
|
if (!html) return html
|
||||||
|
return html.replace(/<img\b[^>]*>/gi, (tag) => {
|
||||||
|
const m = tag.match(/\balt="(\d*\.?\d+)"/i)
|
||||||
|
if (!m) return tag
|
||||||
|
const ratio = Number.parseFloat(m[1])
|
||||||
|
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) return tag
|
||||||
|
const style = `display:block;width:${(ratio * 100).toFixed(1)}%;max-width:100%;height:auto;margin-inline:auto;`
|
||||||
|
if (/\bstyle="/i.test(tag)) return tag.replace(/\bstyle="/i, 'style="' + style)
|
||||||
|
return tag.replace(/^<img/i, '<img style="' + style + '"')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
|
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
|
||||||
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
|
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
|
||||||
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
|
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import LeftNav from '../components/LeftNav.vue'
|
import LeftNav from '../components/LeftNav.vue'
|
||||||
import RightRail from '../components/RightRail.vue'
|
import RightRail from '../components/RightRail.vue'
|
||||||
@@ -9,7 +9,7 @@ import LinkCard from '../components/LinkCard.vue'
|
|||||||
import { openLightbox } from '../lightbox'
|
import { openLightbox } from '../lightbox'
|
||||||
import { publicApi } from '../api'
|
import { publicApi } from '../api'
|
||||||
import { site, applyDocTitle } from '../site'
|
import { site, applyDocTitle } from '../site'
|
||||||
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
|
import { applyImageRatio, formatDate, minutesLabel, sanitizeHtml } from '../utils'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const post = ref(null)
|
const post = ref(null)
|
||||||
@@ -33,6 +33,14 @@ function withHeadingIds(html) {
|
|||||||
const contentHtml = ref('')
|
const contentHtml = ref('')
|
||||||
const headings = ref([])
|
const headings = ref([])
|
||||||
|
|
||||||
|
// 顶部阅读进度条(classic):滚动过正文才算数,短文太短不显示
|
||||||
|
const progress = ref(0)
|
||||||
|
function onScroll() {
|
||||||
|
const doc = document.documentElement
|
||||||
|
const total = doc.scrollHeight - window.innerHeight
|
||||||
|
progress.value = total > 300 ? Math.min(100, (window.scrollY / total) * 100) : 0
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -42,7 +50,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
post.value = await publicApi.post(route.params.slug)
|
post.value = await publicApi.post(route.params.slug)
|
||||||
const prepared = withHeadingIds(post.value.content_html)
|
const prepared = withHeadingIds(post.value.content_html)
|
||||||
contentHtml.value = prepared.html
|
contentHtml.value = applyImageRatio(prepared.html)
|
||||||
headings.value = prepared.headings
|
headings.value = prepared.headings
|
||||||
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
|
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -52,7 +60,11 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
|
||||||
watch(() => route.params.slug, load)
|
watch(() => route.params.slug, load)
|
||||||
|
|
||||||
const isShort = computed(() => post.value && post.value.kind === 'short')
|
const isShort = computed(() => post.value && post.value.kind === 'short')
|
||||||
@@ -71,6 +83,8 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="shell">
|
<div v-else class="shell">
|
||||||
|
<!-- 阅读进度:贴在视口顶部的细线 -->
|
||||||
|
<div class="reading-bar" :style="{ width: progress + '%' }" aria-hidden="true"></div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
|
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
|
||||||
补上,和 HomeView 的三栏一致。 -->
|
补上,和 HomeView 的三栏一致。 -->
|
||||||
@@ -146,7 +160,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
|||||||
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
|
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
|
||||||
<CommentSection v-if="post" :post-id="post.id" />
|
<CommentSection v-if="post" :post-id="post.id" />
|
||||||
</main>
|
</main>
|
||||||
<RightRail />
|
<RightRail :toc="isShort ? [] : headings" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user