Files
ONE/frontend/src/api.js
T
Sakurasan f7a2e83727 编辑器三件套(参考 xLog):CodeMirror 源码模式 + 外链图片一键转存 + 上传进度/前置校验
- md 页签从裸 textarea 升级 CodeMirror 6:markdown 语法高亮、行内历史,粘贴/拖图直传;
  markdown 仍是唯一真相源,转存/切换用 syncCM 全量灌回,工具栏选区辅助改为面向 CM
- POST /api/admin/files/import:外链抓取复用 linkmeta SSRF 防护拨号与跳转限制,
  二进制传输放宽 30s;类型按内容嗅探反查白名单,与手动上传共用去重/落盘(persistFile 抽取共享)
- 上传走 XHR 进度回调 + 编辑器 toast 栈:类型/大小前置校验(与后端白名单一致)、逐文件进度
- persistFile 去重路径补 URL 解析——此前命中去重返回的行没有 URL,转存替换会把正文图链清空(已修复受损数据)
- hub nil 安全(测试环境未装配时不 panic)
2026-09-28 23:42:24 +08:00

190 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const base = ''
// 侧栏数据(标签 / 最近更新)每页都会重新挂载请求,这里做 30s 的
// 模块级缓存 + 并发去重;失败不缓存,下一次调用会重新请求。
function cached(fn, ttl = 30000) {
let p = null
let at = 0
return (...args) => {
const now = Date.now()
if (p && now - at < ttl) return p
at = now
const cur = fn(...args)
p = cur
cur.catch(() => {
if (p === cur) p = null
})
return cur
}
}
async function request(path, { method = 'GET', body, auth = false } = {}) {
const headers = { 'Content-Type': 'application/json' }
const res = await fetch(base + path, {
method,
headers,
credentials: 'include',
body: body === undefined ? undefined : JSON.stringify(body)
})
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
const err = new Error('未登录或登录已过期')
err.status = 401
throw err
}
if (!res.ok) {
let msg = `请求失败(${res.status})`
try {
const data = await res.json()
if (data && data.error) msg = data.error
} catch (_) {
/* 非 JSON 响应 */
}
const err = new Error(msg)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
export const publicApi = {
site: () => request('/api/site'),
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
archive: () => request('/api/archive'),
tags: cached(() => request('/api/tags')),
projects: cached(() => request('/api/projects')),
latest: cached(() => request('/api/posts?size=5'))
}
export const adminApi = {
login: (username, password) =>
request('/api/admin/login', { method: 'POST', body: { username, password } }),
logout: () => request('/api/admin/logout', { method: 'POST' }),
me: () => request('/api/admin/me'),
dashboard: () => request('/api/admin/dashboard'),
posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)),
post: (id) => request('/api/admin/posts/' + id),
createPost: (body) => request('/api/admin/posts', { method: 'POST', body }),
updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }),
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'),
createTag: (body) => request('/api/admin/tags', { method: 'POST', body }),
updateTag: (id, body) => request('/api/admin/tags/' + id, { method: 'PUT', body }),
deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }),
mergeTag: (id, toId) =>
request('/api/admin/tags/' + id + '/merge', { method: 'POST', body: { to_id: toId } }),
projects: (params = {}) => request('/api/admin/projects?' + new URLSearchParams(params)),
createProject: (body) => request('/api/admin/projects', { method: 'POST', body }),
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
// ---------- 评论管理 ----------
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
approveComment: (id) =>
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
setReaderBanned: (id, banned) =>
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' }),
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
uploadFile: (file, onProgress) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', base + '/api/admin/files')
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
}
xhr.onload = () => {
if (xhr.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
reject(new Error('未登录或登录已过期'))
return
}
let data = {}
try {
data = JSON.parse(xhr.responseText)
} catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data)
else reject(new Error(data.error || `上传失败(${xhr.status})`))
}
xhr.onerror = () => reject(new Error('网络错误,上传中止'))
const fd = new FormData()
fd.append('file', file)
xhr.send(fd)
}),
// 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors }
importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }),
uploadFiles: async (formData) => {
const res = await fetch(base + '/api/admin/files', {
method: 'POST',
credentials: 'include',
body: formData
})
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
const err = new Error('未登录或登录已过期')
err.status = 401
throw err
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.error || `上传失败(${res.status})`)
err.status = res.status
throw err
}
return data
}
}
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
// 前端只拿 /api/auth/me 的结果做展示,不存 token。
// 注意 me 在匿名时返回 200 {user:null} 而不是 401 —— request() 对每个 401
// 都会派发 one:unauthorized,匿名访客每次开页都会刷一遍事件。
export const readerApi = {
me: () => request('/api/auth/me'),
providers: () => request('/api/auth/providers'),
logout: () => request('/api/auth/logout', { method: 'POST' }),
// Telegram 是 Login Widget:官方脚本回调里直接带着签名字段,没有 code 可换
telegram: (payload) => request('/api/auth/telegram', { method: 'POST', body: payload }),
comments: (postId, { sort = 'default', page = 1, size = 10 } = {}) =>
request('/api/comments?' + new URLSearchParams({ post_id: postId, sort, page, size })),
thread: (rootId, cursor = '', size = 10) =>
request(`/api/comments/${rootId}/thread?` + new URLSearchParams({ cursor, size })),
create: (postId, bodyMd, parentId = 0) =>
request('/api/comments', {
method: 'POST',
body: { post_id: postId, parent_id: parentId, body_md: bodyMd }
}),
edit: (id, bodyMd) =>
request(`/api/comments/${id}`, { method: 'PUT', body: { body_md: bodyMd } }),
remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' })
}
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
const USER_KEY = 'one.admin.user'
export const session = {
get user() {
return localStorage.getItem(USER_KEY) || ''
},
set user(v) {
if (v) localStorage.setItem(USER_KEY, v)
else localStorage.removeItem(USER_KEY)
},
clear() {
localStorage.removeItem(USER_KEY)
}
}