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) } }