feat(web): add Vue workspace with file tree, tabs, autosave and live sync
- Login and first-run setup screens - File tree with inline create/rename, context menu, drag-and-drop move and trash with undo; all operations update the UI first and roll back on failure - Tabs keep a CodeMirror state each; edits autosave after 500 ms with If-Match revisions, unsent edits persist in IndexedDB, and conflicts or remote deletions surface as banners with explicit choices - WebSocket events sync external edits, moves and deletions into open tabs; reconnects trigger a full resync - Pasted or dropped images show instantly and upload to note.assets/ - Responsive layout with a swipe-in sidebar drawer on phones - Switch to TypeScript 6: vue-tsc does not support TypeScript 7 yet
This commit is contained in:
1 parent
c4d2bd4636
commit
02d904056c
28 files changed
+2231
-398
No files matched your search
@@ -0,0 +1,104 @@
|
||||
// REST API 客户端,与服务端 internal/api 对应
|
||||
|
||||
export interface Entry {
|
||||
path: string
|
||||
dir: boolean
|
||||
size: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface TrashItem {
|
||||
id: string
|
||||
path: string
|
||||
dir: boolean
|
||||
deletedAt: number
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
readonly data: Record<string, unknown> = {},
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
// 401 时通知应用回到登录页
|
||||
let onUnauthorized: () => void = () => {}
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn
|
||||
}
|
||||
|
||||
export function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
url: string,
|
||||
init: { json?: unknown; body?: BodyInit; headers?: Record<string, string>; keepalive?: boolean } = {},
|
||||
) {
|
||||
const headers: Record<string, string> = { ...init.headers }
|
||||
let body = init.body
|
||||
if (init.json !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify(init.json)
|
||||
}
|
||||
const res = await fetch(url, { method, headers, body, credentials: 'same-origin', keepalive: init.keepalive })
|
||||
if (!res.ok) {
|
||||
let data: Record<string, unknown> = {}
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
// 非 JSON 错误体(如反向代理返回的页面)
|
||||
}
|
||||
if (res.status === 401 && !url.startsWith('/api/v1/auth/')) onUnauthorized()
|
||||
throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `请求失败(${res.status})`, data)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const json = async <T>(p: Promise<Response>): Promise<T> => (await p).json()
|
||||
|
||||
export const api = {
|
||||
authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')),
|
||||
setup: (code: string, password: string) => request('POST', '/api/v1/auth/setup', { json: { code, password } }),
|
||||
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
|
||||
logout: () => request('POST', '/api/v1/auth/logout'),
|
||||
|
||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
||||
|
||||
async readFile(path: string): Promise<{ content: string; rev: string }> {
|
||||
const res = await request('GET', '/api/v1/files/' + encodePath(path))
|
||||
return { content: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
|
||||
},
|
||||
|
||||
// ifMatch 为空字符串表示新建(文件必须不存在),undefined 表示无条件覆盖
|
||||
// keepalive 让请求在页面关闭后仍能发出(浏览器限制请求体不超过 64 KB,超出时退回普通请求)
|
||||
async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
|
||||
if (ifMatch === '') headers['If-None-Match'] = '*'
|
||||
else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"`
|
||||
const res = request('PUT', '/api/v1/files/' + encodePath(path), {
|
||||
body: content,
|
||||
headers,
|
||||
keepalive: keepalive && new Blob([content]).size < 60_000,
|
||||
})
|
||||
return (await json<{ rev: string }>(res)).rev
|
||||
},
|
||||
|
||||
deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))),
|
||||
move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }),
|
||||
mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }),
|
||||
|
||||
async upload(note: string, file: File): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name || 'image')
|
||||
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
|
||||
},
|
||||
|
||||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||||
}
|
||||
Reference in new issue
Block a user