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:
john
2026-09-10 18:55:46 +08:00
parent d07815d317
commit 6d5eb15b1b
22 changed files with 3833 additions and 9 deletions
+32
View File
@@ -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,
};
}
+12
View File
@@ -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,
};
+444
View File
@@ -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' });
}
},
};
}
+90
View File
@@ -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,
};
}
+84
View File
@@ -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,
};
}
+115
View File
@@ -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 });
});
+129
View File
@@ -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() };
}
+60
View File
@@ -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);
}
+81
View File
@@ -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 };
}
+89
View File
@@ -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 });
}
+84
View File
@@ -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);
}
+28
View File
@@ -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;
}
+500
View File
@@ -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
+5
View File
@@ -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

+159
View File
@@ -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
View File
@@ -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();
});
}
+21 -9
View File
@@ -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' ? (
+28
View File
@@ -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;
}
+7
View File
@@ -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'),
}}
/>
);
+29
View File
@@ -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>
);
}