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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#5b8cff"/>
|
||||
<path fill="#fff" d="M8 10h10l3 3h3v11H8z"/>
|
||||
<path fill="#3dd68c" d="M18 10v3h3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 210 B |
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OA 网盘 · Memind</title>
|
||||
<link rel="icon" href="/oa-drive/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/oa-drive/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="top-bar drop" id="dropZone" tabindex="0" title="拖放文件到此处上传">
|
||||
<span class="top-title">OA 网盘</span>
|
||||
<span class="top-sub">云端同步 · 拖放/<kbd>⌘V</kbd>粘贴上传</span>
|
||||
<div class="links" id="links"></div>
|
||||
<div class="top-actions">
|
||||
<button type="button" class="primary" id="pickFiles">选择文件</button>
|
||||
<button type="button" id="pickFolder">选择文件夹</button>
|
||||
</div>
|
||||
<input type="file" id="fileInput" multiple />
|
||||
<input type="file" id="folderInput" webkitdirectory directory multiple />
|
||||
</div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
<div class="progress" id="progress"><i></i></div>
|
||||
<div class="progress-detail" id="progressDetail">
|
||||
<div class="progress-detail-head">
|
||||
<strong id="progressSummary">上传中…</strong>
|
||||
<span id="progressSpeed">—</span>
|
||||
</div>
|
||||
<div class="progress-detail-list" id="progressList"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-shell" id="filePanelShell">
|
||||
<div class="panel" id="filePanel">
|
||||
<div class="panel-head">
|
||||
<span>
|
||||
共享文件 <small id="fileCount">0 个</small>
|
||||
<span class="sync-badge" id="syncBadge"><span class="dot"></span><span id="syncText">连接中…</span></span>
|
||||
</span>
|
||||
<div class="bulk-actions">
|
||||
<div class="toolbar-search">
|
||||
<input type="search" id="searchInput" placeholder="搜索文件名…" autocomplete="off" />
|
||||
</div>
|
||||
<select class="conflict-select" id="conflictSelect" title="冲突策略">
|
||||
<option value="rename">冲突:重命名</option>
|
||||
<option value="overwrite">冲突:覆盖</option>
|
||||
<option value="skip">冲突:跳过</option>
|
||||
</select>
|
||||
<div class="view-toggle" role="group" aria-label="视图切换">
|
||||
<button type="button" id="viewListBtn" class="active" title="列表视图">☰</button>
|
||||
<button type="button" id="viewIconsBtn" title="图标视图">▦</button>
|
||||
</div>
|
||||
<label class="select-all-label">
|
||||
<input type="checkbox" id="selectAllCheck" title="全选" aria-label="全选" /> 全选
|
||||
</label>
|
||||
<button type="button" id="downloadZipBtn" disabled title="打包下载所选">打包下载</button>
|
||||
<button type="button" id="newFolderBtn">新建文件夹</button>
|
||||
<button type="button" class="danger" id="bulkDeleteBtn" disabled>删除所选</button>
|
||||
<button type="button" id="trashBtn" title="回收站">回收站</button>
|
||||
<button type="button" class="notify-btn" id="notifyBtn" title="开启桌面通知">🔔</button>
|
||||
<button type="button" id="refreshBtn">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
<div class="file-list" id="fileList">
|
||||
<div class="list-header">
|
||||
<div class="col-check"></div>
|
||||
<div class="col-name"><span class="sortable active" data-sort="name">名称 <span class="sort-mark">▲</span></span></div>
|
||||
<div class="col-size"><span class="sortable" data-sort="size">大小</span></div>
|
||||
<div class="col-date"><span class="sortable" data-sort="mtime">修改日期</span></div>
|
||||
<div class="col-actions"></div>
|
||||
</div>
|
||||
<div class="list-body" id="fileTree" tabindex="0" aria-label="文件列表"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-resizer" id="panelResizer" title="拖动调整窗口大小" role="separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag overlay -->
|
||||
<div class="drag-overlay" id="dragOverlay">
|
||||
<div class="drag-overlay-box">
|
||||
<div class="icon">📂</div>
|
||||
<div class="hint" id="dragOverlayHint">松手移入</div>
|
||||
<div class="sub" id="dragOverlaySub">释放以上传文件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview modal -->
|
||||
<div class="modal" id="previewModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head">
|
||||
<span id="previewTitle"></span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<a class="btn" id="previewDownload" href="#">下载</a>
|
||||
<a class="btn" id="previewOpen" href="#" target="_blank" rel="noopener">新标签打开</a>
|
||||
<button type="button" id="previewClose">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" id="previewBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div class="modal" id="detailModal">
|
||||
<div class="modal-box sm">
|
||||
<div class="modal-head">
|
||||
<span id="detailTitle">文件详情</span>
|
||||
<button type="button" id="detailClose">关闭</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<dl class="detail-grid" id="detailGrid"></dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict modal -->
|
||||
<div class="modal" id="conflictModal">
|
||||
<div class="modal-box sm">
|
||||
<div class="modal-head">
|
||||
<span>文件冲突</span>
|
||||
<button type="button" id="conflictClose">关闭</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="conflictDesc">以下文件已存在,请选择处理方式:</p>
|
||||
<div class="conflict-list" id="conflictList"></div>
|
||||
<div class="conflict-actions">
|
||||
<label><input type="radio" name="conflictChoice" value="rename" checked /> 重命名</label>
|
||||
<label><input type="radio" name="conflictChoice" value="overwrite" /> 覆盖</label>
|
||||
<label><input type="radio" name="conflictChoice" value="skip" /> 跳过</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" id="conflictCancel">取消</button>
|
||||
<button type="button" class="primary" id="conflictConfirm">继续</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trash drawer -->
|
||||
<div class="trash-backdrop" id="trashBackdrop"></div>
|
||||
<aside class="trash-drawer" id="trashDrawer">
|
||||
<div class="trash-drawer-head">
|
||||
<span>回收站</span>
|
||||
<button type="button" id="trashClose">关闭</button>
|
||||
</div>
|
||||
<div class="trash-drawer-body" id="trashBody">
|
||||
<div class="trash-empty">加载中…</div>
|
||||
</div>
|
||||
<div class="trash-drawer-foot">
|
||||
<button type="button" id="trashEmptyBtn">清空回收站</button>
|
||||
<button type="button" id="trashRefreshBtn">刷新</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script src="/oa-drive/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+16
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
<h2>{selectedCategory.name}</h2>
|
||||
</div>
|
||||
{['oa', 'public', 'health'].includes(selectedCategory.code) && (
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-primary"
|
||||
onClick={() => openUpload(selectedCategory)}
|
||||
>
|
||||
{selectedCategory.code === 'health' ? '上传报告' : '上传资料'}
|
||||
</button>
|
||||
)}
|
||||
<div className="mindspace-category-actions">
|
||||
{selectedCategory.code === 'oa' && routeSync?.pushOaDrive && (
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-secondary"
|
||||
onClick={() => routeSync.pushOaDrive?.()}
|
||||
>
|
||||
文件管理
|
||||
</button>
|
||||
)}
|
||||
{['oa', 'public', 'health'].includes(selectedCategory.code) && (
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-primary"
|
||||
onClick={() => openUpload(selectedCategory)}
|
||||
>
|
||||
{selectedCategory.code === 'health' ? '上传报告' : '上传资料'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCategory.code === 'health' ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<MindSpaceSaveCategory | 'health'>(['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 <OaDriveRoute user={user} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MindSpaceView
|
||||
user={user}
|
||||
@@ -48,6 +54,7 @@ export function MindSpaceRoute({
|
||||
pushWechatConfig: () => navigate('/space/wechat-config'),
|
||||
pushCategory: (code) => navigate(`/space?category=${code}`),
|
||||
pushPage: (id) => navigate(`/space/page/${id}`),
|
||||
pushOaDrive: () => navigate('/space/oa/drive'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 <Navigate to="/?return_to=/space/oa/drive" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oa-drive-shell">
|
||||
<div className="oa-drive-toolbar">
|
||||
<button type="button" className="mindspace-inline-back" onClick={() => navigate('/space?category=oa')}>
|
||||
返回 OA 工作区
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
className="oa-drive-frame"
|
||||
title="OA 网盘"
|
||||
src="/oa-drive/index.html"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user