diff --git a/mindspace-oa-drive/drive-context.mjs b/mindspace-oa-drive/drive-context.mjs new file mode 100644 index 0000000..73cf1d3 --- /dev/null +++ b/mindspace-oa-drive/drive-context.mjs @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveMindSpaceUserPublishDir } from '../mindspace-runtime-config.mjs'; +import { ensureUserZoneDirs, resolveZoneDir } from '../user-space.mjs'; +import { OA_DRIVE_DEFAULTS } from './drive-defaults.mjs'; + +export function resolveUserOaDriveRoot(h5Root, userId) { + const user = typeof userId === 'object' && userId !== null ? userId : { id: userId }; + const workspaceRoot = resolveMindSpaceUserPublishDir(h5Root, user); + ensureUserZoneDirs(workspaceRoot); + return resolveZoneDir(workspaceRoot, 'oa'); +} + +export function createDriveContext(h5Root, userId) { + const normalizedUserId = String( + typeof userId === 'object' && userId !== null ? userId.id : userId, + ).trim(); + const uploadDir = resolveUserOaDriveRoot(h5Root, normalizedUserId); + fs.mkdirSync(uploadDir, { recursive: true }); + for (const dirName of [ + OA_DRIVE_DEFAULTS.trashDirName, + OA_DRIVE_DEFAULTS.metaDirName, + OA_DRIVE_DEFAULTS.sessionDirName, + ]) { + fs.mkdirSync(path.join(uploadDir, dirName), { recursive: true }); + } + return { + userId: normalizedUserId, + uploadDir, + ...OA_DRIVE_DEFAULTS, + }; +} diff --git a/mindspace-oa-drive/drive-defaults.mjs b/mindspace-oa-drive/drive-defaults.mjs new file mode 100644 index 0000000..08129f4 --- /dev/null +++ b/mindspace-oa-drive/drive-defaults.mjs @@ -0,0 +1,12 @@ +export const OA_DRIVE_TRASH_DIR = '.drive-trash'; +export const OA_DRIVE_META_DIR = '.drive-meta'; +export const OA_DRIVE_SESSION_DIR = '.drive-sessions'; + +export const OA_DRIVE_DEFAULTS = { + maxBodyBytes: 2 * 1024 * 1024 * 1024, + pageSize: 100, + chunkSize: 4 * 1024 * 1024, + trashDirName: OA_DRIVE_TRASH_DIR, + metaDirName: OA_DRIVE_META_DIR, + sessionDirName: OA_DRIVE_SESSION_DIR, +}; diff --git a/mindspace-oa-drive/drive-handlers.mjs b/mindspace-oa-drive/drive-handlers.mjs new file mode 100644 index 0000000..5c00ea7 --- /dev/null +++ b/mindspace-oa-drive/drive-handlers.mjs @@ -0,0 +1,444 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + enrichFiles, + isPreviewable, + mimeFor, + removeFileMeta, + renameFileMeta, + setFileMeta, + statEntry, +} from './lib/fs-tree.mjs'; +import { folderNameOk, joinRel, normalizeRelPath, safeJoin } from './lib/paths.mjs'; +import { renameEntry, resolveConflictPath, transferEntry } from './lib/transfer.mjs'; +import { + emptyTrash, + listTrash, + moveToTrash, + purgeTrashItem, + restoreFromTrash, +} from './lib/trash.mjs'; +import { createZipStream } from './lib/zip.mjs'; +import { + appendUploadChunk, + completeUploadSession, + createUploadSession, + getSession, +} from './lib/upload-resume.mjs'; + +const ROUTE_PREFIX = '/mindspace/v1/oa/drive'; + +function pathBasename(p) { + return p.split(/[/\\]/).pop(); +} + +function sendJson(res, status, data) { + res.status(status).json(data); +} + +function sendDriveFile(res, filePath, { download = false, downloadName, contentType } = {}) { + const stream = fs.createReadStream(filePath); + const inline = !download && isPreviewable(filePath); + res.set({ + 'Content-Type': contentType || mimeFor(filePath), + 'Content-Disposition': download + ? `attachment; filename*=UTF-8''${encodeURIComponent(downloadName || pathBasename(filePath))}` + : inline + ? 'inline' + : `attachment; filename*=UTF-8''${encodeURIComponent(downloadName || pathBasename(filePath))}`, + 'Cache-Control': 'no-store', + }); + stream.pipe(res); +} + +async function readBodyBuffer(req, limit) { + if (Buffer.isBuffer(req.body) && req.body.length > 0) { + if (req.body.length > limit) throw Object.assign(new Error('payload too large'), { code: 'payload_too_large' }); + return req.body; + } + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > limit) throw Object.assign(new Error('payload too large'), { code: 'payload_too_large' }); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +async function readJsonBody(req, limit = 1024 * 1024) { + const raw = (await readBodyBuffer(req, limit)).toString('utf8').trim(); + if (!raw) return {}; + return JSON.parse(raw); +} + +function parseMultipart(buffer, boundary) { + const parts = []; + const delim = Buffer.from(`--${boundary}`); + let start = buffer.indexOf(delim) + delim.length + 2; + while (start < buffer.length) { + const next = buffer.indexOf(delim, start); + const chunk = buffer.subarray(start, next === -1 ? buffer.length : next - 2); + if (!chunk.length) break; + const headerEnd = chunk.indexOf('\r\n\r\n'); + if (headerEnd === -1) break; + const headers = chunk.subarray(0, headerEnd).toString('utf8'); + const body = chunk.subarray(headerEnd + 4); + const nameMatch = headers.match(/name="([^"]+)"/); + const fileMatch = headers.match(/filename="([^"]*)"/); + parts.push({ + name: nameMatch?.[1] || '', + filename: fileMatch?.[1] || '', + body, + }); + if (next === -1) break; + start = next + delim.length + 2; + } + return parts; +} + +function clientMetaFromReq(req) { + const rawLabel = req.headers['x-fds-client-label'] || 'web'; + let clientLabel = rawLabel; + try { + clientLabel = decodeURIComponent(rawLabel); + } catch {} + return { + clientId: req.headers['x-fds-client-id'] || 'web', + clientLabel, + userAgent: req.headers['user-agent'] || '', + }; +} + +function resolveSubPath(req) { + const raw = req.path.startsWith(ROUTE_PREFIX) + ? req.path.slice(ROUTE_PREFIX.length) + : req.path; + return raw.startsWith('/') ? raw : `/${raw}`; +} + +export function createDriveHandlers({ + getRuntime, + getQuotaInfo = async () => null, + assertUploadAllowed = async () => {}, + afterMutate = async () => {}, +} = {}) { + async function handleUpload(req, res, runtime, url) { + const { ctx } = runtime; + const onConflict = url.searchParams.get('onConflict') || 'rename'; + const ctype = req.headers['content-type'] || ''; + const boundaryMatch = ctype.match(/boundary=(.+)$/); + if (!boundaryMatch) { + return sendJson(res, 400, { error: 'expected multipart' }); + } + const buffer = await readBodyBuffer(req, ctx.maxBodyBytes); + const parts = parseMultipart(buffer, boundaryMatch[1]); + const saved = []; + const skipped = []; + const prefix = parts.find((p) => p.name === 'prefix')?.body.toString('utf8').trim() || ''; + const clientMeta = clientMetaFromReq(req); + let totalAdded = 0; + + for (const part of parts) { + if (part.name !== 'file' || !part.filename) continue; + try { + let relPath = part.filename.replace(/\\/g, '/').replace(/^\/+/, ''); + if (prefix) relPath = joinRel(prefix, relPath); + await assertUploadAllowed(ctx.userId, part.body.length); + let dest = safeJoin(ctx.uploadDir, relPath); + if (fs.existsSync(dest)) { + if (onConflict === 'skip') { + skipped.push(relPath); + continue; + } + if (onConflict === 'overwrite') await fsp.rm(dest, { recursive: true, force: true }); + else { + dest = resolveConflictPath(dest, 'rename'); + relPath = dest.slice(ctx.uploadDir.length + 1).replace(/\\/g, '/'); + } + } + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.writeFile(dest, part.body); + await setFileMeta(ctx, relPath, clientMeta); + saved.push(relPath); + totalAdded += part.body.length; + } catch { + skipped.push(part.filename); + } + } + + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, saved, skipped, bytesAdded: totalAdded }); + } + + return { + async handle(req, res) { + const runtime = getRuntime(req.currentUser.id); + const { ctx } = runtime; + const subPath = resolveSubPath(req); + const url = new URL(subPath + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''), 'http://local'); + const method = req.method; + + try { + if (method === 'GET' && subPath === '/info') { + const quota = await getQuotaInfo(req.currentUser.id); + return sendJson(res, 200, { + pageSize: ctx.pageSize, + chunkSize: ctx.chunkSize, + quota, + }); + } + + if (method === 'GET' && subPath === '/events') { + res.set({ + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write(': connected\n\n'); + const remove = runtime.addSseClient(res); + req.on('close', remove); + const payload = runtime.getListCache().files.length + ? runtime.getListCache() + : await runtime.refreshFileList(); + res.write(`data: ${JSON.stringify({ ...payload, delta: false })}\n\n`); + return; + } + + if (method === 'GET' && subPath === '/files') { + const payload = await runtime.refreshFileList(); + return sendJson(res, 200, { ...payload, delta: false }); + } + + if (method === 'GET' && subPath === '/trash') { + return sendJson(res, 200, { items: await listTrash(ctx) }); + } + + if (method === 'GET' && subPath === '/download-zip') { + const items = (url.searchParams.get('items') || '') + .split(',') + .map(decodeURIComponent) + .filter(Boolean); + const stream = await createZipStream(ctx, items); + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="files.zip"', + 'Cache-Control': 'no-store', + }); + stream.pipe(res); + return; + } + + if ( + method === 'GET' + && (subPath.startsWith('/view/') || subPath.startsWith('/download/')) + ) { + const rel = subPath.startsWith('/view/') + ? decodeURIComponent(subPath.slice('/view/'.length)) + : decodeURIComponent(subPath.slice('/download/'.length)); + const entry = statEntry(ctx, rel); + if (!entry || entry.isDirectory) { + return res.status(404).send('not found'); + } + const download = subPath.startsWith('/download/') || url.searchParams.get('download') === '1'; + return sendDriveFile(res, entry.full, { download, downloadName: pathBasename(rel) }); + } + + if (method === 'POST' && subPath === '/upload') { + return handleUpload(req, res, runtime, url); + } + + if (method === 'POST' && subPath === '/folders') { + const body = await readJsonBody(req); + const name = normalizeRelPath(body.name); + if (!folderNameOk(name)) return sendJson(res, 400, { error: 'invalid name' }); + const parent = normalizeRelPath(body.parent); + const rel = joinRel(parent, name); + const target = safeJoin(ctx.uploadDir, rel); + if (fs.existsSync(target)) return sendJson(res, 409, { error: 'exists' }); + await fsp.mkdir(target, { recursive: false }); + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, path: rel }); + } + + if (method === 'POST' && subPath === '/rename') { + const body = await readJsonBody(req); + const onConflict = body.onConflict || 'rename'; + const result = await renameEntry({ + uploadDir: ctx.uploadDir, + fromRel: normalizeRelPath(body.from), + newName: normalizeRelPath(body.name), + onConflict, + }); + if (!result) return sendJson(res, 409, { error: 'skipped' }); + await renameFileMeta(ctx, result.from, result.to); + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, ...result }); + } + + if (method === 'POST' && subPath === '/copy') { + const body = await readJsonBody(req); + const dest = normalizeRelPath(body.dest); + const onConflict = body.onConflict || 'rename'; + const items = Array.isArray(body.items) ? body.items.map(normalizeRelPath).filter(Boolean) : []; + const copied = []; + const errors = []; + const clientMeta = clientMetaFromReq(req); + for (const fromRel of items) { + try { + const result = await transferEntry({ + uploadDir: ctx.uploadDir, + fromRel, + destFolder: dest, + mode: 'copy', + onConflict, + }); + if (!result) continue; + await setFileMeta(ctx, result.to, clientMeta); + copied.push(result); + } catch (err) { + errors.push({ from: fromRel, error: err.message }); + } + } + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, copied, errors }); + } + + if (method === 'POST' && subPath === '/move') { + const body = await readJsonBody(req); + const dest = normalizeRelPath(body.dest); + const onConflict = body.onConflict || 'rename'; + const items = Array.isArray(body.items) ? body.items.map(normalizeRelPath).filter(Boolean) : []; + const moved = []; + const errors = []; + for (const fromRel of items) { + try { + const result = await transferEntry({ + uploadDir: ctx.uploadDir, + fromRel, + destFolder: dest, + mode: 'move', + onConflict, + }); + if (!result) continue; + await renameFileMeta(ctx, result.from, result.to); + moved.push(result); + } catch (err) { + errors.push({ from: fromRel, error: err.message }); + } + } + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, moved, errors }); + } + + if (method === 'POST' && subPath === '/trash') { + const body = await readJsonBody(req); + const items = Array.isArray(body.items) ? body.items.map(normalizeRelPath).filter(Boolean) : []; + const trashed = []; + for (const rel of items) { + try { + trashed.push(await moveToTrash(ctx, rel)); + } catch {} + } + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, trashed }); + } + + if (method === 'POST' && subPath === '/trash/restore') { + const body = await readJsonBody(req); + const restored = await restoreFromTrash(ctx, body.id, body.onConflict || 'rename'); + if (!restored) return sendJson(res, 409, { error: 'skipped' }); + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, restored }); + } + + if (method === 'DELETE' && subPath === '/trash') { + await emptyTrash(ctx); + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true }); + } + + if (method === 'DELETE' && subPath.startsWith('/trash/')) { + const id = decodeURIComponent(subPath.slice('/trash/'.length)); + await purgeTrashItem(ctx, id); + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true }); + } + + if (method === 'POST' && subPath === '/upload/session') { + const body = await readJsonBody(req); + await assertUploadAllowed(ctx.userId, Number(body.size)); + const session = await createUploadSession(ctx, { + relPath: body.name, + totalSize: body.size, + prefix: normalizeRelPath(body.prefix), + clientMeta: { ...clientMetaFromReq(req), uploadedAt: Date.now() }, + }); + return sendJson(res, 200, session); + } + + if (method === 'PATCH' && subPath.startsWith('/upload/session/')) { + const id = subPath.slice('/upload/session/'.length); + const range = req.headers['content-range'] || ''; + const match = range.match(/bytes (\d+)-(\d+)\/(\d+)/); + const offset = match ? Number(match[1]) : 0; + const buffer = await readBodyBuffer(req, ctx.maxBodyBytes); + const meta = await appendUploadChunk(ctx, id, buffer, offset); + return sendJson(res, 200, meta); + } + + if (method === 'POST' && subPath.startsWith('/upload/session/') && subPath.endsWith('/complete')) { + const id = subPath.slice('/upload/session/'.length, -'/complete'.length); + const result = await completeUploadSession(ctx, id); + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true, ...result }); + } + + if (method === 'GET' && subPath.startsWith('/upload/session/')) { + const id = subPath.slice('/upload/session/'.length); + return sendJson(res, 200, await getSession(ctx, id)); + } + + if (method === 'DELETE' && subPath.startsWith('/files/')) { + const rel = decodeURIComponent(subPath.slice('/files/'.length)); + const permanent = url.searchParams.get('permanent') === '1'; + if (permanent) { + const target = safeJoin(ctx.uploadDir, rel); + if (!fs.existsSync(target)) { + return res.status(404).send('not found'); + } + await fsp.rm(target, { recursive: true, force: true }); + await removeFileMeta(ctx, rel); + } else { + await moveToTrash(ctx, rel); + } + await runtime.notifyChange(); + await afterMutate(ctx.userId); + return sendJson(res, 200, { ok: true }); + } + + return sendJson(res, 404, { error: 'not found' }); + } catch (error) { + if (error?.code === 'payload_too_large') { + return sendJson(res, 413, { error: error.message }); + } + if (error?.code === 'quota_exceeded') { + return sendJson(res, 413, { error: error.message, code: 'quota_exceeded', details: error.details }); + } + console.error('[oa-drive]', error); + return sendJson(res, 500, { error: error?.message || 'error' }); + } + }, + }; +} diff --git a/mindspace-oa-drive/drive-runtime.mjs b/mindspace-oa-drive/drive-runtime.mjs new file mode 100644 index 0000000..497b2df --- /dev/null +++ b/mindspace-oa-drive/drive-runtime.mjs @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import { enrichFiles, walkUploadDir } from './lib/fs-tree.mjs'; + +export function createDriveRuntime(ctx) { + const state = { + ctx, + listCache: { files: [], dirs: [], ts: 0, added: [], removed: [] }, + sseClients: new Set(), + watchTimer: null, + watcher: null, + }; + + async function refreshFileList() { + const { files, dirs } = await walkUploadDir(ctx, ctx.uploadDir); + const prevNames = new Set(state.listCache.files.map((f) => f.name)); + const added = files.filter((f) => !prevNames.has(f.name)).map((f) => f.name); + const nextNames = new Set(files.map((f) => f.name)); + const removed = state.listCache.files.filter((f) => !nextNames.has(f.name)).map((f) => f.name); + const enriched = await enrichFiles(ctx, files); + state.listCache = { + files: enriched, + dirs, + ts: Date.now(), + added, + removed, + delta: added.length > 0 || removed.length > 0, + }; + return state.listCache; + } + + function broadcastList(payload) { + const body = `data: ${JSON.stringify(payload)}\n\n`; + for (const client of state.sseClients) { + try { + client.write(body); + } catch { + state.sseClients.delete(client); + } + } + } + + async function notifyChange() { + const payload = await refreshFileList(); + broadcastList(payload); + return payload; + } + + function scheduleNotify() { + clearTimeout(state.watchTimer); + state.watchTimer = setTimeout(() => { + notifyChange().catch(console.error); + }, 250); + } + + function initWatcher() { + if (state.watcher) return; + try { + state.watcher = fs.watch(ctx.uploadDir, { recursive: true }, scheduleNotify); + } catch (err) { + console.warn('[oa-drive] fs.watch unavailable:', err.message); + } + } + + async function bootstrap() { + await refreshFileList(); + initWatcher(); + } + + function dispose() { + clearTimeout(state.watchTimer); + if (state.watcher) { + state.watcher.close(); + state.watcher = null; + } + state.sseClients.clear(); + } + + return { + ctx, + getListCache: () => state.listCache, + refreshFileList, + notifyChange, + addSseClient(res) { + state.sseClients.add(res); + return () => state.sseClients.delete(res); + }, + bootstrap, + dispose, + }; +} diff --git a/mindspace-oa-drive/drive-service.mjs b/mindspace-oa-drive/drive-service.mjs new file mode 100644 index 0000000..9903b98 --- /dev/null +++ b/mindspace-oa-drive/drive-service.mjs @@ -0,0 +1,84 @@ +import { computeUserSpaceQuotaStats } from '../mindspace-space-quota.mjs'; +import { createDriveContext } from './drive-context.mjs'; +import { createDriveHandlers } from './drive-handlers.mjs'; +import { createDriveRuntime } from './drive-runtime.mjs'; + +export function createMindSpaceOaDriveService({ + h5Root, + getMindSpace = () => null, + getMindSpaceAssets = () => null, + getPool = () => null, +} = {}) { + if (!h5Root) { + throw new Error('createMindSpaceOaDriveService requires h5Root'); + } + + const runtimes = new Map(); + + function getRuntime(userId) { + const key = String(userId); + if (!runtimes.has(key)) { + const ctx = createDriveContext(h5Root, key); + const runtime = createDriveRuntime(ctx); + void runtime.bootstrap(); + runtimes.set(key, runtime); + } + return runtimes.get(key); + } + + async function getQuotaInfo(userId) { + const mindSpace = getMindSpace(); + if (!mindSpace) return null; + const quota = await mindSpace.getQuota(userId); + if (!quota) return null; + const stats = computeUserSpaceQuotaStats({ + quota_bytes: quota.quotaBytes, + used_bytes: quota.usedBytes, + reserved_bytes: quota.reservedBytes, + }); + return { + quotaBytes: stats.quotaBytes, + usedBytes: stats.usedBytes, + reservedBytes: stats.reservedBytes, + availableBytes: Math.max(0, stats.availableBytes), + }; + } + + async function assertUploadAllowed(userId, sizeBytes) { + const required = Math.max(0, Number(sizeBytes) || 0); + if (!required) return; + const quota = await getQuotaInfo(userId); + if (!quota) return; + if (quota.availableBytes < required) { + throw Object.assign(new Error('剩余空间不足'), { + code: 'quota_exceeded', + details: { + requiredBytes: required, + availableBytes: Math.max(0, quota.availableBytes), + }, + }); + } + } + + async function afterMutate(userId) { + const assets = getMindSpaceAssets(); + if (assets?.syncWorkspaceAssets) { + await assets.syncWorkspaceAssets(userId, { categoryCode: 'oa' }).catch((error) => { + console.warn('[oa-drive] workspace sync failed:', error?.message ?? error); + }); + } + } + + const handlers = createDriveHandlers({ + getRuntime, + getQuotaInfo, + assertUploadAllowed, + afterMutate, + }); + + return { + getRuntime, + handlers, + getPool, + }; +} diff --git a/mindspace-oa-drive/drive-service.test.mjs b/mindspace-oa-drive/drive-service.test.mjs new file mode 100644 index 0000000..f5eb659 --- /dev/null +++ b/mindspace-oa-drive/drive-service.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createMindSpaceOaDriveService } from './drive-service.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); + +test('oa drive upload/list/rename/trash against user oa root', async () => { + const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'memind-oa-drive-')); + const userId = 'user-oa-drive-test'; + const workspaceRoot = path.join(tmpRoot, 'MindSpace', userId); + await fsp.mkdir(path.join(workspaceRoot, 'oa'), { recursive: true }); + + const mindSpace = { + async getQuota() { + return { + quotaBytes: 1024 * 1024 * 1024, + usedBytes: 0, + reservedBytes: 0, + availableBytes: 1024 * 1024 * 1024, + }; + }, + }; + const syncCalls = []; + const assets = { + async syncWorkspaceAssets(uid, options) { + syncCalls.push({ uid, options }); + }, + }; + + const service = createMindSpaceOaDriveService({ + h5Root: tmpRoot, + getMindSpace: () => mindSpace, + getMindSpaceAssets: () => assets, + }); + const runtime = service.getRuntime(userId); + await runtime.bootstrap(); + + const req = { + method: 'POST', + path: '/mindspace/v1/oa/drive/folders', + url: '/mindspace/v1/oa/drive/folders', + headers: { 'content-type': 'application/json' }, + currentUser: { id: userId }, + async *[Symbol.asyncIterator]() { + yield Buffer.from(JSON.stringify({ name: 'box', parent: '' })); + }, + body: Buffer.from(JSON.stringify({ name: 'box', parent: '' })), + on() {}, + }; + const res = { + statusCode: 200, + headers: {}, + set(fields) { + Object.assign(this.headers, fields); + }, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.payload = payload; + }, + send(body) { + this.body = body; + }, + write() {}, + end() {}, + }; + + await service.handlers.handle(req, res); + assert.equal(res.statusCode, 200); + assert.equal(res.payload.ok, true); + + const helloPath = path.join(workspaceRoot, 'oa', 'box', 'hello.txt'); + await fsp.mkdir(path.dirname(helloPath), { recursive: true }); + await fsp.writeFile(helloPath, 'hello'); + + const listReq = { + method: 'GET', + path: '/mindspace/v1/oa/drive/files', + url: '/mindspace/v1/oa/drive/files', + headers: {}, + currentUser: { id: userId }, + on() {}, + }; + const listRes = { + statusCode: 200, + set() {}, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.payload = payload; + }, + send() {}, + write() {}, + end() {}, + }; + await service.handlers.handle(listReq, listRes); + assert.ok(listRes.payload.files.some((file) => file.name === 'box/hello.txt')); + + assert.ok(syncCalls.length >= 1); + assert.equal(syncCalls[0].uid, userId); + assert.equal(syncCalls[0].options.categoryCode, 'oa'); + + runtime.dispose(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); diff --git a/mindspace-oa-drive/lib/fs-tree.mjs b/mindspace-oa-drive/lib/fs-tree.mjs new file mode 100644 index 0000000..9721a37 --- /dev/null +++ b/mindspace-oa-drive/lib/fs-tree.mjs @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { isReservedName, metaFile, safeJoin } from './paths.mjs'; + +const PREVIEWABLE = new Set([ + '.html', '.htm', '.css', '.js', '.mjs', '.json', '.png', '.jpg', '.jpeg', '.gif', + '.webp', '.svg', '.pdf', '.txt', '.md', '.csv', '.xml', '.mp4', '.webm', '.mp3', '.wav', '.ogg', +]); + +export function isPreviewable(filePath) { + return PREVIEWABLE.has(path.extname(filePath).toLowerCase()); +} + +export function mimeFor(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const map = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.csv': 'text/csv; charset=utf-8', + '.xml': 'application/xml; charset=utf-8', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + }; + return map[ext] || 'application/octet-stream'; +} + +export async function walkUploadDir(ctx, dir, base = dir) { + const entries = await fsp.readdir(dir, { withFileTypes: true }); + const files = []; + const dirs = []; + for (const entry of entries) { + if (isReservedName(ctx, entry.name)) continue; + const full = path.join(dir, entry.name); + const rel = path.relative(base, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + dirs.push(rel); + const nested = await walkUploadDir(ctx, full, base); + files.push(...nested.files); + dirs.push(...nested.dirs); + } else if (entry.isFile()) { + const stat = await fsp.stat(full); + files.push({ + name: rel, + size: stat.size, + mtime: stat.mtimeMs, + previewable: isPreviewable(full), + }); + } + } + return { + files: files.sort((a, b) => b.mtime - a.mtime), + dirs, + }; +} + +export async function loadMeta(ctx) { + const file = metaFile(ctx); + try { + const stat = await fsp.stat(file); + const raw = await fsp.readFile(file, 'utf8'); + return { mtime: stat.mtimeMs, data: JSON.parse(raw) }; + } catch { + return { mtime: Date.now(), data: { files: {} } }; + } +} + +export async function saveMeta(ctx, data) { + const file = metaFile(ctx); + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, JSON.stringify(data, null, 2)); +} + +export async function setFileMeta(ctx, relPath, meta) { + const loaded = await loadMeta(ctx); + loaded.data.files[relPath] = { + ...(loaded.data.files[relPath] || {}), + ...meta, + updatedAt: Date.now(), + }; + await saveMeta(ctx, loaded.data); +} + +export async function removeFileMeta(ctx, relPath) { + const loaded = await loadMeta(ctx); + delete loaded.data.files[relPath]; + await saveMeta(ctx, loaded.data); +} + +export async function renameFileMeta(ctx, fromRel, toRel) { + const loaded = await loadMeta(ctx); + if (loaded.data.files[fromRel]) { + loaded.data.files[toRel] = { ...loaded.data.files[fromRel], updatedAt: Date.now() }; + delete loaded.data.files[fromRel]; + await saveMeta(ctx, loaded.data); + } +} + +export async function enrichFiles(ctx, files) { + const loaded = await loadMeta(ctx); + return files.map((f) => ({ + ...f, + origin: loaded.data.files[f.name] || null, + })); +} + +export function statEntry(ctx, rel) { + const full = safeJoin(ctx.uploadDir, rel); + if (!fs.existsSync(full)) return null; + const stat = fs.statSync(full); + return { full, stat, isDirectory: stat.isDirectory() }; +} diff --git a/mindspace-oa-drive/lib/paths.mjs b/mindspace-oa-drive/lib/paths.mjs new file mode 100644 index 0000000..e3c1870 --- /dev/null +++ b/mindspace-oa-drive/lib/paths.mjs @@ -0,0 +1,60 @@ +import path from 'node:path'; + +export function normalizeRelPath(value) { + return String(value || '') + .trim() + .replace(/\\/g, '/') + .replace(/^\/+|\/+$/g, ''); +} + +export function folderNameOk(name) { + return !!name && !name.includes('/') && name !== '.' && name !== '..'; +} + +export function safeJoin(base, rel) { + const baseResolved = path.resolve(base); + const normalized = path.normalize(rel).replace(/^(\.\.(\/|\\|$))+/, ''); + const full = path.resolve(baseResolved, normalized); + if (full !== baseResolved && !full.startsWith(baseResolved + path.sep)) { + throw new Error('invalid path'); + } + return full; +} + +export function basenameOf(rel) { + const parts = normalizeRelPath(rel).split('/'); + return parts[parts.length - 1] || rel; +} + +export function parentOf(rel) { + const normalized = normalizeRelPath(rel); + const idx = normalized.lastIndexOf('/'); + return idx >= 0 ? normalized.slice(0, idx) : ''; +} + +export function joinRel(parent, name) { + const p = normalizeRelPath(parent); + const n = normalizeRelPath(name); + return p ? `${p}/${n}` : n; +} + +export function isReservedName(ctx, name) { + const base = basenameOf(name); + return ( + [ctx.trashDirName, ctx.metaDirName, ctx.sessionDirName].includes(base) + || base.startsWith('.drive-') + || base.startsWith('.fds-') + ); +} + +export function trashRoot(ctx) { + return path.join(ctx.uploadDir, ctx.trashDirName); +} + +export function metaFile(ctx) { + return path.join(ctx.uploadDir, ctx.metaDirName, 'registry.json'); +} + +export function sessionRoot(ctx) { + return path.join(ctx.uploadDir, ctx.sessionDirName); +} diff --git a/mindspace-oa-drive/lib/transfer.mjs b/mindspace-oa-drive/lib/transfer.mjs new file mode 100644 index 0000000..94ed6d0 --- /dev/null +++ b/mindspace-oa-drive/lib/transfer.mjs @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { basenameOf, joinRel, safeJoin } from './paths.mjs'; + +export function resolveConflictPath(destPath, mode = 'rename') { + if (!fs.existsSync(destPath)) return destPath; + if (mode === 'skip') return null; + if (mode === 'overwrite') return destPath; + const dir = path.dirname(destPath); + const ext = path.extname(destPath); + const base = path.basename(destPath, ext); + let i = 1; + while (fs.existsSync(path.join(dir, `${base} (${i})${ext}`))) i += 1; + return path.join(dir, `${base} (${i})${ext}`); +} + +export async function ensureParentDir(destPath) { + await fsp.mkdir(path.dirname(destPath), { recursive: true }); +} + +export async function copyEntryRecursive(src, dest) { + const stat = await fsp.stat(src); + if (stat.isDirectory()) { + await fsp.mkdir(dest, { recursive: true }); + const entries = await fsp.readdir(src, { withFileTypes: true }); + for (const entry of entries) { + await copyEntryRecursive(path.join(src, entry.name), path.join(dest, entry.name)); + } + return; + } + await ensureParentDir(dest); + await fsp.copyFile(src, dest); +} + +export async function transferEntry({ uploadDir, fromRel, destFolder, mode = 'move', onConflict = 'rename' }) { + const src = safeJoin(uploadDir, fromRel); + if (!fs.existsSync(src)) throw new Error('not found'); + const base = basenameOf(fromRel); + const toRel = joinRel(destFolder, base); + let dest = safeJoin(uploadDir, toRel); + const srcResolved = path.resolve(src); + if (destFolder === fromRel || dest.startsWith(srcResolved + path.sep)) throw new Error('invalid move'); + + const resolved = resolveConflictPath(dest, fs.existsSync(dest) ? onConflict : 'rename'); + if (!resolved) return null; + dest = resolved; + const finalRel = path.relative(uploadDir, dest).split(path.sep).join('/'); + + if (fs.existsSync(dest) && onConflict === 'overwrite') { + const stat = await fsp.stat(dest); + if (stat.isDirectory()) await fsp.rm(dest, { recursive: true, force: true }); + else await fsp.unlink(dest); + } + + await ensureParentDir(dest); + if (mode === 'copy') { + await copyEntryRecursive(src, dest); + } else { + await fsp.rename(src, dest); + } + return { from: fromRel, to: finalRel }; +} + +export async function renameEntry({ uploadDir, fromRel, newName, onConflict = 'rename' }) { + const src = safeJoin(uploadDir, fromRel); + if (!fs.existsSync(src)) throw new Error('not found'); + const parent = fromRel.includes('/') ? fromRel.slice(0, fromRel.lastIndexOf('/')) : ''; + const toRel = joinRel(parent, newName); + if (toRel === fromRel) return { from: fromRel, to: toRel }; + let dest = safeJoin(uploadDir, toRel); + const resolved = resolveConflictPath(dest, fs.existsSync(dest) ? onConflict : 'rename'); + if (!resolved) return null; + dest = resolved; + const finalRel = path.relative(uploadDir, dest).split(path.sep).join('/'); + if (fs.existsSync(dest) && onConflict === 'overwrite') { + await fsp.rm(dest, { recursive: true, force: true }); + } + await fsp.rename(src, dest); + return { from: fromRel, to: finalRel }; +} diff --git a/mindspace-oa-drive/lib/trash.mjs b/mindspace-oa-drive/lib/trash.mjs new file mode 100644 index 0000000..11b8616 --- /dev/null +++ b/mindspace-oa-drive/lib/trash.mjs @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { basenameOf, joinRel, safeJoin, trashRoot } from './paths.mjs'; +import { removeFileMeta } from './fs-tree.mjs'; + +async function ensureTrashDir(ctx) { + await fsp.mkdir(trashRoot(ctx), { recursive: true }); +} + +function trashNameFor(rel) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `${stamp}__${rel.replace(/\//g, '__')}`; +} + +export async function moveToTrash(ctx, rel) { + await ensureTrashDir(ctx); + const src = safeJoin(ctx.uploadDir, rel); + if (!fs.existsSync(src)) throw new Error('not found'); + const entryName = trashNameFor(rel); + const dest = path.join(trashRoot(ctx), entryName); + await fsp.rename(src, dest); + await removeFileMeta(ctx, rel); + return { + id: entryName, + original: rel, + trashedAt: Date.now(), + }; +} + +export async function listTrash(ctx) { + await ensureTrashDir(ctx); + const entries = await fsp.readdir(trashRoot(ctx), { withFileTypes: true }); + const items = []; + for (const entry of entries) { + const full = path.join(trashRoot(ctx), entry.name); + const stat = await fsp.stat(full); + const match = entry.name.match(/^(.+)__(.+)$/); + items.push({ + id: entry.name, + original: match ? match[2].replace(/__/g, '/') : entry.name, + trashedAt: stat.mtimeMs, + isDirectory: stat.isDirectory(), + size: stat.isDirectory() ? null : stat.size, + }); + } + return items.sort((a, b) => b.trashedAt - a.trashedAt); +} + +export async function restoreFromTrash(ctx, id, onConflict = 'rename') { + const src = path.join(trashRoot(ctx), id); + if (!fs.existsSync(src)) throw new Error('not found'); + const item = (await listTrash(ctx)).find((x) => x.id === id); + if (!item) throw new Error('not found'); + let dest = safeJoin(ctx.uploadDir, item.original); + if (fs.existsSync(dest)) { + if (onConflict === 'skip') return null; + if (onConflict === 'overwrite') await fsp.rm(dest, { recursive: true, force: true }); + else { + const base = basenameOf(item.original); + const parent = item.original.includes('/') ? item.original.slice(0, item.original.lastIndexOf('/')) : ''; + let i = 1; + let candidate = joinRel(parent, `${path.parse(base).name} (恢复)${path.extname(base)}`); + while (fs.existsSync(safeJoin(ctx.uploadDir, candidate))) { + i += 1; + candidate = joinRel(parent, `${path.parse(base).name} (恢复 ${i})${path.extname(base)}`); + } + dest = safeJoin(ctx.uploadDir, candidate); + } + } + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.rename(src, dest); + const finalRel = path.relative(ctx.uploadDir, dest).split(path.sep).join('/'); + return { id, from: item.original, to: finalRel }; +} + +export async function emptyTrash(ctx) { + await ensureTrashDir(ctx); + const entries = await fsp.readdir(trashRoot(ctx), { withFileTypes: true }); + for (const entry of entries) { + await fsp.rm(path.join(trashRoot(ctx), entry.name), { recursive: true, force: true }); + } +} + +export async function purgeTrashItem(ctx, id) { + const target = path.join(trashRoot(ctx), id); + if (!fs.existsSync(target)) throw new Error('not found'); + await fsp.rm(target, { recursive: true, force: true }); +} diff --git a/mindspace-oa-drive/lib/upload-resume.mjs b/mindspace-oa-drive/lib/upload-resume.mjs new file mode 100644 index 0000000..e2aa1df --- /dev/null +++ b/mindspace-oa-drive/lib/upload-resume.mjs @@ -0,0 +1,84 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { joinRel, normalizeRelPath, safeJoin, sessionRoot } from './paths.mjs'; +import { setFileMeta } from './fs-tree.mjs'; + +const sessions = new Map(); + +function sessionFile(ctx, id) { + return path.join(sessionRoot(ctx), `${id}.part`); +} + +function sessionMetaFile(ctx, id) { + return path.join(sessionRoot(ctx), `${id}.json`); +} + +export async function ensureSessionDir(ctx) { + await fsp.mkdir(sessionRoot(ctx), { recursive: true }); +} + +export async function createUploadSession(ctx, { relPath, totalSize, prefix = '', clientMeta = {} }) { + await ensureSessionDir(ctx); + const id = crypto.randomBytes(8).toString('hex'); + const rel = joinRel(prefix, normalizeRelPath(relPath)); + const meta = { + id, + rel, + totalSize: Number(totalSize), + received: 0, + clientMeta, + createdAt: Date.now(), + userId: ctx.userId, + }; + await fsp.writeFile(sessionMetaFile(ctx, id), JSON.stringify(meta)); + await fsp.writeFile(sessionFile(ctx, id), Buffer.alloc(0)); + sessions.set(`${ctx.userId}:${id}`, meta); + return meta; +} + +async function loadSession(ctx, id) { + const key = `${ctx.userId}:${id}`; + if (sessions.has(key)) return sessions.get(key); + const raw = await fsp.readFile(sessionMetaFile(ctx, id), 'utf8'); + const meta = JSON.parse(raw); + if (meta.userId && meta.userId !== ctx.userId) { + throw new Error('session not found'); + } + sessions.set(key, meta); + return meta; +} + +export async function appendUploadChunk(ctx, id, buffer, offset) { + const meta = await loadSession(ctx, id); + const file = sessionFile(ctx, id); + const handle = await fsp.open(file, 'r+'); + try { + await handle.write(buffer, 0, buffer.length, Number(offset)); + } finally { + await handle.close(); + } + meta.received = Math.max(meta.received, Number(offset) + buffer.length); + await fsp.writeFile(sessionMetaFile(ctx, id), JSON.stringify(meta)); + return meta; +} + +export async function completeUploadSession(ctx, id) { + const meta = await loadSession(ctx, id); + const part = sessionFile(ctx, id); + const stat = await fsp.stat(part); + if (stat.size < meta.totalSize) throw new Error('incomplete'); + const dest = safeJoin(ctx.uploadDir, meta.rel); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.rename(part, dest); + await fsp.unlink(sessionMetaFile(ctx, id)).catch(() => {}); + sessions.delete(`${ctx.userId}:${id}`); + if (meta.clientMeta?.clientId) { + await setFileMeta(ctx, meta.rel, meta.clientMeta); + } + return { saved: meta.rel, size: stat.size }; +} + +export async function getSession(ctx, id) { + return loadSession(ctx, id); +} diff --git a/mindspace-oa-drive/lib/zip.mjs b/mindspace-oa-drive/lib/zip.mjs new file mode 100644 index 0000000..abd0e7c --- /dev/null +++ b/mindspace-oa-drive/lib/zip.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { safeJoin } from './paths.mjs'; + +export async function createZipStream(ctx, items) { + const existing = []; + for (const rel of items) { + const full = safeJoin(ctx.uploadDir, rel); + if (fs.existsSync(full)) existing.push({ rel, full }); + } + if (!existing.length) throw new Error('no files'); + + const tmpZip = path.join(ctx.uploadDir, ctx.metaDirName, `download-${Date.now()}.zip`); + await fsp.mkdir(path.dirname(tmpZip), { recursive: true }); + + await new Promise((resolve, reject) => { + const args = ['-r', tmpZip, ...existing.map((x) => x.full)]; + const proc = spawn('zip', args, { cwd: ctx.uploadDir }); + proc.on('error', reject); + proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`zip exit ${code}`)))); + }); + + const stream = fs.createReadStream(tmpZip); + stream.on('close', () => fsp.unlink(tmpZip).catch(() => {})); + return stream; +} diff --git a/public/oa-drive/app.css b/public/oa-drive/app.css new file mode 100644 index 0000000..45036c4 --- /dev/null +++ b/public/oa-drive/app.css @@ -0,0 +1,500 @@ +:root { + --bg: #0f1117; + --panel: #171a22; + --border: #2a3140; + --text: #e8ecf4; + --muted: #8b95a8; + --accent: #5b8cff; + --accent-2: #3dd68c; + --danger: #ff6b7a; + --drop: rgba(91, 140, 255, 0.12); + --warning: #ffb347; +} +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: radial-gradient(circle at top, #1a2236 0%, var(--bg) 45%); + color: var(--text); +} +.wrap { max-width: min(1280px, 96vw); margin: 0 auto; padding: 10px 16px 20px; } + +/* Compact top bar */ +.top-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border: 1px dashed var(--border); + border-radius: 8px; + background: var(--drop); + transition: .15s; + cursor: pointer; + min-height: 34px; + flex-wrap: nowrap; + overflow: hidden; +} +.top-bar.dragover { border-color: var(--accent); background: rgba(91,140,255,.22); } +.top-title { font-size: 13px; font-weight: 600; white-space: nowrap; flex: none; } +.top-sub kbd { + font-size: 10px; padding: 0 3px; border-radius: 3px; + border: 1px solid var(--border); background: var(--panel); font-family: inherit; +} +.top-sub { + font-size: 11px; color: var(--muted); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; flex: none; max-width: 220px; +} +.links { + display: flex; flex-wrap: nowrap; gap: 4px; flex: 1; min-width: 0; + overflow: hidden; justify-content: flex-end; +} +.link-chip { + background: var(--panel); border: 1px solid var(--border); border-radius: 999px; + padding: 2px 8px; font-size: 10px; color: var(--accent); text-decoration: none; + white-space: nowrap; flex: none; +} +.link-chip:hover { border-color: var(--accent); } +.link-chip.active { border-color: var(--accent); background: #e8f0fe; color: #0066cc; font-weight: 600; } +.top-actions { display: flex; gap: 4px; flex: none; } +.top-actions button { padding: 3px 8px; font-size: 11px; min-height: 0; line-height: 1.3; } +.top-actions button.primary { padding: 3px 9px; } + +/* Legacy drop (unused) */ +.drop { + border: 1.5px dashed var(--border); border-radius: 12px; background: var(--drop); + padding: 20px 16px; text-align: center; transition: .15s; cursor: pointer; +} +.drop.dragover { border-color: var(--accent); background: rgba(91,140,255,.22); transform: scale(1.005); } +.drop-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; } +.drop-hint { color: var(--muted); font-size: 11px; line-height: 1.5; } +.drop-hint kbd { + font-size: 10px; padding: 1px 4px; border-radius: 4px; + border: 1px solid var(--border); background: var(--panel); +} +.actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 10px; } +button, .btn { + appearance: none; border: 1px solid var(--border); background: var(--panel); color: var(--text); + border-radius: 6px; padding: 5px 10px; font-size: 12px; cursor: pointer; text-decoration: none; + display: inline-flex; align-items: center; gap: 4px; +} +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button:hover, .btn:hover { filter: brightness(1.08); } +button:disabled { opacity: 0.45; cursor: not-allowed; filter: none; } +.status { min-height: 16px; margin: 8px 0 4px; font-size: 11px; color: var(--muted); } +.status.ok { color: var(--accent-2); } +.status.err { color: var(--danger); } + +/* Sync badge */ +.sync-badge { + display: inline-flex; align-items: center; gap: 4px; + font-size: 10px; margin-left: 6px; font-weight: 400; +} +.sync-badge .dot { + width: 6px; height: 6px; border-radius: 50%; background: var(--accent-2); + flex: none; +} +.sync-badge.offline .dot { background: var(--danger); } +.sync-badge.offline { color: var(--danger); } + +/* Simple progress bar */ +.progress { height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; margin-top: 8px; display: none; } +.progress.show { display: block; } +.progress > i { display: block; height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .2s; } + +/* Upload progress detail panel */ +.progress-detail { + display: none; margin-top: 8px; background: var(--panel); border: 1px solid var(--border); + border-radius: 10px; padding: 10px 12px; font-size: 11px; +} +.progress-detail.show { display: block; } +.progress-detail-head { + display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; + color: var(--muted); +} +.progress-detail-head strong { color: var(--text); font-size: 12px; } +.progress-detail-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } +.progress-item { display: grid; grid-template-columns: minmax(0, 1fr) 72px 56px; gap: 8px; align-items: center; } +.progress-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.progress-item-bar { + height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; grid-column: 1 / -1; +} +.progress-item-bar i { display: block; height: 100%; width: 0%; background: var(--accent); transition: width .15s; } +.progress-item-meta { font-size: 10px; color: var(--muted); text-align: right; } +.progress-item-status { font-size: 10px; text-align: right; } +.progress-item-status.done { color: var(--accent-2); } +.progress-item-status.err { color: var(--danger); } + +/* Drag overlay */ +.drag-overlay { + position: fixed; inset: 0; z-index: 200; pointer-events: none; + display: none; align-items: center; justify-content: center; + background: rgba(15, 17, 23, 0.72); backdrop-filter: blur(4px); +} +.drag-overlay.show { display: flex; pointer-events: none; } +.drag-overlay-box { + border: 2px dashed var(--accent); border-radius: 20px; padding: 32px 48px; + background: rgba(91, 140, 255, 0.15); text-align: center; +} +.drag-overlay-box .icon { font-size: 48px; margin-bottom: 12px; } +.drag-overlay-box .hint { font-size: 18px; font-weight: 600; color: var(--text); } +.drag-overlay-box .sub { font-size: 13px; color: var(--muted); margin-top: 6px; } + +/* Panel shell */ +.panel-shell { margin-top: 8px; position: relative; width: 100%; max-width: 100%; } +.panel { + display: flex; flex-direction: column; + background: #f5f5f7; border: 1px solid #d1d1d6; + border-radius: 10px; overflow: hidden; color: #1d1d1f; + height: 520px; min-height: 280px; max-height: 85vh; +} +.panel-resizer { + position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; + cursor: nwse-resize; z-index: 3; touch-action: none; +} +.panel-resizer::after { + content: ''; position: absolute; right: 4px; bottom: 4px; width: 10px; height: 10px; + border-right: 2px solid #86868b; border-bottom: 2px solid #86868b; + border-radius: 0 0 2px 0; opacity: 0.75; transition: opacity .15s; +} +.panel-resizer:hover::after, +.panel-shell-resizing .panel-resizer::after { opacity: 1; border-color: #0066cc; } +body.panel-shell-resizing { user-select: none; cursor: nwse-resize; } + +/* Toolbar */ +.panel-head { + display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; + padding: 10px 14px; border-bottom: 1px solid #d1d1d6; font-weight: 600; + font-size: 13px; background: #ebebed; flex: none; +} +.panel-head small { color: #6e6e73; font-weight: 400; } +.panel-head button { + background: #fff; border-color: #c7c7cc; color: #1d1d1f; padding: 4px 10px; + border-radius: 6px; font-size: 12px; +} +.panel-head .bulk-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.panel-head .select-all-label { + display: inline-flex; align-items: center; gap: 4px; font-weight: 400; + font-size: 12px; color: #6e6e73; cursor: pointer; user-select: none; +} +.panel-head .select-all-label input { width: 14px; height: 14px; accent-color: #0066cc; cursor: pointer; } +.panel-head .bulk-actions button.danger { + background: #fff; border-color: #ff3b30; color: #ff3b30; +} +.panel-head .bulk-actions button.danger:disabled { opacity: 0.45; cursor: not-allowed; } +.panel-head .bulk-actions button.danger:not(:disabled):hover { background: #ff3b30; color: #fff; } + +/* Search bar */ +.toolbar-search { + display: flex; align-items: center; gap: 6px; flex: 1; min-width: 140px; max-width: 220px; +} +.toolbar-search input { + flex: 1; min-width: 0; padding: 4px 8px; border: 1px solid #c7c7cc; border-radius: 6px; + font-size: 12px; background: #fff; color: #1d1d1f; +} +.toolbar-search input:focus { outline: none; border-color: #0066cc; box-shadow: 0 0 0 2px rgba(0,102,204,.15); } +.toolbar-search input::placeholder { color: #aeaeb2; } + +/* Conflict policy select */ +.conflict-select { + padding: 4px 6px; border: 1px solid #c7c7cc; border-radius: 6px; + font-size: 11px; background: #fff; color: #1d1d1f; cursor: pointer; +} +.conflict-select:focus { outline: none; border-color: #0066cc; } + +/* Notification toggle */ +.notify-btn { position: relative; } +.notify-btn.active { background: #e8f0fe; border-color: #0066cc; color: #0066cc; } +.notify-btn .badge { + position: absolute; top: -4px; right: -4px; width: 8px; height: 8px; + border-radius: 50%; background: var(--accent-2); display: none; +} +.notify-btn.has-unread .badge { display: block; } + +.view-toggle { + display: inline-flex; border: 1px solid #c7c7cc; border-radius: 6px; overflow: hidden; background: #fff; +} +.view-toggle button { + border: none; border-radius: 0; padding: 4px 8px; min-height: 0; background: #fff; color: #6e6e73; + font-size: 14px; line-height: 1; +} +.view-toggle button + button { border-left: 1px solid #c7c7cc; } +.view-toggle button.active { background: #e8f0fe; color: #0066cc; } +.view-toggle button:hover { filter: none; background: #f2f2f7; } +.view-toggle button.active:hover { background: #e8f0fe; } + +.breadcrumb { + display: flex; flex-wrap: wrap; align-items: center; gap: 4px; + padding: 8px 14px; border-bottom: 1px solid #d1d1d6; background: #fafafa; + font-size: 12px; color: #6e6e73; flex: none; +} +.breadcrumb .bc-link { + background: none; border: none; padding: 0; color: #0066cc; cursor: pointer; + font-size: 12px; border-radius: 0; min-height: 0; +} +.breadcrumb .bc-link:hover { text-decoration: underline; filter: none; } +.breadcrumb .bc-sep { color: #aeaeb2; user-select: none; } +.breadcrumb .bc-current { color: #1d1d1f; font-weight: 600; } + +/* File list */ +.file-list { + font-size: 13px; line-height: 1.2; flex: 1; min-height: 0; + display: flex; flex-direction: column; +} +.list-header, .list-row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 88px 148px 72px; + align-items: center; column-gap: 12px; padding: 0 12px; +} +.list-header .col-check, .list-row .col-check { + display: flex; align-items: center; justify-content: center; + cursor: pointer; min-height: 28px; +} +.list-header .col-check input, .list-row .col-check input, +.list-row .col-check { + width: 14px; height: 14px; cursor: pointer; accent-color: #0066cc; +} +.list-row .col-check { width: 28px; margin: 0; } +.list-header { + height: 28px; background: #ebebed; border-bottom: 1px solid #d1d1d6; + color: #6e6e73; font-size: 12px; user-select: none; flex: none; +} +.list-header .sortable { + cursor: pointer; display: inline-flex; align-items: center; gap: 4px; +} +.list-header .sortable:hover { color: #1d1d1f; } +.list-header .sortable.active { color: #1d1d1f; font-weight: 600; } +.list-header .col-size, .list-row .col-size { text-align: right; font-variant-numeric: tabular-nums; } +.list-header .col-date, .list-row .col-date { color: #6e6e73; font-variant-numeric: tabular-nums; white-space: nowrap; } +.list-body { + background: #fff; flex: 1; min-height: 0; overflow: auto; position: relative; +} +.list-body.drop-target { background: #d6e8ff; outline: 1px dashed #0066cc; outline-offset: -2px; } +.list-row { + min-height: 28px; border-bottom: 1px solid #ececf0; position: relative; +} +.list-row:last-child { border-bottom: none; } +.list-row:hover { background: #e8f0fe; } +.list-row.selected { background: #cce4ff; } +.list-row.folder { cursor: default; } +.list-row[draggable="true"], .icon-item[draggable="true"] { cursor: grab; } +.list-row.dragging, .icon-item.dragging { opacity: 0.45; } +.list-row .col-name { + display: flex; align-items: center; gap: 6px; min-width: 0; +} +.list-row .chevron { + width: 12px; flex: none; color: #86868b; font-size: 10px; text-align: center; +} +.list-row .chevron.hidden { visibility: hidden; } + +/* Origin badge */ +.origin-badge { + flex: none; font-size: 9px; padding: 1px 5px; border-radius: 999px; + background: #f2f2f7; border: 1px solid #d1d1d6; color: #6e6e73; + max-width: 72px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.origin-badge.self { background: #e8f0fe; border-color: #99caff; color: #0066cc; } + +/* Inline rename */ +.rename-input { + flex: 1; min-width: 0; padding: 1px 4px; border: 1px solid #0066cc; border-radius: 4px; + font-size: 13px; font-family: inherit; background: #fff; color: #1d1d1f; +} +.rename-input:focus { outline: none; box-shadow: 0 0 0 2px rgba(0,102,204,.2); } + +.file-icon { + width: 16px; height: 16px; flex: none; background-size: contain; background-repeat: no-repeat; +} +.file-icon.large { width: 48px; height: 48px; } +.file-icon.folder { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%234da3ff' d='M1.5 3.5A1 1 0 0 1 2.5 2.5h3.2l1.3 1.3h6.5a1 1 0 0 1 1 1v7.7a1 1 0 0 1-1 1h-12a1 1 0 0 1-1-1z'/%3E%3C/svg%3E"); +} +.file-icon.doc { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23c7c7cc' d='M3 1.5h6.5L13 5v9.5H3z'/%3E%3Cpath fill='%23fff' d='M9.5 1.5V5H13'/%3E%3C/svg%3E"); +} +.file-icon.sheet { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%2334c759' d='M3 1.5h6.5L13 5v9.5H3z'/%3E%3Cpath fill='%23fff' d='M9.5 1.5V5H13'/%3E%3C/svg%3E"); +} +.file-icon.word { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%232b579a' d='M3 1.5h6.5L13 5v9.5H3z'/%3E%3Cpath fill='%23fff' d='M9.5 1.5V5H13'/%3E%3C/svg%3E"); +} +.file-icon.pdf { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23ff3b30' d='M3 1.5h6.5L13 5v9.5H3z'/%3E%3Cpath fill='%23fff' d='M9.5 1.5V5H13'/%3E%3C/svg%3E"); +} +.file-icon.video { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect fill='%235b8cff' x='2' y='3' width='12' height='10' rx='1.5'/%3E%3Cpath fill='%23fff' d='M7 6.5v3l3-1.5z'/%3E%3C/svg%3E"); +} +.list-row .name-link { + color: #1d1d1f; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.list-row .name-link.previewable:hover { color: #0066cc; text-decoration: underline; } +.list-row .name-text { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #1d1d1f; +} +.list-row .col-size { color: #6e6e73; } +.list-row .col-actions { + display: flex; justify-content: flex-end; gap: 4px; opacity: 0; +} +.list-row:hover .col-actions { opacity: 1; } +.list-row .col-actions button, .list-row .col-actions a { + background: transparent; border: none; color: #0066cc; padding: 2px 4px; + font-size: 12px; border-radius: 4px; min-height: 0; +} +.list-row .col-actions button:hover, .list-row .col-actions a:hover { + background: rgba(0,102,204,.1); filter: none; +} +.list-row .col-actions button[data-del] { color: #ff3b30; } +.list-row .col-actions button[data-info] { color: #6e6e73; } + +/* Empty + empty folder drop zone */ +.empty { + padding: 40px 18px; text-align: center; color: #86868b; background: #fff; font-size: 13px; +} +.empty-drop-zone { + margin: 24px auto; max-width: 360px; padding: 32px 20px; + border: 2px dashed #c7c7cc; border-radius: 12px; background: #fafafa; + color: #6e6e73; transition: .15s; +} +.empty-drop-zone.dragover { + border-color: #0066cc; background: #d6e8ff; color: #0066cc; +} +.empty-drop-zone .icon { font-size: 32px; margin-bottom: 8px; } + +/* Load more */ +.load-more-wrap { padding: 10px; text-align: center; background: #fff; border-top: 1px solid #ececf0; } +.load-more-btn { + background: #fff; border: 1px solid #c7c7cc; color: #0066cc; padding: 6px 16px; + border-radius: 6px; font-size: 12px; cursor: pointer; +} +.load-more-btn:hover { background: #e8f0fe; filter: none; } + +input[type=file] { display: none; } +.list-row.drop-target { background: #d6e8ff !important; } + +/* Icon mode */ +.file-list.icon-mode .list-header { display: none; } +.file-list.icon-mode .list-body { + display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 8px; padding: 12px; align-content: start; +} +.icon-item { + position: relative; display: flex; flex-direction: column; align-items: center; + gap: 6px; padding: 10px 6px 8px; border-radius: 8px; cursor: default; + border: 1px solid transparent; min-width: 0; text-align: center; +} +.icon-item.folder { cursor: default; } +.icon-item:hover { background: #e8f0fe; border-color: #d1e3ff; } +.icon-item.selected { background: #cce4ff; border-color: #99caff; } +.icon-item.drop-target { background: #d6e8ff !important; border-color: #0066cc; } +.icon-item .item-check-wrap { + position: absolute; top: 4px; left: 4px; z-index: 2; + display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; cursor: pointer; +} +.icon-item .item-check { + width: 14px; height: 14px; accent-color: #0066cc; cursor: pointer; margin: 0; +} +.icon-item .icon-thumb { + width: 56px; height: 56px; display: flex; align-items: center; justify-content: center; flex: none; +} +.icon-item .icon-thumb .file-icon { width: 48px; height: 48px; } +.icon-item .icon-thumb-img { + width: 56px; height: 56px; object-fit: cover; border-radius: 6px; + border: 1px solid #ececf0; background: #fafafa; +} +.icon-item .icon-name { + width: 100%; font-size: 11px; line-height: 1.35; color: #1d1d1f; + overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + word-break: break-all; +} +.icon-item .icon-name a { color: inherit; text-decoration: none; } +.icon-item .icon-name a.previewable:hover { color: #0066cc; text-decoration: underline; } +.icon-item .icon-meta { font-size: 10px; color: #86868b; line-height: 1.2; } +.icon-item .icon-actions { + display: flex; gap: 6px; opacity: 0; font-size: 11px; +} +.icon-item:hover .icon-actions { opacity: 1; } +.icon-item .icon-actions a, .icon-item .icon-actions button { + background: none; border: none; color: #0066cc; padding: 0; font-size: 11px; min-height: 0; +} +.icon-item .icon-actions button[data-del] { color: #ff3b30; } + +/* Modals (shared) */ +.modal { + position: fixed; inset: 0; background: rgba(0,0,0,.72); display: none; align-items: center; + justify-content: center; z-index: 100; padding: 24px; +} +.modal.show { display: flex; } +.modal-box { + background: var(--panel); border: 1px solid var(--border); border-radius: 16px; + max-width: min(960px, 96vw); max-height: 92vh; width: 100%; display: flex; flex-direction: column; +} +.modal-box.sm { max-width: min(480px, 96vw); } +.modal-head { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 16px; border-bottom: 1px solid var(--border); gap: 12px; +} +.modal-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.modal-body { padding: 16px; overflow: auto; min-height: 0; } +.modal-body img, .modal-body video { max-width: 100%; display: block; margin: 0 auto; } +.modal-body iframe { width: 100%; height: 70vh; border: 0; background: #fff; } +.modal-body pre { + margin: 0; padding: 16px; white-space: pre-wrap; word-break: break-word; + font-size: 13px; line-height: 1.6; +} +.modal-foot { + display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; + border-top: 1px solid var(--border); +} + +/* Detail modal */ +.detail-grid { + display: grid; grid-template-columns: 100px 1fr; gap: 8px 12px; font-size: 13px; +} +.detail-grid dt { color: var(--muted); } +.detail-grid dd { margin: 0; word-break: break-all; } + +/* Conflict modal */ +.conflict-list { max-height: 240px; overflow-y: auto; margin: 12px 0; } +.conflict-item { + padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; + margin-bottom: 6px; font-size: 12px; +} +.conflict-item .path { color: var(--muted); font-size: 11px; margin-top: 2px; } +.conflict-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } +.conflict-actions label { + display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; +} + +/* Trash drawer */ +.trash-drawer { + position: fixed; top: 0; right: 0; bottom: 0; width: min(400px, 92vw); + background: var(--panel); border-left: 1px solid var(--border); + z-index: 90; transform: translateX(100%); transition: transform .25s ease; + display: flex; flex-direction: column; +} +.trash-drawer.show { transform: translateX(0); } +.trash-drawer-head { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 16px; border-bottom: 1px solid var(--border); font-weight: 600; +} +.trash-drawer-body { flex: 1; overflow-y: auto; padding: 8px; } +.trash-item { + display: flex; align-items: center; gap: 8px; padding: 10px; + border: 1px solid var(--border); border-radius: 8px; margin-bottom: 6px; font-size: 12px; +} +.trash-item-info { flex: 1; min-width: 0; } +.trash-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.trash-item-meta { font-size: 10px; color: var(--muted); margin-top: 2px; } +.trash-item-actions { display: flex; gap: 4px; flex-shrink: 0; } +.trash-item-actions button { padding: 4px 8px; font-size: 11px; } +.trash-empty { padding: 32px 16px; text-align: center; color: var(--muted); font-size: 13px; } +.trash-drawer-foot { + padding: 12px 16px; border-top: 1px solid var(--border); + display: flex; justify-content: space-between; gap: 8px; +} +.trash-backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 85; + display: none; +} +.trash-backdrop.show { display: block; } diff --git a/public/oa-drive/app.js b/public/oa-drive/app.js new file mode 100644 index 0000000..73c7f1a --- /dev/null +++ b/public/oa-drive/app.js @@ -0,0 +1,1761 @@ +(() => { + 'use strict'; + + const API_BASE = '/api/mindspace/v1/oa/drive'; + const fetchOpts = { credentials: 'same-origin' }; + + /* ── DOM refs ── */ + const $ = (id) => document.getElementById(id); + const dropZone = $('dropZone'); + const fileInput = $('fileInput'); + const folderInput = $('folderInput'); + const statusEl = $('status'); + const progressEl = $('progress'); + const progressBar = progressEl.querySelector('i'); + const progressDetail = $('progressDetail'); + const progressSummary = $('progressSummary'); + const progressSpeed = $('progressSpeed'); + const progressList = $('progressList'); + const fileTreeEl = $('fileTree'); + const fileCountEl = $('fileCount'); + const linksEl = $('links'); + const syncBadgeEl = $('syncBadge'); + const syncTextEl = $('syncText'); + const previewModal = $('previewModal'); + const previewTitle = $('previewTitle'); + const previewBody = $('previewBody'); + const previewDownload = $('previewDownload'); + const previewOpen = $('previewOpen'); + const selectAllCheck = $('selectAllCheck'); + const bulkDeleteBtn = $('bulkDeleteBtn'); + const downloadZipBtn = $('downloadZipBtn'); + const breadcrumbEl = $('breadcrumb'); + const fileListEl = $('fileList'); + const viewListBtn = $('viewListBtn'); + const viewIconsBtn = $('viewIconsBtn'); + const panelShell = $('filePanelShell'); + const filePanel = $('filePanel'); + const panelResizer = $('panelResizer'); + const searchInput = $('searchInput'); + const conflictSelect = $('conflictSelect'); + const notifyBtn = $('notifyBtn'); + const dragOverlay = $('dragOverlay'); + const dragOverlayHint = $('dragOverlayHint'); + const detailModal = $('detailModal'); + const detailGrid = $('detailGrid'); + const detailTitle = $('detailTitle'); + const conflictModal = $('conflictModal'); + const conflictList = $('conflictList'); + const trashDrawer = $('trashDrawer'); + const trashBackdrop = $('trashBackdrop'); + const trashBody = $('trashBody'); + + /* ── Storage keys ── */ + const VIEW_KEY = 'file-drop-sync-view-mode'; + const PANEL_SIZE_KEY = 'file-drop-sync-panel-size'; + const SORT_KEY = 'file-drop-sync-sort-key'; + const SORT_ASC_KEY = 'file-drop-sync-sort-asc'; + const PATH_KEY = 'file-drop-sync-current-path'; + const ON_CONFLICT_KEY = 'file-drop-sync-on-conflict'; + const CLIENT_ID_KEY = 'fds-client-id'; + const CLIENT_LABEL_KEY = 'fds-client-label'; + const NOTIFY_KEY = 'fds-notify-enabled'; + + const DEFAULT_PANEL_HEIGHT = 520; + const MIN_PANEL_WIDTH = 520; + const MIN_PANEL_HEIGHT = 280; + const CHUNK_THRESHOLD = 8 * 1024 * 1024; + + /* ── State ── */ + let serverInfo = { pageSize: 100, chunkSize: 4 * 1024 * 1024 }; + let lastSnapshot = ''; + let currentFiles = []; + let currentDirs = []; + let currentPath = localStorage.getItem(PATH_KEY) || ''; + let sortKey = localStorage.getItem(SORT_KEY) || 'name'; + let sortAsc = localStorage.getItem(SORT_ASC_KEY) !== 'false'; + let onConflict = localStorage.getItem(ON_CONFLICT_KEY) || 'rename'; + let viewMode = localStorage.getItem(VIEW_KEY) === 'icons' ? 'icons' : 'list'; + let searchQuery = ''; + let displayLimit = 100; + let selected = new Set(); + let visibleKeys = []; + let lastSelectedIndex = -1; + let renameKey = null; + let sseConnected = false; + let lastSyncTime = null; + let dragDepth = 0; + let dragTargetFolder = ''; + let internalClipboard = null; + let pendingConflictResolve = null; + let uploadAbort = false; + let filesHydrated = false; + + let panelSize = (() => { + try { + const saved = JSON.parse(localStorage.getItem(PANEL_SIZE_KEY) || 'null'); + if (saved && Number(saved.height) >= MIN_PANEL_HEIGHT) { + return { width: saved.width ? Number(saved.width) : null, height: Number(saved.height) }; + } + } catch {} + return { width: null, height: DEFAULT_PANEL_HEIGHT }; + })(); + + /* ── Client identity ── */ + function generateUuid() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + if (globalThis.crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + return `fds-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + } + + function getClientId() { + let id = localStorage.getItem(CLIENT_ID_KEY); + if (!id) { + id = generateUuid(); + localStorage.setItem(CLIENT_ID_KEY, id); + } + return id; + } + + function getClientLabel() { + let label = localStorage.getItem(CLIENT_LABEL_KEY); + if (!label) { + label = `浏览器 · ${navigator.platform || 'Web'}`; + localStorage.setItem(CLIENT_LABEL_KEY, label); + } + return label; + } + + function clientHeaders(extra = {}) { + return { + 'X-Fds-Client-Id': getClientId(), + 'X-Fds-Client-Label': encodeURIComponent(getClientLabel()), + ...extra, + }; + } + + /* ── Utilities ── */ + function fmtSize(n) { + if (n == null || n === '') return '—'; + if (n < 1024) return n + ' B'; + if (n < 1024 ** 2) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 ** 3) return (n / 1024 ** 2).toFixed(1) + ' MB'; + return (n / 1024 ** 3).toFixed(2) + ' GB'; + } + + function fmtTime(ms) { + if (!ms) return '—'; + const d = new Date(ms); + return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + + function fmtSpeed(bytesPerSec) { + if (!bytesPerSec || !Number.isFinite(bytesPerSec)) return '—'; + return fmtSize(bytesPerSec) + '/s'; + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[c]); + } + + function setStatus(text, type = '') { + statusEl.textContent = text; + statusEl.className = 'status ' + type; + } + + function viewUrl(name) { + return API_BASE + '/view/' + encodeURIComponent(name).replace(/%2F/g, '/'); + } + + function downloadUrl(name) { + return API_BASE + '/download/' + encodeURIComponent(name).replace(/%2F/g, '/'); + } + + function extOf(name) { + const i = name.lastIndexOf('.'); + return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; + } + + function fileIconClass(name) { + const ext = extOf(name); + if (['xlsx', 'xls', 'csv'].includes(ext)) return 'sheet'; + if (['doc', 'docx'].includes(ext)) return 'word'; + if (ext === 'pdf') return 'pdf'; + if (['mp4', 'webm', 'mov'].includes(ext)) return 'video'; + return 'doc'; + } + + function isImageFile(name) { + return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extOf(name)); + } + + function rowKey(row) { + return row.type === 'folder' ? row.fullPath : row.fullName; + } + + function parentPath(key) { + const i = key.lastIndexOf('/'); + return i >= 0 ? key.slice(0, i) : ''; + } + + function persistPath() { + localStorage.setItem(PATH_KEY, currentPath); + } + + function persistSort() { + localStorage.setItem(SORT_KEY, sortKey); + localStorage.setItem(SORT_ASC_KEY, String(sortAsc)); + } + + function updateSyncBadge(connected) { + sseConnected = connected; + syncBadgeEl.classList.toggle('offline', !connected); + if (!connected) { + syncTextEl.textContent = '离线 · 重连中…'; + return; + } + if (lastSyncTime) { + const t = lastSyncTime.toLocaleTimeString('zh-CN', { hour12: false }); + syncTextEl.textContent = `已连接 · ${t}`; + } else { + syncTextEl.textContent = '已连接'; + } + } + + /* ── Panel resize ── */ + function maxPanelHeight() { + return Math.max(MIN_PANEL_HEIGHT, Math.floor(window.innerHeight * 0.85)); + } + + function maxPanelWidth() { + const wrap = panelShell.parentElement; + return Math.max(MIN_PANEL_WIDTH, (wrap?.clientWidth || window.innerWidth - 32)); + } + + function applyPanelSize() { + filePanel.style.height = `${Math.min(panelSize.height, maxPanelHeight())}px`; + if (panelSize.width) { + panelShell.style.width = `${Math.min(panelSize.width, maxPanelWidth())}px`; + } else { + panelShell.style.width = ''; + } + } + + function savePanelSize() { + localStorage.setItem(PANEL_SIZE_KEY, JSON.stringify(panelSize)); + } + + function initPanelResize() { + applyPanelSize(); + panelResizer.addEventListener('mousedown', (e) => { + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const rect = panelShell.getBoundingClientRect(); + const startW = rect.width; + const startH = filePanel.getBoundingClientRect().height; + document.body.classList.add('panel-shell-resizing'); + const onMove = (ev) => { + panelSize.width = Math.min(maxPanelWidth(), Math.max(MIN_PANEL_WIDTH, Math.round(startW + ev.clientX - startX))); + panelSize.height = Math.min(maxPanelHeight(), Math.max(MIN_PANEL_HEIGHT, Math.round(startH + ev.clientY - startY))); + applyPanelSize(); + }; + const onUp = () => { + document.body.classList.remove('panel-shell-resizing'); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + savePanelSize(); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + window.addEventListener('resize', () => { + if (panelSize.width && panelSize.width > maxPanelWidth()) { + panelSize.width = maxPanelWidth(); + applyPanelSize(); + savePanelSize(); + } + if (panelSize.height > maxPanelHeight()) { + panelSize.height = maxPanelHeight(); + applyPanelSize(); + savePanelSize(); + } + }); + } + + /* ── Tree building ── */ + function folderSize(node) { + let total = 0; + for (const f of node.files) total += f.size; + for (const child of node.dirs.values()) total += folderSize(child); + return total; + } + + function folderMtime(node) { + let max = 0; + for (const f of node.files) max = Math.max(max, f.mtime); + for (const child of node.dirs.values()) max = Math.max(max, folderMtime(child)); + return max; + } + + function buildTree(files, dirs = []) { + const root = { name: '', dirs: new Map(), files: [] }; + for (const dirPath of dirs) { + const parts = dirPath.split('/'); + let node = root; + for (const part of parts) { + if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] }); + node = node.dirs.get(part); + } + } + for (const f of files) { + const parts = f.name.split('/'); + let node = root; + for (let i = 0; i < parts.length - 1; i++) { + if (!node.dirs.has(parts[i])) node.dirs.set(parts[i], { name: parts[i], dirs: new Map(), files: [] }); + node = node.dirs.get(parts[i]); + } + node.files.push({ ...f, baseName: parts[parts.length - 1] }); + } + return root; + } + + function getNodeAtPath(tree, path) { + if (!path) return tree; + let node = tree; + for (const part of path.split('/')) { + if (!node.dirs.has(part)) return null; + node = node.dirs.get(part); + } + return node; + } + + function compareRows(a, b) { + let va = a.sortVal[sortKey]; + let vb = b.sortVal[sortKey]; + if (sortKey === 'name') { + va = String(va).toLowerCase(); + vb = String(vb).toLowerCase(); + } + if (va < vb) return sortAsc ? -1 : 1; + if (va > vb) return sortAsc ? 1 : -1; + return 0; + } + + function originBadgeHtml(origin) { + if (!origin?.clientLabel) return ''; + const self = origin.clientId === getClientId(); + return `${escapeHtml(origin.clientLabel)}`; + } + + /* ── Selection ── */ + function updateSelectionUi() { + const count = selected.size; + bulkDeleteBtn.disabled = count === 0; + bulkDeleteBtn.textContent = count ? `删除所选 (${count})` : '删除所选'; + downloadZipBtn.disabled = count === 0; + + const allSelected = visibleKeys.length > 0 && visibleKeys.every((k) => selected.has(k)); + const someSelected = visibleKeys.some((k) => selected.has(k)); + selectAllCheck.checked = allSelected; + selectAllCheck.indeterminate = !allSelected && someSelected; + + fileTreeEl.querySelectorAll('.list-row, .icon-item').forEach((el) => { + const key = el.dataset.key; + el.classList.toggle('selected', key ? selected.has(key) : false); + const cb = el.querySelector('.row-check, .item-check'); + if (cb) cb.checked = key ? selected.has(key) : false; + }); + } + + function syncCheckboxSelection(checkbox, shiftKey = false) { + const rowEl = checkbox.closest('[data-key]'); + if (!rowEl) return; + const key = rowEl.dataset.key; + const index = visibleKeys.indexOf(key); + if (index < 0) return; + + if (shiftKey && lastSelectedIndex >= 0) { + const start = Math.min(lastSelectedIndex, index); + const end = Math.max(lastSelectedIndex, index); + setSelected(visibleKeys.slice(start, end + 1), 'add'); + } else if (checkbox.checked) { + setSelected([key], 'add'); + } else { + selected.delete(key); + updateSelectionUi(); + } + lastSelectedIndex = index; + } + + function setSelected(keys, mode = 'replace') { + if (mode === 'replace') selected = new Set(keys); + else if (mode === 'toggle') { + for (const key of keys) { + if (selected.has(key)) selected.delete(key); + else selected.add(key); + } + } else if (mode === 'add') { + for (const key of keys) selected.add(key); + } + updateSelectionUi(); + } + + function selectAllVisible() { + setSelected(visibleKeys, 'replace'); + lastSelectedIndex = visibleKeys.length - 1; + } + + function clearSelection() { + selected.clear(); + lastSelectedIndex = -1; + updateSelectionUi(); + } + + function handleRowSelect(e, key, index) { + if (e.metaKey || e.ctrlKey) { + setSelected([key], 'toggle'); + lastSelectedIndex = index; + return; + } + if (e.shiftKey && lastSelectedIndex >= 0) { + const start = Math.min(lastSelectedIndex, index); + const end = Math.max(lastSelectedIndex, index); + setSelected(visibleKeys.slice(start, end + 1), 'add'); + return; + } + setSelected([key], 'replace'); + lastSelectedIndex = index; + } + + /* ── View mode ── */ + function setViewMode(mode) { + viewMode = mode === 'icons' ? 'icons' : 'list'; + localStorage.setItem(VIEW_KEY, viewMode); + fileListEl.classList.toggle('icon-mode', viewMode === 'icons'); + viewListBtn.classList.toggle('active', viewMode === 'list'); + viewIconsBtn.classList.toggle('active', viewMode === 'icons'); + lastSnapshot = ''; + renderFiles(currentFiles); + } + + function updateSortHeader() { + document.querySelectorAll('.list-header .sortable').forEach((el) => { + const key = el.dataset.sort; + el.classList.toggle('active', key === sortKey); + let mark = el.querySelector('.sort-mark'); + if (key === sortKey) { + if (!mark) { + mark = document.createElement('span'); + mark.className = 'sort-mark'; + el.appendChild(document.createTextNode(' ')); + el.appendChild(mark); + } + mark.textContent = sortAsc ? '▲' : '▼'; + } else if (mark) { + mark.remove(); + } + }); + } + + /* ── Breadcrumb ── */ + function updateBreadcrumb() { + if (!currentPath) { + breadcrumbEl.innerHTML = '全部文件'; + return; + } + const parts = currentPath.split('/'); + let html = ''; + let acc = ''; + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part; + html += '/'; + if (acc === currentPath) { + html += `${escapeHtml(part)}`; + } else { + html += ``; + } + } + breadcrumbEl.innerHTML = html; + } + + function enterFolder(path) { + currentPath = path; + persistPath(); + displayLimit = serverInfo.pageSize || 100; + clearSelection(); + lastSnapshot = ''; + renderFiles(currentFiles); + } + + /* ── Render ── */ + function renderFolderContents(node, pathPrefix = '') { + const siblings = []; + for (const [dirName, child] of node.dirs) { + const fullPath = pathPrefix ? `${pathPrefix}/${dirName}` : dirName; + siblings.push({ + type: 'folder', + name: dirName, + fullPath, + size: folderSize(child), + mtime: folderMtime(child), + sortVal: { name: dirName, size: folderSize(child), mtime: folderMtime(child) }, + }); + } + for (const f of node.files) { + siblings.push({ + type: 'file', + name: f.baseName, + fullName: f.name, + size: f.size, + mtime: f.mtime, + previewable: f.previewable, + origin: f.origin, + sortVal: { name: f.baseName, size: f.size, mtime: f.mtime }, + }); + } + siblings.sort(compareRows); + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + return siblings.filter((r) => r.name.toLowerCase().includes(q)); + } + return siblings; + } + + function listIconHtml(row) { + if (row.type === 'folder') return ''; + return ``; + } + + function iconThumbHtml(row) { + if (row.type === 'folder') return ''; + if (isImageFile(row.fullName)) { + return ``; + } + return ``; + } + + function rowHtml(row) { + const key = rowKey(row); + const isRenaming = renameKey === key; + const originHtml = row.type === 'file' ? originBadgeHtml(row.origin) : ''; + + if (row.type === 'folder') { + const nameCell = isRenaming + ? `` + : `${escapeHtml(row.name)}`; + return ` +
+ +
+ + ${listIconHtml(row)} + ${nameCell} +
+
${fmtSize(row.size)}
+
${fmtTime(row.mtime)}
+
+ +
+
`; + } + + const cls = row.previewable ? 'previewable' : ''; + const nameCell = isRenaming + ? `` + : `${escapeHtml(row.name)}`; + + return ` +
+ +
+ + ${listIconHtml(row)} + ${nameCell} + ${originHtml} +
+
${fmtSize(row.size)}
+
${fmtTime(row.mtime)}
+
+ + 下载 + +
+
`; + } + + function iconHtml(row) { + const key = rowKey(row); + const isRenaming = renameKey === key; + const originHtml = row.type === 'file' && row.origin?.clientLabel + ? `
${escapeHtml(row.origin.clientLabel)}
` : ''; + + if (row.type === 'folder') { + const nameInner = isRenaming + ? `` + : escapeHtml(row.name); + return ` +
+ +
${iconThumbHtml(row)}
+
${nameInner}
+
${fmtSize(row.size)}
+
`; + } + + const cls = row.previewable ? 'previewable' : ''; + const nameInner = isRenaming + ? `` + : row.previewable + ? `${escapeHtml(row.name)}` + : escapeHtml(row.name); + + return ` +
+ +
${iconThumbHtml(row)}
+
${nameInner}
+
${fmtSize(row.size)}
+ ${originHtml} +
+ + 下载 + +
+
`; + } + + function renderFiles(files, meta = {}) { + if (Array.isArray(files)) currentFiles = files; + if (Array.isArray(meta.dirs)) currentDirs = meta.dirs; + + const snapshot = JSON.stringify(currentFiles.length) + '|' + currentPath + '|' + sortKey + '|' + sortAsc + '|' + viewMode + '|' + searchQuery + '|' + displayLimit + '|' + renameKey; + if (snapshot === lastSnapshot && !meta.force) return; + lastSnapshot = snapshot; + + const tree = buildTree(currentFiles, currentDirs); + let node = getNodeAtPath(tree, currentPath); + if (!node) { + currentPath = ''; + persistPath(); + node = tree; + } + + const allRows = renderFolderContents(node, currentPath); + const totalCount = allRows.length; + const rows = allRows.slice(0, displayLimit); + const hasMore = totalCount > displayLimit; + + fileCountEl.textContent = searchQuery + ? `${rows.length}/${totalCount} 项` + : `${totalCount} 项`; + updateBreadcrumb(); + + if (meta.added?.length) { + setStatus(`已同步 ${meta.added.length} 个新文件`, 'ok'); + notifyExternalChanges(meta.added, meta.files || currentFiles); + } + + if (!allRows.length) { + const backBtn = currentPath + ? `` + : ''; + const emptyHtml = currentPath + ? `
+
+
📥
+
此文件夹为空
+
拖入文件到此处上传,或返回上级查看其它文件
+ ${backBtn} +
+
` + : `
还没有文件,先拖一个进来吧${backBtn}
`; + fileTreeEl.innerHTML = emptyHtml; + visibleKeys = []; + clearSelection(); + updateSortHeader(); + return; + } + + visibleKeys = rows.map(rowKey); + for (const key of [...selected]) { + if (!visibleKeys.includes(key)) selected.delete(key); + } + + let html = rows.map(viewMode === 'icons' ? iconHtml : rowHtml).join(''); + if (hasMore) { + html += `
`; + } + fileTreeEl.innerHTML = html; + updateSortHeader(); + updateSelectionUi(); + + if (renameKey) { + const input = fileTreeEl.querySelector('.rename-input'); + if (input) { + input.focus(); + input.select(); + } + } + } + + /* ── File list merge (SSE delta) ── */ + function mergeFilePayload(payload) { + const useFullSnapshot = !payload.delta || !filesHydrated; + if (useFullSnapshot) { + return { files: payload.files || [], dirs: payload.dirs || [] }; + } + const byName = new Map(currentFiles.map((f) => [f.name, f])); + for (const name of payload.removed || []) byName.delete(name); + for (const f of payload.files || []) byName.set(f.name, f); + const dirs = payload.dirs || currentDirs; + return { files: [...byName.values()], dirs }; + } + + function applyFilePayload(payload) { + const merged = mergeFilePayload(payload); + filesHydrated = true; + lastSyncTime = new Date(); + updateSyncBadge(true); + lastSnapshot = ''; + renderFiles(merged.files, { ...payload, dirs: merged.dirs, files: merged.files }); + } + + /* ── API: load info ── */ + async function loadInfo() { + const res = await fetch(API_BASE + '/info', fetchOpts); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + serverInfo = await res.json(); + displayLimit = serverInfo.pageSize || 100; + if (linksEl) { + const quota = serverInfo.quota; + if (quota) { + linksEl.innerHTML = `剩余 ${fmtSize(quota.availableBytes)} / ${fmtSize(quota.quotaBytes)}`; + } else { + linksEl.innerHTML = ''; + } + } + } + + async function refreshFiles() { + try { + const res = await fetch(API_BASE + '/files', fetchOpts); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + applyFilePayload({ ...data, delta: false }); + } catch (err) { + setStatus(`无法加载文件:${err.message}`, 'err'); + } + } + + /* ── SSE ── */ + function connectEvents() { + const es = new EventSource(API_BASE + '/events'); + es.onopen = () => updateSyncBadge(true); + es.onmessage = (e) => { + try { + applyFilePayload(JSON.parse(e.data)); + } catch {} + }; + es.onerror = () => { + updateSyncBadge(false); + es.close(); + setTimeout(connectEvents, 2000); + }; + } + + /* ── Notifications ── */ + function isNotifyEnabled() { + return localStorage.getItem(NOTIFY_KEY) === 'true'; + } + + async function requestNotifyPermission() { + if (!('Notification' in window)) { + setStatus('此浏览器不支持桌面通知', 'err'); + return false; + } + const perm = await Notification.requestPermission(); + const enabled = perm === 'granted'; + localStorage.setItem(NOTIFY_KEY, String(enabled)); + notifyBtn.classList.toggle('active', enabled); + return enabled; + } + + function notifyExternalChanges(addedNames, allFiles) { + if (!isNotifyEnabled() || Notification.permission !== 'granted') return; + const myId = getClientId(); + const external = addedNames.filter((name) => { + const f = allFiles.find((x) => x.name === name); + return f?.origin?.clientId && f.origin.clientId !== myId; + }); + if (!external.length) return; + const label = external.length === 1 ? external[0].split('/').pop() : `${external.length} 个文件`; + const origin = allFiles.find((x) => x.name === external[0])?.origin?.clientLabel || '其他设备'; + new Notification('文件互传 · 新文件', { + body: `${origin} 添加了 ${label}`, + icon: '/favicon.ico', + }); + } + + /* ── Conflict modal ── */ + function showConflictModal(conflicts) { + return new Promise((resolve) => { + conflictList.innerHTML = conflicts.map((c) => + `
${escapeHtml(c.name)}
${escapeHtml(c.path)}
` + ).join(''); + const chosen = document.querySelector('input[name="conflictChoice"]:checked'); + if (chosen) chosen.checked = true; + conflictModal.classList.add('show'); + pendingConflictResolve = resolve; + }); + } + + function closeConflictModal(result) { + conflictModal.classList.remove('show'); + if (pendingConflictResolve) { + const choice = document.querySelector('input[name="conflictChoice"]:checked')?.value || 'rename'; + pendingConflictResolve(result === false ? null : choice); + pendingConflictResolve = null; + } + } + + function detectLocalConflicts(fileEntries, prefix) { + const existing = new Set(currentFiles.map((f) => f.name)); + const conflicts = []; + for (const { file, relPath } of fileEntries) { + let fullPath = relPath.replace(/\\/g, '/'); + if (prefix) fullPath = `${prefix}/${fullPath}`.replace(/\/+/g, '/'); + if (existing.has(fullPath)) { + conflicts.push({ name: file.name, path: fullPath }); + } + } + return conflicts; + } + + /* ── Upload progress UI ── */ + function createProgressItem(name) { + const id = 'pi-' + Math.random().toString(36).slice(2, 8); + const el = document.createElement('div'); + el.className = 'progress-item'; + el.id = id; + el.innerHTML = ` + ${escapeHtml(name)} + 0% + 等待 +
`; + progressList.appendChild(el); + return { + id, + el, + setProgress(pct, speed) { + el.querySelector('.progress-item-bar i').style.width = `${Math.round(pct * 100)}%`; + el.querySelector('.progress-item-meta').textContent = `${Math.round(pct * 100)}% · ${fmtSpeed(speed)}`; + }, + setStatus(text, type = '') { + const s = el.querySelector('.progress-item-status'); + s.textContent = text; + s.className = 'progress-item-status' + (type ? ' ' + type : ''); + }, + }; + } + + function showUploadProgress(show) { + progressEl.classList.toggle('show', show); + progressDetail.classList.toggle('show', show); + if (!show) { + progressBar.style.width = '0%'; + progressList.innerHTML = ''; + } + } + + /* ── Upload: single file XHR ── */ + function uploadSingleXHR(file, relPath, prefix, conflictPolicy, onProgress) { + return new Promise((resolve, reject) => { + const form = new FormData(); + if (prefix) form.append('prefix', prefix); + form.append('file', file, relPath); + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/upload?onConflict=${encodeURIComponent(conflictPolicy)}`); + for (const [k, v] of Object.entries(clientHeaders())) { + try { + xhr.setRequestHeader(k, v); + } catch (err) { + console.warn('skip header', k, err); + } + } + let lastLoaded = 0; + let lastTime = Date.now(); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const now = Date.now(); + const dt = (now - lastTime) / 1000; + const speed = dt > 0 ? (e.loaded - lastLoaded) / dt : 0; + lastLoaded = e.loaded; + lastTime = now; + onProgress(e.loaded / e.total, speed); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { resolve(JSON.parse(xhr.responseText)); } + catch { resolve({ saved: [relPath], skipped: [] }); } + } else reject(new Error(xhr.responseText || xhr.statusText)); + }; + xhr.onerror = () => reject(new Error('网络错误')); + xhr.send(form); + }); + } + + /* ── Upload: chunked session ── */ + async function uploadChunked(file, relPath, prefix, conflictPolicy, onProgress) { + const sessionRes = await fetch(API_BASE + '/upload/session', { ...fetchOpts, + method: 'POST', + headers: clientHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name: relPath, size: file.size, prefix }), + }); + if (!sessionRes.ok) throw new Error(await sessionRes.text()); + const session = await sessionRes.json(); + + const chunkSize = serverInfo.chunkSize || 4 * 1024 * 1024; + let offset = 0; + let lastTime = Date.now(); + let lastOffset = 0; + + while (offset < file.size) { + if (uploadAbort) throw new Error('已取消'); + const end = Math.min(offset + chunkSize, file.size); + const chunk = file.slice(offset, end); + const rangeEnd = end - 1; + const patchRes = await fetch(`${API_BASE}/upload/session/${session.id}`, { ...fetchOpts, + method: 'PATCH', + headers: clientHeaders({ 'Content-Range': `bytes ${offset}-${rangeEnd}/${file.size}` }), + body: chunk, + }); + if (!patchRes.ok) throw new Error(await patchRes.text()); + const now = Date.now(); + const dt = (now - lastTime) / 1000; + const speed = dt > 0 ? (end - lastOffset) / dt : 0; + lastTime = now; + lastOffset = end; + offset = end; + onProgress(offset / file.size, speed); + } + + const completeRes = await fetch(`${API_BASE}/upload/session/${session.id}/complete`, { ...fetchOpts, + method: 'POST', + headers: clientHeaders(), + }); + if (!completeRes.ok) throw new Error(await completeRes.text()); + return completeRes.json(); + } + + /* ── Upload batch ── */ + async function uploadFiles(fileList, prefix = currentPath) { + const files = [...fileList].filter((f) => f && (typeof f.size !== 'number' || f.size >= 0)); + if (!files.length) { + setStatus('没有可上传的文件', 'err'); + return; + } + + const entries = files.map((file) => ({ + file, + relPath: (file.webkitRelativePath || file.name).replace(/\\/g, '/'), + })); + + let conflictPolicy = onConflict; + + uploadAbort = false; + showUploadProgress(true); + progressSummary.textContent = `上传 0/${files.length}`; + setStatus(`正在上传 ${files.length} 个文件${prefix ? ' 到 ' + prefix : ''}…`); + + const trackers = entries.map(({ file }) => createProgressItem(file.name)); + let doneCount = 0; + let savedTotal = 0; + let failed = 0; + let lastError = ''; + + for (let i = 0; i < entries.length; i++) { + if (uploadAbort) break; + const { file, relPath } = entries[i]; + const tracker = trackers[i]; + tracker.setStatus('上传中'); + + const onProgress = (pct, speed) => { + tracker.setProgress(pct, speed); + progressSpeed.textContent = fmtSpeed(speed); + const overall = (doneCount + pct) / files.length; + progressBar.style.width = `${Math.round(overall * 100)}%`; + }; + + try { + if (file.size > CHUNK_THRESHOLD) { + await uploadChunked(file, relPath, prefix, conflictPolicy, onProgress); + } else { + await uploadSingleXHR(file, relPath, prefix, conflictPolicy, onProgress); + } + tracker.setProgress(1, 0); + tracker.setStatus('完成', 'done'); + doneCount += 1; + savedTotal += 1; + } catch (err) { + lastError = err?.message || String(err); + tracker.setStatus('失败', 'err'); + failed += 1; + doneCount += 1; + } + progressSummary.textContent = `上传 ${doneCount}/${files.length}`; + } + + lastSnapshot = ''; + await refreshFiles(); + + if (failed) { + const detail = lastError ? `:${lastError.slice(0, 120)}` : ''; + setStatus(`上传完成,${savedTotal} 成功,${failed} 失败${detail}`, failed === files.length ? 'err' : ''); + } else setStatus(`已上传 ${savedTotal} 个文件`, 'ok'); + + setTimeout(() => showUploadProgress(false), 800); + fileInput.value = ''; + folderInput.value = ''; + } + + /* ── DataTransfer helpers ── */ + async function collectEntry(entry, base = '') { + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => { + const rel = base ? `${base}/${file.name}` : file.name; + Object.defineProperty(file, 'webkitRelativePath', { value: rel }); + resolve([file]); + }); + }); + } + if (entry.isDirectory) { + const reader = entry.createReader(); + const out = []; + const read = () => new Promise((resolve) => reader.readEntries(resolve)); + let entries = await read(); + while (entries.length) { + for (const child of entries) { + const childBase = base ? `${base}/${entry.name}` : entry.name; + out.push(...await collectEntry(child, childBase)); + } + entries = await read(); + } + return out; + } + return []; + } + + function normalizePastedFile(file, index = 0) { + if (file.name) return file; + const ext = (file.type || '').split('/')[1] || 'bin'; + const suffix = index > 0 ? `-${index + 1}` : ''; + return new File([file], `粘贴文件${suffix}.${ext}`, { type: file.type || 'application/octet-stream' }); + } + + /** 必须在 paste/drop 事件回调里同步调用 getAsFile(),不能先 await */ + function extractDataTransferSync(dt) { + const syncFiles = []; + const asyncEntries = []; + if (!dt) return { syncFiles, asyncEntries }; + + for (const item of [...(dt.items || [])]) { + if (item.kind !== 'file') continue; + const file = item.getAsFile(); + if (file) { + syncFiles.push(normalizePastedFile(file, syncFiles.length)); + continue; + } + const entry = item.webkitGetAsEntry?.(); + if (entry) asyncEntries.push(entry); + } + + if (!syncFiles.length && dt.files?.length) { + [...dt.files].forEach((file, i) => syncFiles.push(normalizePastedFile(file, i))); + } + + return { syncFiles, asyncEntries }; + } + + async function fromDataTransfer(dt) { + const { syncFiles, asyncEntries } = extractDataTransferSync(dt); + const files = [...syncFiles]; + for (const entry of asyncEntries) { + files.push(...await collectEntry(entry)); + } + return files; + } + + async function fromClipboard(clipboardData) { + return fromDataTransfer(clipboardData); + } + + /* ── File operations ── */ + async function deleteKeys(keys) { + const list = [...keys]; + if (!list.length) return; + const label = list.length === 1 ? '这个文件' : `这 ${list.length} 项`; + if (!confirm(`确定删除${label}?(移入回收站)`)) return; + + bulkDeleteBtn.disabled = true; + setStatus(`正在删除 ${list.length} 项…`); + let failed = 0; + for (const key of list) { + try { + const res = await fetch(API_BASE + '/files/' + encodeURIComponent(key).replace(/%2F/g, '/'), { method: 'DELETE' }); + if (!res.ok) failed += 1; + else selected.delete(key); + } catch { + failed += 1; + } + } + lastSnapshot = ''; + await refreshFiles(); + if (failed) setStatus(`删除完成,${failed} 项失败`, 'err'); + else setStatus(`已移入回收站 ${list.length - failed} 项`, 'ok'); + } + + async function createFolder() { + const name = prompt('新建文件夹名称:'); + if (!name) return; + const trimmed = name.trim(); + if (!trimmed || /[\\/]/.test(trimmed) || trimmed === '.' || trimmed === '..') { + setStatus('文件夹名称无效', 'err'); + return; + } + try { + const res = await fetch(API_BASE + '/folders', { ...fetchOpts, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmed, parent: currentPath }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (data.error === 'exists') throw new Error('名称已存在'); + throw new Error(data.error || res.statusText); + } + setStatus(`已创建文件夹「${trimmed}」`, 'ok'); + lastSnapshot = ''; + await refreshFiles(); + } catch (err) { + setStatus('创建失败: ' + err.message, 'err'); + } + } + + async function moveItems(keys, destFolder) { + const dest = destFolder || ''; + const toMove = keys.filter((key) => { + if (parentPath(key) === dest) return false; + if (dest === key || dest.startsWith(key + '/')) return false; + return true; + }); + if (!toMove.length) return; + + setStatus(`正在移动 ${toMove.length} 项…`); + try { + const res = await fetch(API_BASE + '/move', { ...fetchOpts, + method: 'POST', + headers: clientHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ items: toMove, dest, onConflict }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + lastSnapshot = ''; + clearSelection(); + await refreshFiles(); + if (data.errors?.length) { + setStatus(`已移动 ${data.moved?.length || 0} 项,${data.errors.length} 项失败`, 'err'); + } else { + setStatus(`已移动 ${data.moved?.length || 0} 项`, 'ok'); + } + } catch (err) { + setStatus('移动失败: ' + err.message, 'err'); + } + } + + async function copyItems(keys, destFolder) { + const dest = destFolder || currentPath; + setStatus(`正在复制 ${keys.length} 项…`); + try { + const res = await fetch(API_BASE + '/copy', { ...fetchOpts, + method: 'POST', + headers: clientHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ items: keys, dest, onConflict }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + lastSnapshot = ''; + await refreshFiles(); + setStatus(`已复制 ${data.copied?.length || 0} 项`, 'ok'); + } catch (err) { + setStatus('复制失败: ' + err.message, 'err'); + } + } + + async function pasteClipboard() { + if (!internalClipboard?.items?.length) return; + const dest = currentPath; + if (internalClipboard.mode === 'cut') { + await moveItems(internalClipboard.items, dest); + internalClipboard = null; + } else { + await copyItems(internalClipboard.items, dest); + } + } + + function startRename(key) { + renameKey = key; + lastSnapshot = ''; + renderFiles(currentFiles, { force: true }); + } + + async function commitRename(key, newName) { + renameKey = null; + const trimmed = newName.trim(); + if (!trimmed || trimmed === key.split('/').pop()) { + renderFiles(currentFiles, { force: true }); + return; + } + if (/[\\/]/.test(trimmed)) { + setStatus('名称不能包含 / 或 \\', 'err'); + renderFiles(currentFiles, { force: true }); + return; + } + try { + const res = await fetch(API_BASE + '/rename', { ...fetchOpts, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: key, name: trimmed, onConflict }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + if (selected.has(key) && data.to) { + selected.delete(key); + selected.add(data.to); + } + setStatus('已重命名', 'ok'); + lastSnapshot = ''; + await refreshFiles(); + } catch (err) { + setStatus('重命名失败: ' + err.message, 'err'); + renderFiles(currentFiles, { force: true }); + } + } + + function downloadSelectedZip() { + const items = [...selected].filter((k) => { + const f = currentFiles.find((x) => x.name === k); + return f || currentDirs.includes(k); + }); + if (!items.length) return; + const url = API_BASE + '/download-zip?items=' + items.map(encodeURIComponent).join(','); + window.location.href = url; + } + + /* ── Detail modal ── */ + function openDetail(key) { + const file = currentFiles.find((f) => f.name === key); + const folder = currentDirs.includes(key); + + detailTitle.textContent = key.split('/').pop() || key; + const rows = [ + ['路径', key], + ['类型', folder ? '文件夹' : '文件'], + ]; + if (file) { + rows.push(['大小', fmtSize(file.size)]); + rows.push(['修改时间', fmtTime(file.mtime)]); + if (file.origin) { + rows.push(['来源设备', file.origin.clientLabel || '—']); + rows.push(['来源 ID', file.origin.clientId || '—']); + } + } else if (folder) { + const tree = buildTree(currentFiles, currentDirs); + const node = getNodeAtPath(tree, key); + if (node) { + rows.push(['大小', fmtSize(folderSize(node))]); + rows.push(['修改时间', fmtTime(folderMtime(node))]); + } + } + detailGrid.innerHTML = rows.map(([k, v]) => + `
${escapeHtml(k)}
${escapeHtml(String(v))}
` + ).join(''); + detailModal.classList.add('show'); + } + + /* ── Preview ── */ + function openPreview(name) { + const url = viewUrl(name); + const ext = extOf(name); + previewTitle.textContent = name; + previewDownload.href = downloadUrl(name); + previewOpen.href = url; + previewBody.innerHTML = ''; + + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) { + previewBody.innerHTML = `${escapeHtml(name)}`; + } else if (['mp4', 'webm'].includes(ext)) { + previewBody.innerHTML = ``; + } else if (['mp3', 'wav', 'ogg'].includes(ext)) { + previewBody.innerHTML = ``; + } else if (ext === 'pdf') { + previewBody.innerHTML = ``; + } else if (['txt', 'md', 'json', 'js', 'mjs', 'css', 'html', 'htm', 'csv', 'xml'].includes(ext)) { + fetch(url).then((r) => r.text()).then((text) => { + previewBody.innerHTML = `
${escapeHtml(text)}
`; + }); + } else { + window.open(url, '_blank'); + return; + } + previewModal.classList.add('show'); + } + + /* ── Trash ── */ + async function loadTrash() { + trashBody.innerHTML = '
加载中…
'; + try { + const res = await fetch(API_BASE + '/trash', fetchOpts); + const data = await res.json(); + const items = data.items || []; + if (!items.length) { + trashBody.innerHTML = '
回收站为空
'; + return; + } + trashBody.innerHTML = items.map((item) => ` +
+
+
${escapeHtml(item.original.split('/').pop())}
+
${escapeHtml(item.original)} · ${fmtTime(item.trashedAt)} · ${item.isDirectory ? '文件夹' : fmtSize(item.size)}
+
+
+ + +
+
`).join(''); + } catch { + trashBody.innerHTML = '
加载失败
'; + } + } + + function openTrash() { + trashDrawer.classList.add('show'); + trashBackdrop.classList.add('show'); + loadTrash(); + } + + function closeTrash() { + trashDrawer.classList.remove('show'); + trashBackdrop.classList.remove('show'); + } + + async function restoreTrashItem(id) { + try { + const res = await fetch(API_BASE + '/trash/restore', { ...fetchOpts, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, onConflict }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + setStatus('已恢复', 'ok'); + await loadTrash(); + lastSnapshot = ''; + await refreshFiles(); + } catch (err) { + setStatus('恢复失败: ' + err.message, 'err'); + } + } + + async function purgeTrashItem(id) { + if (!confirm('永久删除此项?不可恢复。')) return; + try { + await fetch(API_BASE + '/trash/' + encodeURIComponent(id), { method: 'DELETE' }); + await loadTrash(); + setStatus('已永久删除', 'ok'); + } catch (err) { + setStatus('删除失败: ' + err.message, 'err'); + } + } + + async function emptyTrash() { + if (!confirm('清空回收站?所有项目将永久删除。')) return; + try { + await fetch(API_BASE + '/trash', { ...fetchOpts, method: 'DELETE' }); + await loadTrash(); + setStatus('回收站已清空', 'ok'); + } catch (err) { + setStatus('清空失败: ' + err.message, 'err'); + } + } + + /* ── Drag overlay ── */ + function showDragOverlay(target) { + const label = target || currentPath || '根目录'; + dragOverlayHint.textContent = `松手移入 ${label || '根目录'}`; + dragOverlay.classList.add('show'); + } + + function hideDragOverlay() { + dragOverlay.classList.remove('show'); + } + + /* ── Event bindings ── */ + function bindEvents() { + conflictSelect.value = onConflict; + conflictSelect.addEventListener('change', () => { + onConflict = conflictSelect.value; + localStorage.setItem(ON_CONFLICT_KEY, onConflict); + }); + + notifyBtn.classList.toggle('active', isNotifyEnabled() && Notification.permission === 'granted'); + notifyBtn.addEventListener('click', () => requestNotifyPermission()); + + searchInput.addEventListener('input', () => { + searchQuery = searchInput.value.trim(); + displayLimit = serverInfo.pageSize || 100; + lastSnapshot = ''; + renderFiles(currentFiles); + }); + + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); + dropZone.addEventListener('drop', async (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + hideDragOverlay(); + await uploadFiles(await fromDataTransfer(e.dataTransfer)); + }); + + document.addEventListener('dragenter', (e) => { + if (![...e.dataTransfer.types].includes('Files')) return; + dragDepth += 1; + showDragOverlay(dragTargetFolder || currentPath || '根目录'); + }); + document.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) hideDragOverlay(); + }); + document.addEventListener('drop', () => { dragDepth = 0; hideDragOverlay(); }); + + fileTreeEl.addEventListener('dragstart', (e) => { + const row = e.target.closest('[data-key][draggable="true"]'); + if (!row || e.target.closest('.col-check, .item-check-wrap, .row-check, .item-check, .col-actions, .icon-actions, a, button, .rename-input')) { + e.preventDefault(); + return; + } + const key = row.dataset.key; + const keys = selected.has(key) && selected.size > 1 ? [...selected] : [key]; + e.dataTransfer.setData('application/x-fds-keys', JSON.stringify(keys)); + e.dataTransfer.effectAllowed = 'move'; + row.classList.add('dragging'); + }); + + fileTreeEl.addEventListener('dragend', () => { + fileTreeEl.querySelectorAll('.dragging').forEach((el) => el.classList.remove('dragging')); + fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); + dragTargetFolder = ''; + }); + + fileTreeEl.addEventListener('dragover', (e) => { + const hasInternal = e.dataTransfer.types.includes('application/x-fds-keys'); + const hasExternal = [...e.dataTransfer.types].includes('Files'); + if (!hasInternal && !hasExternal) return; + e.preventDefault(); + e.dataTransfer.dropEffect = hasInternal ? 'move' : 'copy'; + const folderRow = e.target.closest('[data-folder]'); + fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); + if (folderRow) { + folderRow.classList.add('drop-target'); + dragTargetFolder = folderRow.getAttribute('data-folder'); + showDragOverlay(dragTargetFolder); + } else { + fileTreeEl.classList.add('drop-target'); + dragTargetFolder = currentPath || '根目录'; + showDragOverlay(dragTargetFolder); + } + }); + + fileTreeEl.addEventListener('dragleave', (e) => { + if (!fileTreeEl.contains(e.relatedTarget)) { + fileTreeEl.classList.remove('drop-target'); + fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); + } + }); + + fileTreeEl.addEventListener('drop', async (e) => { + e.preventDefault(); + fileTreeEl.classList.remove('drop-target'); + fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); + hideDragOverlay(); + + const internal = e.dataTransfer.getData('application/x-fds-keys'); + const folderRow = e.target.closest('[data-folder]'); + const destFolder = folderRow ? folderRow.getAttribute('data-folder') : currentPath; + + if (internal) { + try { + await moveItems(JSON.parse(internal), destFolder); + } catch {} + return; + } + + await uploadFiles(await fromDataTransfer(e.dataTransfer), destFolder); + }); + + breadcrumbEl.addEventListener('click', (e) => { + const btn = e.target.closest('.bc-link'); + if (!btn) return; + enterFolder(btn.getAttribute('data-path') || ''); + }); + + document.querySelector('.list-header').addEventListener('click', (e) => { + const btn = e.target.closest('.sortable'); + if (!btn) return; + const key = btn.dataset.sort; + if (sortKey === key) sortAsc = !sortAsc; + else { sortKey = key; sortAsc = key === 'name'; } + persistSort(); + lastSnapshot = ''; + renderFiles(currentFiles); + }); + + fileTreeEl.addEventListener('change', (e) => { + const rowCheck = e.target.closest('.row-check, .item-check'); + if (!rowCheck || e.target !== rowCheck) return; + e.stopPropagation(); + syncCheckboxSelection(rowCheck, e.shiftKey); + fileTreeEl.focus(); + }); + + fileTreeEl.addEventListener('click', async (e) => { + if (e.target.id === 'loadMoreBtn') { + displayLimit += serverInfo.pageSize || 100; + lastSnapshot = ''; + renderFiles(currentFiles); + return; + } + + if (e.target.id === 'backToRootBtn') { + enterFolder(''); + return; + } + + const rowEl = e.target.closest('[data-key]'); + const checkWrap = e.target.closest('.col-check, .item-check-wrap'); + const rowCheck = e.target.closest('.row-check, .item-check') + || checkWrap?.querySelector('.row-check, .item-check'); + + if (rowCheck && rowEl && e.shiftKey) { + e.preventDefault(); + const nextChecked = !selected.has(rowEl.dataset.key); + rowCheck.checked = nextChecked; + syncCheckboxSelection(rowCheck, true); + fileTreeEl.focus(); + return; + } + + if (rowCheck || checkWrap) { + return; + } + + const infoBtn = e.target.closest('[data-info]'); + if (infoBtn) { + openDetail(decodeURIComponent(infoBtn.getAttribute('data-info'))); + return; + } + + const delBtn = e.target.closest('[data-del]'); + if (delBtn) { + await deleteKeys([decodeURIComponent(delBtn.getAttribute('data-del'))]); + return; + } + + const link = e.target.closest('[data-name]'); + if (link && link.dataset.preview === '1') { + e.preventDefault(); + openPreview(link.dataset.name); + return; + } + + if (rowEl && !e.target.closest('.col-check, .item-check-wrap, .col-actions, .icon-actions, [data-name], .rename-input')) { + const key = rowEl.dataset.key; + const index = visibleKeys.indexOf(key); + handleRowSelect(e, key, index); + fileTreeEl.focus(); + } + }); + + fileTreeEl.addEventListener('dblclick', (e) => { + const folderRow = e.target.closest('[data-folder]'); + if (folderRow && !e.target.closest('.col-check, .item-check-wrap, .col-actions, .icon-actions, .row-check, .item-check')) { + enterFolder(folderRow.getAttribute('data-folder')); + } + }); + + fileTreeEl.addEventListener('keydown', (e) => { + if (renameKey) return; + + if (e.key === 'F2' && selected.size === 1) { + e.preventDefault(); + startRename([...selected][0]); + return; + } + + if (e.key === ' ' && selected.size === 1) { + e.preventDefault(); + openDetail([...selected][0]); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { + e.preventDefault(); + selectAllVisible(); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') { + e.preventDefault(); + if (selected.size) internalClipboard = { mode: 'copy', items: [...selected] }; + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'x') { + e.preventDefault(); + if (selected.size) internalClipboard = { mode: 'cut', items: [...selected] }; + return; + } + + /* ⌘V 交给 document paste 事件:同时支持桌面文件粘贴与内部剪切/复制 */ + + if (e.key === 'Escape') { + e.preventDefault(); + if (selected.size) clearSelection(); + else if (currentPath) { + const parts = currentPath.split('/'); + parts.pop(); + enterFolder(parts.join('/')); + } + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + if (selected.size) { + e.preventDefault(); + deleteKeys(selected); + } + } + }); + + fileTreeEl.addEventListener('blur', (e) => { + const input = e.target.closest?.('.rename-input'); + if (input) { + commitRename(input.dataset.rename, input.value); + } + }, true); + + fileTreeEl.addEventListener('keydown', (e) => { + const input = e.target.closest?.('.rename-input'); + if (!input) return; + if (e.key === 'Enter') { + e.preventDefault(); + commitRename(input.dataset.rename, input.value); + } else if (e.key === 'Escape') { + e.preventDefault(); + renameKey = null; + renderFiles(currentFiles, { force: true }); + } + }, true); + + document.addEventListener('paste', (e) => { + if (document.activeElement?.closest('.rename-input, input, textarea')) return; + + const { syncFiles, asyncEntries } = extractDataTransferSync(e.clipboardData); + if (syncFiles.length || asyncEntries.length) { + e.preventDefault(); + void (async () => { + const files = [...syncFiles]; + for (const entry of asyncEntries) { + files.push(...await collectEntry(entry)); + } + if (files.length) await uploadFiles(files); + else setStatus('无法读取剪贴板文件,请改用拖放或「选择文件」', 'err'); + })(); + return; + } + + if (internalClipboard?.items?.length) { + e.preventDefault(); + void pasteClipboard(); + return; + } + + const types = [...(e.clipboardData?.types || [])]; + if (types.includes('Files') || types.some((t) => t.includes('uri-list'))) { + setStatus('浏览器无法读取 Finder 复制的文件,请直接拖放到页面', 'err'); + } + }, true); + + selectAllCheck.addEventListener('change', () => { + if (selectAllCheck.checked) selectAllVisible(); + else clearSelection(); + fileTreeEl.focus(); + }); + + bulkDeleteBtn.addEventListener('click', () => deleteKeys(selected)); + downloadZipBtn.addEventListener('click', () => downloadSelectedZip()); + viewListBtn.addEventListener('click', () => setViewMode('list')); + viewIconsBtn.addEventListener('click', () => setViewMode('icons')); + $('newFolderBtn').addEventListener('click', () => createFolder()); + $('refreshBtn').addEventListener('click', () => { lastSnapshot = ''; refreshFiles(); }); + $('pickFiles').onclick = () => fileInput.click(); + $('pickFolder').onclick = () => folderInput.click(); + fileInput.onchange = () => uploadFiles(fileInput.files); + folderInput.onchange = () => uploadFiles(folderInput.files); + + $('previewClose').onclick = () => previewModal.classList.remove('show'); + previewModal.addEventListener('click', (e) => { if (e.target === previewModal) previewModal.classList.remove('show'); }); + $('detailClose').onclick = () => detailModal.classList.remove('show'); + detailModal.addEventListener('click', (e) => { if (e.target === detailModal) detailModal.classList.remove('show'); }); + + $('conflictClose').onclick = () => closeConflictModal(false); + $('conflictCancel').onclick = () => closeConflictModal(false); + $('conflictConfirm').onclick = () => closeConflictModal(true); + + $('trashBtn').addEventListener('click', openTrash); + $('trashClose').addEventListener('click', closeTrash); + trashBackdrop.addEventListener('click', closeTrash); + $('trashRefreshBtn').addEventListener('click', loadTrash); + $('trashEmptyBtn').addEventListener('click', emptyTrash); + + trashBody.addEventListener('click', (e) => { + const restoreBtn = e.target.closest('[data-restore]'); + if (restoreBtn) { restoreTrashItem(restoreBtn.getAttribute('data-restore')); return; } + const purgeBtn = e.target.closest('[data-purge]'); + if (purgeBtn) { purgeTrashItem(purgeBtn.getAttribute('data-purge')); } + }); + } + + /* ── Init ── */ + bindEvents(); + initPanelResize(); + setViewMode(viewMode); + void (async () => { + await loadInfo(); + await refreshFiles(); + connectEvents(); + })(); +})(); diff --git a/public/oa-drive/favicon.svg b/public/oa-drive/favicon.svg new file mode 100644 index 0000000..051c18c --- /dev/null +++ b/public/oa-drive/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/oa-drive/index.html b/public/oa-drive/index.html new file mode 100644 index 0000000..cb4c54d --- /dev/null +++ b/public/oa-drive/index.html @@ -0,0 +1,159 @@ + + + + + + OA 网盘 · Memind + + + + +
+
+ OA 网盘 + 云端同步 · 拖放/⌘V粘贴上传 + +
+ + +
+ + +
+ +
+
+
+
+ 上传中… + +
+
+
+ +
+
+
+ + 共享文件 0 个 + 连接中… + +
+ + +
+ + +
+ + + + + + + +
+
+ +
+
+
+
名称
+
大小
+
修改日期
+
+
+
+
+
+ +
+
+ + +
+
+
📂
+
松手移入
+
释放以上传文件
+
+
+ + + + + + + + + + + +
+ + + + + diff --git a/server.mjs b/server.mjs index 63f2fae..f4d323b 100644 --- a/server.mjs +++ b/server.mjs @@ -55,6 +55,8 @@ import { attachPortalMindSpacePageCoreRoutes } from './server/portal-mindspace-p import { attachPortalMindSpacePagePublishRoutes } from './server/portal-mindspace-page-publish-routes.mjs'; import { attachPortalMindSpaceWechatRoutes } from './server/portal-mindspace-wechat-routes.mjs'; import { attachPortalMindSpaceSpaceRoutes } from './server/portal-mindspace-space-routes.mjs'; +import { attachPortalMindSpaceOaDriveRoutes } from './server/portal-mindspace-oa-drive-routes.mjs'; +import { createMindSpaceOaDriveService } from './mindspace-oa-drive/drive-service.mjs'; import { createMindSpaceWechatMpConfigService } from './mindspace-wechat-mp-config.mjs'; import { createMindSpaceWechatPageDraftService } from './mindspace-wechat-page-draft.mjs'; import { attachPortalTemplateCatalogRoutes } from './server/portal-template-catalog-routes.mjs'; @@ -335,6 +337,7 @@ let plazaSeo = null; let plazaOps = null; let plazaRedis = createNoopPlazaRedis(); let mindSpaceCleanup = null; +let mindSpaceOaDrive = null; let mindSpaceAgentJobs = null; let mindSpaceAgentRunner = null; let rechargeService = null; @@ -451,6 +454,12 @@ async function bootstrapUserAuth() { plazaPosts = domainServices.plazaPosts; plazaOps = domainServices.plazaOps; mindSpaceCleanup = domainServices.mindSpaceCleanup; + mindSpaceOaDrive = createMindSpaceOaDriveService({ + h5Root: H5_ROOT, + getMindSpace: () => mindSpace, + getMindSpaceAssets: () => mindSpaceAssets, + getPool: () => pool, + }); mindSpaceWechatMpConfig = createMindSpaceWechatMpConfigService(pool, { env: process.env, }); @@ -978,6 +987,13 @@ attachPortalMindSpaceSpaceRoutes(api, { handleMindSpaceError: mindSpaceError, }); +attachPortalMindSpaceOaDriveRoutes(api, { + getOaDriveService: () => mindSpaceOaDrive, + ensureMindSpaceEnabled, + rawUploadBody, + handleMindSpaceError: mindSpaceError, +}); + attachPortalTemplateCatalogRoutes(api, { getTemplateCatalog: () => templateCatalogService, ensureMindSpaceEnabled, diff --git a/server/portal-mindspace-oa-drive-routes.mjs b/server/portal-mindspace-oa-drive-routes.mjs new file mode 100644 index 0000000..a4e5a12 --- /dev/null +++ b/server/portal-mindspace-oa-drive-routes.mjs @@ -0,0 +1,59 @@ +function assertRouter(api) { + if ( + !api + || typeof api.use !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceOaDriveRoutes requires an Express-compatible router', + ); + } +} + +function isOaDrivePath(req) { + return req.path === '/mindspace/v1/oa/drive' + || req.path.startsWith('/mindspace/v1/oa/drive/'); +} + +function needsRawBody(req) { + if (req.method === 'PATCH') return true; + if (req.method === 'POST' && req.path === '/mindspace/v1/oa/drive/upload') return true; + return false; +} + +export function attachPortalMindSpaceOaDriveRoutes( + api, + { + getOaDriveService = () => null, + ensureMindSpaceEnabled = () => false, + rawUploadBody = (_req, _res, next) => next(), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + } = {}, +) { + assertRouter(api); + + api.use(async (req, res, next) => { + if (!isOaDrivePath(req)) return next(); + if (!ensureMindSpaceEnabled(res, req, { upload: true })) return; + const service = getOaDriveService(); + if (!service) { + return res.status(503).json({ error: { code: 'feature_disabled', message: 'OA 网盘未启用' } }); + } + + const run = async () => { + try { + await service.handlers.handle(req, res); + } catch (error) { + handleMindSpaceError(res, req, error); + } + }; + + if (needsRawBody(req)) { + return rawUploadBody(req, res, () => { + void run(); + }); + } + return run(); + }); +} diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 53639f9..7e942a4 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -512,6 +512,7 @@ type MindSpaceRouteSync = { pushWechatConfig: () => void; pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void; pushPage: (pageId: string) => void; + pushOaDrive?: () => void; }; function MindSpaceBalanceRing() { @@ -2862,15 +2863,26 @@ export function MindSpaceView({

{selectedCategory.name}

- {['oa', 'public', 'health'].includes(selectedCategory.code) && ( - - )} +
+ {selectedCategory.code === 'oa' && routeSync?.pushOaDrive && ( + + )} + {['oa', 'public', 'health'].includes(selectedCategory.code) && ( + + )} +
{selectedCategory.code === 'health' ? ( diff --git a/src/index.css b/src/index.css index 9a2f863..ad02a7b 100644 --- a/src/index.css +++ b/src/index.css @@ -12898,3 +12898,31 @@ body, max-width: 58%; } } + +.mindspace-category-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.oa-drive-shell { + display: flex; + flex-direction: column; + height: 100dvh; + background: #f4f1ea; +} + +.oa-drive-toolbar { + flex: 0 0 auto; + padding: 10px 16px; + border-bottom: 1px solid rgba(24, 33, 29, 0.08); + background: #fffaf0; +} + +.oa-drive-frame { + flex: 1 1 auto; + width: 100%; + border: 0; + background: #fff; +} diff --git a/src/routes/MindSpaceRoute.tsx b/src/routes/MindSpaceRoute.tsx index cc3e841..88c618f 100644 --- a/src/routes/MindSpaceRoute.tsx +++ b/src/routes/MindSpaceRoute.tsx @@ -1,5 +1,6 @@ import { matchPath, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { MindSpaceView } from '../components/MindSpaceView'; +import { OaDriveRoute } from './OaDriveRoute'; import type { MindSpaceSaveCategory, PortalUser } from '../types'; const CATEGORY_CODES = new Set(['draft', 'oa', 'private', 'public', 'health']); @@ -28,9 +29,14 @@ export function MindSpaceRoute({ const pageMatch = matchPath('/space/page/:pageId', location.pathname); const achievementsMatch = matchPath('/space/achievements', location.pathname); const wechatConfigMatch = matchPath('/space/wechat-config', location.pathname); + const oaDriveMatch = matchPath('/space/oa/drive', location.pathname); const pageId = pageMatch?.params.pageId ?? null; const categoryCode = parseCategory(searchParams.get('category'), healthEnabled); + if (oaDriveMatch) { + return ; + } + return ( navigate('/space/wechat-config'), pushCategory: (code) => navigate(`/space?category=${code}`), pushPage: (id) => navigate(`/space/page/${id}`), + pushOaDrive: () => navigate('/space/oa/drive'), }} /> ); diff --git a/src/routes/OaDriveRoute.tsx b/src/routes/OaDriveRoute.tsx new file mode 100644 index 0000000..c2eb6db --- /dev/null +++ b/src/routes/OaDriveRoute.tsx @@ -0,0 +1,29 @@ +import { Navigate, useNavigate } from 'react-router-dom'; +import type { PortalUser } from '../types'; + +export function OaDriveRoute({ + user, +}: { + user: PortalUser | null; +}) { + const navigate = useNavigate(); + + if (!user) { + return ; + } + + return ( +
+
+ +
+