Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ IFCCONVERT_BINARY=IfcConvert
BUILDINGSMART_VALIDATE_URL=http://buildingsmart-validate:8080
BUILDINGSMART_VALIDATE_OPERATION_PATH=/api/validate
BUILDINGSMART_VALIDATE_TOKEN=REPLACE_IF_ENABLED
IFCDB_AGENT_URL=http://ifcdb-agent:8080
IFCDB_AGENT_VERSION=v1.0.9
IFCDB_AGENT_HEALTH_PATH=/health
IFCDB_AGENT_INDEX_PATH=/api/v1/ifcdb/index
IFCDB_AGENT_QUERY_PATH=/api/v1/ifcdb/query
IFCDB_AGENT_EXPORT_PATH=/api/v1/ifcdb/export
IFCDB_AGENT_CLASH_PATH=/api/v1/ifcdb/clash
IFCDB_AGENT_QUANTITY_PATH=/api/v1/ifcdb/quantity
OCCT_WORKER_URL=http://occt-worker:8080
CADQUERY_WORKER_URL=http://cadquery-worker:8080
FREECAD_WORKER_URL=http://freecad-worker:8080
Expand Down
238 changes: 206 additions & 32 deletions 03-frontend/components/ModuleFileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { FileOperationDialog, type FileDialogMode, type FileDialogPayload } from
import { FilePreviewDrawer } from '@/components/FilePreviewDrawer';
import { LocalFileUploader } from '@/components/LocalFileUploader';
import { moduleBackendAdapter, type ModuleBackendSnapshot } from '@/lib/module-backend-adapter';
import { isBackendModuleFileId, moduleFileApiClient } from '@/lib/module-file-api-client';
import {
architokenOpenFileEventName,
architokenPendingOpenFileKey,
Expand Down Expand Up @@ -63,6 +64,19 @@ const statusLabels: Record<ModuleFileNode['status'], string> = {
archived: '已归档',
};

function isBackendBackedNode(node: ModuleFileNode | null): boolean {
return Boolean(
node && (node.source === 'backend' || isBackendModuleFileId(node.id)),
);
}

function backendErrorSummary(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return '后端 CDE 请求失败';
}

export function ModuleFileExplorer({
spec,
onAudit,
Expand Down Expand Up @@ -104,6 +118,42 @@ export function ModuleFileExplorer({
setSnapshot(moduleBackendAdapter.snapshot(spec.id));
}, [spec.id]);

useEffect(() => {
let cancelled = false;

async function hydrateBackendFiles() {
try {
const payload = await moduleFileApiClient.listModuleFiles(spec.id, {
limit: 500,
});
if (cancelled || payload.files.length === 0) {
return;
}

const result = moduleBackendAdapter.replaceModuleFilesFromBackend(
spec.id,
payload.files,
);
if (cancelled || result.count === 0) {
return;
}

onAudit?.(result.auditEvent);
setActionMessage(`已同步后端 CDE 文件 ${result.count} 项。`);
setSnapshot(moduleBackendAdapter.snapshot(spec.id));
} catch {
// Backend CDE sync is authoritative in production, but local dev and
// isolated UI tests still use the session adapter as a fallback.
}
}

void hydrateBackendFiles();

return () => {
cancelled = true;
};
}, [onAudit, spec.id]);

useEffect(() => {
let cancelled = false;

Expand Down Expand Up @@ -416,17 +466,46 @@ export function ModuleFileExplorer({
async function confirmDialog(payload: FileDialogPayload) {
const parentId = dialogTarget?.type === 'folder' ? dialogTarget.id : currentFolderId;
const name = payload.name?.trim();
const parentIsBackendWritable =
parentId === rootId || isBackendModuleFileId(parentId);

if (dialogMode === 'new') {
const result = moduleBackendAdapter.createFile({
moduleId: spec.id,
parentId,
name: name || (payload.nodeType === 'file' ? '新建文件.md' : '新建文件夹'),
type: payload.nodeType ?? 'folder',
});
setSelectedNodeId(result.node.id);
setActionMessage(`已新建 ${result.node.type === 'folder' ? '文件夹' : '文件'}: ${result.node.name}`);
record(result.auditEvent);
const nodeType = payload.nodeType ?? 'folder';
const nodeName = name || (nodeType === 'file' ? '新建文件.md' : '新建文件夹');
let handled = false;
if (parentIsBackendWritable) {
try {
const backendNode = await moduleFileApiClient.createModuleFile({
moduleId: spec.id,
parentId,
name: nodeName,
kind: nodeType,
owner: '当前用户',
tags: [nodeType, 'frontend-cde'],
});
const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode);
setSelectedNodeId(result.node.id);
setActionMessage(`已写入后端 CDE 并新建 ${result.node.type === 'folder' ? '文件夹' : '文件'}: ${result.node.name}`);
record(result.auditEvent);
handled = true;
} catch (error) {
if (isBackendModuleFileId(parentId)) {
setActionMessage(`新建未写入后端 CDE: ${backendErrorSummary(error)}`);
handled = true;
}
}
}
if (!handled) {
const result = moduleBackendAdapter.createFile({
moduleId: spec.id,
parentId,
name: nodeName,
type: nodeType,
});
setSelectedNodeId(result.node.id);
setActionMessage(`已新建 ${result.node.type === 'folder' ? '文件夹' : '文件'}: ${result.node.name}`);
record(result.auditEvent);
}
}
if (dialogMode === 'upload') {
if (payload.file) {
Expand All @@ -436,22 +515,97 @@ export function ModuleFileExplorer({
}
}
if (dialogMode === 'move' && dialogTarget && payload.targetParentId) {
const result = moduleBackendAdapter.moveFile(dialogTarget.id, payload.targetParentId);
setSelectedNodeId(result.node.id);
setActionMessage(`已移动: ${result.node.name}`);
record(result.auditEvent);
const targetIsBackendWritable =
payload.targetParentId === rootId ||
isBackendModuleFileId(payload.targetParentId);
let handled = false;
if (isBackendBackedNode(dialogTarget) && targetIsBackendWritable) {
try {
const backendNode = await moduleFileApiClient.moveModuleFile(
dialogTarget.id,
{
moduleId: spec.id,
targetParentId: payload.targetParentId,
actor: 'FileExplorer',
},
);
const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode);
setSelectedNodeId(result.node.id);
setActionMessage(`已移动并同步后端 CDE: ${result.node.name}`);
record(result.auditEvent);
handled = true;
} catch (error) {
setActionMessage(`移动未写入后端 CDE: ${backendErrorSummary(error)}`);
handled = true;
}
} else if (isBackendBackedNode(dialogTarget)) {
setActionMessage('移动未执行: 目标目录不是后端 CDE 节点。');
handled = true;
}
if (!handled) {
const result = moduleBackendAdapter.moveFile(dialogTarget.id, payload.targetParentId);
setSelectedNodeId(result.node.id);
setActionMessage(`已移动: ${result.node.name}`);
record(result.auditEvent);
}
}
if (dialogMode === 'rename' && dialogTarget && name) {
const result = moduleBackendAdapter.renameFile(dialogTarget.id, name);
setSelectedNodeId(result.node.id);
setActionMessage(`已重命名为: ${result.node.name}`);
record(result.auditEvent);
let handled = false;
if (isBackendBackedNode(dialogTarget)) {
try {
const backendNode = await moduleFileApiClient.updateModuleFile(
dialogTarget.id,
{ name },
);
const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode);
setSelectedNodeId(result.node.id);
setActionMessage(`已重命名并同步后端 CDE: ${result.node.name}`);
record(result.auditEvent);
handled = true;
} catch (error) {
setActionMessage(`重命名未写入后端 CDE: ${backendErrorSummary(error)}`);
handled = true;
}
}
if (!handled) {
const result = moduleBackendAdapter.renameFile(dialogTarget.id, name);
setSelectedNodeId(result.node.id);
setActionMessage(`已重命名为: ${result.node.name}`);
record(result.auditEvent);
}
}
if (dialogMode === 'share' && dialogTarget) {
const result = moduleBackendAdapter.shareFile(dialogTarget.id);
setLastShareLink(result.link);
setActionMessage(`分享链接已生成: ${result.link.fileName}`);
record(result.auditEvent);
let handled = false;
if (isBackendBackedNode(dialogTarget)) {
try {
const share = await moduleFileApiClient.shareModuleFile(
dialogTarget.id,
['read', 'share'],
'FileExplorer',
);
const backendNode = await moduleFileApiClient.getModuleFile(dialogTarget.id);
const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode);
setLastShareLink({
id: `backend-share-${share.fileId}`,
fileId: share.fileId,
fileName: result.node.name,
url: share.shareUrl,
createdAt: new Date().toISOString(),
});
setActionMessage(`后端 CDE 分享链接已生成: ${result.node.name}`);
record(result.auditEvent);
handled = true;
} catch (error) {
setActionMessage(`分享未写入后端 CDE: ${backendErrorSummary(error)}`);
handled = true;
}
}
if (!handled) {
const result = moduleBackendAdapter.shareFile(dialogTarget.id);
setLastShareLink(result.link);
setActionMessage(`分享链接已生成: ${result.link.fileName}`);
record(result.auditEvent);
}
}
if (dialogMode === 'delete' && dialogTarget) {
if (dialogTarget.localFileId) {
Expand All @@ -463,18 +617,38 @@ export function ModuleFileExplorer({
throw new Error(`Delete failed: ${response.status}`);
}
}
const result = moduleBackendAdapter.deleteFile(dialogTarget.id);
setSelectedNodeId((current) => (current === dialogTarget.id ? null : current));
if (previewNode?.id === dialogTarget.id) {
setPreviewNode(null);
setFullView(false);
let handled = false;
if (isBackendBackedNode(dialogTarget)) {
try {
const backendNode = await moduleFileApiClient.trashModuleFile(dialogTarget.id);
const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode);
setSelectedNodeId((current) => (current === dialogTarget.id ? null : current));
if (previewNode?.id === dialogTarget.id) {
setPreviewNode(null);
setFullView(false);
}
setActionMessage(`${result.node.name} 已移入后端 CDE 回收站。`);
record(result.auditEvent);
handled = true;
} catch (error) {
setActionMessage(`删除未写入后端 CDE: ${backendErrorSummary(error)}`);
handled = true;
}
}
if (!handled) {
const result = moduleBackendAdapter.deleteFile(dialogTarget.id);
setSelectedNodeId((current) => (current === dialogTarget.id ? null : current));
if (previewNode?.id === dialogTarget.id) {
setPreviewNode(null);
setFullView(false);
}
setActionMessage(
dialogTarget.localFileId
? `${result.node.name} 已从本地运行索引和当前目录删除。`
: `${result.node.name} 已移入回收站。`,
);
record(result.auditEvent);
}
setActionMessage(
dialogTarget.localFileId
? `${result.node.name} 已从本地运行索引和当前目录删除。`
: `${result.node.name} 已移入回收站。`,
);
record(result.auditEvent);
}

setDialogMode(null);
Expand Down
4 changes: 2 additions & 2 deletions 03-frontend/lib/adapter-source-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1759,7 +1759,7 @@ export const adapterSourceRegistry = [
'selected_external_process',
'active',
'LGPL/GPL depending on build',
['.mp4', '.webm', '.mov', '.mkv', '.wav', '.mp3', '.flac', '.ogg', '.webp', '.gif'],
['.mp4', '.webm', '.mov', '.mkv', '.avi', '.wav', '.mp3', '.m4a', '.flac', '.ogg', '.webp', '.gif'],
['import', 'export'],
'Selected media transcode engine through external-process worker isolation.',
),
Expand Down Expand Up @@ -1862,7 +1862,7 @@ export const formatAdapterRequirements = [
),
requirement(
'video',
['.mp4', '.webm', '.mov', '.mkv'],
['.mp4', '.webm', '.mov', '.mkv', '.avi'],
['browser video viewer', 'transcode worker', 'video generation provider'],
['ffmpeg', 'blender', 'blender-mcp'],
'direct_browser',
Expand Down
51 changes: 51 additions & 0 deletions 03-frontend/lib/file-type-registry.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading