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 `
+
${escapeHtml(text)}`;
+ });
+ } else {
+ window.open(url, '_blank');
+ return;
+ }
+ previewModal.classList.add('show');
+ }
+
+ /* ── Trash ── */
+ async function loadTrash() {
+ trashBody.innerHTML = '以下文件已存在,请选择处理方式:
+ +