From 6d5eb15b1b44949fa3d9cb7b08c7c0375ca68d64 Mon Sep 17 00:00:00 2001
From: john
Date: Thu, 10 Sep 2026 18:55:46 +0800
Subject: [PATCH] feat(mindspace): add per-user OA cloud drive with fds-style
file manager
Expose authenticated /api/mindspace/v1/oa/drive APIs over each user's oa/ workspace and wire a MindSpace entry at /space/oa/drive.
Co-authored-by: Cursor
---
mindspace-oa-drive/drive-context.mjs | 32 +
mindspace-oa-drive/drive-defaults.mjs | 12 +
mindspace-oa-drive/drive-handlers.mjs | 444 +++++
mindspace-oa-drive/drive-runtime.mjs | 90 +
mindspace-oa-drive/drive-service.mjs | 84 +
mindspace-oa-drive/drive-service.test.mjs | 115 ++
mindspace-oa-drive/lib/fs-tree.mjs | 129 ++
mindspace-oa-drive/lib/paths.mjs | 60 +
mindspace-oa-drive/lib/transfer.mjs | 81 +
mindspace-oa-drive/lib/trash.mjs | 89 +
mindspace-oa-drive/lib/upload-resume.mjs | 84 +
mindspace-oa-drive/lib/zip.mjs | 28 +
public/oa-drive/app.css | 500 ++++++
public/oa-drive/app.js | 1761 +++++++++++++++++++
public/oa-drive/favicon.svg | 5 +
public/oa-drive/index.html | 159 ++
server.mjs | 16 +
server/portal-mindspace-oa-drive-routes.mjs | 59 +
src/components/MindSpaceView.tsx | 30 +-
src/index.css | 28 +
src/routes/MindSpaceRoute.tsx | 7 +
src/routes/OaDriveRoute.tsx | 29 +
22 files changed, 3833 insertions(+), 9 deletions(-)
create mode 100644 mindspace-oa-drive/drive-context.mjs
create mode 100644 mindspace-oa-drive/drive-defaults.mjs
create mode 100644 mindspace-oa-drive/drive-handlers.mjs
create mode 100644 mindspace-oa-drive/drive-runtime.mjs
create mode 100644 mindspace-oa-drive/drive-service.mjs
create mode 100644 mindspace-oa-drive/drive-service.test.mjs
create mode 100644 mindspace-oa-drive/lib/fs-tree.mjs
create mode 100644 mindspace-oa-drive/lib/paths.mjs
create mode 100644 mindspace-oa-drive/lib/transfer.mjs
create mode 100644 mindspace-oa-drive/lib/trash.mjs
create mode 100644 mindspace-oa-drive/lib/upload-resume.mjs
create mode 100644 mindspace-oa-drive/lib/zip.mjs
create mode 100644 public/oa-drive/app.css
create mode 100644 public/oa-drive/app.js
create mode 100644 public/oa-drive/favicon.svg
create mode 100644 public/oa-drive/index.html
create mode 100644 server/portal-mindspace-oa-drive-routes.mjs
create mode 100644 src/routes/OaDriveRoute.tsx
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 = `
`;
+ } 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 (
+
+
+
+
+
+
+ );
+}