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:
eezd committed 2026-10-16 18:37:14 +08:00
1 parent c4d2bd4636
commit 02d904056c
28 files changed
+2231 -398

No files matched your search

+104
View File
@@ -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}`),
}