后台管理重塑 + 主题 / 皮肤双轴 + a11y 与编辑器增强

Backend
- Post 加 cover_url,Tag 加 color;老库 ALTER 兼容
- admin API: GET /api/admin/dashboard,POST /bulk,POST /tags/:id/merge,
  PUT /tags/:id 接 {name,color},Settings 加 light_skin_id(兼容老 theme_id)
- 白名单兜底(非法 light_skin_id 落 paper)
- 新增合并 / Dashboard / Theme 测试覆盖

Frontend
- AdminLayout 改为侧边栏布局;新增 Dashboard 总览页
- PostsView 卡片化预览 + 筛选 chip + 批量操作 + 排序
- EditorView 增强:封面图 / WYSIWYG↔MD 双模式 / 实时大纲 / 字数统计 /
  自动保存条 / ⌘S / unsaved 守卫(beforeunload + beforeRouteLeave)
- TagsView 加色板 + 合并到目标
- SettingsView 主题外观改为「亮色皮肤」三选一 + 「暗色皮肤」固定墨色说明
- 新增 CommandPalette(⌘K)+ ShortcutsPanel(?)
- api.js 接新接口;router.js 拆 /admin → /admin/posts

主题 / 皮肤双轴
- site.js: themeMode(light/dark/auto)+ light_skin_id;data-theme 前台,
  data-admin-theme 后台(仅跟随 mode 切明暗)
- styles.css: :root[data-theme] 四套皮肤 + :root[data-admin-theme=dark] 后台墨感
- 修 sticky head / ThemeSwitcher / 表单 / 批量条 等 token 引用
- 后台用独立 --admin-* 系列,与前台主题解耦
- 左栏底部放 inline 形态 ThemeSwitcher(图标按钮 36×36),mobile 保留浮窗

