feat(files): add v2 task and metadata workflows

This commit is contained in:
yoyuzh
2026-04-09 00:42:41 +08:00
parent c5362ebe31
commit 977eb60b17
60 changed files with 5218 additions and 72 deletions

View File

@@ -628,11 +628,13 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
});
}
export async function apiDownload(path: string) {
export async function apiDownload(path: string, init: ApiRequestInit = {}) {
const headers = new Headers(init.headers);
headers.set('Accept', '*/*');
const response = await performRequest(path, {
headers: {
Accept: '*/*',
},
...init,
headers,
});
if (!response.ok) {

View File

@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildBackgroundTasksPath,
cancelBackgroundTask,
createMediaMetadataTask,
getBackgroundTask,
listBackgroundTasks,
parseBackgroundTaskState,
} from './background-tasks';
test('buildBackgroundTasksPath defaults to the first ten tasks', () => {
assert.equal(buildBackgroundTasksPath(), '/tasks?page=0&size=10');
});
test('listBackgroundTasks requests the v2 task list and unwraps the page payload', async () => {
const originalFetch = globalThis.fetch;
try {
let requestUrl = '';
let requestMethod = '';
globalThis.fetch = async (input, init) => {
requestUrl = String(input);
requestMethod = init?.method || 'GET';
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
items: [
{
id: 1,
type: 'MEDIA_META',
status: 'QUEUED',
userId: 7,
publicStateJson: '{"fileId":1}',
correlationId: 'corr-1',
errorMessage: null,
createdAt: '2026-04-09T10:00:00',
updatedAt: '2026-04-09T10:00:00',
finishedAt: null,
},
],
total: 1,
page: 0,
size: 10,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
const payload = await listBackgroundTasks();
assert.equal(requestUrl, '/api/v2/tasks?page=0&size=10');
assert.equal(requestMethod, 'GET');
assert.deepEqual(payload, {
items: [
{
id: 1,
type: 'MEDIA_META',
status: 'QUEUED',
userId: 7,
publicStateJson: '{"fileId":1}',
correlationId: 'corr-1',
errorMessage: null,
createdAt: '2026-04-09T10:00:00',
updatedAt: '2026-04-09T10:00:00',
finishedAt: null,
},
],
total: 1,
page: 0,
size: 10,
});
} finally {
globalThis.fetch = originalFetch;
}
});
test('getBackgroundTask and cancelBackgroundTask hit the task detail endpoints', async () => {
const originalFetch = globalThis.fetch;
try {
const calls: Array<{url: string; method: string}> = [];
globalThis.fetch = async (input, init) => {
calls.push({
url: String(input),
method: init?.method || 'GET',
});
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
id: 123,
type: 'ARCHIVE',
status: 'COMPLETED',
userId: 7,
publicStateJson: '{"worker":"noop"}',
correlationId: null,
errorMessage: null,
createdAt: '2026-04-09T10:00:00',
updatedAt: '2026-04-09T10:00:00',
finishedAt: '2026-04-09T10:01:00',
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
await getBackgroundTask(123);
await cancelBackgroundTask(123);
assert.deepEqual(calls, [
{ url: '/api/v2/tasks/123', method: 'GET' },
{ url: '/api/v2/tasks/123', method: 'DELETE' },
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('createMediaMetadataTask sends the queued file task payload', async () => {
const originalFetch = globalThis.fetch;
try {
let requestUrl = '';
let requestMethod = '';
let requestBody = '';
globalThis.fetch = async (input, init) => {
requestUrl = String(input);
requestMethod = init?.method || 'GET';
requestBody = String(init?.body || '');
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
id: 123,
type: 'MEDIA_META',
status: 'QUEUED',
userId: 7,
publicStateJson: '{"fileId":9}',
correlationId: 'media-9',
errorMessage: null,
createdAt: '2026-04-09T10:00:00',
updatedAt: '2026-04-09T10:00:00',
finishedAt: null,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
const payload = await createMediaMetadataTask({
fileId: 9,
path: '/docs/photo.png',
correlationId: 'media-9',
});
assert.equal(requestUrl, '/api/v2/tasks/media-metadata');
assert.equal(requestMethod, 'POST');
assert.deepEqual(JSON.parse(requestBody), {
fileId: 9,
path: '/docs/photo.png',
correlationId: 'media-9',
});
assert.equal(payload.status, 'QUEUED');
} finally {
globalThis.fetch = originalFetch;
}
});
test('parseBackgroundTaskState handles valid and invalid JSON defensively', () => {
assert.deepEqual(parseBackgroundTaskState(null), {});
assert.deepEqual(parseBackgroundTaskState(''), {});
assert.deepEqual(parseBackgroundTaskState('not-json'), {});
assert.deepEqual(parseBackgroundTaskState('[]'), {});
assert.deepEqual(parseBackgroundTaskState('{"worker":"media-metadata","fileId":9}'), {
worker: 'media-metadata',
fileId: 9,
});
});

View File

@@ -0,0 +1,93 @@
import { apiV2Request } from './api';
import type { PageResponse } from './types';
export type BackgroundTaskType = 'ARCHIVE' | 'EXTRACT' | 'MEDIA_META';
export type BackgroundTaskStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
export interface BackgroundTask {
id: number;
type: BackgroundTaskType;
status: BackgroundTaskStatus;
userId: number;
publicStateJson: string;
correlationId: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
}
export type BackgroundTaskState = Record<string, unknown>;
export interface BackgroundTaskPage extends PageResponse<BackgroundTask> {}
export interface ListBackgroundTasksParams {
page?: number;
size?: number;
}
export interface CreateMediaMetadataTaskParams {
fileId: number;
path: string;
correlationId?: string;
}
function appendNumberParam(searchParams: URLSearchParams, key: string, value?: number) {
if (value === undefined || value === null || Number.isNaN(value)) {
return;
}
searchParams.set(key, String(value));
}
export function buildBackgroundTasksPath(params: ListBackgroundTasksParams = {}) {
const searchParams = new URLSearchParams();
appendNumberParam(searchParams, 'page', params.page ?? 0);
appendNumberParam(searchParams, 'size', params.size ?? 10);
const query = searchParams.toString();
return query ? `/tasks?${query}` : '/tasks';
}
export function listBackgroundTasks(params: ListBackgroundTasksParams = {}) {
return apiV2Request<BackgroundTaskPage>(buildBackgroundTasksPath(params));
}
export function getBackgroundTask(id: number) {
return apiV2Request<BackgroundTask>(`/tasks/${id}`);
}
export function cancelBackgroundTask(id: number) {
return apiV2Request<BackgroundTask>(`/tasks/${id}`, {
method: 'DELETE',
});
}
export function createMediaMetadataTask(params: CreateMediaMetadataTaskParams) {
return apiV2Request<BackgroundTask>('/tasks/media-metadata', {
method: 'POST',
body: {
fileId: params.fileId,
path: params.path,
correlationId: params.correlationId,
},
});
}
export function parseBackgroundTaskState(publicStateJson?: string | null): BackgroundTaskState {
if (!publicStateJson) {
return {};
}
try {
const parsed = JSON.parse(publicStateJson);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed as BackgroundTaskState;
} catch {
return {};
}
}

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildFileEventsPath, createSseEventParser, getFileEventsReconnectDelayMs } from './file-events';
test('buildFileEventsPath encodes the watched path', () => {
assert.equal(
buildFileEventsPath('/课程资料/2026 春'),
'/files/events?path=%2F%E8%AF%BE%E7%A8%8B%E8%B5%84%E6%96%99%2F2026+%E6%98%A5',
);
});
test('createSseEventParser ignores READY and returns file events', () => {
const parser = createSseEventParser();
const events = parser.push([
'event: READY',
'data: {"eventType":"READY","path":"/"}',
'',
'event: CREATED',
'data: {"eventType":"CREATED","fileId":42,"fromPath":null,"toPath":"/notes.txt","clientId":"other","createdAt":"2026-04-08T12:00:00","payload":"{}"}',
'',
'',
].join('\n'));
assert.deepEqual(events, [
{
eventType: 'CREATED',
fileId: 42,
fromPath: null,
toPath: '/notes.txt',
clientId: 'other',
createdAt: '2026-04-08T12:00:00',
payload: '{}',
},
]);
});
test('createSseEventParser keeps partial chunks until the event is complete', () => {
const parser = createSseEventParser();
assert.deepEqual(parser.push('event: RENAMED\ndata: {"eventType":"REN'), []);
assert.deepEqual(parser.push('AMED","fileId":7,"fromPath":"/old.txt","toPath":"/new.txt"}\n\n'), [
{
eventType: 'RENAMED',
fileId: 7,
fromPath: '/old.txt',
toPath: '/new.txt',
},
]);
});
test('getFileEventsReconnectDelayMs uses capped backoff for stream reconnects', () => {
assert.equal(getFileEventsReconnectDelayMs(0), 1000);
assert.equal(getFileEventsReconnectDelayMs(1), 1500);
assert.equal(getFileEventsReconnectDelayMs(2), 2250);
assert.equal(getFileEventsReconnectDelayMs(10), 5000);
});

View File

@@ -0,0 +1,220 @@
import { apiDownload } from './api';
export type FileEventType = 'CREATED' | 'UPDATED' | 'RENAMED' | 'MOVED' | 'DELETED' | 'RESTORED';
export interface FileEventMessage {
eventType: FileEventType;
fileId?: number | null;
fromPath?: string | null;
toPath?: string | null;
clientId?: string | null;
createdAt?: string;
payload?: unknown;
}
export interface FileEventsSubscription {
close: () => void;
}
interface SubscribeFileEventsOptions {
onError?: (error: unknown) => void;
onFileEvent: (event: FileEventMessage) => void;
path: string;
}
const READY_EVENT_TYPE = 'READY';
const FILE_EVENT_TYPES = new Set<FileEventType>([
'CREATED',
'UPDATED',
'RENAMED',
'MOVED',
'DELETED',
'RESTORED',
]);
const FILE_EVENTS_RECONNECT_INITIAL_DELAY_MS = 1000;
const FILE_EVENTS_RECONNECT_MAX_DELAY_MS = 5000;
const FILE_EVENTS_RECONNECT_MULTIPLIER = 1.5;
export function getFileEventsReconnectDelayMs(attempt: number) {
return Math.min(
Math.round(FILE_EVENTS_RECONNECT_INITIAL_DELAY_MS * FILE_EVENTS_RECONNECT_MULTIPLIER ** Math.max(0, attempt)),
FILE_EVENTS_RECONNECT_MAX_DELAY_MS,
);
}
function sleep(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const timeoutId = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, ms);
const handleAbort = () => {
clearTimeout(timeoutId);
signal.removeEventListener('abort', handleAbort);
reject(signal.reason);
};
signal.addEventListener('abort', handleAbort, { once: true });
});
}
export function buildFileEventsPath(path: string) {
const normalizedPath = path.trim() || '/';
const searchParams = new URLSearchParams({
path: normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`,
});
return `/files/events?${searchParams.toString()}`;
}
function parseSseBlock(block: string): FileEventMessage | null {
const dataLines: string[] = [];
let eventName = '';
for (const line of block.split('\n')) {
if (!line || line.startsWith(':')) {
continue;
}
const separatorIndex = line.indexOf(':');
const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line;
const rawValue = separatorIndex >= 0 ? line.slice(separatorIndex + 1) : '';
const value = rawValue.startsWith(' ') ? rawValue.slice(1) : rawValue;
if (field === 'event') {
eventName = value;
} else if (field === 'data') {
dataLines.push(value);
}
}
if (eventName === READY_EVENT_TYPE || dataLines.length === 0) {
return null;
}
const payload = JSON.parse(dataLines.join('\n')) as {
clientId?: string | null;
createdAt?: string;
eventType?: string;
fileId?: number | null;
fromPath?: string | null;
payload?: unknown;
toPath?: string | null;
};
if (payload.eventType === READY_EVENT_TYPE) {
return null;
}
const eventType = payload.eventType || eventName;
if (!FILE_EVENT_TYPES.has(eventType as FileEventType)) {
return null;
}
return {
...payload,
eventType: eventType as FileEventType,
};
}
export function createSseEventParser() {
let buffer = '';
return {
push(chunk: string) {
buffer += chunk.replace(/\r\n/g, '\n');
const events: FileEventMessage[] = [];
while (true) {
const eventBoundary = buffer.indexOf('\n\n');
if (eventBoundary < 0) {
break;
}
const block = buffer.slice(0, eventBoundary);
buffer = buffer.slice(eventBoundary + 2);
const event = parseSseBlock(block);
if (event) {
events.push(event);
}
}
return events;
},
};
}
export function subscribeFileEvents({
onError,
onFileEvent,
path,
}: SubscribeFileEventsOptions): FileEventsSubscription {
const abortController = new AbortController();
let closed = false;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
const readStream = async () => {
let reconnectAttempt = 0;
while (!closed) {
let streamHadData = false;
try {
const parser = createSseEventParser();
const response = await apiDownload(`/v2${buildFileEventsPath(path)}`, {
signal: abortController.signal,
});
if (!response.body) {
throw new Error('文件事件流不可用');
}
reader = response.body.getReader();
const decoder = new TextDecoder();
while (!closed) {
const { done, value } = await reader.read();
if (done) {
break;
}
streamHadData = true;
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
onFileEvent(event);
}
}
} catch (error) {
if (!closed) {
onError?.(error);
}
} finally {
reader?.releaseLock();
reader = null;
}
if (closed) {
break;
}
const nextAttempt = streamHadData ? 0 : reconnectAttempt;
await sleep(getFileEventsReconnectDelayMs(nextAttempt), abortController.signal).catch(() => undefined);
reconnectAttempt = streamHadData ? 0 : reconnectAttempt + 1;
if (abortController.signal.aborted) {
break;
}
}
};
void readStream();
return {
close() {
closed = true;
abortController.abort();
reader?.cancel().catch(() => undefined);
},
};
}

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildFileSearchPath, searchFiles } from './file-search';
test('buildFileSearchPath includes search filters and encodes values', () => {
assert.equal(
buildFileSearchPath({
name: '课件 2026',
type: 'folder',
sizeGte: 1024,
sizeLte: 4096,
createdGte: '2026-04-08T12:00:00',
updatedLte: '2026-04-08T23:59:59',
page: 2,
size: 50,
}),
'/files/search?name=%E8%AF%BE%E4%BB%B6+2026&type=folder&sizeGte=1024&sizeLte=4096&createdGte=2026-04-08T12%3A00%3A00&updatedLte=2026-04-08T23%3A59%3A59&page=2&size=50',
);
});
test('buildFileSearchPath skips empty filters', () => {
assert.equal(
buildFileSearchPath({
name: ' ',
type: 'all',
createdGte: '',
}),
'/files/search?type=all',
);
});
test('searchFiles uses the v2 search endpoint and unwraps the page payload', async () => {
const originalFetch = globalThis.fetch;
try {
let requestUrl = '';
globalThis.fetch = async (input) => {
requestUrl = String(input);
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
items: [
{
id: 1,
filename: '说明.txt',
path: '/',
size: 12,
contentType: 'text/plain',
directory: false,
createdAt: '2026-04-08T12:00:00',
},
],
total: 1,
page: 0,
size: 20,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
const payload = await searchFiles({
name: '说明',
type: 'file',
page: 0,
size: 20,
});
assert.equal(requestUrl, '/api/v2/files/search?name=%E8%AF%B4%E6%98%8E&type=file&page=0&size=20');
assert.deepEqual(payload, {
items: [
{
id: 1,
filename: '说明.txt',
path: '/',
size: 12,
contentType: 'text/plain',
directory: false,
createdAt: '2026-04-08T12:00:00',
},
],
total: 1,
page: 0,
size: 20,
});
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,56 @@
import { apiV2Request } from './api';
import type { FileMetadata, PageResponse } from './types';
export type FileSearchType = 'file' | 'directory' | 'folder' | 'all';
export interface FileSearchParams {
name?: string;
type?: FileSearchType;
sizeGte?: number;
sizeLte?: number;
createdGte?: string;
createdLte?: string;
updatedGte?: string;
updatedLte?: string;
page?: number;
size?: number;
}
function appendStringParam(searchParams: URLSearchParams, key: string, value?: string) {
const normalizedValue = value?.trim();
if (!normalizedValue) {
return;
}
searchParams.set(key, normalizedValue);
}
function appendNumberParam(searchParams: URLSearchParams, key: string, value?: number) {
if (value === undefined || value === null || Number.isNaN(value)) {
return;
}
searchParams.set(key, String(value));
}
export function buildFileSearchPath(params: FileSearchParams = {}) {
const searchParams = new URLSearchParams();
appendStringParam(searchParams, 'name', params.name);
appendStringParam(searchParams, 'type', params.type);
appendNumberParam(searchParams, 'sizeGte', params.sizeGte);
appendNumberParam(searchParams, 'sizeLte', params.sizeLte);
appendStringParam(searchParams, 'createdGte', params.createdGte);
appendStringParam(searchParams, 'createdLte', params.createdLte);
appendStringParam(searchParams, 'updatedGte', params.updatedGte);
appendStringParam(searchParams, 'updatedLte', params.updatedLte);
appendNumberParam(searchParams, 'page', params.page);
appendNumberParam(searchParams, 'size', params.size);
const query = searchParams.toString();
return query ? `/files/search?${query}` : '/files/search';
}
export function searchFiles(params: FileSearchParams = {}) {
return apiV2Request<PageResponse<FileMetadata>>(buildFileSearchPath(params));
}

View File

@@ -27,8 +27,9 @@ import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadReq
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { subscribeFileEvents } from '@/src/lib/file-events';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
@@ -192,6 +193,22 @@ export default function MobileFiles() {
}
}, []);
useEffect(() => {
const subscription = subscribeFileEvents({
path: toBackendPath(currentPath),
onFileEvent: () => {
const activePath = currentPathRef.current;
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
loadCurrentPath(activePath).catch(() => undefined);
},
onError: () => undefined,
});
return () => {
subscription.close();
};
}, [currentPath]);
const handleBreadcrumbClick = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
};

View File

@@ -29,8 +29,16 @@ import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadReq
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
import {
cancelBackgroundTask,
createMediaMetadataTask,
listBackgroundTasks,
type BackgroundTask,
} from '@/src/lib/background-tasks';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { subscribeFileEvents } from '@/src/lib/file-events';
import { searchFiles } from '@/src/lib/file-search';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
@@ -88,6 +96,10 @@ function toBackendPath(pathParts: string[]) {
return toDirectoryPath(pathParts);
}
function splitBackendPath(path: string) {
return path.split('/').filter(Boolean);
}
function DirectoryTreeItem({
node,
onSelect,
@@ -221,6 +233,18 @@ export default function Files() {
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchAppliedQuery, setSearchAppliedQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileMetadata[] | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState('');
const [selectedSearchFile, setSelectedSearchFile] = useState<FileMetadata | null>(null);
const searchRequestIdRef = useRef(0);
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTask[]>([]);
const [backgroundTasksLoading, setBackgroundTasksLoading] = useState(false);
const [backgroundTasksError, setBackgroundTasksError] = useState('');
const [backgroundTaskNotice, setBackgroundTaskNotice] = useState<{ kind: 'success' | 'error'; message: string } | null>(null);
const [backgroundTaskActionId, setBackgroundTaskActionId] = useState<number | null>(null);
const recordDirectoryChildren = (pathParts: string[], items: FileMetadata[]) => {
setDirectoryChildren((previous) => {
@@ -337,12 +361,47 @@ export default function Files() {
directoryInputRef.current.setAttribute('directory', '');
}, []);
const handleSidebarClick = (pathParts: string[]) => {
useEffect(() => {
const subscription = subscribeFileEvents({
path: toBackendPath(currentPath),
onFileEvent: () => {
const activePath = currentPathRef.current;
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
loadCurrentPath(activePath).catch(() => undefined);
},
onError: () => undefined,
});
return () => {
subscription.close();
};
}, [currentPath]);
useEffect(() => {
void loadBackgroundTasks();
}, []);
const clearSearchState = () => {
searchRequestIdRef.current += 1;
setSearchQuery('');
setSearchAppliedQuery('');
setSearchResults(null);
setSearchLoading(false);
setSearchError('');
setSelectedSearchFile(null);
};
const handleNavigateToPath = (pathParts: string[]) => {
clearSearchState();
setCurrentPath(pathParts);
setSelectedFile(null);
setActiveDropdown(null);
};
const handleSidebarClick = (pathParts: string[]) => {
handleNavigateToPath(pathParts);
};
const handleDirectoryToggle = async (pathParts: string[]) => {
const path = toBackendPath(pathParts);
let shouldLoadChildren = false;
@@ -377,15 +436,125 @@ export default function Files() {
const handleFolderDoubleClick = (file: UiFile) => {
if (file.type === 'folder') {
setCurrentPath([...currentPath, file.name]);
setSelectedFile(null);
handleNavigateToPath([...currentPath, file.name]);
}
};
const handleBreadcrumbClick = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
handleNavigateToPath(currentPath.slice(0, index + 1));
};
const handleSearchSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const nextQuery = searchQuery.trim();
if (!nextQuery) {
clearSearchState();
return;
}
const requestId = searchRequestIdRef.current + 1;
searchRequestIdRef.current = requestId;
setSearchAppliedQuery(nextQuery);
setSearchLoading(true);
setSearchError('');
setSearchResults(null);
setSelectedSearchFile(null);
setSelectedFile(null);
setActiveDropdown(null);
try {
const response = await searchFiles({
name: nextQuery,
type: 'all',
page: 0,
size: 100,
});
if (searchRequestIdRef.current !== requestId) {
return;
}
setSearchResults(response.items);
} catch (error) {
if (searchRequestIdRef.current !== requestId) {
return;
}
setSearchResults([]);
setSearchError(error instanceof Error ? error.message : '搜索失败');
} finally {
if (searchRequestIdRef.current === requestId) {
setSearchLoading(false);
}
}
};
const loadBackgroundTasks = async () => {
setBackgroundTasksLoading(true);
setBackgroundTasksError('');
try {
const response = await listBackgroundTasks({ page: 0, size: 10 });
setBackgroundTasks(response.items);
} catch (error) {
setBackgroundTasksError(error instanceof Error ? error.message : '获取后台任务失败');
} finally {
setBackgroundTasksLoading(false);
}
};
const handleCreateMediaMetadataTask = async () => {
if (!selectedFile || selectedFile.type === 'folder') {
return;
}
const taskPath = currentPath.length === 0 ? `/${selectedFile.name}` : `${toBackendPath(currentPath)}/${selectedFile.name}`;
const correlationId = `media-meta:${selectedFile.id}:${Date.now()}`;
setBackgroundTaskNotice(null);
setBackgroundTaskActionId(selectedFile.id);
try {
await createMediaMetadataTask({
fileId: selectedFile.id,
path: taskPath,
correlationId,
});
setBackgroundTaskNotice({
kind: 'success',
message: '已创建媒体信息提取任务,可在右侧后台任务面板查看状态。',
});
await loadBackgroundTasks();
} catch (error) {
setBackgroundTaskNotice({
kind: 'error',
message: error instanceof Error ? error.message : '创建媒体信息提取任务失败',
});
} finally {
setBackgroundTaskActionId(null);
}
};
const handleCancelBackgroundTask = async (taskId: number) => {
setBackgroundTaskNotice(null);
setBackgroundTaskActionId(taskId);
try {
await cancelBackgroundTask(taskId);
setBackgroundTaskNotice({
kind: 'success',
message: `已取消任务 ${taskId},后台列表已刷新。`,
});
await loadBackgroundTasks();
} catch (error) {
setBackgroundTaskNotice({
kind: 'error',
message: error instanceof Error ? error.message : '取消任务失败',
});
} finally {
setBackgroundTaskActionId(null);
}
};
const openRenameModal = (file: UiFile) => {
@@ -753,6 +922,7 @@ export default function Files() {
};
const directoryTree = buildDirectoryTree(directoryChildren, currentPath, expandedDirectories);
const isSearchActive = searchAppliedQuery.trim().length > 0;
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
@@ -857,8 +1027,174 @@ export default function Files() {
</div>
</div>
<form className="border-b border-white/10 p-4 pt-0" onSubmit={handleSearchSubmit}>
<div className="mt-3 flex flex-col gap-2 md:flex-row">
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="按文件名搜索"
className="h-10 border-white/10 bg-black/20 text-white placeholder:text-slate-500 focus-visible:ring-[#336EFF]"
/>
<div className="flex gap-2">
<Button type="submit" className="shrink-0" disabled={searchLoading}>
{searchLoading ? '搜索中...' : '搜索'}
</Button>
{isSearchActive ? (
<Button
type="button"
variant="outline"
className="shrink-0 border-white/10 text-slate-300 hover:bg-white/10"
onClick={() => {
clearSearchState();
}}
>
</Button>
) : null}
</div>
</div>
{searchError ? <p className="mt-2 text-sm text-red-400">{searchError}</p> : null}
</form>
{/* File List */}
<div className="flex-1 overflow-y-auto p-4">
{isSearchActive ? (
<div className="flex-1 overflow-y-auto p-4">
{searchLoading ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="h-12 w-12 opacity-20" />
<p className="text-sm">...</p>
</div>
) : (searchResults?.length ?? 0) === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="h-12 w-12 opacity-20" />
<p className="text-sm"></p>
</div>
) : viewMode === 'list' ? (
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-white/5 text-xs font-semibold uppercase tracking-wider text-slate-500">
<th className="w-[40%] pb-3 pl-4 font-medium"></th>
<th className="hidden w-[26%] pb-3 font-medium md:table-cell"></th>
<th className="hidden w-[20%] pb-3 font-medium lg:table-cell"></th>
<th className="w-[10%] pb-3 font-medium"></th>
<th className="w-[4%] pb-3"></th>
</tr>
</thead>
<tbody>
{searchResults?.map((file) => {
const uiFile = toUiFile(file);
const selected = selectedSearchFile?.id === file.id;
return (
<tr
key={file.id}
onClick={() => setSelectedSearchFile(file)}
onDoubleClick={() => {
if (file.directory) {
handleNavigateToPath(splitBackendPath(file.path));
}
}}
className={cn(
'group cursor-pointer border-b border-white/5 transition-colors last:border-0',
selected ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]',
)}
>
<td className="max-w-0 py-3 pl-4">
<div className="flex min-w-0 items-center gap-3">
<FileTypeIcon type={uiFile.type} size="sm" />
<div className="min-w-0">
<span
className={cn('block truncate text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}
title={uiFile.name}
>
{ellipsizeFileName(uiFile.name, 48)}
</span>
<span className="hidden truncate text-xs text-slate-500 md:block" title={file.path}>
{file.path}
</span>
</div>
</div>
</td>
<td className="hidden py-3 text-sm text-slate-400 md:table-cell">{file.path}</td>
<td className="hidden py-3 text-sm text-slate-400 lg:table-cell">{uiFile.modified}</td>
<td className="py-3 font-mono text-sm text-slate-400">{uiFile.size}</td>
<td className="py-3 pr-4 text-right">
<FileActionMenu
file={uiFile}
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload}
onShare={handleShare}
onMove={(targetFile) => openTargetActionModal(targetFile, 'move')}
onCopy={(targetFile) => openTargetActionModal(targetFile, 'copy')}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
allowMutatingActions={false}
/>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{searchResults?.map((file) => {
const uiFile = toUiFile(file);
const selected = selectedSearchFile?.id === file.id;
return (
<div
key={file.id}
onClick={() => setSelectedSearchFile(file)}
onDoubleClick={() => {
if (file.directory) {
handleNavigateToPath(splitBackendPath(file.path));
}
}}
className={cn(
'group relative flex cursor-pointer flex-col items-center rounded-xl border p-4 transition-all',
selected
? 'border-[#336EFF]/30 bg-[#336EFF]/10'
: 'border-white/5 bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04]',
)}
>
<div className="absolute right-2 top-2">
<FileActionMenu
file={uiFile}
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload}
onShare={handleShare}
onMove={(targetFile) => openTargetActionModal(targetFile, 'move')}
onCopy={(targetFile) => openTargetActionModal(targetFile, 'copy')}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
allowMutatingActions={false}
/>
</div>
<FileTypeIcon type={uiFile.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" />
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}>
{ellipsizeFileName(uiFile.name, 24)}
</span>
<span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(uiFile.type).badgeClassName)}>
{uiFile.typeLabel}
</span>
<span className="mt-2 text-xs text-slate-500">
{uiFile.type === 'folder' ? uiFile.modified : uiFile.size}
</span>
</div>
);
})}
</div>
)}
</div>
) : null}
<div className={cn('flex-1 overflow-y-auto p-4', isSearchActive ? 'hidden' : '')}>
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="w-12 h-12 opacity-20" />
@@ -989,13 +1325,13 @@ export default function Files() {
</div>
</Card>
{/* Right Sidebar (Details) */}
{selectedFile && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="w-full lg:w-72 shrink-0"
>
{/* Right Sidebar (Details + Tasks) */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="w-full lg:w-72 shrink-0 space-y-4"
>
{selectedFile && (
<Card className="h-full">
<CardHeader className="pb-4 border-b border-white/10">
<CardTitle className="text-base"></CardTitle>
@@ -1031,6 +1367,17 @@ export default function Files() {
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openTargetActionModal(selectedFile, 'copy')}>
<Copy className="w-4 h-4" />
</Button>
{selectedFile.type !== 'folder' ? (
<Button
variant="outline"
className="col-span-2 w-full gap-2 border-white/10 bg-white/5 hover:bg-white/10"
onClick={() => void handleCreateMediaMetadataTask()}
disabled={backgroundTaskActionId === selectedFile.id}
>
<RotateCcw className={cn('w-4 h-4', backgroundTaskActionId === selectedFile.id ? 'animate-spin' : '')} />
{backgroundTaskActionId === selectedFile.id ? '创建中...' : '提取媒体信息'}
</Button>
) : null}
<Button
variant="outline"
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
@@ -1062,8 +1409,97 @@ export default function Files() {
</div>
</CardContent>
</Card>
</motion.div>
)}
)}
<Card>
<CardHeader className="border-b border-white/10 pb-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base"></CardTitle>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void loadBackgroundTasks()}
aria-label="刷新后台任务"
>
<RotateCcw className={cn('h-4 w-4', backgroundTasksLoading ? 'animate-spin' : '')} />
</button>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4">
{backgroundTaskNotice ? (
<div
className={cn(
'rounded-xl border px-3 py-2 text-xs leading-relaxed',
backgroundTaskNotice.kind === 'error'
? 'border-red-500/20 bg-red-500/10 text-red-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200',
)}
aria-live="polite"
>
{backgroundTaskNotice.message}
</div>
) : null}
{backgroundTasksError ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{backgroundTasksError}
</div>
) : null}
{backgroundTasksLoading ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
...
</div>
) : backgroundTasks.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
</div>
) : (
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
{backgroundTasks.map((task) => {
const canCancel = task.status === 'QUEUED' || task.status === 'RUNNING';
return (
<div key={task.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{getBackgroundTaskTypeLabel(task.type)}</p>
<p className={cn('text-xs', getBackgroundTaskStatusClassName(task.status))}>
{getBackgroundTaskStatusLabel(task.status)}
</p>
</div>
{canCancel ? (
<Button
type="button"
variant="outline"
className="shrink-0 border-white/10 bg-white/5 px-3 text-xs text-slate-200 hover:bg-white/10"
onClick={() => void handleCancelBackgroundTask(task.id)}
disabled={backgroundTaskActionId === task.id}
>
{backgroundTaskActionId === task.id ? '取消中...' : '取消'}
</Button>
) : null}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="min-w-0">
<p className="text-slate-500"></p>
<p className="truncate text-slate-300">{formatTaskDateTime(task.createdAt)}</p>
</div>
<div className="min-w-0">
<p className="text-slate-500"></p>
<p className="truncate text-slate-300">{task.finishedAt ? formatTaskDateTime(task.finishedAt) : '未完成'}</p>
</div>
</div>
{task.errorMessage ? (
<div className="mt-3 break-words rounded-lg border border-red-500/20 bg-red-500/10 px-2 py-1 text-xs leading-relaxed text-red-200">
{task.errorMessage}
</div>
) : null}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
<AnimatePresence>
{renameModalOpen && (
@@ -1221,6 +1657,51 @@ function DetailItem({ label, value }: { label: string; value: string }) {
);
}
function formatTaskDateTime(value: string) {
return formatDateTime(value);
}
function getBackgroundTaskTypeLabel(type: BackgroundTask['type']) {
switch (type) {
case 'ARCHIVE':
return '压缩任务';
case 'EXTRACT':
return '解压任务';
case 'MEDIA_META':
return '媒体信息提取任务';
}
}
function getBackgroundTaskStatusLabel(status: BackgroundTask['status']) {
switch (status) {
case 'QUEUED':
return '排队中';
case 'RUNNING':
return '执行中';
case 'COMPLETED':
return '已完成';
case 'FAILED':
return '已失败';
case 'CANCELLED':
return '已取消';
}
}
function getBackgroundTaskStatusClassName(status: BackgroundTask['status']) {
switch (status) {
case 'QUEUED':
return 'text-amber-300';
case 'RUNNING':
return 'text-sky-300';
case 'COMPLETED':
return 'text-emerald-300';
case 'FAILED':
return 'text-red-300';
case 'CANCELLED':
return 'text-slate-400';
}
}
function FileActionMenu({
file,
activeDropdown,
@@ -1232,6 +1713,7 @@ function FileActionMenu({
onRename,
onDelete,
onClose,
allowMutatingActions = true,
}: {
file: UiFile;
activeDropdown: number | null;
@@ -1243,6 +1725,7 @@ function FileActionMenu({
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onClose: () => void;
allowMutatingActions?: boolean;
}) {
return (
<div className="relative inline-block text-left">
@@ -1295,46 +1778,50 @@ function FileActionMenu({
<Share2 className="w-4 h-4" />
</button>
) : null}
<button
onClick={(event) => {
event.stopPropagation();
onMove(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Folder className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onCopy(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onRename(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
{allowMutatingActions ? (
<>
<button
onClick={(event) => {
event.stopPropagation();
onMove(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Folder className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onCopy(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onRename(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : null}
</motion.div>
)}
</AnimatePresence>