文件删除前查引用:被文章/项目/站主头像用着就先挡住
- store.FileReferences(key) 反查谁在用这个文件:文章(封面 + content_md +
content_html)、项目封面、settings.owner_avatar_key。匹配的键是 files.key 本身
而不是完整 URL——本地 /uploads/{key}、R2 {publicBase}/{key}、缩略图
/uploads/thumb/{key} 三种形态都以 key 结尾,换存储端后正文里的老链接照样查得到。
草稿也算:现在没发布,删了将来发出来就是裂的。
- 按需 LIKE 现扫,不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处,
计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。
- 新端点 GET /api/admin/files/{id}/refs;DELETE 默认对在用文件返回 409(消息带引用数
和前三个位置名),明确带 force=1 才真删。守卫放在动 blob 之前——存储端一删就没法回头。
- 后台删除弹层先把引用清单摊出来(文章《标题》(草稿)、站主头像),仍然要删才带 force。
引用查询失败就按无引用走:真被引用时后端会 409,不会静默删掉在用文件。
顺手把另一个 agent 工具的本地草稿目录(.zcode/、.zcodeignore)加进 .gitignore。
This commit is contained in:
1 parent
f1e639e0ba
commit
1b3eb870da
8 files changed
+427
-4
No files matched your search
@@ -12,6 +12,10 @@ data/
|
||||
# 开发进程日志 / pid
|
||||
.run/
|
||||
|
||||
# 其他 agent 工具的本地草稿(计划、忽略清单),不进仓库
|
||||
.zcode/
|
||||
.zcodeignore
|
||||
|
||||
# 二进制
|
||||
one-server
|
||||
backend/one-server
|
||||
|
||||
@@ -894,13 +894,27 @@ func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error)
|
||||
}
|
||||
|
||||
func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/"))
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/")
|
||||
idPart, sub := rest, ""
|
||||
if i := strings.Index(rest, "/"); i >= 0 {
|
||||
idPart, sub = rest[:i], rest[i+1:]
|
||||
}
|
||||
// /files/{id} 与 /files/{id}/refs 两种形态,别的一律不收
|
||||
if sub != "" && (sub != "refs" || r.Method != http.MethodGet) {
|
||||
httpx.BadRequest(w, "bad file path")
|
||||
return
|
||||
}
|
||||
id, err := parseInt(idPart)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad file id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if sub == "refs" {
|
||||
a.fileRefs(w, r, id)
|
||||
return
|
||||
}
|
||||
f, err := a.Store.GetFile(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
@@ -922,6 +936,15 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 正文/封面/项目/站主头像里还在用就先挡住,除非明确带 force=1。
|
||||
// 检查放在删对象之前:一旦 blob 删了就没法回头。
|
||||
if refs, err := a.Store.FileReferences(f.Key); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
} else if len(refs) > 0 && httpx.QueryString(r, "force") != "1" {
|
||||
httpx.Error(w, http.StatusConflict, fileInUseMessage(refs))
|
||||
return
|
||||
}
|
||||
// 先删对象存储再删行:存储端失败时行保留,可以重试
|
||||
if err := a.Blobs.Delete(r.Context(), f.Key); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
@@ -940,6 +963,49 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// fileRefs 报告这个文件被谁引用,供前端在删除弹层里列出来。
|
||||
func (a *API) fileRefs(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
f, err := a.Store.GetFile(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
refs, err := a.Store.FileReferences(f.Key)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"key": f.Key, "count": len(refs), "items": refs})
|
||||
}
|
||||
|
||||
// fileInUseMessage 是挡住删除时回给调用方的一句话,只列前三个引用。
|
||||
func fileInUseMessage(refs []model.FileRef) string {
|
||||
names := make([]string, 0, 3)
|
||||
for _, x := range refs {
|
||||
if x.Kind == "avatar" {
|
||||
names = append(names, "站主头像")
|
||||
continue
|
||||
}
|
||||
t := x.Title
|
||||
if t == "" {
|
||||
t = x.Slug
|
||||
}
|
||||
names = append(names, "《"+t+"》")
|
||||
if len(names) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
label := strings.Join(names, "、")
|
||||
if len(refs) > len(names) {
|
||||
label += " 等"
|
||||
}
|
||||
return fmt.Sprintf("该文件正被 %d 处引用:%s,删除后这些位置会失效(确认要删带 force=1)", len(refs), label)
|
||||
}
|
||||
|
||||
// ---------- comments(评论审核与管理) ----------
|
||||
|
||||
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// seed 放一个本地 blob + 一行 files,再按 body 建一篇引用它的短文(body 为空表示不引用)。
|
||||
func seed(t *testing.T, a *API, key, body string) model.File {
|
||||
t.Helper()
|
||||
if err := a.Blobs.Put(t.Context(), key, strings.NewReader("pngbytes"), 8, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := a.Store.CreateFile(model.File{
|
||||
Key: key, Name: filepath.Base(key), Mime: "image/png",
|
||||
Size: 8, SHA256: strings.Repeat("f", 64), Store: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "" {
|
||||
if _, err := a.Store.Create(model.PostInput{
|
||||
Kind: model.KindShort, Slug: "s-" + key, ContentMd: body, Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestFileRefsEndpoint(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a.Blobs = storage.NewLocal(t.TempDir())
|
||||
f := seed(t, a, "2026/09/aaa.png", `看图 `)
|
||||
// 顺手把站主头像也指到同一张图,refs 要把这一路也报出来
|
||||
if err := a.Store.SetSetting("owner_avatar_key", f.Key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := doAs(t, h, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
Key string `json:"key"`
|
||||
Count int `json:"count"`
|
||||
Items []model.FileRef `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Key != f.Key || v.Count != 2 || len(v.Items) != 2 {
|
||||
t.Fatalf("refs 不对: %+v", v)
|
||||
}
|
||||
var kinds = map[string]bool{}
|
||||
for _, x := range v.Items {
|
||||
kinds[x.Kind] = true
|
||||
}
|
||||
if !kinds["post"] || !kinds["avatar"] {
|
||||
t.Fatalf("缺引用类型: %+v", v.Items)
|
||||
}
|
||||
|
||||
// 不存在的文件:404 而不是空列表
|
||||
rec = doAs(t, h, http.MethodGet, "/api/admin/files/9999/refs", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
// 乱七八糟的子路径不收
|
||||
rec = doAs(t, h, http.MethodGet, "/api/admin/files/1/nope", "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。
|
||||
func TestFileDeleteBlockedByRefs(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
dir := t.TempDir()
|
||||
a.Blobs = storage.NewLocal(dir)
|
||||
f := seed(t, a, "2026/09/bbb.png", ``)
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(e.Error, "1 处引用") {
|
||||
t.Fatalf("错误消息没报引用数: %q", e.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "2026/09/bbb.png")); err != nil {
|
||||
t.Fatalf("被挡住时不该动存储: %v", err)
|
||||
}
|
||||
if _, err := a.Store.GetFile(f.ID); err != nil {
|
||||
t.Fatalf("被挡住时不该删行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 明确带 force=1 才真删:行、blob 一起走。
|
||||
func TestFileDeleteForceProceeds(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
dir := t.TempDir()
|
||||
a.Blobs = storage.NewLocal(dir)
|
||||
f := seed(t, a, "2026/09/ccc.png", ``)
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("行应已删除, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "2026/09/ccc.png")); !os.IsNotExist(err) {
|
||||
t.Fatalf("blob 应已删除, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。
|
||||
func TestFileDeleteUnreferenced(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a.Blobs = storage.NewLocal(t.TempDir())
|
||||
f := seed(t, a, "2026/09/ddd.png", "")
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("行应已删除, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,16 @@ type FilePage struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// FileRef 是「谁在用这个文件」的一处记录,供删除前的引用检查。
|
||||
// Kind 取 post / project / avatar;avatar 没有可跳转的行,ID 为 0。
|
||||
type FileRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
|
||||
type CommentPage struct {
|
||||
Items []Comment `json:"items"`
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
func countKind(refs []model.FileRef, kind string) int {
|
||||
n := 0
|
||||
for _, r := range refs {
|
||||
if r.Kind == kind {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 同一个 key 在正文里的三种形态都要认出来:R2 直链、本地 /uploads、缩略图路径。
|
||||
func TestFileReferencesMatchesKeyNotURL(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
const key = "2026/09/deadbeefcafe.png"
|
||||
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindLong, Title: "直链引用", Slug: "r2",
|
||||
CoverURL: "https://cdn.example.com/" + key,
|
||||
ContentMd: "正文配图 ",
|
||||
Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindShort, Slug: "local",
|
||||
ContentMd: "本地形态  和缩略图 ",
|
||||
Status: model.StatusDraft,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
refs, err := s.FileReferences(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 两篇文章各算一处:同一行里多次命中不重复计
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("want 2 refs, got %+v", refs)
|
||||
}
|
||||
if countKind(refs, "post") != 2 {
|
||||
t.Fatalf("两条都该是 post: %+v", refs)
|
||||
}
|
||||
// 草稿也要报出来(发布后就会图裂)
|
||||
if refs[1].Status != model.StatusDraft {
|
||||
t.Fatalf("草稿状态没带出来: %+v", refs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileReferencesProjectsAndAvatar(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
const key = "2026/09/111122223333.webp"
|
||||
|
||||
if _, err := s.CreateProject(model.ProjectInput{
|
||||
Title: "ONE", Slug: "one", CoverURL: "/uploads/" + key, Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSetting("owner_avatar_key", key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
refs, err := s.FileReferences(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if countKind(refs, "project") != 1 {
|
||||
t.Fatalf("项目封面没查到: %+v", refs)
|
||||
}
|
||||
if countKind(refs, "avatar") != 1 {
|
||||
t.Fatalf("站主头像没查到: %+v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// 没被引用、以及 key 为空时都不能误报——空 pattern 会 LIKE '%%' 命中全库。
|
||||
func TestFileReferencesNoFalsePositive(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindLong, Title: "无关", Slug: "x",
|
||||
ContentMd: "正文里没有这张图", CoverURL: "/uploads/2026/09/aaaa.png",
|
||||
Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同内容不同月上传出的 key 只是月份段不同,不该互相命中
|
||||
refs, err := s.FileReferences("2026/10/aaaa.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("换月的 key 误报: %+v", refs)
|
||||
}
|
||||
|
||||
empty, err := s.FileReferences("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("空 key 不该匹配任何东西,得到 %+v", empty)
|
||||
}
|
||||
}
|
||||
@@ -1650,6 +1650,68 @@ func (s *Store) GetFileByKey(key string) (model.File, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// FileReferences 反查谁在用这个文件:文章(封面或正文)、项目封面、站主头像。
|
||||
// 匹配的键是 files.key 本身而不是完整 URL——本地存成 /uploads/{key}、R2 存成
|
||||
// {publicBase}/{key}、缩略图是 /uploads/thumb/{key},三种形态都以 key 结尾,
|
||||
// 换存储端后正文里的老链接也照样能查到。
|
||||
// 用 LIKE 现扫而不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处,
|
||||
// 计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。
|
||||
func (s *Store) FileReferences(key string) ([]model.FileRef, error) {
|
||||
if key == "" {
|
||||
return []model.FileRef{}, nil
|
||||
}
|
||||
like := "%" + key + "%"
|
||||
out := []model.FileRef{}
|
||||
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT id,title,slug,status FROM posts
|
||||
WHERE cover_url LIKE ? OR content_md LIKE ? OR content_html LIKE ?
|
||||
ORDER BY published_at DESC LIMIT 100`), like, like, like)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var r model.FileRef
|
||||
r.Kind = "post"
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Slug, &r.Status); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
proj, err := s.db.Query(s.db.Q(`SELECT id,title,slug,status FROM projects WHERE cover_url LIKE ? LIMIT 100`), like)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for proj.Next() {
|
||||
var r model.FileRef
|
||||
r.Kind = "project"
|
||||
if err := proj.Scan(&r.ID, &r.Title, &r.Slug, &r.Status); err != nil {
|
||||
proj.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := proj.Err(); err != nil {
|
||||
proj.Close()
|
||||
return nil, err
|
||||
}
|
||||
proj.Close()
|
||||
|
||||
// 头像存的就是 key(不是 URL),等值比较
|
||||
if st, err := s.GetSettings(); err != nil {
|
||||
return nil, err
|
||||
} else if st.AuthorAvatarKey == key {
|
||||
out = append(out, model.FileRef{Kind: "avatar"})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。
|
||||
func (s *Store) DeleteFile(id int64) (model.File, error) {
|
||||
f, err := s.GetFile(id)
|
||||
|
||||
@@ -89,10 +89,32 @@ async function copyUrl(f) {
|
||||
}
|
||||
}
|
||||
|
||||
// 引用位置的展示文案:文章/项目带标题,头像没有标题可指。
|
||||
// 草稿也列出来——现在没发布,删了图将来发出来就是裂的。
|
||||
function refLabel(x) {
|
||||
if (x.kind === 'avatar') return '站主头像'
|
||||
const head = x.kind === 'project' ? '项目' : '文章'
|
||||
const name = x.title || x.slug || '未命名'
|
||||
return `${head}《${name}》${x.status === 'draft' ? '(草稿)' : ''}`
|
||||
}
|
||||
|
||||
async function remove(f) {
|
||||
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
|
||||
// 删除前查一次引用:后端默认会挡住被引用的文件(409),这里先把清单摊出来,
|
||||
// 用户看完仍然要删才带 force=1 过去。
|
||||
let refs = []
|
||||
try {
|
||||
await adminApi.deleteFile(f.id)
|
||||
refs = (await adminApi.fileRefs(f.id)).items || []
|
||||
} catch (_) {
|
||||
// 查询失败就按无引用走:真被引用时后端会返回 409,不会静默删掉在用文件
|
||||
}
|
||||
let tip = `删除「${f.name}」?存储里的文件会一并删除。`
|
||||
if (refs.length) {
|
||||
const list = refs.slice(0, 3).map(refLabel).join('、')
|
||||
tip = `删除「${f.name}」?\n它正被 ${refs.length} 处引用:${list}${refs.length > 3 ? ' 等' : ''}。\n删掉这些地方会图裂。`
|
||||
}
|
||||
if (!window.confirm(tip)) return
|
||||
try {
|
||||
await adminApi.deleteFile(f.id, refs.length > 0)
|
||||
toastOk(`已删除「${f.name}」`)
|
||||
// 当前页删空时退一页,其余直接重载
|
||||
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
|
||||
|
||||
+4
-1
@@ -104,7 +104,10 @@ export const adminApi = {
|
||||
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
|
||||
// ---------- 文件上传 ----------
|
||||
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
|
||||
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
|
||||
// 删除前查引用(被哪些文章/项目/站主头像用着);force 用于明知有引用仍要删
|
||||
fileRefs: (id) => request('/api/admin/files/' + id + '/refs'),
|
||||
deleteFile: (id, force = false) =>
|
||||
request('/api/admin/files/' + id + (force ? '?force=1' : ''), { method: 'DELETE' }),
|
||||
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
|
||||
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
|
||||
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
|
||||
|
||||
Reference in new issue
Block a user