a11y
- 全局 :focus-visible 描边(前后台跟随 token)
- prefers-reduced-motion 全局降级
- color-scheme 随主题切
- meta theme-color 双套(light/dark)
- 命令面板 / 快捷键面板:role=dialog aria-modal aria-label + 搜索框 aria-label
- icon-only 按钮加 aria-label(tag × / 清除选择 / 移除封面等)
- 输入框 aria-label + spellcheck=false
- PostsView 筛选同步 URL(可分享、可刷新保留)
- img width/height + loading=lazy
- utils.js 用 Intl.DateTimeFormat(zh-CN / sv-SE)
This commit is contained in:
cjun
2026-09-21 22:14:58 +08:00
parent 4d8f2de3a4
commit a721e26e45
32 changed files with 5823 additions and 976 deletions
+100 -20
View File
@@ -29,6 +29,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/logout", a.logout) mux.HandleFunc("/api/admin/logout", a.logout)
mux.HandleFunc("/api/admin/me", a.guard(a.me)) mux.HandleFunc("/api/admin/me", a.guard(a.me))
mux.HandleFunc("/api/admin/dashboard", a.guard(a.dashboard))
mux.HandleFunc("/api/admin/posts", a.guard(a.listPosts)) mux.HandleFunc("/api/admin/posts", a.guard(a.listPosts))
mux.HandleFunc("/api/admin/posts/", a.guard(a.postByID)) mux.HandleFunc("/api/admin/posts/", a.guard(a.postByID))
mux.HandleFunc("/api/admin/tags", a.guard(a.listTags)) mux.HandleFunc("/api/admin/tags", a.guard(a.listTags))
@@ -128,12 +129,13 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
o := store.ListOptions{ o := store.ListOptions{
Kind: httpx.QueryString(r, "kind"), Kind: httpx.QueryString(r, "kind"),
Tag: httpx.QueryString(r, "tag"), Tag: httpx.QueryString(r, "tag"),
Query: httpx.QueryString(r, "q"), Query: httpx.QueryString(r, "q"),
Status: httpx.QueryString(r, "status"), Status: httpx.QueryString(r, "status"),
Page: httpx.QueryInt(r, "page", 1), OrderBy: httpx.QueryString(r, "order"),
Size: httpx.QueryInt(r, "size", 20), Page: httpx.QueryInt(r, "page", 1),
Size: httpx.QueryInt(r, "size", 20),
} }
if o.Status == "" { if o.Status == "" {
o.Status = "any" o.Status = "any"
@@ -150,7 +152,7 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
httpx.BadRequest(w, "invalid body") httpx.BadRequest(w, "invalid body")
return return
} }
in.Status = normalizeStatus(in.Status) in.Status = store.NormalizeStatus(in.Status)
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)
@@ -162,21 +164,16 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
} }
} }
func normalizeStatus(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case model.StatusDraft, model.StatusPublished:
return strings.ToLower(strings.TrimSpace(s))
default:
return model.StatusDraft
}
}
func (a *API) postByID(w http.ResponseWriter, r *http.Request) { func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/posts/"), "/") rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/posts/"), "/")
if rest == "" { if rest == "" {
a.listPosts(w, r) a.listPosts(w, r)
return return
} }
if rest == "bulk" {
a.bulkPosts(w, r)
return
}
id, err := parseInt(rest) id, err := parseInt(rest)
if err != nil { if err != nil {
httpx.BadRequest(w, "bad post id") httpx.BadRequest(w, "bad post id")
@@ -193,7 +190,7 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
return return
} }
if in.Status != "" { if in.Status != "" {
in.Status = normalizeStatus(in.Status) in.Status = store.NormalizeStatus(in.Status)
} }
p, err := a.Store.Update(id, in) p, err := a.Store.Update(id, in)
writeOne(w, p, err) writeOne(w, p, err)
@@ -264,21 +261,27 @@ func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
func (a *API) tagByID(w http.ResponseWriter, r *http.Request) { func (a *API) tagByID(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/tags/"), "/") rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/tags/"), "/")
id, err := parseInt(rest) parts := strings.SplitN(rest, "/", 2)
id, err := parseInt(parts[0])
if err != nil { if err != nil {
httpx.BadRequest(w, "bad tag id") httpx.BadRequest(w, "bad tag id")
return return
} }
if len(parts) == 2 && parts[1] == "merge" {
a.mergeTag(w, r, id)
return
}
switch r.Method { switch r.Method {
case http.MethodPut, http.MethodPatch: case http.MethodPut, http.MethodPatch:
var in struct { var in struct {
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color"`
} }
if err := httpx.Decode(r, &in); err != nil { if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body") httpx.BadRequest(w, "invalid body")
return return
} }
t, err := a.Store.RenameTag(id, in.Name) t, err := a.Store.UpdateTag(id, in.Name, in.Color)
writeTag(w, t, err) writeTag(w, t, err)
case http.MethodDelete: case http.MethodDelete:
if err := a.Store.DeleteTag(id); err != nil { if err := a.Store.DeleteTag(id); err != nil {
@@ -291,6 +294,26 @@ func (a *API) tagByID(w http.ResponseWriter, r *http.Request) {
} }
} }
func (a *API) mergeTag(w http.ResponseWriter, r *http.Request, fromID int64) {
if r.Method != http.MethodPost {
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
return
}
var in struct {
ToID int64 `json:"to_id"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if in.ToID == 0 {
httpx.BadRequest(w, "to_id required")
return
}
t, err := a.Store.MergeTags(fromID, in.ToID)
writeTag(w, t, err)
}
func writeTag(w http.ResponseWriter, t model.Tag, err error) { func writeTag(w http.ResponseWriter, t model.Tag, err error) {
if err != nil { if err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
@@ -348,3 +371,60 @@ func parseInt(s string) (int64, error) {
} }
return n, nil return n, nil
} }
// ---------- dashboard ----------
func (a *API) dashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
return
}
d, err := a.Store.Dashboard()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, d)
}
// ---------- bulk posts ----------
type bulkPostsRequest struct {
IDs []int64 `json:"ids"`
Action string `json:"action"` // "publish" | "draft" | "delete"
}
func (a *API) bulkPosts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
return
}
var in bulkPostsRequest
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if len(in.IDs) == 0 {
httpx.BadRequest(w, "ids required")
return
}
switch in.Action {
case "publish", "draft":
n, err := a.Store.BulkUpdateStatus(in.IDs, store.NormalizeStatus(in.Action))
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true, "updated": n})
case "delete":
var failed int
for _, id := range in.IDs {
if err := a.Store.Delete(id); err != nil {
failed++
}
}
httpx.OK(w, map[string]any{"ok": true, "deleted": len(in.IDs) - failed, "failed": failed})
default:
httpx.BadRequest(w, "action must be one of publish|draft|delete")
}
}
+32
View File
@@ -18,6 +18,7 @@ type Post struct {
Title string `json:"title"` Title string `json:"title"`
Slug string `json:"slug"` Slug string `json:"slug"`
Summary string `json:"summary"` Summary string `json:"summary"`
CoverURL string `json:"cover_url"`
ContentMd string `json:"content_md,omitempty"` ContentMd string `json:"content_md,omitempty"`
ContentHTML string `json:"content_html"` ContentHTML string `json:"content_html"`
Status string `json:"status"` Status string `json:"status"`
@@ -33,6 +34,7 @@ type PostInput struct {
Title string `json:"title"` Title string `json:"title"`
Slug string `json:"slug"` Slug string `json:"slug"`
Summary string `json:"summary"` Summary string `json:"summary"`
CoverURL string `json:"cover_url"`
ContentMd string `json:"content_md"` ContentMd string `json:"content_md"`
Status string `json:"status"` Status string `json:"status"`
PublishedAt string `json:"published_at"` PublishedAt string `json:"published_at"`
@@ -44,6 +46,7 @@ type Tag struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Color string `json:"color"`
Count int `json:"count"` Count int `json:"count"`
} }
@@ -65,6 +68,26 @@ type Page struct {
Size int `json:"size"` Size int `json:"size"`
} }
// Dashboard is the snapshot rendered on /admin (homepage).
type Dashboard struct {
TotalPosts int `json:"total_posts"`
PublishedPosts int `json:"published_posts"`
DraftPosts int `json:"draft_posts"`
ShortPosts int `json:"short_posts"`
LongPosts int `json:"long_posts"`
TotalTags int `json:"total_tags"`
TotalWords int `json:"total_words"`
RecentPosts []Post `json:"recent_posts"`
RecentDrafts []Post `json:"recent_drafts"`
TopTags []Tag `json:"top_tags"`
PublishedByMonth []MonthBucket `json:"published_by_month"`
}
type MonthBucket struct {
Month string `json:"month"` // "YYYY-MM"
Count int `json:"count"`
}
type Settings struct { type Settings struct {
SiteTitle string `json:"site_title"` SiteTitle string `json:"site_title"`
SiteDesc string `json:"site_desc"` SiteDesc string `json:"site_desc"`
@@ -73,4 +96,13 @@ type Settings struct {
FooterNote string `json:"footer_note"` FooterNote string `json:"footer_note"`
ICPLicense string `json:"icp"` ICPLicense string `json:"icp"`
PostsPerPage int `json:"posts_per_page"` PostsPerPage int `json:"posts_per_page"`
// LightSkinID is the front-end skin used when the client (or system)
// prefers light. Valid values: paper / sage / rose.
// Dark side is fixed to ink for now — kept implicit so we can add
// dark variants later without breaking clients.
LightSkinID string `json:"light_skin_id"`
// ThemeID is kept for backward compatibility with older clients that
// only know about a single skin. settingsFromMap falls back to it
// when LightSkinID is empty.
ThemeID string `json:"theme_id,omitempty"`
} }
+325 -28
View File
@@ -44,6 +44,7 @@ func (s *Store) migrate() error {
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL, slug TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '',
cover_url TEXT NOT NULL DEFAULT '',
content_md TEXT NOT NULL DEFAULT '', content_md TEXT NOT NULL DEFAULT '',
content_html TEXT NOT NULL DEFAULT '', content_html TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft', status TEXT NOT NULL DEFAULT 'draft',
@@ -53,9 +54,10 @@ func (s *Store) migrate() error {
reading_minutes INTEGER NOT NULL DEFAULT 1 reading_minutes INTEGER NOT NULL DEFAULT 1
)`, ai), )`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags ( fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags (
id %s, id %s,
name TEXT NOT NULL, name TEXT NOT NULL,
slug TEXT NOT NULL slug TEXT NOT NULL,
color TEXT NOT NULL DEFAULT ''
)`, ai), )`, ai),
`CREATE TABLE IF NOT EXISTS post_tags ( `CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER NOT NULL, post_id INTEGER NOT NULL,
@@ -73,6 +75,20 @@ func (s *Store) migrate() error {
} }
} }
// Lightweight column-add migrations for older databases. SQLite supports
// ADD COLUMN; Postgres doesn't support IF NOT EXISTS on column add, so we
// swallow "already exists" errors either way.
columnAdds := []string{
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
}
for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
!strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migrate column: %w", err)
}
}
// Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support. // Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support.
indexes := []struct{ name, ddl string }{ indexes := []struct{ name, ddl string }{
{"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`}, {"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`},
@@ -144,6 +160,18 @@ func settingsFromMap(m map[string]string) model.Settings {
ICPLicense: m["icp"], ICPLicense: m["icp"],
PostsPerPage: 10, PostsPerPage: 10,
} }
// LightSkinID is the new canonical name; fall back to legacy theme_id
// for clients that haven't been updated, and finally to "paper".
st.LightSkinID = m["light_skin_id"]
if st.LightSkinID == "" {
st.LightSkinID = m["theme_id"]
}
if st.LightSkinID == "" {
st.LightSkinID = "paper"
}
// Mirror the value into ThemeID so legacy API consumers still see it
// in the JSON response.
st.ThemeID = st.LightSkinID
if n := atoi(m["posts_per_page"]); n > 0 { if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n st.PostsPerPage = n
} }
@@ -161,10 +189,21 @@ func atoi(v string) int {
return n return n
} }
// ValidLightSkins is the whitelist of light-side skin ids the API accepts.
// Anything outside this set falls back to "paper" on write.
var ValidLightSkins = map[string]bool{
"paper": true,
"sage": true,
"rose": true,
}
func (s *Store) UpdateSettings(st model.Settings) error { func (s *Store) UpdateSettings(st model.Settings) error {
if st.PostsPerPage <= 0 { if st.PostsPerPage <= 0 {
st.PostsPerPage = 10 st.PostsPerPage = 10
} }
if !ValidLightSkins[st.LightSkinID] {
st.LightSkinID = "paper"
}
sets := map[string]string{ sets := map[string]string{
"site_title": st.SiteTitle, "site_title": st.SiteTitle,
"site_desc": st.SiteDesc, "site_desc": st.SiteDesc,
@@ -173,6 +212,9 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"footer_note": st.FooterNote, "footer_note": st.FooterNote,
"icp": st.ICPLicense, "icp": st.ICPLicense,
"posts_per_page": fmt.Sprint(st.PostsPerPage), "posts_per_page": fmt.Sprint(st.PostsPerPage),
"light_skin_id": st.LightSkinID,
// Mirror to theme_id so any older client still sees something.
"theme_id": st.LightSkinID,
} }
for k, v := range sets { for k, v := range sets {
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
@@ -202,13 +244,13 @@ type ListOptions struct {
OrderBy string OrderBy string
} }
const postCols = `id, kind, title, slug, summary, content_md, content_html, status, const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
published_at, created_at, updated_at, reading_minutes` published_at, created_at, updated_at, reading_minutes`
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) { func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post var p model.Post
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd, err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes) &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
p.Tags = []string{} p.Tags = []string{}
return p, err return p, err
} }
@@ -340,8 +382,8 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
} }
func scanPostInto(row *sql.Row, p *model.Post) error { func scanPostInto(row *sql.Row, p *model.Post) error {
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd, err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes) &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return ErrNotFound return ErrNotFound
} }
@@ -386,11 +428,12 @@ func (s *Store) uniqueSlug(base string, excludeID int64) string {
func (s *Store) Create(in model.PostInput) (model.Post, error) { func (s *Store) Create(in model.PostInput) (model.Post, error) {
p := model.Post{ p := model.Post{
Kind: in.Kind, Kind: in.Kind,
Title: strings.TrimSpace(in.Title), Title: strings.TrimSpace(in.Title),
Slug: in.Slug, Slug: in.Slug,
Status: in.Status, Status: in.Status,
Tags: []string{}, CoverURL: strings.TrimSpace(in.CoverURL),
Tags: []string{},
} }
if p.Kind == "" { if p.Kind == "" {
p.Kind = model.KindLong p.Kind = model.KindLong
@@ -428,17 +471,17 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
} }
var id int64 var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,content_md,content_html,status, q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
published_at,created_at,updated_at,reading_minutes) published_at,created_at,updated_at,reading_minutes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML, err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id) p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
if err != nil { if err != nil {
return p, err return p, err
} }
} else { } else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML, res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes) p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
if err != nil { if err != nil {
return p, err return p, err
@@ -483,6 +526,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
if in.PublishedAt != "" { if in.PublishedAt != "" {
p.PublishedAt = in.PublishedAt p.PublishedAt = in.PublishedAt
} }
// CoverURL is taken verbatim from input. The frontend always sends the
// current value (empty string included), so any update round-trips with
// whatever the user last saved.
p.CoverURL = strings.TrimSpace(in.CoverURL)
p.UpdatedAt = now() p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 { if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes p.ReadingMinutes = *in.ReadingMinutes
@@ -490,9 +537,9 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
p.ReadingMinutes = readingMinutes(in.ContentMd) p.ReadingMinutes = readingMinutes(in.ContentMd)
} }
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,content_md=?, if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`), content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML, p.Status, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil { p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
return p, err return p, err
} }
@@ -504,6 +551,37 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
return s.Get(id) return s.Get(id)
} }
// BulkUpdateStatus flips the status of many posts in a single transaction.
func (s *Store) BulkUpdateStatus(ids []int64, status string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
status = NormalizeStatus(status)
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := []any{status, now()}
for _, id := range ids {
args = append(args, id)
}
q := s.db.Q(`UPDATE posts SET status=?, updated_at=? WHERE id IN (` + placeholders + `)`)
res, err := s.db.Exec(q, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// NormalizeStatus coerces an arbitrary input string into a known post status
// value, defaulting to draft when nothing matches.
func NormalizeStatus(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case model.StatusDraft, model.StatusPublished:
return strings.ToLower(strings.TrimSpace(s))
default:
return model.StatusDraft
}
}
func (s *Store) Delete(id int64) error { func (s *Store) Delete(id int64) error {
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil { if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
return err return err
@@ -568,9 +646,9 @@ func (s *Store) upsertTag(name string) (int64, error) {
} }
func (s *Store) ListTags() ([]model.Tag, error) { func (s *Store) ListTags() ([]model.Tag, error) {
q := s.db.Q(`SELECT t.id, t.name, t.slug, COUNT(pt.post_id) AS c q := s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) AS c
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
GROUP BY t.id, t.name, t.slug ORDER BY c DESC, t.name`) GROUP BY t.id, t.name, t.slug, t.color ORDER BY c DESC, t.name`)
rows, err := s.db.Query(q) rows, err := s.db.Query(q)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -579,7 +657,7 @@ func (s *Store) ListTags() ([]model.Tag, error) {
out := []model.Tag{} out := []model.Tag{}
for rows.Next() { for rows.Next() {
var t model.Tag var t model.Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Count); err != nil { if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil {
return nil, err return nil, err
} }
out = append(out, t) out = append(out, t)
@@ -599,6 +677,59 @@ func (s *Store) CreateTag(name string) (model.Tag, error) {
return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil
} }
// CreateTagFull is used by the admin API when color is supplied.
func (s *Store) CreateTagFull(name, color string) (model.Tag, error) {
name = strings.TrimSpace(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
slug := Slugify(name)
if slug == "" {
return model.Tag{}, errors.New("tag slug required")
}
var id int64
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
if err != nil && err != sql.ErrNoRows {
return model.Tag{}, err
}
if err == nil {
// already exists — update color and return
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil {
return model.Tag{}, err
}
return s.getTag(id)
}
color = strings.TrimSpace(color)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?) RETURNING id`),
name, slug, color).Scan(&id)
if err != nil {
return model.Tag{}, err
}
} else {
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?)`), name, slug, color)
if err != nil {
return model.Tag{}, err
}
id, err = res.LastInsertId()
if err != nil {
return model.Tag{}, err
}
}
return s.getTag(id)
}
func (s *Store) getTag(id int64) (model.Tag, error) {
var t model.Tag
err := s.db.QueryRow(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id)
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
WHERE t.id = ? GROUP BY t.id`), id).Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count)
if err == sql.ErrNoRows {
return t, ErrNotFound
}
return t, err
}
func (s *Store) RenameTag(id int64, name string) (model.Tag, error) { func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
slug := Slugify(name) slug := Slugify(name)
@@ -608,13 +739,49 @@ func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil { if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil {
return model.Tag{}, err return model.Tag{}, err
} }
var t model.Tag return s.getTag(id)
err := s.db.QueryRow(s.db.Q(`SELECT id, name, slug, 0 FROM tags WHERE id = ?`), id). }
Scan(&t.ID, &t.Name, &t.Slug, &t.Count)
if err == sql.ErrNoRows { // UpdateTag is the combined rename + recolor used by the admin UI.
return t, ErrNotFound func (s *Store) UpdateTag(id int64, name, color string) (model.Tag, error) {
name = strings.TrimSpace(name)
slug := Slugify(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
} }
return t, err color = strings.TrimSpace(color)
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=?, color=? WHERE id=?`), name, slug, color, id); err != nil {
return model.Tag{}, err
}
return s.getTag(id)
}
// MergeTags moves every post from fromID to toID, then deletes fromID. It is a
// no-op if the ids are equal or either doesn't exist.
func (s *Store) MergeTags(fromID, toID int64) (model.Tag, error) {
if fromID == toID {
return model.Tag{}, errors.New("source and target are the same tag")
}
if _, err := s.getTag(fromID); err != nil {
return model.Tag{}, err
}
if _, err := s.getTag(toID); err != nil {
return model.Tag{}, err
}
// De-duplicate before the move so we don't end up with two rows pointing
// at the same post.
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags
WHERE post_id IN (SELECT post_id FROM post_tags WHERE tag_id = ?)
AND tag_id = ?`), toID, toID); err != nil {
return model.Tag{}, err
}
if _, err := s.db.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil {
return model.Tag{}, err
}
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID); err != nil {
return model.Tag{}, err
}
return s.getTag(toID)
} }
func (s *Store) DeleteTag(id int64) error { func (s *Store) DeleteTag(id int64) error {
@@ -667,3 +834,133 @@ func splitDate(rfc3339 string) (string, string) {
} }
return rfc3339[0:4], rfc3339[5:7] return rfc3339[0:4], rfc3339[5:7]
} }
// ---------- dashboard ----------
// Dashboard produces the snapshot rendered on the admin home page.
func (s *Store) Dashboard() (model.Dashboard, error) {
d := model.Dashboard{
RecentPosts: []model.Post{},
RecentDrafts: []model.Post{},
TopTags: []model.Tag{},
PublishedByMonth: []model.MonthBucket{},
}
// status / kind counts --------------------------------------------------
rows, err := s.db.Query(s.db.Q(`SELECT status, kind, COUNT(*) FROM posts GROUP BY status, kind`))
if err != nil {
return d, err
}
for rows.Next() {
var status, kind string
var n int
if err := rows.Scan(&status, &kind, &n); err != nil {
rows.Close()
return d, err
}
d.TotalPosts += n
switch status {
case model.StatusPublished:
d.PublishedPosts += n
case model.StatusDraft:
d.DraftPosts += n
}
switch kind {
case model.KindShort:
d.ShortPosts += n
case model.KindLong:
d.LongPosts += n
}
}
rows.Close()
// total tag count -------------------------------------------------------
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil {
return d, err
}
// total word count: sum of content_md length ----------------------------
if err := s.db.QueryRow(s.db.Q(`SELECT COALESCE(SUM(LENGTH(content_md)),0) FROM posts`)).Scan(&d.TotalWords); err != nil {
return d, err
}
// recent published posts (5) --------------------------------------------
if err := s.scanPostsInto(s.db.Q(`SELECT `+postCols+` FROM posts
WHERE status=? ORDER BY published_at DESC LIMIT 5`), []any{model.StatusPublished}, &d.RecentPosts); err != nil {
return d, err
}
// recent drafts (5) -----------------------------------------------------
if err := s.scanPostsInto(s.db.Q(`SELECT `+postCols+` FROM posts
WHERE status=? ORDER BY updated_at DESC LIMIT 5`), []any{model.StatusDraft}, &d.RecentDrafts); err != nil {
return d, err
}
// top tags (10) ---------------------------------------------------------
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil {
return d, err
}
tagRows, err := s.db.Query(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) AS c
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
GROUP BY t.id, t.name, t.slug, t.color ORDER BY c DESC, t.name LIMIT 10`))
if err != nil {
return d, err
}
for tagRows.Next() {
var t model.Tag
if err := tagRows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil {
tagRows.Close()
return d, err
}
d.TopTags = append(d.TopTags, t)
}
tagRows.Close()
// published_by_month — last 12 months -----------------------------------
monthRows, err := s.db.Query(s.db.Q(`SELECT substr(published_at,1,7) AS m, COUNT(*) AS c
FROM posts WHERE status=? AND length(published_at) >= 7
GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished)
if err != nil {
return d, err
}
defer monthRows.Close()
for monthRows.Next() {
var b model.MonthBucket
if err := monthRows.Scan(&b.Month, &b.Count); err != nil {
return d, err
}
d.PublishedByMonth = append(d.PublishedByMonth, b)
}
// reverse so we can render left-to-right on the chart
for i, j := 0, len(d.PublishedByMonth)-1; i < j; i, j = i+1, j-1 {
d.PublishedByMonth[i], d.PublishedByMonth[j] = d.PublishedByMonth[j], d.PublishedByMonth[i]
}
return d, nil
}
// scanPostsInto is a small helper that runs a query and scans rows into the
// given slice, attaching tags at the end.
func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error {
rows, err := s.db.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
out := []model.Post{}
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return err
}
if err := s.attachTags(out); err != nil {
return err
}
*dst = out
return nil
}
+224
View File
@@ -40,3 +40,227 @@ func TestPostInputDefaults(t *testing.T) {
t.Errorf("empty status means draft is applied by the API layer, got %q", in.Status) t.Errorf("empty status means draft is applied by the API layer, got %q", in.Status)
} }
} }
func TestNormalizeStatus(t *testing.T) {
cases := map[string]string{
"": model.StatusDraft,
"draft": model.StatusDraft,
"published": model.StatusPublished,
" Published ": model.StatusPublished,
"pending": model.StatusDraft, // unknown → draft
}
for in, want := range cases {
if got := NormalizeStatus(in); got != want {
t.Errorf("NormalizeStatus(%q) = %q, want %q", in, got, want)
}
}
}
func TestRebindBulk(t *testing.T) {
// BulkUpdateStatus uses an IN clause with len(ids) placeholders + 1 for status.
d := &db.DB{Dialect: db.Postgres}
q := d.Q(`UPDATE posts SET status=? WHERE id IN (?,?,?)`)
if got := strings.Count(q, "$"); got != 4 {
t.Errorf("expected 4 placeholders, got %d", got)
}
}
func TestSettingsThemeID(t *testing.T) {
s := openTestStore(t)
st, err := s.GetSettings()
if err != nil {
t.Fatalf("get: %v", err)
}
if st.LightSkinID != "paper" {
t.Errorf("default light_skin_id should be 'paper', got %q", st.LightSkinID)
}
st.LightSkinID = "sage"
st.SiteTitle = "ONE"
if err := s.UpdateSettings(st); err != nil {
t.Fatalf("update: %v", err)
}
got, err := s.GetSettings()
if err != nil {
t.Fatalf("get again: %v", err)
}
if got.LightSkinID != "sage" {
t.Errorf("light_skin_id should persist, got %q", got.LightSkinID)
}
if got.ThemeID != "sage" {
t.Errorf("legacy theme_id should mirror light_skin_id, got %q", got.ThemeID)
}
if got.SiteTitle != "ONE" {
t.Errorf("site_title should persist, got %q", got.SiteTitle)
}
}
func TestSettingsLightSkinFallback(t *testing.T) {
s := openTestStore(t)
// Simulate an older DB that only has theme_id set.
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
"theme_id", "rose"); err != nil {
t.Fatalf("seed theme_id: %v", err)
}
st, err := s.GetSettings()
if err != nil {
t.Fatalf("get: %v", err)
}
if st.LightSkinID != "rose" {
t.Errorf("expected fallback to 'rose' from legacy theme_id, got %q", st.LightSkinID)
}
}
// TestStoreSQLite runs a small integration pass on top of an in-memory SQLite
// database to exercise the new fields (cover_url, tag color) and the merge
// + bulk + dashboard paths.
func TestStoreSQLite(t *testing.T) {
s := openTestStore(t)
p1, err := s.Create(model.PostInput{
Kind: model.KindLong,
Title: "第一篇",
Slug: "first",
Summary: "first summary",
CoverURL: "https://example.com/a.png",
ContentMd: "# hi\n这是一段正文。",
Status: model.StatusPublished,
Tags: []string{"Go", "博客"},
})
if err != nil {
t.Fatalf("create p1: %v", err)
}
if p1.CoverURL == "" {
t.Errorf("cover_url not persisted")
}
if len(p1.Tags) != 2 {
t.Errorf("expected 2 tags, got %v", p1.Tags)
}
p2, err := s.Create(model.PostInput{
Kind: model.KindShort, ContentMd: "碎片想法。", Status: model.StatusDraft,
Tags: []string{"碎片"},
})
if err != nil {
t.Fatalf("create p2: %v", err)
}
// Update cover_url and tags. Frontend sends the current CoverURL back so
// the value is preserved across updates.
upd, err := s.Update(p1.ID, model.PostInput{
Kind: model.KindLong, Title: "第一篇",
CoverURL: p1.CoverURL,
ContentMd: "# hi\n这是新版本。",
Tags: []string{"Go"},
})
if err != nil {
t.Fatalf("update: %v", err)
}
if upd.CoverURL != p1.CoverURL {
t.Errorf("cover_url not preserved when echoed back, got %q", upd.CoverURL)
}
// An empty CoverURL is treated as an explicit clear.
upd2, err := s.Update(p1.ID, model.PostInput{
Kind: model.KindLong, Title: "第一篇", CoverURL: "",
Tags: []string{"Go"},
})
if err != nil {
t.Fatalf("update clear: %v", err)
}
if upd2.CoverURL != "" {
t.Errorf("empty cover_url should clear, got %q", upd2.CoverURL)
}
// restore for downstream tests
if _, err := s.Update(p1.ID, model.PostInput{
Kind: model.KindLong, Title: "第一篇", CoverURL: p1.CoverURL,
Tags: []string{"Go"},
}); err != nil {
t.Fatalf("restore: %v", err)
}
if len(upd.Tags) != 1 || upd.Tags[0] != "Go" {
t.Errorf("tags after update: %v", upd.Tags)
}
// BulkUpdateStatus flips drafts to published
n, err := s.BulkUpdateStatus([]int64{p2.ID}, model.StatusPublished)
if err != nil {
t.Fatalf("bulk: %v", err)
}
if n != 1 {
t.Errorf("expected 1 updated, got %d", n)
}
// Tag merge: create two distinct slug tags, attach both to p1, merge.
if _, err := s.UpdateTag(0, "", ""); err == nil {
t.Errorf("expected error for empty name")
}
tags, err := s.ListTags()
if err != nil {
t.Fatalf("list tags: %v", err)
}
var goID int64
for _, tg := range tags {
if tg.Name == "Go" {
goID = tg.ID
}
}
if goID == 0 {
t.Fatalf("Go tag not found")
}
// Give the surviving target tag a color so we can confirm it sticks.
if _, err := s.UpdateTag(goID, "Go", "#3d7f9c"); err != nil {
t.Fatalf("color target: %v", err)
}
// CreateTagFull uses upsertTag so "GoLang" with its own slug is a fresh row.
golang, err := s.CreateTagFull("GoLang", "")
if err != nil {
t.Fatalf("create golang: %v", err)
}
if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?, ?)`), p1.ID, golang.ID); err != nil {
t.Fatalf("attach golang: %v", err)
}
merged, err := s.MergeTags(golang.ID, goID)
if err != nil {
t.Fatalf("merge: %v", err)
}
if merged.ID != goID {
t.Errorf("merge should return target tag, got %d want %d", merged.ID, goID)
}
if merged.Color != "#3d7f9c" {
t.Errorf("target tag color lost, got %q", merged.Color)
}
// Dashboard
d, err := s.Dashboard()
if err != nil {
t.Fatalf("dashboard: %v", err)
}
if d.TotalPosts != 2 {
t.Errorf("total_posts = %d", d.TotalPosts)
}
if d.PublishedPosts != 2 {
t.Errorf("published_posts = %d", d.PublishedPosts)
}
if len(d.RecentPosts) == 0 {
t.Errorf("recent posts empty")
}
if len(d.TopTags) == 0 {
t.Errorf("top tags empty")
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
d, err := db.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
s, err := New(d)
if err != nil {
t.Fatalf("new store: %v", err)
}
return s
}
+2
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#faf7f1" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1f1d1a" media="(prefers-color-scheme: dark)" />
<title>ONE · 一个博客</title> <title>ONE · 一个博客</title>
<meta name="description" content="长文与短文,同一种节奏。" /> <meta name="description" content="长文与短文,同一种节奏。" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+4 -1
View File
@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@egoist/tailwindcss-icons": "^1.8.0", "@egoist/tailwindcss-icons": "^1.8.0",
"@iconify-json/mingcute": "^1.1.17", "@iconify-json/mingcute": "^1.1.17",
"@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"marked": "^12.0.2", "marked": "^12.0.2",
"vue": "^3.4.0", "vue": "^3.4.0",
@@ -28,6 +29,8 @@
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": ["esbuild"] "onlyBuiltDependencies": [
"esbuild"
]
} }
} }
+1840
View File
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -1,8 +1,41 @@
<script setup> <script setup>
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import TopBar from './components/TopBar.vue' import TopBar from './components/TopBar.vue'
import ThemeSwitcher from './components/ThemeSwitcher.vue'
const route = useRoute()
const isAdmin = computed(() => route.path.startsWith('/admin'))
// body hook:让全局 CSS 知道当前是 admin,
// focus-visible 等样式跟着切 token。
watch(
isAdmin,
(v) => {
if (typeof document !== 'undefined') {
document.body.classList.toggle('admin-shell-active', v)
}
},
{ immediate: true }
)
</script> </script>
<template> <template>
<TopBar /> <TopBar v-if="!isAdmin" />
<RouterView /> <RouterView />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底 -->
<ThemeSwitcher variant="floating" class="theme-floating-only" />
</template> </template>
<style scoped>
/* 窄屏(≤780px)才显示浮动按钮 —— 此时前台左栏 / 后台侧栏都已隐藏 */
.theme-floating-only {
display: none;
}
@media (max-width: 780px) {
.theme-floating-only {
display: inline-flex;
}
}
</style>
+119 -92
View File
@@ -1,12 +1,32 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } 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'
import { site } from '../site' import { site } from '../site'
import CommandPalette from './CommandPalette.vue'
import ShortcutsPanel from './ShortcutsPanel.vue'
import ThemeSwitcher from '../components/ThemeSwitcher.vue'
const router = useRouter() const router = useRouter()
const route = useRoute()
const ready = ref(false) const ready = ref(false)
const paletteOpen = ref(false)
const shortcutsOpen = ref(false)
const counts = ref({ published: 0, draft: 0, tags: 0 })
async function loadCounts() {
try {
const d = await adminApi.dashboard()
counts.value = {
published: d.published_posts,
draft: d.draft_posts,
tags: d.total_tags
}
} catch (e) {
/* ignore — counts are decorative */
}
}
onMounted(async () => { onMounted(async () => {
if (!(await checkAuth())) { if (!(await checkAuth())) {
@@ -14,6 +34,7 @@ onMounted(async () => {
return return
} }
ready.value = true ready.value = true
loadCounts()
}) })
// 会话过期时(请求返回 401)统一跳回登录页 // 会话过期时(请求返回 401)统一跳回登录页
@@ -31,111 +52,117 @@ async function logout() {
session.clear() session.clear()
router.replace('/admin/login') router.replace('/admin/login')
} }
const crumb = computed(() => {
const p = route.path
if (p.endsWith('/new')) return '写新的'
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览'
return '后台'
})
function onKeydown(e) {
// ⌘K / Ctrl+K
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
paletteOpen.value = true
return
}
// ?
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const t = e.target
const isInput =
t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)
if (!isInput) {
e.preventDefault()
shortcutsOpen.value = true
}
}
// esc handled in child components
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
// refresh counts when route changes (posts / tags edited)
watch(() => route.path, () => loadCounts())
</script> </script>
<template> <template>
<div v-if="ready" class="admin"> <div v-if="ready" class="admin-shell">
<header class="bar"> <aside class="admin-side">
<div class="brand"> <div class="brand">
<RouterLink to="/" class="site">{{ site.site_title || 'ONE' }}</RouterLink> <RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
<span class="sep">/</span> <span class="label">· 后台</span>
<span class="where">后台</span>
</div> </div>
<nav class="nav"> <nav class="nav">
<RouterLink to="/admin" class="link">文章</RouterLink> <div class="group">工作台</div>
<RouterLink to="/admin/new" class="link">写新的</RouterLink> <RouterLink to="/admin" class="item">
<RouterLink to="/admin/tags" class="link">标签</RouterLink> <span class="ic">◇</span>
<RouterLink to="/admin/settings" class="link">设置</RouterLink> <span>总览</span>
<RouterLink to="/" class="link">看前台</RouterLink> </RouterLink>
<button class="link as-btn" @click="logout">退出</button> <RouterLink to="/admin/posts" class="item" :class="{ 'router-link-exact-active': route.path === '/admin/posts' }">
<span class="ic">▤</span>
<span>文章</span>
<span class="badge">{{ counts.published + counts.draft }}</span>
</RouterLink>
<RouterLink to="/admin/tags" class="item">
<span class="ic">◐</span>
<span>标签</span>
<span class="badge">{{ counts.tags }}</span>
</RouterLink>
<div class="group">操作</div>
<RouterLink to="/admin/new" class="item">
<span class="ic">✎</span>
<span>写新的</span>
</RouterLink>
<RouterLink to="/admin/settings" class="item">
<span class="ic">⚙</span>
<span>设置</span>
</RouterLink>
<div class="group">前台</div>
<a href="/" target="_blank" class="item">
<span class="ic">↗</span>
<span>查看前台</span>
</a>
</nav> </nav>
</header> <div class="theme-zone">
<ThemeSwitcher variant="inline" />
</div>
<div class="footer">
<div class="who">{{ session.user || 'admin' }}</div>
<button class="leave" @click="logout">退出登录 →</button>
</div>
</aside>
<main class="content"> <div class="admin-main">
<RouterView /> <header class="admin-topbar">
</main> <div class="crumbs">
<strong>{{ crumb }}</strong>
</div>
<div class="right">
<button class="cmd-hint" @click="paletteOpen = true">
搜索 / 命令 <kbd>⌘K</kbd>
</button>
</div>
</header>
<main class="admin-content">
<RouterView />
</main>
</div>
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
<ShortcutsPanel v-if="shortcutsOpen" @close="shortcutsOpen = false" />
</div> </div>
<div v-else class="checking">检查登录状态…</div> <div v-else class="checking">检查登录状态…</div>
</template> </template>
<style scoped> <style scoped>
.admin {
min-height: 100vh;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 12px 22px;
border-bottom: 1px solid var(--line);
background: var(--card);
position: sticky;
top: 0;
z-index: 20;
}
.brand {
display: flex;
align-items: baseline;
gap: 8px;
}
.site {
font-family: var(--serif);
font-size: 17px;
}
.sep {
color: var(--faint);
}
.where {
font-size: 13px;
color: var(--muted);
}
.nav {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.link {
padding: 5px 10px;
border-radius: 3px;
font-size: 14px;
color: var(--ink-soft);
border: 0;
background: none;
cursor: pointer;
}
.link:hover {
background: var(--paper-sunken);
color: var(--ink);
}
.link.router-link-exact-active {
color: var(--accent);
}
.as-btn {
color: var(--muted);
}
.content {
max-width: 980px;
margin: 0 auto;
padding: 26px 22px 80px;
}
.checking { .checking {
padding: 80px; padding: 80px;
text-align: center; text-align: center;
color: var(--muted); color: var(--admin-muted);
} }
</style> </style>
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { adminApi } from '../api'
const emit = defineEmits(['close'])
const router = useRouter()
const query = ref('')
const inputEl = ref(null)
const idx = ref(0)
const recentDrafts = ref([])
const baseCommands = [
{ id: 'go-new', label: '写新文章', hint: 'N', action: () => router.push('/admin/new') },
{ id: 'go-dashboard', label: '总览', hint: 'G D', action: () => router.push('/admin') },
{ id: 'go-posts', label: '文章列表', hint: 'G P', action: () => router.push('/admin/posts') },
{ id: 'go-tags', label: '标签管理', hint: 'G T', action: () => router.push('/admin/tags') },
{ id: 'go-settings', label: '设置', hint: 'G S', action: () => router.push('/admin/settings') },
{ id: 'go-site', label: '前台', hint: 'G W', action: () => (window.location.href = '/') }
]
async function loadContext() {
try {
const d = await adminApi.dashboard()
recentDrafts.value = (d.recent_drafts || []).map((p) => ({
id: 'edit-' + p.id,
label: p.title || '(无题草稿)',
hint: p.kind === 'short' ? '短文' : '长文',
action: () => router.push('/admin/' + p.id)
}))
} catch (e) {
/* ignore */
}
}
// Flatten items into [{sec, item, idx}] so we can render sections but still
// track a single cursor across the whole list.
const flat = computed(() => {
const q = query.value.trim().toLowerCase()
const sections = [
{ name: '命令', items: baseCommands },
{ name: '最近草稿', items: recentDrafts.value }
]
const out = []
for (const sec of sections) {
for (const it of sec.items) {
if (q && !it.label.toLowerCase().includes(q)) continue
out.push({ section: sec.name, item: it })
}
}
return out
})
// Re-group for display, while preserving the flat indices.
const sections = computed(() => {
const out = []
let i = 0
let lastName = null
for (const e of flat.value) {
if (e.section !== lastName) {
out.push({ name: e.section, entries: [] })
lastName = e.section
}
out[out.length - 1].entries.push({ ...e.item, _idx: i })
i++
}
return out
})
function run(item) {
item.action()
emit('close')
}
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault()
emit('close')
} else if (e.key === 'ArrowDown') {
e.preventDefault()
idx.value = Math.min(flat.value.length - 1, idx.value + 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
idx.value = Math.max(0, idx.value - 1)
} else if (e.key === 'Enter') {
e.preventDefault()
const it = flat.value[idx.value]
if (it) run(it.item)
}
}
onMounted(async () => {
await loadContext()
await nextTick()
inputEl.value?.focus()
window.addEventListener('keydown', onKey)
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div class="cmd-overlay" role="dialog" aria-modal="true" aria-label="命令面板" @click.self="emit('close')">
<div class="cmd-panel">
<input
ref="inputEl"
v-model="query"
placeholder="输入命令、文章标题…"
aria-label="搜索命令和文章"
@input="idx = 0"
/>
<div class="cmd-list">
<template v-for="(sec, si) in sections" :key="si">
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
{{ sec.name }}
</div>
<div
v-for="(it, ii) in sec.entries"
:key="it.id"
class="row"
:class="{ on: idx === it._idx }"
@click="run(it)"
@mouseenter="idx = it._idx"
>
<span class="label">{{ it.label }}</span>
<span class="hint">{{ it.hint }}</span>
</div>
</template>
<div v-if="!flat.length" class="empty">没找到匹配的命令</div>
</div>
</div>
</div>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate } from '../utils'
const router = useRouter()
const loading = ref(true)
const d = ref(null)
async function load() {
loading.value = true
try {
d.value = await adminApi.dashboard()
} finally {
loading.value = false
}
}
onMounted(load)
function edit(id) {
router.push('/admin/' + id)
}
const chartMax = computed(() => {
if (!d.value || !d.value.published_by_month.length) return 1
return Math.max(1, ...d.value.published_by_month.map((b) => b.count))
})
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="d">
<div class="stat-grid">
<div class="stat accent">
<div class="label">总文章</div>
<div class="value">{{ d.total_posts }}</div>
<div class="hint">长文 {{ d.long_posts }} · 短文 {{ d.short_posts }}</div>
</div>
<div class="stat">
<div class="label">已发布</div>
<div class="value">{{ d.published_posts }}</div>
<div class="hint">前台可见</div>
</div>
<div class="stat">
<div class="label">草稿</div>
<div class="value">{{ d.draft_posts }}</div>
<div class="hint">还在写</div>
</div>
<div class="stat">
<div class="label">标签</div>
<div class="value">{{ d.total_tags }}</div>
<div class="hint">已使用</div>
</div>
<div class="stat">
<div class="label">总字数</div>
<div class="value">{{ d.total_words.toLocaleString() }}</div>
<div class="hint">Markdown 字符</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px;">
<div class="panel">
<div class="panel-title">
<h2>最近 12 个月发布</h2>
<span class="meta">每月文章数</span>
</div>
<div v-if="!d.published_by_month.length" class="empty" style="padding: 24px;">还没有发布数据</div>
<div v-else class="dash-chart">
<div
v-for="b in d.published_by_month"
:key="b.month"
class="bar"
:style="{ height: ((b.count / chartMax) * 100) + '%' }"
:title="`${b.month} · ${b.count} 篇`"
>
<span v-if="b.count" class="v">{{ b.count }}</span>
</div>
</div>
<div v-if="d.published_by_month.length" class="dash-chart-labels" style="display: flex; gap: 6px; margin-top: 6px;">
<span
v-for="b in d.published_by_month"
:key="b.month"
style="flex: 1; font-size: 10px; color: var(--admin-muted); text-align: center;"
>{{ b.month.slice(5) }}</span>
</div>
</div>
<div class="panel">
<div class="panel-title">
<h2>热门标签</h2>
<span class="meta">按文章数</span>
</div>
<div v-if="!d.top_tags.length" class="empty" style="padding: 24px;">还没有标签</div>
<div v-else class="recent-list">
<RouterLink
v-for="t in d.top_tags"
:key="t.id"
:to="`/admin/tags`"
class="recent-item"
>
<span class="tag-dot" :style="{ background: t.color || 'var(--admin-accent)' }"></span>
<span class="title">{{ t.name }}</span>
<span class="meta">{{ t.count }} 篇</span>
</RouterLink>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
<div class="panel">
<div class="panel-title">
<h2>最近发布</h2>
<RouterLink to="/admin/posts" class="meta">查看全部 →</RouterLink>
</div>
<div v-if="!d.recent_posts.length" class="empty" style="padding: 24px;">还没有发布</div>
<div v-else class="recent-list">
<a v-for="p in d.recent_posts" :key="p.id" href="#" class="recent-item" @click.prevent="edit(p.id)">
<span class="title">{{ p.title || '(无题)' }}</span>
<span class="meta">{{ relativeDate(p.published_at) }}</span>
</a>
</div>
</div>
<div class="panel">
<div class="panel-title">
<h2>最近草稿</h2>
<RouterLink to="/admin/new" class="meta">写新的 →</RouterLink>
</div>
<div v-if="!d.recent_drafts.length" class="empty" style="padding: 24px;">没有草稿</div>
<div v-else class="recent-list">
<a v-for="p in d.recent_drafts" :key="p.id" href="#" class="recent-item" @click.prevent="edit(p.id)">
<span class="title">{{ p.title || '(无题草稿)' }}</span>
<span class="meta">{{ relativeDate(p.updated_at) }}</span>
</a>
</div>
</div>
</div>
</section>
</template>
+313 -345
View File
@@ -1,8 +1,11 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { md } from '../utils' import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import { marked } from 'marked'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -14,7 +17,6 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const savedAt = ref('') const savedAt = ref('')
const error = ref('') const error = ref('')
const tab = ref('write') // write | preview
const tagInput = ref('') const tagInput = ref('')
const dirty = ref(false) const dirty = ref(false)
@@ -25,6 +27,7 @@ const form = reactive({
title: '', title: '',
slug: '', slug: '',
summary: '', summary: '',
cover_url: '',
content_md: '', content_md: '',
tags: [], tags: [],
status: 'draft', status: 'draft',
@@ -33,6 +36,14 @@ const form = reactive({
override_minutes: false override_minutes: false
}) })
// ---------- mode: 'wysiwyg' | 'md' ----------
const mode = ref('wysiwyg')
// ---------- Milkdown Crepe ----------
const editorEl = ref(null)
let crepe = null
// ---------- 阅读时长 ---------- // ---------- 阅读时长 ----------
function estimateMinutes(text) { function estimateMinutes(text) {
@@ -42,11 +53,30 @@ function estimateMinutes(text) {
return Math.max(1, Math.floor(cjk / 400) + Math.floor(latin / 220)) return Math.max(1, Math.floor(cjk / 400) + Math.floor(latin / 220))
} }
const cjkChars = computed(() => (form.content_md.match(/[㐀-鿿 -〿＀-￯]/g) || []).length)
const latinWords = computed(() => (form.content_md.match(/[A-Za-z0-9']+/g) || []).length)
const paragraphs = computed(() => {
const t = form.content_md.trim()
if (!t) return 0
return t.split(/\n\s*\n/).filter((p) => p.trim()).length
})
const autoMinutes = computed(() => estimateMinutes(form.content_md)) const autoMinutes = computed(() => estimateMinutes(form.content_md))
const finalMinutes = computed(() => const finalMinutes = computed(() =>
form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : autoMinutes.value form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : autoMinutes.value
) )
// ---------- outline ----------
const outline = computed(() => {
const lines = form.content_md.split('\n')
const out = []
for (const ln of lines) {
const m = /^(#{1,3})\s+(.+?)\s*$/.exec(ln)
if (!m) continue
out.push({ level: m[1].length, text: m[2].trim() })
}
return out
})
// ---------- 时间 ---------- // ---------- 时间 ----------
function isoToLocal(iso) { function isoToLocal(iso) {
@@ -70,30 +100,28 @@ const publishedLocal = ref('')
// ---------- 载入 ---------- // ---------- 载入 ----------
onMounted(async () => { async function loadData() {
if (isEdit.value) { if (isEdit.value) {
try { const p = await adminApi.post(id.value)
const p = await adminApi.post(id.value) form.kind = p.kind || 'long'
form.kind = p.kind || 'long' form.title = p.title || ''
form.title = p.title || '' form.slug = p.slug || ''
form.slug = p.slug || '' form.summary = p.summary || ''
form.summary = p.summary || '' form.cover_url = p.cover_url || ''
form.content_md = p.content_md || '' form.content_md = p.content_md || ''
form.tags = p.tags ? [...p.tags] : [] form.tags = p.tags ? [...p.tags] : []
form.status = p.status || 'draft' form.status = p.status || 'draft'
publishedLocal.value = isoToLocal(p.published_at) publishedLocal.value = isoToLocal(p.published_at)
if (p.reading_minutes && p.reading_minutes !== estimateMinutes(p.content_md || '')) { if (p.reading_minutes && p.reading_minutes !== estimateMinutes(p.content_md || '')) {
form.override_minutes = true form.override_minutes = true
form.reading_minutes = p.reading_minutes form.reading_minutes = p.reading_minutes
}
} catch (e) {
error.value = e.message || '载入失败'
} }
} else { } else {
const raw = localStorage.getItem(DRAFT_KEY) const raw = localStorage.getItem(DRAFT_KEY)
if (raw) { if (raw) {
try { try {
Object.assign(form, JSON.parse(raw)) const d = JSON.parse(raw)
Object.assign(form, d)
savedAt.value = '本地草稿已恢复' savedAt.value = '本地草稿已恢复'
} catch (e) { } catch (e) {
localStorage.removeItem(DRAFT_KEY) localStorage.removeItem(DRAFT_KEY)
@@ -101,12 +129,95 @@ onMounted(async () => {
} }
publishedLocal.value = isoToLocal(new Date().toISOString()) publishedLocal.value = isoToLocal(new Date().toISOString())
} }
loading.value = false }
// 载入完成后的第一次变更才触发自动保存
async function initEditor() {
crepe = new Crepe({
root: editorEl.value,
defaultValue: form.content_md,
features: {
[CrepeFeature.AI]: false
},
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
}
}
})
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
form.content_md = markdown
})
})
await crepe.create()
setTimeout(() => { setTimeout(() => {
dirty.value = false dirty.value = false
watchForm() watchForm()
}, 0) }, 0)
}
onMounted(async () => {
try {
await loadData()
} catch (e) {
error.value = e.message || '载入失败'
}
loading.value = false
await nextTick()
if (mode.value === 'wysiwyg') {
await initEditor()
}
})
onBeforeUnmount(() => {
if (crepe) {
crepe.destroy()
crepe = null
}
})
// ---------- mode switching ----------
async function switchMode(next) {
if (next === mode.value) return
if (next === 'md') {
// WYSIWYG → MD: tear down crepe
if (crepe) {
crepe.destroy()
crepe = null
}
} else {
// MD → WYSIWYG: spin up crepe with the current markdown
await nextTick()
if (!crepe && editorEl.value) {
crepe = new Crepe({
root: editorEl.value,
defaultValue: form.content_md,
features: { [CrepeFeature.AI]: false },
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
}
}
})
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
form.content_md = markdown
})
})
await crepe.create()
}
}
mode.value = next
}
watch(mode, (n) => {
// ensure form is always the source of truth
if (n === 'md' && crepe) {
// last sync already happened via listener
}
}) })
// ---------- 自动保存 ---------- // ---------- 自动保存 ----------
@@ -165,6 +276,7 @@ function buildPayload() {
title: form.title, title: form.title,
slug: form.slug, slug: form.slug,
summary: form.summary, summary: form.summary,
cover_url: form.cover_url,
content_md: form.content_md, content_md: form.content_md,
tags: [...form.tags], tags: [...form.tags],
status: form.status, status: form.status,
@@ -210,6 +322,32 @@ async function unpublish() {
await save('draft') await save('draft')
} }
// 强制立即保存(⌘S)
function onGlobalKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
save()
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
// ---------- unsaved 守卫 ----------
// 关闭/刷新页面
function onBeforeUnload(e) {
if (!dirty.value) return
e.preventDefault()
e.returnValue = ''
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
// 路由内 SPA 离开
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return window.confirm('当前改动还没保存,确定离开吗?')
})
// ---------- 标签 ---------- // ---------- 标签 ----------
function addTag() { function addTag() {
@@ -230,40 +368,40 @@ function removeTag(t) {
form.tags = form.tags.filter((x) => x !== t) form.tags = form.tags.filter((x) => x !== t)
} }
// ---------- 预览 ---------- // ---------- cover ----------
const coverInput = ref('')
const preview = computed(() => md.render(form.content_md || '')) function setCover() {
const v = coverInput.value.trim()
function onKeydown(e) { if (!v) return
// Tab 缩进,而不是跳出输入框 form.cover_url = v
if (e.key === 'Tab') { coverInput.value = ''
e.preventDefault()
const el = e.target
const start = el.selectionStart
const end = el.selectionEnd
form.content_md =
form.content_md.slice(0, start) + ' ' + form.content_md.slice(end)
setTimeout(() => el.setSelectionRange(start + 2, start + 2))
}
} }
function clearCover() {
form.cover_url = ''
}
// preview mode — render markdown to HTML for the side panel
const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks: true }))
</script> </script>
<template> <template>
<section v-if="loading" class="loading">载入中…</section> <section v-if="loading" class="loading">载入中…</section>
<section v-else class="editor"> <section v-else class="editor">
<header class="head"> <header style="display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;">
<div class="left"> <div>
<h1 class="title">{{ isEdit ? '编辑文章' : '写新的' }}</h1> <h1 style="font-family: var(--serif); font-size: 22px;">{{ isEdit ? '编辑文章' : '写新的' }}</h1>
<span class="state"> <div class="save-bar" :class="{ saving, saved: !saving && !dirty && savedAt, dirty: !saving && dirty }">
<span class="dot"></span>
<span v-if="saving">保存中…</span> <span v-if="saving">保存中…</span>
<span v-else-if="dirty">有未保存的改动</span> <span v-else-if="dirty">有未保存的改动</span>
<span v-else-if="savedAt">已保存 {{ savedAt }}</span> <span v-else-if="savedAt">{{ savedAt }}</span>
<span v-else>还没改过</span> <span v-else>还没改过</span>
</span> </div>
</div> </div>
<div style="display: flex; gap: 8px;">
<div class="right">
<button class="btn" @click="form.status === 'published' ? unpublish() : save()"> <button class="btn" @click="form.status === 'published' ? unpublish() : save()">
存草稿 存草稿
</button> </button>
@@ -274,332 +412,162 @@ function onKeydown(e) {
</div> </div>
</header> </header>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" style="color: #b4553f; font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
<div class="grid"> <div class="editor-grid">
<!-- 左:正文 --> <div>
<div class="main"> <!-- cover -->
<div class="kind-switch"> <div class="cover-block" :class="{ has: form.cover_url }">
<button class="kind" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'"> <div v-if="form.cover_url" class="preview" :style="{ backgroundImage: `url(${form.cover_url})` }">
长文 <button class="remove" @click="clearCover">移除封面</button>
</button> </div>
<button class="kind" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'"> <div v-else class="placeholder">+ 添加封面图(输入 URL)</div>
短文 <div v-if="!form.cover_url" class="input-row">
</button> <input
<span class="hint"> v-model="coverInput"
{{ form.kind === 'long' ? '有标题、有结构,适合讲完整一件事' : '一两段话,时间线里不显示标题' }} placeholder="https://…"
type="url"
inputmode="url"
aria-label="封面图 URL"
spellcheck="false"
@keydown.enter.prevent="setCover"
/>
<button @click="setCover">设置</button>
</div>
</div>
<!-- kind switch + title -->
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 10px;">
<button class="chip" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'">长文</button>
<button class="chip" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'">短文</button>
<span style="font-size: 12px; color: var(--admin-muted); margin-left: 4px;">
{{ form.kind === 'short' ? '一两段话,时间线里不显示标题' : '有标题、有结构,适合讲完整一件事' }}
</span> </span>
<span style="flex: 1;"></span>
<div class="editor-mode">
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
</div>
</div> </div>
<input <input
v-model="form.title" v-model="form.title"
class="input title-input" class="input"
style="font-family: var(--serif); font-size: 19px; padding: 10px 12px; margin-bottom: 12px;"
:placeholder="form.kind === 'short' ? '标题(可留空,仅作归档索引)' : '标题'" :placeholder="form.kind === 'short' ? '标题(可留空,仅作归档索引)' : '标题'"
/> />
<div class="tabs"> <!-- editor area -->
<button class="tab" :class="{ on: tab === 'write' }" @click="tab = 'write'">编写</button> <div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<button class="tab" :class="{ on: tab === 'preview' }" @click="tab = 'preview'">预览</button>
<span class="counter">{{ form.content_md.length }} 字</span>
</div>
<textarea <textarea
v-show="tab === 'write'" v-show="mode === 'md'"
class="md-pane"
v-model="form.content_md" v-model="form.content_md"
class="textarea md-input" placeholder="直接写 Markdown…"
rows="20"
:placeholder="form.kind === 'short' ? '写点什么…' : '# 标题\n\n正文,支持 Markdown。'"
@keydown="onKeydown"
></textarea> ></textarea>
<div v-show="tab === 'preview'" class="prose preview" v-html="preview"></div> <div class="editor-stats">
<span><strong>{{ cjkChars }}</strong> 汉字</span>
<span><strong>{{ latinWords }}</strong> 词</span>
<span><strong>{{ paragraphs }}</strong> 段</span>
<span><strong>{{ finalMinutes }}</strong>′ 读时长</span>
<span style="margin-left: auto; color: var(--admin-faint);">⌘S 保存</span>
</div>
</div> </div>
<!-- 右:元信息 --> <aside>
<aside class="side"> <!-- outline -->
<div class="field"> <div v-if="outline.length" class="outline" style="margin-bottom: 18px;">
<label>类型</label> <div class="ttl">大纲</div>
<p class="value">{{ form.kind === 'short' ? '短文' : '长文' }}</p> <ul>
<li
v-for="(o, i) in outline"
:key="i"
:class="{ h3: o.level >= 3 }"
:title="o.text"
>{{ o.text }}</li>
</ul>
</div> </div>
<div class="field"> <!-- meta -->
<label for="slug">Slug(链接)</label> <div class="panel" style="padding: 16px 18px;">
<input id="slug" v-model="form.slug" class="input" placeholder="留空自动生成" /> <div class="field">
<p v-if="form.slug" class="sub">/post/{{ form.slug }}</p> <label>类型</label>
</div> <p style="margin: 0; font-size: 14px;">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
<div class="field">
<label for="summary">摘要</label>
<textarea
id="summary"
v-model="form.summary"
class="textarea"
rows="3"
placeholder="长文显示在时间线里的一段话"
></textarea>
</div>
<div class="field">
<label>标签</label>
<div v-if="form.tags.length" class="taglist">
<span v-for="t in form.tags" :key="t" class="tag-chip">
{{ t }}
<button class="x" @click="removeTag(t)">×</button>
</span>
</div> </div>
<input
v-model="tagInput"
class="input"
placeholder="输入后回车添加"
@keydown="onTagInput"
@blur="addTag"
/>
</div>
<div class="field"> <div class="field">
<label for="pub">发布时间</label> <label for="slug">Slug(链接)</label>
<input id="pub" v-model="publishedLocal" type="datetime-local" class="input" /> <input id="slug" v-model="form.slug" class="input" placeholder="留空自动生成" />
</div> <p v-if="form.slug" style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted); word-break: break-all;">/post/{{ form.slug }}</p>
</div>
<div class="field"> <div class="field">
<label for="mins">阅读时长(分钟)</label> <label for="summary">摘要</label>
<div class="mins-row"> <textarea
id="summary"
v-model="form.summary"
class="textarea"
rows="3"
placeholder="长文显示在时间线里的一段话"
></textarea>
</div>
<div class="field">
<label>标签</label>
<div v-if="form.tags.length" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;">
<span v-for="t in form.tags" :key="t" class="chip">
{{ t }}
<button class="x" @click="removeTag(t)" :aria-label="`移除标签 ${t}`">×</button>
</span>
</div>
<input <input
id="mins" v-model="tagInput"
v-model="form.reading_minutes"
type="number"
min="1"
class="input" class="input"
:disabled="!form.override_minutes" placeholder="输入后回车添加"
:placeholder="String(autoMinutes)" aria-label="添加标签"
@keydown="onTagInput"
@blur="addTag"
/> />
<label class="check">
<input v-model="form.override_minutes" type="checkbox" />
手动指定
</label>
</div> </div>
<p class="sub">自动估算:{{ finalMinutes }} 分钟</p>
</div>
<div class="field"> <div class="field">
<label>状态</label> <label for="pub">发布时间</label>
<p class="value"> <input id="pub" v-model="publishedLocal" type="datetime-local" class="input" />
<span class="dot" :class="form.status"></span> </div>
{{ form.status === 'published' ? '已发布' : '草稿' }}
</p>
</div>
<button class="btn wide" @click="save()">立即保存</button> <div class="field">
<RouterLink to="/admin" class="back">← 返回列表</RouterLink> <label for="mins">阅读时长(分钟)</label>
<div style="display: flex; align-items: center; gap: 10px;">
<input
id="mins"
v-model="form.reading_minutes"
type="number"
min="1"
class="input"
:disabled="!form.override_minutes"
:placeholder="String(autoMinutes)"
/>
<label style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--admin-muted); white-space: nowrap; margin: 0;">
<input v-model="form.override_minutes" type="checkbox" />
手动指定
</label>
</div>
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">自动估算:{{ finalMinutes }} 分钟</p>
</div>
<div class="field">
<label>状态</label>
<p style="margin: 0; font-size: 14px;">
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--admin-faint); margin-right: 6px; vertical-align: 2px;" :style="form.status === 'published' ? { background: 'var(--admin-accent)' } : {}"></span>
{{ form.status === 'published' ? '已发布' : '草稿' }}
</p>
</div>
<button class="btn" style="width: 100%;" @click="save()">立即保存</button>
<RouterLink to="/admin" style="display: inline-block; margin-top: 14px; font-size: 13px; color: var(--admin-accent);">← 返回列表</RouterLink>
</div>
</aside> </aside>
</div> </div>
</section> </section>
</template> </template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.left {
display: flex;
align-items: baseline;
gap: 12px;
}
.title {
font-size: 22px;
}
.state {
font-size: 12.5px;
color: var(--muted);
}
.right {
display: flex;
gap: 8px;
}
.error {
color: #9a5b45;
font-size: 13px;
margin-bottom: 12px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 22px;
align-items: start;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
.kind-switch {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.kind {
padding: 5px 14px;
border: 1px solid var(--line);
background: var(--card);
border-radius: 999px;
cursor: pointer;
font-size: 13px;
}
.kind.on {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.title-input {
font-family: var(--serif);
font-size: 19px;
padding: 10px 12px;
margin-bottom: 14px;
}
.tabs {
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--line);
margin-bottom: -1px;
}
.tab {
padding: 7px 14px;
border: 1px solid transparent;
border-bottom: 0;
background: none;
cursor: pointer;
font-size: 13.5px;
color: var(--muted);
border-radius: 3px 3px 0 0;
}
.tab.on {
color: var(--accent);
border-color: var(--line);
background: var(--card);
margin-bottom: -1px;
}
.counter {
margin-left: auto;
font-size: 12px;
color: var(--faint);
padding-bottom: 6px;
}
.md-input {
min-height: 420px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.8;
border-radius: 0 3px 3px 3px;
}
.preview {
min-height: 420px;
padding: 18px 20px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 0 3px 3px 3px;
}
.side {
border: 1px solid var(--line);
background: var(--card);
border-radius: 4px;
padding: 18px;
}
.value {
margin: 0;
font-size: 14px;
}
.sub {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
word-break: break-all;
}
.taglist {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.x {
border: 0;
background: none;
cursor: pointer;
color: var(--muted);
padding: 0 0 0 4px;
}
.mins-row {
display: flex;
align-items: center;
gap: 10px;
}
.check {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
margin: 0;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--faint);
margin-right: 6px;
vertical-align: 2px;
}
.dot.published {
background: var(--accent);
}
.wide {
width: 100%;
}
.back {
display: inline-block;
margin-top: 14px;
font-size: 13px;
color: var(--accent);
}
</style>
+8 -6
View File
@@ -71,13 +71,14 @@ async function submit() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 40px 20px; padding: 40px 20px;
background: var(--admin-bg);
} }
.card { .card {
width: 100%; width: 100%;
max-width: 380px; max-width: 380px;
background: var(--card); background: var(--admin-card);
border: 1px solid var(--line); border: 1px solid var(--admin-line);
border-radius: 4px; border-radius: 4px;
padding: 28px; padding: 28px;
} }
@@ -85,10 +86,11 @@ async function submit() {
.title { .title {
font-size: 22px; font-size: 22px;
margin: 6px 0 22px; margin: 6px 0 22px;
color: var(--admin-ink);
} }
.error { .error {
color: #9a5b45; color: var(--admin-danger);
font-size: 13px; font-size: 13px;
margin: -6px 0 14px; margin: -6px 0 14px;
} }
@@ -97,13 +99,13 @@ async function submit() {
margin-top: 18px; margin-top: 18px;
font-size: 12.5px; font-size: 12.5px;
line-height: 1.8; line-height: 1.8;
color: var(--muted); color: var(--admin-muted);
} }
.hint code { .hint code {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
background: var(--paper-sunken); background: var(--admin-paper-sunken);
padding: 1px 4px; padding: 1px 4px;
border-radius: 3px; border-radius: 3px;
} }
@@ -112,6 +114,6 @@ async function submit() {
display: inline-block; display: inline-block;
margin-top: 14px; margin-top: 14px;
font-size: 13px; font-size: 13px;
color: var(--accent); color: var(--admin-accent);
} }
</style> </style>
+206 -210
View File
@@ -1,33 +1,49 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { 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'
const route = useRoute()
const router = useRouter() const router = useRouter()
const items = ref([]) const items = ref([])
const total = ref(0) const total = ref(0)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const allTags = ref([])
const status = ref('') // 筛选状态——初始值从 URL 读,变化时 push 回 URL
const kind = ref('') const status = ref(String(route.query.status || ''))
const q = ref('') const kind = ref(String(route.query.kind || ''))
const page = ref(1) const tagFilter = ref(String(route.query.tag || ''))
const sort = ref(String(route.query.sort || 'newest'))
const q = ref(String(route.query.q || ''))
const page = ref(Number(route.query.page || 1))
const size = 20 const size = 20
const selected = ref(new Set())
const orderMap = {
newest: 'published_at DESC',
oldest: 'published_at ASC',
updated: 'updated_at DESC',
longest: 'reading_minutes DESC'
}
async function load() { async function load() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const params = { page: page.value, size } const params = { page: page.value, size, order: orderMap[sort.value] || orderMap.newest }
if (status.value) params.status = status.value if (status.value) params.status = status.value
if (kind.value) params.kind = kind.value if (kind.value) params.kind = kind.value
if (tagFilter.value) params.tag = tagFilter.value
if (q.value.trim()) params.q = q.value.trim() if (q.value.trim()) params.q = q.value.trim()
const data = await adminApi.posts(params) const data = await adminApi.posts(params)
items.value = data.items || [] items.value = data.items || []
total.value = data.total || 0 total.value = data.total || 0
selected.value = new Set()
} catch (e) { } catch (e) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
} finally { } finally {
@@ -35,19 +51,77 @@ async function load() {
} }
} }
onMounted(load) async function loadTags() {
watch([status, kind, page], load) try {
const data = await adminApi.tags()
allTags.value = data.tags || data || []
} catch (e) {
/* ignore */
}
}
onMounted(() => {
loadTags()
load()
})
// 数据变化 → 同步 URL(用 replace,不污染历史栈)
function syncQuery() {
const q2 = { ...route.query }
const setOrDel = (k, v, def = '') => {
if (v && v !== def) q2[k] = v
else delete q2[k]
}
setOrDel('status', status.value)
setOrDel('kind', kind.value)
setOrDel('tag', tagFilter.value)
setOrDel('sort', sort.value, 'newest')
setOrDel('q', q.value.trim())
if (page.value > 1) q2.page = String(page.value)
else delete q2.page
router.replace({ path: '/admin/posts', query: q2 })
}
watch([status, kind, tagFilter, sort, page], () => {
syncQuery()
load()
})
let searchTimer let searchTimer
function onSearch() { function onSearch() {
clearTimeout(searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
page.value = 1 page.value = 1
syncQuery()
load() load()
}, 300) }, 300)
} }
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size))) const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
const allSelected = computed(() => items.value.length && selected.value.size === items.value.length)
function toggle(id) {
const s = new Set(selected.value)
if (s.has(id)) s.delete(id)
else s.add(id)
selected.value = s
}
function toggleAll() {
if (allSelected.value) selected.value = new Set()
else selected.value = new Set(items.value.map((p) => p.id))
}
async function bulk(action) {
if (!selected.value.size) return
if (action === 'delete' && !window.confirm(`删除 ${selected.value.size} 篇文章?此操作不可撤销。`)) return
try {
await adminApi.bulkPosts([...selected.value], action)
await load()
} catch (e) {
alert(e.message || '操作失败')
}
}
async function remove(id, title) { async function remove(id, title) {
if (!window.confirm(`删除《${title}》?此操作不可撤销。`)) return if (!window.confirm(`删除《${title}》?此操作不可撤销。`)) return
@@ -62,29 +136,76 @@ async function remove(id, title) {
function edit(id) { function edit(id) {
router.push(`/admin/${id}`) router.push(`/admin/${id}`)
} }
function clearFilters() {
status.value = ''
kind.value = ''
tagFilter.value = ''
q.value = ''
page.value = 1
syncQuery()
load()
}
</script> </script>
<template> <template>
<section> <section>
<header class="head"> <header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 class="title">文章 <span class="count">{{ total }}</span></h1> <h1 style="font-family: var(--serif); font-size: 22px;">文章 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span></h1>
<RouterLink to="/admin/new" class="btn btn-primary">写新的</RouterLink> <RouterLink to="/admin/new" class="btn btn-primary">+ 写新的</RouterLink>
</header> </header>
<div class="filters"> <div class="posts-toolbar">
<input v-model="q" class="input search" placeholder="搜索标题 / 摘要 / 正文" @input="onSearch" /> <input
<select v-model="status" class="select"> v-model="q"
<option value="">全部状态</option> class="input grow"
<option value="published">已发布</option> placeholder="搜索标题 / 摘要 / 正文"
<option value="draft">草稿</option> aria-label="搜索文章"
</select> type="search"
<select v-model="kind" class="select"> spellcheck="false"
<option value="">全部类型</option> @input="onSearch"
<option value="long">长文</option> />
<option value="short">短文</option> <select v-model="sort" class="select" style="width: auto; min-width: 130px;" aria-label="排序方式">
<option value="newest">最新发布</option>
<option value="oldest">最早发布</option>
<option value="updated">最近修改</option>
<option value="longest">字数最多</option>
</select> </select>
</div> </div>
<div class="posts-toolbar" style="margin-bottom: 16px;">
<span style="font-size: 12px; color: var(--admin-muted); margin-right: 4px;">状态</span>
<button class="chip" :class="{ on: status === '' }" @click="status = ''">全部</button>
<button class="chip" :class="{ on: status === 'published' }" @click="status = 'published'">已发布</button>
<button class="chip" :class="{ on: status === 'draft' }" @click="status = 'draft'">草稿</button>
<span style="font-size: 12px; color: var(--admin-muted); margin: 0 4px 0 12px;">类型</span>
<button class="chip" :class="{ on: kind === '' }" @click="kind = ''">全部</button>
<button class="chip" :class="{ on: kind === 'long' }" @click="kind = 'long'">长文</button>
<button class="chip" :class="{ on: kind === 'short' }" @click="kind = 'short'">短文</button>
<span v-if="allTags.length" style="font-size: 12px; color: var(--admin-muted); margin: 0 4px 0 12px;">标签</span>
<button class="chip" :class="{ on: tagFilter === '' }" @click="tagFilter = ''">全部</button>
<button
v-for="t in allTags"
:key="t.id"
class="chip"
:class="{ on: tagFilter === t.name }"
@click="tagFilter = t.name"
>
<span class="tag-dot" :style="{ background: t.color || 'var(--admin-accent)' }" aria-hidden="true"></span>
{{ t.name }}
</button>
<button v-if="status || kind || tagFilter || q" class="chip" style="margin-left: auto;" @click="clearFilters">清除筛选 ✕</button>
</div>
<div v-if="selected.size" class="bulk-bar">
<span>已选 <strong>{{ selected.size }}</strong> 篇</span>
<span class="grow"></span>
<button @click="bulk('publish')">批量发布</button>
<button @click="bulk('draft')">转为草稿</button>
<button @click="bulk('delete')">批量删除</button>
<button class="x" @click="selected = new Set(); load()" aria-label="清除选择">×</button>
</div>
<div v-if="loading" class="loading">载入中…</div> <div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div> <div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty"> <div v-else-if="!items.length" class="empty">
@@ -92,196 +213,71 @@ function edit(id) {
</div> </div>
<template v-else> <template v-else>
<table class="table"> <div style="margin-bottom: 8px;">
<thead> <label style="font-size: 13px; color: var(--admin-muted); cursor: pointer;">
<tr> <input type="checkbox" :checked="allSelected" @change="toggleAll" style="margin-right: 6px;" />
<th class="c-title">标题</th> 全选当前页
<th class="c-kind">类型</th> </label>
<th class="c-status">状态</th> </div>
<th class="c-date">发布时间</th> <div
<th class="c-act"></th> v-for="p in items"
</tr> :key="p.id"
</thead> class="post-card has-check"
<tbody> :class="{ selected: selected.has(p.id) }"
<tr v-for="p in items" :key="p.id"> @click.self="edit(p.id)"
<td class="c-title"> >
<a href="#" class="name" @click.prevent="edit(p.id)"> <input
{{ p.title || '无题' }} type="checkbox"
</a> :checked="selected.has(p.id)"
<span v-if="p.tags && p.tags.length" class="tags"> class="check"
<span v-for="t in p.tags" :key="t" class="tag-chip">{{ t }}</span> :aria-label="`选择《${p.title || '无题'}》`"
</span> @click.stop="toggle(p.id)"
</td> />
<td class="c-kind"> <div class="cover">
<span class="badge" :class="{ short: p.kind === 'short' }"> <img
{{ p.kind === 'short' ? '短文' : '长文' }} v-if="p.cover_url"
</span> :src="p.cover_url"
</td> alt=""
<td class="c-status"> referrerpolicy="no-referrer"
<span class="dot" :class="p.status"></span>{{ p.status === 'published' ? '已发布' : '草稿' }} width="96"
</td> height="64"
<td class="c-date"> loading="lazy"
<span :title="formatDateShort(p.published_at)">{{ relativeDate(p.published_at) }}</span> decoding="async"
</td> />
<td class="c-act"> <span v-else class="ph" aria-hidden="true">{{ p.kind === 'short' ? '短' : '长' }}</span>
<a href="#" @click.prevent="edit(p.id)">编辑</a> </div>
<a v-if="p.status === 'published'" :href="`/post/${p.slug}`" target="_blank">查看</a> <div class="body">
<a href="#" class="danger" @click.prevent="remove(p.id, p.title)">删除</a> <div class="title">{{ p.title || '无题' }}</div>
</td> <div v-if="p.summary" class="summary">{{ p.summary }}</div>
</tr> <div class="meta">
</tbody> <span class="dot" :class="p.status" aria-hidden="true"></span>
</table> <span>{{ p.status === 'published' ? '已发布' : '草稿' }}</span>
<span style="margin: 0 6px;" aria-hidden="true">·</span>
<span>{{ p.kind === 'short' ? '短文' : '长文' }}</span>
<span style="margin: 0 6px;" aria-hidden="true">·</span>
<span :title="formatDateShort(p.published_at)">{{ relativeDate(p.published_at) }}</span>
<template v-if="p.tags && p.tags.length">
<span style="margin: 0 6px;" aria-hidden="true">·</span>
<span v-for="t in p.tags" :key="t" class="chip-static" style="font-size: 11px;">{{ t }}</span>
</template>
</div>
</div>
<div class="stats" aria-hidden="true">
<div>{{ p.reading_minutes || 1 }}′</div>
<div>{{ (p.content_md || '').length }} 字</div>
</div>
<div class="actions">
<a href="#" @click.prevent.stop="edit(p.id)">编辑</a>
<a v-if="p.status === 'published'" :href="`/post/${p.slug}`" target="_blank" @click.stop>查看</a>
<a href="#" class="danger" @click.prevent.stop="remove(p.id, p.title)">删除</a>
</div>
</div>
<nav v-if="pageCount > 1" class="pager"> <nav v-if="pageCount > 1" aria-label="分页" style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;">
<button class="btn" :disabled="page <= 1" @click="page = page - 1">← 上一页</button> <button class="btn" :disabled="page <= 1" @click="page = page - 1" aria-label="上一页">← 上一页</button>
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span> <span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="page = page + 1">下一页 →</button> <button class="btn" :disabled="page >= pageCount" @click="page = page + 1" aria-label="下一页">下一页 →</button>
</nav> </nav>
</template> </template>
</section> </section>
</template> </template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title {
font-size: 22px;
}
.count {
font-family: var(--sans);
font-size: 13px;
color: var(--muted);
font-weight: 400;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search {
flex: 1;
min-width: 200px;
}
.select {
width: auto;
min-width: 120px;
}
.table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--line);
border-radius: 4px;
font-size: 14px;
}
.table th {
text-align: left;
font-weight: 500;
font-size: 12px;
letter-spacing: 0.06em;
color: var(--muted);
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--line-soft);
vertical-align: top;
}
.table tr:last-child td {
border-bottom: 0;
}
.table tr:hover td {
background: var(--paper-sunken);
}
.name {
font-size: 15px;
}
.tags {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 6px;
}
.badge {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--line);
border-radius: 2px;
font-size: 12px;
color: var(--muted);
}
.badge.short {
color: var(--accent);
border-color: var(--accent-line);
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--faint);
margin-right: 6px;
vertical-align: 2px;
}
.dot.published {
background: var(--accent);
}
.c-act {
white-space: nowrap;
text-align: right;
}
.c-act a {
margin-left: 12px;
font-size: 13px;
color: var(--accent);
}
.c-act a.danger {
color: #9a5b45;
}
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
}
.page-info {
font-size: 13px;
color: var(--muted);
}
@media (max-width: 640px) {
.c-kind,
.c-date {
display: none;
}
}
</style>
+130 -92
View File
@@ -1,42 +1,47 @@
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, ref } from 'vue'
import { adminApi } from '../api' import { adminApi } from '../api'
import { site } from '../site' import { SKIN_CONSTANTS } from '../site'
const form = reactive({
site_title: '',
site_desc: '',
author_name: '',
author_bio: '',
footer_note: '',
icp: '',
posts_per_page: 10
})
const tab = ref('basic')
const settings = ref(null)
const loading = ref(true) const loading = ref(true)
const saving = ref(false)
const error = ref('') const error = ref('')
const ok = ref('') const saving = ref(false)
const savedAt = ref('')
onMounted(async () => { // 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
const lightSkins = [
{ 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' }
async function load() {
loading.value = true
try { try {
Object.assign(form, await adminApi.settings()) settings.value = await adminApi.settings()
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
settings.value.light_skin_id = 'paper'
}
} catch (e) { } catch (e) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
} finally { } finally {
loading.value = false loading.value = false
} }
}) }
onMounted(load)
async function save() { async function save() {
if (!settings.value) return
saving.value = true saving.value = true
error.value = '' error.value = ''
ok.value = ''
try { try {
const saved = await adminApi.saveSettings({ ...form }) const s = await adminApi.saveSettings(settings.value)
Object.assign(form, saved) settings.value = s
Object.assign(site, saved) savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
ok.value = '已保存'
} catch (e) { } catch (e) {
error.value = e.message || '保存失败' error.value = e.message || '保存失败'
} finally { } finally {
@@ -46,79 +51,112 @@ async function save() {
</script> </script>
<template> <template>
<section> <section v-if="loading" class="loading">载入中…</section>
<header class="head"> <section v-else-if="error" class="empty">{{ error }}</section>
<h1 class="title">站点设置</h1> <section v-else-if="settings">
</header> <h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">设置</h1>
<div v-if="loading" class="loading">载入中…</div> <div class="settings-grid">
<div class="settings-tabs">
<form v-else class="form" @submit.prevent="save"> <button :class="{ on: tab === 'basic' }" @click="tab = 'basic'">基础信息</button>
<div class="field"> <button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
<label for="t">站点标题</label> <button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
<input id="t" v-model="form.site_title" class="input" />
</div> </div>
<div class="field"> <div>
<label for="d">站点简介</label> <!-- basic -->
<input id="d" v-model="form.site_desc" class="input" /> <div v-if="tab === 'basic'" class="panel">
<div class="panel-title"><h2>站点信息</h2></div>
<div class="field">
<label>站点标题</label>
<input v-model="settings.site_title" class="input" spellcheck="false" />
</div>
<div class="field">
<label>站点副标题</label>
<input v-model="settings.site_desc" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者昵称</label>
<input v-model="settings.author_name" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者简介</label>
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
</div>
<div class="field">
<label>页脚备注</label>
<input v-model="settings.footer_note" class="input" spellcheck="false" />
</div>
</div>
<!-- theme -->
<div v-if="tab === 'theme'" class="panel">
<div class="panel-title"><h2>主题外观</h2></div>
<div class="field">
<label>亮色皮肤</label>
<div class="theme-swatches">
<div
v-for="sk in lightSkins"
: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>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
访客在前台选择「自动」且系统偏好浅色时使用。
</p>
</div>
<div class="field">
<label>暗色皮肤</label>
<div class="theme-swatches">
<div
class="sw on"
:style="{ background: darkSkin.bg, borderColor: darkSkin.fg }"
:title="darkSkin.label + '(当前固定)'"
>
<span :style="{ background: darkSkin.fg }">{{ darkSkin.label }}</span>
</div>
</div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
暗色端固定为墨色。访客在前台选择「深色」或「自动 + 系统暗」时启用。
</p>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<label>访客端的覆盖</label>
<p style="margin: 0; font-size: 12.5px; color: var(--admin-muted); line-height: 1.7;">
访客在前台右下角的主题开关会锁定「浅色」或「深色」,
这两种情况下你选的皮肤会被忽略;他们切回「自动」时才会按上面的规则显示。
</p>
</div>
</div>
<!-- advanced -->
<div v-if="tab === 'advanced'" class="panel">
<div class="panel-title"><h2>高级</h2></div>
<div class="field">
<label>每页文章数</label>
<input v-model.number="settings.posts_per_page" type="number" min="1" max="50" class="input" />
</div>
<div class="field">
<label>ICP 备案号</label>
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存设置' }}
</button>
<span v-if="savedAt" style="font-size: 12.5px; color: var(--admin-muted);">已保存 {{ savedAt }}</span>
</div>
</div> </div>
</div>
<div class="field">
<label for="a">作者名</label>
<input id="a" v-model="form.author_name" class="input" />
</div>
<div class="field">
<label for="b">作者简介</label>
<textarea id="b" v-model="form.author_bio" class="textarea" rows="3"></textarea>
</div>
<div class="field">
<label for="f">页脚信息</label>
<input id="f" v-model="form.footer_note" class="input" />
</div>
<div class="field">
<label for="i">备案号(可留空)</label>
<input id="i" v-model="form.icp" class="input" />
</div>
<div class="field">
<label for="p">每页文章数</label>
<input id="p" v-model.number="form.posts_per_page" type="number" min="1" max="100" class="input" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? '保存中…' : '保存' }}
</button>
</form>
</section> </section>
</template> </template>
<style scoped>
.head {
margin-bottom: 18px;
}
.title {
font-size: 22px;
}
.form {
max-width: 520px;
}
.error {
color: #9a5b45;
font-size: 13px;
}
.ok {
color: var(--accent);
font-size: 13px;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
const emit = defineEmits(['close'])
const groups = [
{
title: '导航',
shortcuts: [
{ keys: ['G', 'D'], label: '去总览' },
{ keys: ['G', 'P'], label: '去文章列表' },
{ keys: ['G', 'T'], label: '去标签' },
{ keys: ['G', 'S'], label: '去设置' },
{ keys: ['N'], label: '写新文章' }
]
},
{
title: '命令',
shortcuts: [
{ keys: ['⌘', 'K'], label: '打开命令面板' },
{ keys: ['?'], label: '显示本面板' },
{ keys: ['Esc'], label: '关闭弹层' }
]
},
{
title: '编辑器',
shortcuts: [
{ keys: ['⌘', 'S'], label: '立即保存' },
{ keys: ['⌘', 'B'], label: '加粗(编辑器内)' },
{ keys: ['⌘', 'I'], label: '斜体(编辑器内)' },
{ keys: ['/'], label: '唤出命令菜单(编辑器内)' }
]
}
]
function onKey(e) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div class="cmd-overlay" role="dialog" aria-modal="true" aria-label="快捷键面板" @click.self="emit('close')">
<div class="cmd-panel" style="width: 640px;">
<div style="padding: 16px 20px; border-bottom: 1px solid var(--admin-line); font-weight: 600;">
快捷键
</div>
<div style="padding: 16px 20px; max-height: 60vh; overflow-y: auto;">
<div v-for="g in groups" :key="g.title" style="margin-bottom: 18px;">
<div style="font-size: 12px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px;">
{{ g.title }}
</div>
<div class="shortcut-grid">
<div v-for="s in g.shortcuts" :key="s.label" class="row">
<span class="lab">{{ s.label }}</span>
<span>
<kbd v-for="k in s.keys" :key="k" style="margin-right: 4px;">{{ k }}</kbd>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
+94 -128
View File
@@ -5,176 +5,142 @@ import { adminApi } from '../api'
const tags = ref([]) const tags = ref([])
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const name = ref('') const newName = ref('')
const editing = ref(null) const newColor = ref('')
const editName = ref('')
async function load() { async function load() {
loading.value = true loading.value = true
error.value = ''
try { try {
const data = await adminApi.tags() const data = await adminApi.tags()
tags.value = data.tags || [] tags.value = data.tags || data || []
} catch (e) { } catch (e) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
onMounted(load) onMounted(load)
async function create() { async function create() {
if (!name.value.trim()) return const name = newName.value.trim()
if (!name) return
try { try {
await adminApi.createTag(name.value.trim()) await adminApi.createTag({ name, color: newColor.value })
name.value = '' newName.value = ''
newColor.value = ''
await load() await load()
} catch (e) { } catch (e) {
error.value = e.message || '创建失败' alert(e.message || '创建失败')
} }
} }
function startEdit(t) { async function update(t) {
editing.value = t.id
editName.value = t.name
}
async function saveEdit(id) {
try { try {
await adminApi.renameTag(id, editName.value) await adminApi.updateTag(t.id, { name: t.name, color: t.color })
editing.value = null
await load() await load()
} catch (e) { } catch (e) {
error.value = e.message || '重命名失败' alert(e.message || '更新失败')
} }
} }
async function remove(t) { async function remove(t) {
if (!window.confirm(`删除标签「${t.name}」?文章上的关联会一起移除。`)) return if (!window.confirm(`删除标签「${t.name}」?关联的所有文章会被取消关联。`)) return
try { try {
await adminApi.deleteTag(t.id) await adminApi.deleteTag(t.id)
await load() await load()
} catch (e) { } catch (e) {
error.value = e.message || '删除失败' alert(e.message || '删除失败')
}
}
const mergeTarget = ref(null)
const mergeInto = ref('')
const merging = ref(null)
function startMerge(t) {
merging.value = t.id
mergeInto.value = ''
}
function cancelMerge() {
merging.value = null
mergeInto.value = ''
}
async function doMerge(fromTag) {
const toId = Number(mergeInto.value)
if (!toId || toId === fromTag.id) {
alert('选一个不同的目标标签')
return
}
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
try {
await adminApi.mergeTag(fromTag.id, toId)
cancelMerge()
await load()
} catch (e) {
alert(e.message || '合并失败')
} }
} }
</script> </script>
<template> <template>
<section> <section>
<header class="head"> <header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 class="title">标签</h1> <h1 style="font-family: var(--serif); font-size: 22px;">标签 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ tags.length }}</span></h1>
</header> </header>
<form class="new" @submit.prevent="create"> <div class="panel" style="margin-bottom: 16px;">
<input v-model="name" class="input" placeholder="新标签名" /> <div class="panel-title"><h2>新建标签</h2></div>
<button class="btn btn-primary" type="submit">添加</button> <div style="display: flex; gap: 10px; align-items: center;">
</form> <input
v-model="newName"
<p v-if="error" class="error">{{ error }}</p> class="input"
style="flex: 1;"
placeholder="标签名"
aria-label="新标签名"
spellcheck="false"
@keydown.enter="create"
/>
<input
v-model="newColor"
type="color"
aria-label="标签颜色"
style="width: 38px; height: 38px; padding: 0; border: 1px solid var(--admin-line); border-radius: 6px; background: transparent; cursor: pointer;"
/>
<button class="btn btn-primary" @click="create">添加</button>
</div>
</div>
<div v-if="loading" class="loading">载入中…</div> <div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!tags.length" class="empty">还没有标签。</div> <div v-else-if="!tags.length" class="empty">还没有标签。</div>
<ul v-else class="list"> <div v-else>
<li v-for="t in tags" :key="t.id" class="row"> <div v-for="t in tags" :key="t.id" class="tag-row">
<template v-if="editing === t.id"> <label class="swatch" :style="{ background: t.color || 'transparent' }">
<input v-model="editName" class="input" @keyup.enter="saveEdit(t.id)" /> <input
<button class="btn" @click="saveEdit(t.id)">保存</button> type="color"
<button class="btn" @click="editing = null">取消</button> v-model="t.color"
</template> @change="update(t)"
<template v-else> />
<span class="name">{{ t.name }}</span> </label>
<code class="slug">/tag/{{ t.slug }}</code> <input v-model="t.name" class="name" spellcheck="false" :aria-label="`编辑标签 ${t.name}`" @blur="update(t)" @keydown.enter="$event.target.blur()" />
<span class="count">{{ t.count }} 篇</span> <span class="count">{{ t.count }} 篇</span>
<span class="act"> <span v-if="merging === t.id" style="display: flex; gap: 6px; align-items: center;">
<a href="#" @click.prevent="startEdit(t)">重命名</a> <select v-model="mergeInto" class="select" style="width: auto;" :aria-label="`将 ${t.name} 合并到目标`">
<a href="#" class="danger" @click.prevent="remove(t)">删除</a> <option value="">合并到…</option>
</span> <option v-for="other in tags.filter(o => o.id !== t.id)" :key="other.id" :value="other.id">
</template> {{ other.name }}
</li> </option>
</ul> </select>
<button class="acts" style="color: var(--admin-accent);" @click="doMerge(t)">确认</button>
<button class="acts" style="color: var(--admin-muted);" @click="cancelMerge">取消</button>
</span>
<span v-else class="acts">
<button @click="startMerge(t)">合并到…</button>
<button class="danger" @click="remove(t)">删除</button>
</span>
</div>
</div>
</section> </section>
</template> </template>
<style scoped>
.head {
margin-bottom: 16px;
}
.title {
font-size: 22px;
}
.new {
display: flex;
gap: 8px;
margin-bottom: 16px;
max-width: 420px;
}
.error {
color: #9a5b45;
font-size: 13px;
margin-bottom: 12px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
font-size: 14px;
}
.row:last-child {
border-bottom: 0;
}
.name {
min-width: 90px;
}
.slug {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
}
.count {
font-size: 12px;
color: var(--muted);
}
.act {
margin-left: auto;
display: flex;
gap: 14px;
}
.act a {
font-size: 13px;
color: var(--accent);
}
.act a.danger {
color: #9a5b45;
}
@media (max-width: 640px) {
.slug {
display: none;
}
}
</style>
+7 -2
View File
@@ -43,15 +43,20 @@ export const adminApi = {
request('/api/admin/login', { method: 'POST', body: { username, password } }), request('/api/admin/login', { method: 'POST', body: { username, password } }),
logout: () => request('/api/admin/logout', { method: 'POST' }), logout: () => request('/api/admin/logout', { method: 'POST' }),
me: () => request('/api/admin/me'), me: () => request('/api/admin/me'),
dashboard: () => request('/api/admin/dashboard'),
posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)), posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)),
post: (id) => request('/api/admin/posts/' + id), post: (id) => request('/api/admin/posts/' + id),
createPost: (body) => request('/api/admin/posts', { method: 'POST', body }), createPost: (body) => request('/api/admin/posts', { method: 'POST', body }),
updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }), updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }),
deletePost: (id) => request('/api/admin/posts/' + id, { method: 'DELETE' }), deletePost: (id) => request('/api/admin/posts/' + id, { method: 'DELETE' }),
bulkPosts: (ids, action) =>
request('/api/admin/posts/bulk', { method: 'POST', body: { ids, action } }),
tags: () => request('/api/admin/tags'), tags: () => request('/api/admin/tags'),
createTag: (name) => request('/api/admin/tags', { method: 'POST', body: { name } }), createTag: (body) => request('/api/admin/tags', { method: 'POST', body }),
renameTag: (id, name) => request('/api/admin/tags/' + id, { method: 'PUT', body: { name } }), updateTag: (id, body) => request('/api/admin/tags/' + id, { method: 'PUT', body }),
deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }), deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }),
mergeTag: (id, toId) =>
request('/api/admin/tags/' + id + '/merge', { method: 'POST', body: { to_id: toId } }),
settings: () => request('/api/admin/settings'), settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }) saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
} }
+15 -17
View File
@@ -2,6 +2,7 @@
import { site } from '../site' import { site } from '../site'
import { publicApi } from '../api' import { publicApi } from '../api'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
const tags = ref([]) const tags = ref([])
@@ -39,8 +40,6 @@ onMounted(async () => {
</RouterLink> </RouterLink>
</nav> </nav>
<a class="subscribe" href="/rss.xml">订阅 RSS</a>
<div v-if="tags.length" class="tagbox"> <div v-if="tags.length" class="tagbox">
<p class="eyebrow">常读标签</p> <p class="eyebrow">常读标签</p>
<div class="tags"> <div class="tags">
@@ -49,6 +48,11 @@ onMounted(async () => {
</RouterLink> </RouterLink>
</div> </div>
</div> </div>
<div class="theme-box">
<p class="eyebrow">主题</p>
<ThemeSwitcher variant="inline" />
</div>
</div> </div>
</aside> </aside>
</template> </template>
@@ -130,21 +134,6 @@ onMounted(async () => {
color: var(--accent); color: var(--accent);
} }
.subscribe {
align-self: flex-start;
padding: 7px 20px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 14px;
border: 1px solid var(--accent);
}
.subscribe:hover {
background: #356f88;
color: #fff;
}
.tagbox { .tagbox {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
padding-top: 14px; padding-top: 14px;
@@ -156,4 +145,13 @@ onMounted(async () => {
gap: 6px; gap: 6px;
margin-top: 8px; margin-top: 8px;
} }
.theme-box {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.theme-box .theme-switcher {
margin-top: 8px;
}
</style> </style>
+1 -1
View File
@@ -71,7 +71,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
display: grid; display: grid;
grid-template-columns: 40px minmax(0, 1fr); grid-template-columns: 40px minmax(0, 1fr);
gap: 12px; gap: 12px;
padding: 18px 16px; padding: 14px 16px;
margin: 0 -16px; margin: 0 -16px;
border-bottom: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft);
transition: background 0.15s ease; transition: background 0.15s ease;
+103
View File
@@ -0,0 +1,103 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
// 循环顺序:auto → light → dark → auto
const order = ['auto', 'light', 'dark']
const meta = {
auto: { icon: '◐', label: '跟随系统', hint: '跟随操作系统的明暗偏好' },
light: { icon: '☀', label: '浅色', hint: '始终使用浅色' },
dark: { icon: '☾', label: '深色', hint: '始终使用深色' }
}
const props = defineProps({
// floating: 全局右下角浮动(仅 mobile 用)
// inline: 嵌入父容器,跟左栏布局一起
variant: {
type: String,
default: 'inline',
validator: (v) => ['floating', 'inline'].includes(v)
}
})
const current = () => meta[site.themeMode] || meta.auto
const nextMode = () => {
const i = order.indexOf(site.themeMode)
return order[(i + 1) % order.length]
}
function cycle() {
site.themeMode = nextMode()
}
const classes = computed(() => ['theme-switcher', `theme-switcher--${props.variant}`])
</script>
<template>
<button
:class="classes"
type="button"
:title="`${current().label} · 点击切换到 ${meta[nextMode()].label}`"
:aria-label="`主题:${current().label}`"
@click="cycle"
>
<span class="ic">{{ current().icon }}</span>
</button>
</template>
<style scoped>
.theme-switcher {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--admin-line);
border-radius: 999px;
background: var(--admin-card);
color: var(--admin-ink-soft);
cursor: pointer;
font-family: inherit;
line-height: 1;
padding: 0;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.theme-switcher:hover {
background: var(--admin-paper-sunken);
color: var(--admin-ink);
border-color: var(--admin-accent-line);
}
.theme-switcher .ic {
font-size: 19px;
line-height: 1;
}
/* ---------- inline 形态:嵌入左栏底部 ---------- */
.theme-switcher--inline {
width: 36px;
height: 36px;
}
/* ---------- floating 形态:右下角浮动 ---------- */
.theme-switcher--floating {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 100;
width: 42px;
height: 42px;
backdrop-filter: blur(6px);
box-shadow: var(--admin-shadow);
}
@media (max-width: 480px) {
.theme-switcher--floating {
right: 12px;
bottom: 12px;
width: 38px;
height: 38px;
}
}
</style>
+1 -1
View File
@@ -29,7 +29,7 @@ import { site } from '../site'
top: 0; top: 0;
z-index: 20; z-index: 20;
padding: 10px 18px; padding: 10px 18px;
background: rgba(250, 247, 241, 0.94); background-color: var(--paper);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
+2 -1
View File
@@ -13,7 +13,8 @@ const routes = [
path: '/admin', path: '/admin',
component: () => import('./admin/AdminLayout.vue'), component: () => import('./admin/AdminLayout.vue'),
children: [ children: [
{ path: '', name: 'admin-posts', component: () => import('./admin/PostsView.vue') }, { path: '', name: 'admin-dashboard', component: () => import('./admin/DashboardView.vue') },
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') }, { path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') }, { path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') }, { path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
+99 -3
View File
@@ -1,6 +1,52 @@
import { reactive } from 'vue' import { reactive, watch } from 'vue'
import { publicApi } from './api' import { publicApi } from './api'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink'
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
const LIGHT_SKINS = ['paper', 'sage', 'rose']
// 客户端显示模式 —— 持久化在 localStorage
const MODE_KEY = 'one.themeMode'
const VALID_MODES = ['light', 'dark', 'auto']
function readMode() {
if (typeof localStorage === 'undefined') return 'auto'
try {
const v = localStorage.getItem(MODE_KEY)
return VALID_MODES.includes(v) ? v : 'auto'
} catch (_) {
return 'auto'
}
}
function systemPrefersDark() {
if (typeof window === 'undefined' || !window.matchMedia) return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
// 模式 → 实际 data-theme 值
//
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
// dark → ink(强制暗色)
//
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
// 这样访客无论是手动锁浅色、还是系统亮色跟随自动,都能感受到站主定下的气质。
// 暗色端固定为 ink,等后续真要加暗色变体再扩字段。
function effectiveTheme(mode, lightSkin) {
if (mode === 'dark') return 'ink'
if (mode === 'auto') {
// auto 模式:系统暗 → ink;系统亮 → 站主选的亮色皮肤
return systemPrefersDark() ? 'ink' : (lightSkin || 'paper')
}
// light → 站主选的亮色皮肤(让访客也能感受到站主的气质)
return lightSkin || 'paper'
}
function isValidLightSkin(id) {
return LIGHT_SKINS.includes(id)
}
export const site = reactive({ export const site = reactive({
site_title: 'ONE · 一个博客', site_title: 'ONE · 一个博客',
site_desc: '长文与短文,同一种节奏。', site_desc: '长文与短文,同一种节奏。',
@@ -9,21 +55,71 @@ export const site = reactive({
footer_note: '© ONE · 一个博客', footer_note: '© ONE · 一个博客',
icp: '', icp: '',
posts_per_page: 10, posts_per_page: 10,
light_skin_id: 'paper',
// theme_id 是老字段,保留读取兼容用;新代码不要再写。
theme_id: 'paper',
themeMode: readMode(),
loaded: false loaded: false
}) })
// data-theme → 前台皮肤(paper / sage / rose / ink)
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
function applyTheme() {
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
document.documentElement.setAttribute('data-theme', effectiveTheme(site.themeMode, site.light_skin_id))
document.documentElement.setAttribute('data-admin-theme', isDark ? 'dark' : 'light')
}
applyTheme()
// auto 模式下系统切到夜间时实时跟随
if (typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onSystemChange = () => {
if (site.themeMode === 'auto') applyTheme()
}
if (mq.addEventListener) mq.addEventListener('change', onSystemChange)
else if (mq.addListener) mq.addListener(onSystemChange)
}
export async function loadSite() { export async function loadSite() {
try { try {
Object.assign(site, await publicApi.site()) const data = await publicApi.site()
Object.assign(site, data)
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
site.loaded = true site.loaded = true
} catch (e) { } catch (e) {
// 站点还没起来时用默认值,不阻塞首屏
site.loaded = false site.loaded = false
} }
applyTheme()
return site return site
} }
// 访客切模式 → 落 localStorage + 立刻应用
watch(
() => site.themeMode,
() => {
try {
localStorage.setItem(MODE_KEY, site.themeMode)
} catch (_) {
/* private mode 等场景静默 */
}
applyTheme()
}
)
// 站主改 light_skin_id → 仅在 auto 模式下立即可见;
// 否则尊重访客的显式选择。
watch(() => site.light_skin_id, () => {
if (site.themeMode === 'auto') applyTheme()
})
export function applyDocTitle(sub) { export function applyDocTitle(sub) {
const base = site.site_title || 'ONE' const base = site.site_title || 'ONE'
document.title = sub ? `${sub} · ${base}` : base document.title = sub ? `${sub} · ${base}` : base
} }
export const SKIN_CONSTANTS = Object.freeze({
DARK_SKIN,
LIGHT_SKINS: [...LIGHT_SKINS]
})
+1794 -4
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -11,23 +11,26 @@ export function renderMarkdown(text) {
// 编辑器按 `md.render(...)` 的用法调用 // 编辑器按 `md.render(...)` 的用法调用
export const md = { render: renderMarkdown } export const md = { render: renderMarkdown }
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
const shortDateFmt = new Intl.DateTimeFormat('sv-SE') // YYYY-MM-DD
export function formatDate(iso) { export function formatDate(iso) {
if (!iso) return '' if (!iso) return ''
const d = new Date(iso) const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso if (Number.isNaN(d.getTime())) return iso
const y = d.getFullYear() return longDateFmt.format(d)
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y} 年 ${m} 月 ${day} 日`
} }
export function formatDateShort(iso) { export function formatDateShort(iso) {
if (!iso) return '' if (!iso) return ''
const d = new Date(iso) const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso if (Number.isNaN(d.getTime())) return iso
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( return shortDateFmt.format(d)
d.getDate()
).padStart(2, '0')}`
} }
// 时间线上的相对时间:三天内用「几小时前」,同年省略年份 // 时间线上的相对时间:三天内用「几小时前」,同年省略年份
+1 -1
View File
@@ -51,7 +51,7 @@ applyDocTitle('关于')
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background: rgba(250, 247, 241, 0.94); background-color: var(--paper);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 14px 16px; padding: 14px 16px;
+1 -1
View File
@@ -80,7 +80,7 @@ applyDocTitle('归档')
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background: rgba(250, 247, 241, 0.94); background-color: var(--paper);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 14px 16px; padding: 14px 16px;
+8 -7
View File
@@ -135,29 +135,30 @@ applyDocTitle('')
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background: rgba(250, 247, 241, 0.94); background-color: var(--paper);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 14px 16px 0; padding: 8px 16px 0;
} }
.page-title { .page-title {
font-size: 20px; font-size: 17px;
font-weight: 600;
} }
.tabs { .tabs {
display: flex; display: flex;
gap: 4px; gap: 2px;
margin-top: 6px; margin-top: 4px;
} }
.tab { .tab {
position: relative; position: relative;
padding: 8px 14px 10px; padding: 5px 12px 7px;
background: none; background: none;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 13.5px;
color: var(--muted); color: var(--muted);
} }
+1 -1
View File
@@ -95,7 +95,7 @@ applyDocTitle('标签')
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background: rgba(250, 247, 241, 0.94); background-color: var(--paper);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 14px 16px; padding: 14px 16px;
+1 -1
View File
@@ -70,7 +70,7 @@ applyDocTitle('标签')
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background: rgba(250, 247, 241, 0.94); background-color: var(--paper);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 14px 16px; padding: 14px 16px;
+3 -1
View File
@@ -8,6 +8,8 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"allowJs": true,
"checkJs": false,
"noEmit": true, "noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"strict": true, "strict": true,
@@ -17,5 +19,5 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.d.ts", "src/**/*.vue"]
} }