feat(web): space roles, upload queue with pipelined frames, background resume and faster sending
This commit is contained in:
1 parent
96c4335cb5
commit
a574583943
39 files changed
+1515
-194
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import type { AccountInvitation } from './account';
|
||||
import type { WorkspaceRoleRequest } from './generated/api';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types';
|
||||
|
||||
/**
|
||||
@@ -24,10 +25,16 @@ export interface WorkspaceApi {
|
||||
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
|
||||
sendText(text: string, threadId?: string, localId?: string): Promise<void>;
|
||||
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void>;
|
||||
/** 取消排队中或上传中的文件;返回是否找到这个任务 */
|
||||
cancelUpload(localId: string): Promise<boolean>;
|
||||
createThread(name: string, groupId?: string): Promise<string>;
|
||||
createGroup(name: string): Promise<string>;
|
||||
renameGroup(id: string, name: string): Promise<void>;
|
||||
deleteGroup(id: string): Promise<void>;
|
||||
createRole(role: WorkspaceRoleRequest): Promise<string>;
|
||||
updateRole(id: string, role: WorkspaceRoleRequest): Promise<void>;
|
||||
deleteRole(id: string): Promise<void>;
|
||||
setRoleMembers(id: string, members: string[]): Promise<void>;
|
||||
groupThread(id: string, groupId?: string): Promise<void>;
|
||||
renameThread(threadId: string, name: string): Promise<void>;
|
||||
setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): Promise<void>;
|
||||
|
||||
@@ -201,8 +201,11 @@ export async function clearDraftFiles(resourceId: string): Promise<void> {
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** 离线队列只是崩溃/断网后的续传保险,不必每帧都等磁盘落盘:relaxed 让浏览器先确认再慢慢写,单帧写入快数倍 */
|
||||
export async function queueFrame(frame: QueuedFrame): Promise<void> {
|
||||
await (await db()).put('outbox', frame);
|
||||
const tx = (await db()).transaction('outbox', 'readwrite', { durability: 'relaxed' });
|
||||
void tx.store.put(frame);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function queuedFrames(resourceId: string): Promise<QueuedFrame[]> {
|
||||
@@ -210,14 +213,29 @@ export async function queuedFrames(resourceId: string): Promise<QueuedFrame[]> {
|
||||
return frames.sort((a, b) => a.createdAt - b.createdAt || a.itemId.localeCompare(b.itemId) || a.index - b.index);
|
||||
}
|
||||
|
||||
/** 只读键不读内容:续传前先看有哪些条目的哪些帧还没发,不把整个离线队列(可能几个 GB)读进内存 */
|
||||
export async function queuedFrameKeys(resourceId: string): Promise<string[]> {
|
||||
return (await db()).getAllKeysFromIndex('outbox', 'by-resource', resourceId);
|
||||
}
|
||||
|
||||
export async function queuedFrame(key: string): Promise<QueuedFrame | undefined> {
|
||||
return (await db()).get('outbox', key);
|
||||
}
|
||||
|
||||
export async function dequeueFrame(key: string): Promise<void> {
|
||||
await (await db()).delete('outbox', key);
|
||||
}
|
||||
|
||||
export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> {
|
||||
/** 离线队列里最早的一帧;accept 用来跳过正由上传流水线处理的条目,或只取指定条目 */
|
||||
export async function nextQueuedFrame(resourceId: string, accept?: (itemId: string) => boolean): Promise<QueuedFrame | undefined> {
|
||||
const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]);
|
||||
const values = await (await db()).getAllFromIndex('outbox', 'by-resource-created', range, 1);
|
||||
return values[0];
|
||||
if (!accept) return (await (await db()).getAllFromIndex('outbox', 'by-resource-created', range, 1))[0];
|
||||
let cursor = await (await db()).transaction('outbox').store.index('by-resource-created').openCursor(range);
|
||||
while (cursor) {
|
||||
if (accept(cursor.value.itemId)) return cursor.value;
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function lastQueuedAt(resourceId: string): Promise<number> {
|
||||
@@ -239,7 +257,8 @@ export interface QueuedLocation { key: string; resourceId: string; itemId: strin
|
||||
export async function queueLocation(resourceId: string, itemId: string, threadId: string, folderId?: string): Promise<void> {
|
||||
await (await db()).put('locationOutbox', { key: `${resourceId}:${itemId}`, resourceId, itemId, threadId, folderId });
|
||||
}
|
||||
export async function nextQueuedLocation(resourceId: string): Promise<QueuedLocation | undefined> {
|
||||
return (await (await db()).getAllFromIndex('locationOutbox', 'by-resource', resourceId, 1))[0];
|
||||
export async function nextQueuedLocation(resourceId: string, accept?: (itemId: string) => boolean): Promise<QueuedLocation | undefined> {
|
||||
if (!accept) return (await (await db()).getAllFromIndex('locationOutbox', 'by-resource', resourceId, 1))[0];
|
||||
return (await (await db()).getAllFromIndex('locationOutbox', 'by-resource', resourceId)).find((row) => accept(row.itemId));
|
||||
}
|
||||
export async function dequeueLocation(key: string): Promise<void> { await (await db()).delete('locationOutbox', key); }
|
||||
@@ -37,8 +37,13 @@ export const realApi: WorkspaceApi = {
|
||||
|
||||
sendText: (text, threadId, localId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, localId, apiBase: API_BASE }),
|
||||
sendFiles: (files, options, threadId, folderId, localIds) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }),
|
||||
cancelUpload: (localId) => callWorker<boolean>('cancelUpload', { id: currentChannelId(), localId, apiBase: API_BASE }),
|
||||
createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
|
||||
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
|
||||
createRole: (role) => callWorker<string>('createRole', { id: currentChannelId(), role, apiBase: API_BASE }),
|
||||
updateRole: (roleId, role) => callWorker<void>('updateRole', { id: currentChannelId(), roleId, role, apiBase: API_BASE }),
|
||||
deleteRole: (roleId) => callWorker<void>('deleteRole', { id: currentChannelId(), roleId, apiBase: API_BASE }),
|
||||
setRoleMembers: (roleId, members) => callWorker<void>('setRoleMembers', { id: currentChannelId(), roleId, members, apiBase: API_BASE }),
|
||||
deleteGroup: (groupId) => callWorker<void>('deleteGroup', { id: currentChannelId(), groupId, apiBase: API_BASE }),
|
||||
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
|
||||
groupThread: (threadId, groupId) => callWorker<void>('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api';
|
||||
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceRole, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api';
|
||||
|
||||
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
|
||||
@@ -88,7 +88,7 @@ export interface ShareSettings {
|
||||
|
||||
/** protocol §7、§14:由帧推导,不单独存储 */
|
||||
export type ItemState =
|
||||
| { kind: 'receiving'; progress: number; phase?: 'encrypting' | 'uploading'; speedBytesPerSecond?: number }
|
||||
| { kind: 'receiving'; progress: number; phase?: 'queued' | 'encrypting' | 'uploading'; speedBytesPerSecond?: number; etaSeconds?: number; sentBytes?: number; totalBytes?: number }
|
||||
| { kind: 'complete' }
|
||||
| { kind: 'incomplete' }
|
||||
| { kind: 'tampered' };
|
||||
@@ -159,6 +159,7 @@ export interface Snapshot {
|
||||
|
||||
export type ThreadInfo = Omit<WorkspaceChannel, 'groupId'> & { groupId?: string };
|
||||
export type ChannelGroupInfo = WorkspaceGroup;
|
||||
export type RoleInfo = WorkspaceRole;
|
||||
export type FolderInfo = Omit<WorkspaceFolder, 'parentId'> & { parentId?: string };
|
||||
|
||||
export type WorkspaceEvent =
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { setApiBase, getServerInfo } from './worker/http';
|
||||
import type { WorkspaceRoleRequest } from './generated/api';
|
||||
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
|
||||
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
import { cancelFileUpload } from './worker/uploadQueue';
|
||||
import { stopChannel } from './worker/channelTransport';
|
||||
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
|
||||
import { connectShare, getShareBlob, stopShare } from './worker/shareRead';
|
||||
@@ -12,7 +14,7 @@ import { setEventSink } from './worker/port';
|
||||
import { serial } from './worker/serial';
|
||||
import { errorMessage } from './errors';
|
||||
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
||||
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
||||
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
||||
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
|
||||
|
||||
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
||||
@@ -62,6 +64,10 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters<typeof updateChannelSettings>[1]);
|
||||
case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
|
||||
case 'createGroup': return createGroup(id, String(payload.name ?? ''));
|
||||
case 'createRole': return createRole(id, payload.role as WorkspaceRoleRequest);
|
||||
case 'updateRole': return updateRole(id, String(payload.roleId ?? ''), payload.role as WorkspaceRoleRequest);
|
||||
case 'deleteRole': return deleteRole(id, String(payload.roleId ?? ''));
|
||||
case 'setRoleMembers': return setRoleMembers(id, String(payload.roleId ?? ''), (payload.members as string[] | undefined) ?? []);
|
||||
case 'deleteGroup': return deleteGroup(id, String(payload.groupId ?? ''));
|
||||
case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? ''));
|
||||
case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
|
||||
@@ -77,6 +83,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'connectChannel': void connectChannelResource(id); return true;
|
||||
case 'stopChannel': stopChannel(id); return true;
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : undefined);
|
||||
case 'cancelUpload': return cancelFileUpload(id, String(payload.localId ?? ''));
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined, Array.isArray(payload.localIds) ? payload.localIds as string[] : undefined);
|
||||
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
|
||||
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
|
||||
|
||||
@@ -278,8 +278,8 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
|
||||
}
|
||||
export async function listChannelInvitations(channelId: string): Promise<Invitation[]> {
|
||||
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
|
||||
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read') }).then((r) => r.json() as Promise<Invitation[]>);
|
||||
}
|
||||
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
|
||||
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
|
||||
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read') });
|
||||
}
|
||||
@@ -214,6 +214,8 @@ export async function signObjectDeletion(resource: SavedResource, session: Chann
|
||||
const { state } = await readDataKeys(resource, session);
|
||||
const object = state.objects.find(row => row.itemId === itemId);
|
||||
const signed = object?.event;
|
||||
// 末帧从未上传的对象没有签名清单:没有可签的内容,服务端允许作者和空间主直接删除
|
||||
if (object && !signed?.signature) return {};
|
||||
if (!signed?.manifestHash || !signed.frameCount || (object?.domain !== 'chat' && object?.domain !== 'files'))
|
||||
throw new Error('对象缺少已验签的密文清单,不能生成删除事件');
|
||||
const manifestHash = fromBase64Url(signed.manifestHash, 32);
|
||||
@@ -231,6 +233,8 @@ export async function verifyObjectDeletion(resource: SavedResource, session: Cha
|
||||
}
|
||||
const deleted = object?.deleteEvent;
|
||||
if (resource.mode === 'raw' && !deleted?.signature) return true;
|
||||
// 没传完的上传从未发布过有效内容,删掉它不需要事件(见 signObjectDeletion)
|
||||
if (object && !object.event?.signature && !deleted?.signature) return true;
|
||||
if (!object || !deleted?.signature || !deleted.eventId || !deleted.kind || !deleted.signerDeviceId
|
||||
|| !deleted.signerRootPublicKey || !deleted.signerDevicePublicKey || !deleted.deviceCertificateSignature
|
||||
|| !object.authorAccount || !object.event?.manifestHash || !object.event.frameCount || (object.domain !== 'chat' && object.domain !== 'files')) return false;
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { removeChannelItem } from './channelItems';
|
||||
import { queueFileCatalog } from './channelThreads';
|
||||
import { enqueueFileUploads } from './uploadQueue';
|
||||
import { senderSnapshot } from './senderSnapshot';
|
||||
import { prepareObject } from './channelDataKeys';
|
||||
import { errorMessage } from '../errors';
|
||||
|
||||
import { saveResource, queueLocation } from '../database';
|
||||
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { FileItem, FileUploadOptions, Item } from '../types';
|
||||
|
||||
import { queueLocation } from '../database';
|
||||
import type { FileUploadOptions, Item } from '../types';
|
||||
import { blobs, getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { accountDisplay } from './accountSession';
|
||||
|
||||
|
||||
|
||||
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
import { publishWorkspace } from './presentation';
|
||||
|
||||
export async function sendText(resourceId: string, text: string, threadId?: string,
|
||||
@@ -33,93 +24,17 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
|
||||
eventKind: event?.kind ?? 'chat.create', ...(event?.targetItemId ? { targetItemId: event.targetItemId } : {}) });
|
||||
await queueLocation(resourceId, item.id, threadId);
|
||||
const uploaded = await flushOutbox(resource, session);
|
||||
// 只发这条消息自己的帧,不被队列里已有的大文件堵住
|
||||
const uploaded = await flushOutbox(resource, session, undefined, { only: new Set([plan.itemId]) });
|
||||
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
|
||||
return { itemId: plan.itemId, uploaded };
|
||||
}
|
||||
|
||||
/** 文件进入该空间的上传队列:一次传一个,其余排队。文件内部的帧并发上传,见 uploadQueue */
|
||||
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
|
||||
const device = await deviceIdentity(session.endpointSecret);
|
||||
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
const author = await senderSnapshot();
|
||||
for (const [index, file] of files.entries()) {
|
||||
const localId = localIds?.[index];
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
try {
|
||||
const prepared = await prepareObject(resource, session, threadId, 'files');
|
||||
const plan = session.encryptFile(file, {}, {
|
||||
itemId: prepared.itemIdBytes,
|
||||
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
||||
ts, maxFrameData, threadId, folderId,
|
||||
});
|
||||
item = {
|
||||
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
...(author ? { author } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: plan.gated
|
||||
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
|
||||
: null,
|
||||
preview: previewKindOf(file.type, file.name), gated: plan.gated,
|
||||
};
|
||||
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
|
||||
await saveResource(resource);
|
||||
blobs.set(`${resourceId}:${item.id}`, file);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => {
|
||||
if (!item) return;
|
||||
item = {
|
||||
...item,
|
||||
state: {
|
||||
kind: 'receiving',
|
||||
progress: Math.max(0, Math.min(0.99, progress)),
|
||||
phase,
|
||||
...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }),
|
||||
},
|
||||
};
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
};
|
||||
const planned = await persistPlan(resource, plan, (_frames) => {
|
||||
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
|
||||
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||
}, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' });
|
||||
const uploadedFrames = new Set<number>();
|
||||
await queueFileCatalog(resource, session, item);
|
||||
await queueLocation(resourceId, item.id, threadId, folderId);
|
||||
let uploadedBytes = 0;
|
||||
const uploadStartedAt = performance.now();
|
||||
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||
if (frame.itemId !== item?.id) return;
|
||||
if (complete && !uploadedFrames.has(frame.index)) {
|
||||
uploadedFrames.add(frame.index);
|
||||
uploadedBytes += totalBytes;
|
||||
}
|
||||
const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes);
|
||||
const elapsed = Math.max(1, performance.now() - uploadStartedAt);
|
||||
updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed);
|
||||
});
|
||||
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||
resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry);
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' });
|
||||
} catch (error) {
|
||||
if (item) {
|
||||
item = { ...item, state: { kind: 'incomplete' } };
|
||||
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === item?.id ? item! : entry);
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
}
|
||||
publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return enqueueFileUploads(resourceId, files, suppliedOptions, threadId, folderId, localIds);
|
||||
}
|
||||
|
||||
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { ChannelSession } from '@webdrop/core';
|
||||
import type { SavedResource } from '../database';
|
||||
import type { FileItem } from '../types';
|
||||
import { persistPlan } from './transfer';
|
||||
import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup } from './workspaceStructure';
|
||||
import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup, createWorkspaceRole } from './workspaceStructure';
|
||||
import { getSession } from './sessions';
|
||||
import { initializeChannelKeys, prepareObject } from './channelDataKeys';
|
||||
import { toPublishedFile } from './fileCatalog';
|
||||
import { apiFetch } from './http';
|
||||
import { WORKSPACE_APP, type ResourceControl } from '../protocol/workspace';
|
||||
import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest } from '../generated/api';
|
||||
import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest, WorkspaceRoleRequest, WorkspaceRoleMembersRequest } from '../generated/api';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -27,6 +27,14 @@ export async function renameGroup(resourceId: string, id: string, name: string):
|
||||
export async function deleteGroup(resourceId: string, id: string): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'groups/' + id, 'DELETE');
|
||||
}
|
||||
export async function createRole(resourceId: string, role: WorkspaceRoleRequest): Promise<string> { return createWorkspaceRole(resourceId, role); }
|
||||
export async function updateRole(resourceId: string, id: string, role: WorkspaceRoleRequest): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'roles/' + id, 'PATCH', role);
|
||||
}
|
||||
export async function deleteRole(resourceId: string, id: string): Promise<void> { await changeWorkspace(resourceId, 'roles/' + id, 'DELETE'); }
|
||||
export async function setRoleMembers(resourceId: string, id: string, members: string[]): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'roles/' + id + '/members', 'PUT', { members } satisfies WorkspaceRoleMembersRequest);
|
||||
}
|
||||
export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
|
||||
import { loadWorkspace } from './workspaceStructure';
|
||||
import { flushOutbox } from './transfer';
|
||||
import { resumeOutbox } from './resumeUploads';
|
||||
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
|
||||
import { errorMessage } from '../errors';
|
||||
import { saveResource } from '../database';
|
||||
@@ -43,19 +44,24 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
let initialized = false;
|
||||
let transport = 0;
|
||||
let backoff = 1000;
|
||||
flushTimer = setInterval(() => {
|
||||
if (!signal.aborted) void flushOutbox(resource, session).catch((error: unknown) => {
|
||||
publishWorkspace(id, { t: 'error', message: errorMessage(error) });
|
||||
});
|
||||
}, 2500);
|
||||
// 离线队列的续传在后台跑:几个 GB 的遗留不能挡住频道加载,也不能叠出一堆并行的续传
|
||||
let resuming = false;
|
||||
const resume = () => {
|
||||
if (signal.aborted || resuming) return;
|
||||
resuming = true;
|
||||
void resumeOutbox(resource, session, signal).then(() => flushOutbox(resource, session))
|
||||
.catch((error: unknown) => { if (!signal.aborted) publishWorkspace(id, { t: 'error', message: errorMessage(error) }); })
|
||||
.finally(() => { resuming = false; });
|
||||
};
|
||||
flushTimer = setInterval(resume, 2500);
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
if (!initialized) {
|
||||
[stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session), joining]);
|
||||
if (signal.aborted) break;
|
||||
await flushOutbox(resource, session);
|
||||
initialized = true;
|
||||
publishWorkspace(id, { t: 'ready' });
|
||||
resume();
|
||||
}
|
||||
stream.online();
|
||||
if (transport === 0) await socketStream(id, session, signal, stream);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { QueuedFrame } from '../database';
|
||||
import { FrameStash } from './frameStash';
|
||||
|
||||
const frame = (index: number): QueuedFrame => ({ key: `k${index}`, resourceId: 'r', itemId: 'i', index, bytes: new Uint8Array(10), extraHeaders: {}, createdAt: index });
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function fakeDisk(writeMs = 5) {
|
||||
const stored = new Set<string>(); const writes: string[] = []; const removes: string[] = [];
|
||||
return {
|
||||
stored, writes, removes,
|
||||
put: async (f: QueuedFrame) => { await sleep(writeMs); stored.add(f.key); writes.push(f.key); },
|
||||
remove: async (key: string) => { stored.delete(key); removes.push(key); },
|
||||
};
|
||||
}
|
||||
|
||||
describe('FrameStash', () => {
|
||||
it('宽限期内就被确认的帧:根本不写盘', async () => {
|
||||
const disk = fakeDisk(5);
|
||||
const stash = new FrameStash(disk.put, disk.remove, 8, 100, 4);
|
||||
const frames = [0, 1, 2, 3].map(frame);
|
||||
for (const f of frames) await stash.add(f);
|
||||
await sleep(20);
|
||||
for (const f of frames) stash.accepted(f);
|
||||
await stash.drain();
|
||||
expect(disk.writes).toEqual([]);
|
||||
expect(disk.stored.size).toBe(0);
|
||||
});
|
||||
|
||||
it('超过宽限期还没确认的帧会写入,之后确认时再删除', async () => {
|
||||
const disk = fakeDisk(1);
|
||||
const stash = new FrameStash(disk.put, disk.remove, 8, 30, 4);
|
||||
const first = frame(0); await stash.add(first);
|
||||
await sleep(80);
|
||||
expect(disk.stored.has('k0')).toBe(true);
|
||||
stash.accepted(first);
|
||||
await sleep(10);
|
||||
expect(disk.stored.has('k0')).toBe(false);
|
||||
});
|
||||
|
||||
it('断网时(一直不确认)drain 之后全部帧都已落盘,续传才有东西可发', async () => {
|
||||
const disk = fakeDisk(1);
|
||||
const stash = new FrameStash(disk.put, disk.remove, 3, 5000, 4);
|
||||
for (let index = 0; index < 10; index += 1) await stash.add(frame(index));
|
||||
await stash.drain();
|
||||
expect(disk.stored.size).toBe(10);
|
||||
});
|
||||
|
||||
it('积压超过上限时 add 会等待写盘,不让内存里越堆越多', async () => {
|
||||
const disk = fakeDisk(30);
|
||||
const stash = new FrameStash(disk.put, disk.remove, 2, 5000, 1);
|
||||
let added = 0;
|
||||
const producer = (async () => { for (let index = 0; index < 6; index += 1) { await stash.add(frame(index)); added += 1; } })();
|
||||
await sleep(40);
|
||||
expect(added).toBeLessThan(6);
|
||||
await producer; await stash.drain();
|
||||
expect(disk.stored.size).toBe(6);
|
||||
});
|
||||
|
||||
it('取消后不会再写入新的帧', async () => {
|
||||
const disk = fakeDisk(5);
|
||||
const stash = new FrameStash(disk.put, disk.remove, 8, 20, 4);
|
||||
await stash.add(frame(0)); await stash.add(frame(1));
|
||||
await stash.discard();
|
||||
const before = disk.writes.length;
|
||||
await sleep(60);
|
||||
expect(disk.writes.length).toBe(before);
|
||||
});
|
||||
|
||||
it('写盘失败会让加密端停下来并抛出', async () => {
|
||||
const stash = new FrameStash(async () => { throw new Error('quota'); }, async () => {}, 1, 0, 0);
|
||||
await expect((async () => { for (let index = 0; index < 5; index += 1) await stash.add(frame(index)); await stash.drain(); })()).rejects.toThrow('quota');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { QueuedFrame } from '../database';
|
||||
|
||||
interface Pending { frame: QueuedFrame; at: number }
|
||||
|
||||
/**
|
||||
* 离线队列的「后写」:加密好的帧先在内存里直接上传,只有没能及时上传的才写进 IndexedDB 留作续传保险。
|
||||
* 每帧写盘要几十毫秒,同步写会把上传速度压到约 25 MB/s;网络够快时帧在宽限期内就已被确认,根本不用写。
|
||||
* 网络慢或断了,帧超过宽限期(或积压太多)就在后台写入,离线续传不受影响(protocol §5:重试发送的仍是同一份字节)。
|
||||
*/
|
||||
export class FrameStash {
|
||||
private pending: Pending[] = [];
|
||||
private readonly written = new Set<string>();
|
||||
private writing: Promise<void> | undefined;
|
||||
private error: unknown;
|
||||
private forced = false;
|
||||
private waiters: (() => void)[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly put: (frame: QueuedFrame) => Promise<void>,
|
||||
private readonly remove: (key: string) => Promise<void>,
|
||||
/** 还没写入的帧超过这个数就让加密端等一等,避免断网后内存里越堆越多 */
|
||||
private readonly maxPending = 16,
|
||||
/** 帧在内存里等服务端确认的宽限时间,超过才写盘 */
|
||||
private readonly graceMs = 400,
|
||||
/** 积压超过这个数就不再等宽限期,马上写最早的。要大于上传流水线本身在途+缓冲的帧数(约 10),否则正常上传也会被逼着写盘 */
|
||||
private readonly eagerAbove = 12,
|
||||
) {}
|
||||
|
||||
/** 新加密出一帧。积压太多时等待写盘追上来 */
|
||||
async add(frame: QueuedFrame): Promise<void> {
|
||||
if (this.error) throw this.error;
|
||||
this.pending.push({ frame, at: performance.now() });
|
||||
this.kick();
|
||||
this.notify();
|
||||
while (this.pending.length > this.maxPending && !this.error) await this.wait();
|
||||
if (this.error) throw this.error;
|
||||
}
|
||||
|
||||
/** 服务端确认了这一帧:还没写盘的不用写了,已经写了的从队列里删掉 */
|
||||
accepted(frame: QueuedFrame): void {
|
||||
const at = this.pending.findIndex((entry) => entry.frame.key === frame.key);
|
||||
if (at >= 0) { this.pending.splice(at, 1); this.notify(); return; }
|
||||
if (this.written.delete(frame.key)) void this.remove(frame.key).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** 等所有该写的帧都落盘(不再等宽限期)。全部上传完成时这里几乎是空的 */
|
||||
async drain(): Promise<void> {
|
||||
this.forced = true; this.kick(); this.notify();
|
||||
while ((this.pending.length || this.writing) && !this.error) await this.wait();
|
||||
if (this.error) throw this.error;
|
||||
}
|
||||
|
||||
/** 取消上传:丢掉还没写的帧,等正在写的那一帧结束。已写入的由调用方用 dequeueItem 清掉 */
|
||||
async discard(): Promise<void> {
|
||||
this.pending = [];
|
||||
this.notify();
|
||||
await this.writing;
|
||||
}
|
||||
|
||||
private kick(): void {
|
||||
if (this.writing || !this.pending.length) return;
|
||||
this.writing = (async () => {
|
||||
try {
|
||||
while (this.pending.length) {
|
||||
const head = this.pending[0]!;
|
||||
const age = performance.now() - head.at;
|
||||
if (!this.forced && age < this.graceMs && this.pending.length <= this.eagerAbove && this.pending.length < this.maxPending) {
|
||||
// 还在宽限期里:等它被确认(会被移出 pending)、或有新帧到来、或宽限期到
|
||||
await Promise.race([this.wait(), new Promise((resolve) => setTimeout(resolve, this.graceMs - age))]);
|
||||
continue;
|
||||
}
|
||||
await this.put(head.frame);
|
||||
// 写的过程中如果已经被确认,accepted() 已把它移出 pending:这次写入白做了,补一个删除
|
||||
if (this.pending[0] === head) { this.pending.shift(); this.written.add(head.frame.key); }
|
||||
else await this.remove(head.frame.key).catch(() => undefined);
|
||||
this.notify();
|
||||
}
|
||||
} catch (error) { this.error = error; }
|
||||
finally { this.writing = undefined; this.notify(); }
|
||||
})();
|
||||
}
|
||||
|
||||
private wait(): Promise<void> { return new Promise((resolve) => { this.waiters.push(resolve); }); }
|
||||
private notify(): void { const waiting = this.waiters; this.waiters = []; for (const resume of waiting) resume(); }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { supportsApiContract } from '../contract';
|
||||
import { accountToken } from './accountSession';
|
||||
import type { ServerInfoBody } from '../generated/api';
|
||||
|
||||
export type ApiError = Error & { status?: number; code?: string };
|
||||
export type ApiError = Error & { status?: number; code?: string; /** 服务端要求的等待秒数(429 的 Retry-After) */ retryAfter?: number };
|
||||
let apiBase = '/api/v1';
|
||||
let contractCheck: Promise<ServerInfoBody> | undefined;
|
||||
|
||||
@@ -69,24 +69,37 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
|
||||
export async function apiUpload(
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
body: ArrayBuffer,
|
||||
body: ArrayBuffer | Uint8Array | Blob,
|
||||
onProgress: (loadedBytes: number, totalBytes: number) => void,
|
||||
signal?: AbortSignal,
|
||||
/** 这么久没有任何上传进展就放弃这次请求(弱网下连接静默卡死),由调用方重试 */
|
||||
stallTimeoutMs?: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await getServerInfo();
|
||||
const response = await new Promise<Response>((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new DOMException('上传已取消', 'AbortError')); return; }
|
||||
const xhr = new XMLHttpRequest();
|
||||
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
|
||||
let stalled = false; let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const watch = () => {
|
||||
if (!stallTimeoutMs) return;
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => { stalled = true; xhr.abort(); }, stallTimeoutMs);
|
||||
};
|
||||
xhr.open('POST', apiPath(path));
|
||||
xhr.withCredentials = false;
|
||||
xhr.responseType = 'text';
|
||||
xhr.setRequestHeader('Cache-Control', 'no-store');
|
||||
// 不要设 Cache-Control 请求头:它不在服务端 CORS 允许的头里,跨域部署(API 在另一个域名)时预检会失败;POST 本来也不会被缓存
|
||||
const token = accountToken();
|
||||
if (token) xhr.setRequestHeader('X-WD-Account', token);
|
||||
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
watch();
|
||||
if (event.lengthComputable) onProgress(event.loaded, event.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
clearTimeout(stallTimer);
|
||||
if (xhr.status === 0) {
|
||||
reject(new Error('network_error'));
|
||||
return;
|
||||
@@ -94,20 +107,27 @@ export async function apiUpload(
|
||||
const responseHeaders = new Headers();
|
||||
const contentType = xhr.getResponseHeader('Content-Type');
|
||||
if (contentType) responseHeaders.set('Content-Type', contentType);
|
||||
const retryAfter = xhr.getResponseHeader('Retry-After');
|
||||
if (retryAfter) responseHeaders.set('Retry-After', retryAfter);
|
||||
resolve(new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders }));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('network_error'));
|
||||
xhr.onabort = () => reject(new DOMException('上传已取消', 'AbortError'));
|
||||
xhr.send(body);
|
||||
xhr.onerror = () => { clearTimeout(stallTimer); reject(new Error('network_error')); };
|
||||
xhr.onabort = () => { clearTimeout(stallTimer); reject(stalled ? new Error('network_error') : new DOMException('上传已取消', 'AbortError')); };
|
||||
watch();
|
||||
xhr.send(body as XMLHttpRequestBodyInit);
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
const code = typeof result?.error === 'string' && /^[a-z_]{1,40}$/.test(result.error) ? result.error : 'request_failed';
|
||||
throw fail(response.status, code);
|
||||
const error = fail(response.status, code);
|
||||
const wait = Number(response.headers.get('Retry-After'));
|
||||
if (Number.isFinite(wait) && wait > 0) error.retryAfter = wait;
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error) throw error;
|
||||
if (error instanceof Error && error.message === 'api_contract_mismatch') throw error;
|
||||
if (signal?.aborted) throw error;
|
||||
throw new Error('网络连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('resource and rendering boundaries', () => {
|
||||
it('never synchronizes draft content or server structure snapshots inside the key vault', () => {
|
||||
const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2,
|
||||
endpointSecret: new Uint8Array(32), maxMembers: 0, draftItems: [], manifestEntries: [],
|
||||
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } };
|
||||
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], roles: [], rolesSeeded: true, folders: [], locations: [] }, people: [] } };
|
||||
const exported = exportResource(resource);
|
||||
for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { dequeueItem, queuedFrame, queuedFrameKeys, saveResource, type QueuedFrame, type SavedResource } from '../database';
|
||||
import type { ChannelSession } from '@webdrop/core';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { pipelinedItems, uploadFrame } from './transfer';
|
||||
import { FrameUploader, TransferMeter } from './uploadPipeline';
|
||||
|
||||
const FRAME_CONCURRENCY = 4;
|
||||
const keyOf = (key: string) => { const [, itemId = '', index = '0'] = key.split(':'); return { itemId, index: Number(index) }; };
|
||||
/** 内容事件的签名头只在末帧上;离线队列里没有带签名头的末帧,说明上次加密被打断,这个条目无法补完 */
|
||||
const isFinalFrame = (frame: QueuedFrame): boolean => 'X-WD-Event-Signature' in frame.extraHeaders;
|
||||
|
||||
/**
|
||||
* 续传离线队列里上次没发完的内容:按条目分组,同一个条目内的帧按 FrameUploader 的规则并发上传,
|
||||
* 进度会回到聊天里的文件卡片。在后台运行,不挡住频道加载。
|
||||
* 返回 true 表示没有遗留(或都发完了)。
|
||||
*/
|
||||
export async function resumeOutbox(resource: SavedResource, session: ChannelSession, signal?: AbortSignal): Promise<boolean> {
|
||||
const items = new Map<string, number[]>();
|
||||
for (const key of await queuedFrameKeys(resource.id)) {
|
||||
const { itemId, index } = keyOf(key);
|
||||
if (pipelinedItems.has(`${resource.id}:${itemId}`)) continue;
|
||||
(items.get(itemId) ?? items.set(itemId, []).get(itemId)!).push(index);
|
||||
}
|
||||
// 上次会话里还显示「传输中」的文件,如果既没有在续传、也没有可续传的帧,就是被打断的:标成未完成,用户可以删除
|
||||
const stale = (resource.localItems ?? []).filter((item) => item.kind === 'file' && item.state.kind === 'receiving'
|
||||
&& !pipelinedItems.has(`${resource.id}:${item.id}`) && !items.has(item.id));
|
||||
for (const item of stale) await markIncomplete(resource, item.id);
|
||||
let complete = true;
|
||||
for (const [itemId, indices] of items) {
|
||||
if (signal?.aborted) return false;
|
||||
if (!await resumeItem(resource, session, itemId, indices.sort((a, b) => a - b), signal)) complete = false;
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
async function resumeItem(resource: SavedResource, session: ChannelSession, itemId: string, indices: number[], signal?: AbortSignal): Promise<boolean> {
|
||||
const keyFor = (index: number) => `${resource.id}:${itemId}:${String(index).padStart(10, '0')}`;
|
||||
const last = indices[indices.length - 1]!;
|
||||
const lastFrame = await queuedFrame(keyFor(last));
|
||||
// 没有末帧的条目补不完整:留给全局队列处理,或等用户重新发送
|
||||
if (!lastFrame || !isFinalFrame(lastFrame)) { await markIncomplete(resource, itemId); return true; }
|
||||
const queued = new Set(indices);
|
||||
const accepted: number[] = [];
|
||||
for (let index = 0; index <= last; index += 1) if (!queued.has(index)) accepted.push(index);
|
||||
const local = resource.localItems?.find((item) => item.id === itemId);
|
||||
const controller = new AbortController();
|
||||
signal?.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
const meter = new TransferMeter();
|
||||
const total = last + 1;
|
||||
let lastPublished = 0;
|
||||
const publish = (sent: number, force = false) => {
|
||||
if (!local || local.kind !== 'file') return;
|
||||
const now = performance.now();
|
||||
if (!force && now - lastPublished < 150) return;
|
||||
lastPublished = now;
|
||||
const speed = meter.speed();
|
||||
publishWorkspace(resource.id, { t: 'item', item: { ...local, state: { kind: 'receiving', progress: Math.min(0.99, (accepted.length + sent) / total), phase: 'uploading', ...(speed ? { speedBytesPerSecond: speed } : {}) } } });
|
||||
};
|
||||
const uploader = new FrameUploader({
|
||||
gated: local?.kind === 'file' ? Boolean(local.gated) : false, concurrency: FRAME_CONCURRENCY, signal: controller.signal, alreadyAccepted: accepted,
|
||||
send: (frame, hooks, s) => uploadFrame(resource, session, frame, hooks, s),
|
||||
onBytes: (bytes) => { meter.add(bytes); publish(bytes / Math.max(1, lastFrame.bytes.byteLength)); },
|
||||
});
|
||||
try {
|
||||
for (const index of indices) {
|
||||
const frame = index === last ? lastFrame : await queuedFrame(keyFor(index));
|
||||
if (frame) await uploader.push(frame, index === last);
|
||||
}
|
||||
const done = await uploader.finish();
|
||||
if (local && local.kind === 'file') publishWorkspace(resource.id, { t: 'item', item: { ...local, state: done ? { kind: 'complete' } : { kind: 'incomplete' } } });
|
||||
return done;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return false;
|
||||
// 服务端明确拒绝(例如已被删除):这些帧再发也没用,清掉
|
||||
await dequeueItem(resource.id, itemId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function markIncomplete(resource: SavedResource, itemId: string): Promise<void> {
|
||||
const item = resource.localItems?.find((entry) => entry.id === itemId);
|
||||
if (!item || item.state.kind === 'incomplete') return;
|
||||
const next = { ...item, state: { kind: 'incomplete' as const } };
|
||||
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === itemId ? next : entry);
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resource.id, { t: 'item', item: next });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type SavedResource } from '../database';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type QueuedFrame, type SavedResource } from '../database';
|
||||
import { registerItemLocation } from './workspaceStructure';
|
||||
import { verifyObjectEvent } from './channelDataKeys';
|
||||
import { apiFetch, apiUpload, type ApiError } from './http';
|
||||
@@ -11,21 +11,28 @@ import { storeVerifiedDownload } from './downloadStorage';
|
||||
// Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier.
|
||||
export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4;
|
||||
export { MAX_FRAME_DATA };
|
||||
const UPLOAD_RETRY_DELAYS_MS = [500, 1500, 4000] as const;
|
||||
|
||||
/** 正由上传流水线逐帧发送的条目(空间 id:条目 id)。全局续传 flushOutbox 要绕开它们,否则同一帧会发两遍、进度互相抢 */
|
||||
const pipelined = new Set<string>();
|
||||
export { pipelined as pipelinedItems };
|
||||
export function markPipelined(resourceId: string, itemId: string): void { pipelined.add(`${resourceId}:${itemId}`); }
|
||||
export function unmarkPipelined(resourceId: string, itemId: string): void { pipelined.delete(`${resourceId}:${itemId}`); }
|
||||
|
||||
export async function persistPlan(
|
||||
resource: SavedResource,
|
||||
plan: ItemPlan,
|
||||
onProgress?: (frames: number, bytes: number) => void,
|
||||
signedEvent?: { channelId: string; domain: 'chat' | 'files'; epoch: number; eventKind: string; targetItemId?: string },
|
||||
/** 每一帧写入离线队列之后调用;上传流水线借此边加密边上传,返回的 Promise 用来给加密端施加背压 */
|
||||
onFrame?: (frame: QueuedFrame, final: boolean) => Promise<void>,
|
||||
): Promise<{ frames: number; bytes: number }> {
|
||||
const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1);
|
||||
let frames = 0;
|
||||
let bytes = 0;
|
||||
const frameDigests: Uint8Array[] = [];
|
||||
let pending: { index: number; bytes: Uint8Array; extraHeaders?: Record<string, string> } | undefined;
|
||||
const persistFrame = async (frame: NonNullable<typeof pending>, extraHeaders: Record<string, string> = {}) => {
|
||||
await queueFrame({
|
||||
const persistFrame = async (frame: NonNullable<typeof pending>, extraHeaders: Record<string, string> = {}, final = false) => {
|
||||
const queued: QueuedFrame = {
|
||||
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
||||
resourceId: resource.id,
|
||||
itemId: plan.itemId,
|
||||
@@ -33,15 +40,17 @@ export async function persistPlan(
|
||||
bytes: frame.bytes,
|
||||
extraHeaders: { ...(frame.index === 0 ? plan.frame0Headers : {}), ...frame.extraHeaders, ...extraHeaders },
|
||||
createdAt: createdAt + frame.index,
|
||||
});
|
||||
};
|
||||
// 提供了 onFrame 的调用方(上传流水线)自己决定何时落盘,见 frameStash
|
||||
if (!onFrame) await queueFrame(queued);
|
||||
frames += 1;
|
||||
bytes += frame.bytes.byteLength;
|
||||
onProgress?.(frames, bytes);
|
||||
await onFrame?.(queued, final);
|
||||
};
|
||||
for await (const frame of plan.frames) {
|
||||
if (pending) await persistFrame(pending);
|
||||
const frameBuffer = frame.bytes.slice().buffer as ArrayBuffer;
|
||||
frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frameBuffer)));
|
||||
frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frame.bytes)));
|
||||
pending = frame;
|
||||
}
|
||||
if (pending) {
|
||||
@@ -50,7 +59,7 @@ export async function persistPlan(
|
||||
epoch: signedEvent.epoch, itemId: plan.itemId, eventKind: signedEvent.eventKind,
|
||||
...(signedEvent.targetItemId ? { targetItemId: signedEvent.targetItemId } : {}), frameDigests })
|
||||
: {};
|
||||
await persistFrame(pending, headers);
|
||||
await persistFrame(pending, headers, true);
|
||||
}
|
||||
return { frames, bytes };
|
||||
}
|
||||
@@ -61,25 +70,88 @@ function writeRole(session: ChannelSession): 'owner' | 'write' {
|
||||
throw new Error('此链接没有上传权限');
|
||||
}
|
||||
|
||||
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
|
||||
/** 一次请求这么久没有任何上传进展就放弃重试:弱网下连接可能静默卡死,不能一直等 */
|
||||
const FRAME_STALL_TIMEOUT_MS = 30_000;
|
||||
const MAX_UPLOAD_ATTEMPTS = 8;
|
||||
const MAX_RETRY_WAIT_MS = 60_000;
|
||||
|
||||
/** 第 n 次失败后的等待:指数退避加抖动;服务端给了 Retry-After(限流)就按它来 */
|
||||
export function retryDelayMs(attempt: number, retryAfterSeconds?: number): number {
|
||||
if (retryAfterSeconds) return Math.min(MAX_RETRY_WAIT_MS, retryAfterSeconds * 1000 + Math.random() * 250);
|
||||
return Math.min(15_000, 500 * 2 ** attempt) * (0.75 + Math.random() * 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传一帧。可重试的错误(断网、卡住、5xx、429 等)按退避重试,重试发送的永远是同一份字节(protocol §5)。
|
||||
* 返回 false 表示重试用尽(多半断网),帧仍留在离线队列;不可重试的错误会抛出;取消会抛 AbortError。
|
||||
* manageQueue 为 false 时调用方自己管理离线队列(frameStash),这里不动它。
|
||||
*/
|
||||
export async function uploadFrame(
|
||||
resource: SavedResource,
|
||||
session: ChannelSession,
|
||||
frame: QueuedFrame,
|
||||
hooks?: { onLoaded?: (loadedBytes: number, totalBytes: number) => void; onAccepted?: () => void; onRetry?: () => void; manageQueue?: boolean },
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
||||
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
||||
let maxLoadedBytes = 0;
|
||||
for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
await apiUpload(
|
||||
path,
|
||||
{ ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
||||
// 用 Blob 发而不是字节数组:浏览器对字节数组请求体要多拷贝几次,实测上传只有 20–35 MB/s;Blob 走快速通道,单连接 100+ MB/s。
|
||||
// 每次重试都从同一份 frame.bytes 重新包装,发出的字节完全相同(protocol §5)
|
||||
new Blob([frame.bytes as BlobPart]),
|
||||
(loaded, total) => { maxLoadedBytes = Math.max(maxLoadedBytes, loaded); hooks?.onLoaded?.(maxLoadedBytes, total); },
|
||||
signal,
|
||||
FRAME_STALL_TIMEOUT_MS,
|
||||
);
|
||||
if (hooks?.manageQueue !== false) await dequeueFrame(frame.key);
|
||||
hooks?.onAccepted?.();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (signal?.aborted || (error as Error | undefined)?.name === 'AbortError') throw error;
|
||||
const apiError = error as ApiError;
|
||||
const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425
|
||||
|| apiError.status === 429 || apiError.status === 499 || apiError.status >= 500;
|
||||
if (!retryable) throw error;
|
||||
if (attempt === MAX_UPLOAD_ATTEMPTS - 1) return false;
|
||||
hooks?.onRetry?.();
|
||||
maxLoadedBytes = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt, apiError.retryAfter)));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把离线队列里的帧(和待登记的位置)发完。默认处理整个空间的队列,但跳过正由上传流水线处理的条目;
|
||||
* only 指定时只处理这些条目,发文字消息就用它,不被已入队的大文件堵住。
|
||||
*/
|
||||
export async function flushOutbox(
|
||||
resource: SavedResource,
|
||||
session: ChannelSession,
|
||||
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
|
||||
scope?: { only?: ReadonlySet<string> },
|
||||
): Promise<boolean> {
|
||||
return serial(`outbox:${resource.id}`, () => flush(resource, session, onFrameProgress));
|
||||
const lane = scope?.only ? [...scope.only].sort().join(',') : 'all';
|
||||
return serial(`outbox:${resource.id}:${lane}`, () => flush(resource, session, onFrameProgress, scope));
|
||||
}
|
||||
|
||||
async function flush(
|
||||
resource: SavedResource,
|
||||
session: ChannelSession,
|
||||
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
|
||||
scope?: { only?: ReadonlySet<string> },
|
||||
): Promise<boolean> {
|
||||
const accept = (itemId: string) => !pipelined.has(`${resource.id}:${itemId}`) && (!scope?.only || scope.only.has(itemId));
|
||||
for (;;) {
|
||||
const frame = await nextQueuedFrame(resource.id);
|
||||
const frame = await nextQueuedFrame(resource.id, accept);
|
||||
if (!frame) {
|
||||
if (resource.kind !== 'channel') return true;
|
||||
const location = await nextQueuedLocation(resource.id);
|
||||
const location = await nextQueuedLocation(resource.id, accept);
|
||||
if (!location) return true;
|
||||
try {
|
||||
await registerItemLocation(resource.id, { id: location.itemId, threadId: location.threadId, folderId: location.folderId });
|
||||
@@ -91,34 +163,10 @@ async function flush(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
||||
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
||||
let maxLoadedBytes = 0;
|
||||
let accepted = false;
|
||||
for (let attempt = 0; attempt <= UPLOAD_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
try {
|
||||
await apiUpload(
|
||||
path,
|
||||
{ ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
||||
frame.bytes.slice().buffer as ArrayBuffer,
|
||||
(loaded, total) => {
|
||||
maxLoadedBytes = Math.max(maxLoadedBytes, loaded);
|
||||
onFrameProgress?.(frame, maxLoadedBytes, total, false);
|
||||
},
|
||||
);
|
||||
await dequeueFrame(frame.key);
|
||||
onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true);
|
||||
accepted = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425
|
||||
|| apiError.status === 429 || apiError.status === 499 || apiError.status >= 500;
|
||||
if (!retryable) throw error;
|
||||
if (attempt === UPLOAD_RETRY_DELAYS_MS.length) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAYS_MS[attempt]));
|
||||
}
|
||||
}
|
||||
const accepted = await uploadFrame(resource, session, frame, {
|
||||
onLoaded: (loaded, total) => onFrameProgress?.(frame, loaded, total, false),
|
||||
onAccepted: () => onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true),
|
||||
});
|
||||
if (!accepted) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { QueuedFrame } from '../database';
|
||||
import { estimateCipherBytes, FrameUploader, TransferMeter, type SendFrame } from './uploadPipeline';
|
||||
|
||||
const frame = (index: number, size = 100): QueuedFrame => ({ key: `k${index}`, resourceId: 'r', itemId: 'i', index, bytes: new Uint8Array(size), extraHeaders: {}, createdAt: index });
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** 记录每一帧开始、结束的顺序和同时在途的最大数量 */
|
||||
function fakeServer(options: { delay?: (index: number) => number; reject?: (index: number) => 'pause' | 'error' | undefined } = {}) {
|
||||
const log: string[] = []; let inflight = 0; let peak = 0;
|
||||
const send: SendFrame = async (f, { onLoaded }) => {
|
||||
inflight += 1; peak = Math.max(peak, inflight); log.push(`start:${f.index}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, options.delay?.(f.index) ?? 1));
|
||||
onLoaded(f.bytes.byteLength);
|
||||
inflight -= 1;
|
||||
const verdict = options.reject?.(f.index);
|
||||
if (verdict === 'error') throw new Error('boom');
|
||||
if (verdict === 'pause') return false;
|
||||
log.push(`done:${f.index}`);
|
||||
return true;
|
||||
};
|
||||
return { send, log, peak: () => peak };
|
||||
}
|
||||
|
||||
async function run(uploader: FrameUploader, count: number) {
|
||||
for (let index = 0; index < count; index += 1) await uploader.push(frame(index), index === count - 1);
|
||||
return uploader.finish();
|
||||
}
|
||||
|
||||
describe('FrameUploader', () => {
|
||||
it('受限文件:第 0 帧先传,中间帧并发,末帧等其余帧都确认后才发', async () => {
|
||||
const server = fakeServer({ delay: (index) => (index === 2 ? 20 : 2) });
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 3, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
expect(await run(uploader, 6)).toBe(true);
|
||||
expect(server.log.indexOf('done:0')).toBeLessThan(server.log.indexOf('start:1'));
|
||||
expect(server.peak()).toBeGreaterThan(1);
|
||||
expect(server.peak()).toBeLessThanOrEqual(3);
|
||||
const start5 = server.log.indexOf('start:5');
|
||||
for (const index of [0, 1, 2, 3, 4]) expect(server.log.indexOf(`done:${index}`)).toBeLessThan(start5);
|
||||
});
|
||||
|
||||
it('非受限文件:逐帧按顺序上传,不并发', async () => {
|
||||
const server = fakeServer();
|
||||
const uploader = new FrameUploader({ gated: false, concurrency: 4, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
expect(await run(uploader, 4)).toBe(true);
|
||||
expect(server.peak()).toBe(1);
|
||||
expect(server.log).toEqual(['start:0', 'done:0', 'start:1', 'done:1', 'start:2', 'done:2', 'start:3', 'done:3']);
|
||||
});
|
||||
|
||||
it('只有一帧时直接上传', async () => {
|
||||
const server = fakeServer();
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 4, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
expect(await run(uploader, 1)).toBe(true);
|
||||
expect(server.log).toEqual(['start:0', 'done:0']);
|
||||
});
|
||||
|
||||
it('某帧重试用尽暂停:不再发新帧,finish 返回 false', async () => {
|
||||
const server = fakeServer({ reject: (index) => (index === 1 ? 'pause' : undefined) });
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 2, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
expect(await run(uploader, 8)).toBe(false);
|
||||
expect(server.log).not.toContain('start:7');
|
||||
});
|
||||
|
||||
it('不可重试的错误:push 或 finish 抛出,并且不再发新帧', async () => {
|
||||
const server = fakeServer({ reject: (index) => (index === 1 ? 'error' : undefined) });
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 2, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
await expect(run(uploader, 8)).rejects.toThrow('boom');
|
||||
expect(server.log).not.toContain('start:7');
|
||||
});
|
||||
|
||||
it('积压超过上限时 push 会等待,不让加密一口气跑完整个文件', async () => {
|
||||
const server = fakeServer({ delay: () => 15 });
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 1, send: server.send, signal: new AbortController().signal, onBytes: () => {}, buffer: 2 });
|
||||
let pushed = 0;
|
||||
const producer = (async () => { for (let index = 0; index < 6; index += 1) { await uploader.push(frame(index), index === 5); pushed += 1; } })();
|
||||
await tick(); await tick();
|
||||
expect(pushed).toBeLessThan(6);
|
||||
await producer; expect(await uploader.finish()).toBe(true);
|
||||
});
|
||||
|
||||
it('已取消的信号:push 直接中止', async () => {
|
||||
const controller = new AbortController(); controller.abort();
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 2, send: fakeServer().send, signal: controller.signal, onBytes: () => {} });
|
||||
await expect(uploader.push(frame(0), true)).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
it('上报的是已确认加在途的字节数', async () => {
|
||||
const seen: number[] = [];
|
||||
const uploader = new FrameUploader({ gated: false, concurrency: 1, send: fakeServer().send, signal: new AbortController().signal, onBytes: (sent) => seen.push(sent) });
|
||||
await run(uploader, 3);
|
||||
expect(Math.max(...seen)).toBe(300);
|
||||
expect(seen[seen.length - 1]).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FrameUploader 自适应并发', () => {
|
||||
it('遇到重试就把并发减半,不再占满连接', async () => {
|
||||
let retried = false; let droppedTo = 0;
|
||||
const send: SendFrame = async (f, hooks) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
if (f.index === 1 && !retried) { retried = true; hooks.onRetry(); droppedTo = uploader.currentConcurrency; }
|
||||
return true;
|
||||
};
|
||||
const uploader: FrameUploader = new FrameUploader({ gated: true, concurrency: 4, send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
expect(await run(uploader, 20)).toBe(true);
|
||||
expect(droppedTo).toBe(2);
|
||||
});
|
||||
|
||||
it('连续顺利后并发度慢慢恢复', async () => {
|
||||
let first = true;
|
||||
const send: SendFrame = async (f, hooks) => { await new Promise((resolve) => setTimeout(resolve, 1)); if (first && f.index === 1) { first = false; hooks.onRetry(); } return true; };
|
||||
const uploader = new FrameUploader({ gated: true, concurrency: 4, send, signal: new AbortController().signal, onBytes: () => {} });
|
||||
expect(await run(uploader, 60)).toBe(true);
|
||||
expect(uploader.currentConcurrency).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransferMeter', () => {
|
||||
it('用最近一段时间估算速度和剩余时间', () => {
|
||||
const meter = new TransferMeter(3000);
|
||||
expect(meter.speed()).toBeUndefined();
|
||||
meter.add(0, 0); meter.add(1_000_000, 1000); meter.add(2_000_000, 2000);
|
||||
expect(meter.speed()).toBeCloseTo(1_000_000, -3);
|
||||
expect(meter.eta(2_000_000, 5_000_000)).toBe(3);
|
||||
});
|
||||
|
||||
it('速度只看窗口内的样本,网络变慢后很快反映出来', () => {
|
||||
const meter = new TransferMeter(3000);
|
||||
meter.add(0, 0); meter.add(10_000_000, 1000); meter.add(10_100_000, 5000); meter.add(10_200_000, 7000);
|
||||
expect(meter.speed()!).toBeLessThan(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateCipherBytes', () => {
|
||||
it('按帧数乘第一帧密文长度估算', () => {
|
||||
expect(estimateCipherBytes(0, 1000, 1016)).toBe(1016);
|
||||
expect(estimateCipherBytes(2500, 1000, 1016)).toBe(3048);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { QueuedFrame } from '../database';
|
||||
|
||||
/**
|
||||
* 单个文件的帧上传调度(protocol §5、服务端 AppendFrame 的约束):
|
||||
* - 第 0 帧先单独上传(它在服务端创建条目);
|
||||
* - 受限(gated)文件的中间帧彼此独立,可以并发;非受限文件的帧会按到达顺序进入日志,必须逐帧顺序上传;
|
||||
* - 末帧带整份内容的签名,服务端要求前面的帧都已入库,所以必须等其余帧全部确认后再发。
|
||||
*/
|
||||
export type SendFrame = (frame: QueuedFrame, hooks: { onLoaded: (loadedBytes: number) => void; onRetry: () => void }, signal: AbortSignal) => Promise<boolean>;
|
||||
|
||||
interface Options {
|
||||
/** 受限文件的中间帧可以并发 */
|
||||
gated: boolean;
|
||||
concurrency: number;
|
||||
send: SendFrame;
|
||||
signal: AbortSignal;
|
||||
/** 已确认 + 在途的字节数变化时回调 */
|
||||
onBytes: (sentBytes: number) => void;
|
||||
/** 生产者(加密)领先上传的最大帧数,防止整个文件堆在内存里 */
|
||||
buffer?: number;
|
||||
/** 续传:这些帧上次已经被服务端确认,不必再发 */
|
||||
alreadyAccepted?: Iterable<number>;
|
||||
}
|
||||
|
||||
export class FrameUploader {
|
||||
private ready: { frame: QueuedFrame; final: boolean }[] = [];
|
||||
private inflight = 0;
|
||||
private readonly accepted = new Set<number>();
|
||||
private readonly loaded = new Map<number, number>();
|
||||
private acceptedBytes = 0;
|
||||
private finalIndex: number | undefined;
|
||||
private paused = false;
|
||||
private error: unknown;
|
||||
private failed = false;
|
||||
private waiters: (() => void)[] = [];
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.dynamicLimit = options.concurrency;
|
||||
for (const index of options.alreadyAccepted ?? []) this.accepted.add(index);
|
||||
}
|
||||
|
||||
/** 已确认的帧数(含续传前已确认的) */
|
||||
get acceptedCount(): number { return this.accepted.size; }
|
||||
|
||||
/** 当前并发度:遇到重试就减半,连续顺利再慢慢加回去(弱网下少占连接,好网下跑满) */
|
||||
private dynamicLimit: number;
|
||||
private streak = 0;
|
||||
get currentConcurrency(): number { return this.limit; }
|
||||
private get limit(): number { return this.options.gated ? Math.max(1, Math.min(this.options.concurrency, this.dynamicLimit)) : 1; }
|
||||
private get bufferLimit(): number { return this.options.buffer ?? this.limit * 2 + 2; }
|
||||
|
||||
/** 一帧已加密并写入离线队列。积压太多时等待,让加密不要跑得比上传快太多 */
|
||||
async push(frame: QueuedFrame, final: boolean): Promise<void> {
|
||||
if (this.failed) throw this.error;
|
||||
if (this.options.signal.aborted) throw new DOMException('上传已取消', 'AbortError');
|
||||
if (final) this.finalIndex = frame.index;
|
||||
// 已暂停:帧留在离线队列里,等网络恢复后由 flushOutbox 续传
|
||||
if (this.paused) return;
|
||||
this.ready.push({ frame, final });
|
||||
this.pump();
|
||||
while (!final && !this.paused && !this.failed && this.ready.length + this.inflight >= this.bufferLimit) await this.wait();
|
||||
if (this.failed) throw this.error;
|
||||
}
|
||||
|
||||
/** 等所有帧确认。返回 false 表示中途暂停(重试用尽,多半断网);失败会抛出 */
|
||||
async finish(): Promise<boolean> {
|
||||
while (!this.settled()) await this.wait();
|
||||
if (this.failed) throw this.error;
|
||||
return !this.paused;
|
||||
}
|
||||
|
||||
private settled(): boolean {
|
||||
if (this.failed || this.paused) return this.inflight === 0;
|
||||
return this.finalIndex !== undefined && this.accepted.size === this.finalIndex + 1;
|
||||
}
|
||||
|
||||
private canStart(frame: QueuedFrame, final: boolean): boolean {
|
||||
if (final) return this.accepted.size === frame.index && this.inflight === 0;
|
||||
if (frame.index === 0) return this.inflight === 0;
|
||||
if (!this.options.gated) return this.accepted.size === frame.index && this.inflight === 0;
|
||||
return this.accepted.has(0);
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
while (!this.failed && !this.paused && this.inflight < this.limit) {
|
||||
const at = this.ready.findIndex((entry) => this.canStart(entry.frame, entry.final));
|
||||
if (at < 0) break;
|
||||
const [entry] = this.ready.splice(at, 1);
|
||||
this.start(entry!.frame);
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private start(frame: QueuedFrame): void {
|
||||
this.inflight += 1;
|
||||
let retried = false;
|
||||
this.options.send(frame, {
|
||||
onLoaded: (loaded) => { this.loaded.set(frame.index, loaded); this.report(); },
|
||||
onRetry: () => { retried = true; this.dynamicLimit = Math.max(1, Math.floor(this.dynamicLimit / 2)); this.streak = 0; },
|
||||
}, this.options.signal)
|
||||
.then((accepted) => {
|
||||
this.loaded.delete(frame.index);
|
||||
if (accepted) {
|
||||
this.accepted.add(frame.index); this.acceptedBytes += frame.bytes.byteLength;
|
||||
if (!retried && ++this.streak >= 8 && this.dynamicLimit < this.options.concurrency) { this.dynamicLimit += 1; this.streak = 0; }
|
||||
}
|
||||
else { this.paused = true; this.ready = []; }
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.loaded.delete(frame.index);
|
||||
if (!this.failed) { this.failed = true; this.error = error; }
|
||||
this.ready = [];
|
||||
})
|
||||
.finally(() => { this.inflight -= 1; this.report(); this.pump(); });
|
||||
}
|
||||
|
||||
private report(): void {
|
||||
let sent = this.acceptedBytes;
|
||||
for (const value of this.loaded.values()) sent += value;
|
||||
this.options.onBytes(sent);
|
||||
}
|
||||
|
||||
private wait(): Promise<void> { return new Promise((resolve) => { this.waiters.push(resolve); }); }
|
||||
private notify(): void { const waiting = this.waiters; this.waiters = []; for (const resume of waiting) resume(); }
|
||||
}
|
||||
|
||||
/** 最近几秒的滑动平均速度,网络变化后很快跟上,不会被开头的数据拖累 */
|
||||
export class TransferMeter {
|
||||
private samples: { at: number; bytes: number }[] = [];
|
||||
constructor(private readonly windowMs = 3000) {}
|
||||
|
||||
add(bytes: number, at = performance.now()): void {
|
||||
this.samples.push({ at, bytes });
|
||||
const from = at - this.windowMs;
|
||||
while (this.samples.length > 2 && this.samples[1]!.at <= from) this.samples.shift();
|
||||
}
|
||||
|
||||
/** 字节每秒;样本不足或时间太短时为 undefined */
|
||||
speed(): number | undefined {
|
||||
const first = this.samples[0]; const last = this.samples[this.samples.length - 1];
|
||||
if (!first || !last || last.at - first.at < 400) return undefined;
|
||||
const value = (last.bytes - first.bytes) * 1000 / (last.at - first.at);
|
||||
return value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** 剩余秒数 */
|
||||
eta(sentBytes: number, totalBytes: number): number | undefined {
|
||||
const speed = this.speed();
|
||||
return speed ? Math.max(0, Math.ceil((totalBytes - sentBytes) / speed)) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按第一帧的密文长度估算整个文件上传的总字节数;全部加密完成后会用精确值替换 */
|
||||
export function estimateCipherBytes(fileSize: number, maxFrameData: number, firstFrameBytes: number): number {
|
||||
return Math.max(1, Math.ceil(fileSize / maxFrameData)) * firstFrameBytes;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { dequeueFrame, dequeueItem, queueFrame, queueLocation, saveResource, type QueuedFrame, type SavedResource } from '../database';
|
||||
import { errorMessage } from '../errors';
|
||||
import type { FileItem, FileUploadOptions } from '../types';
|
||||
import { prepareObject } from './channelDataKeys';
|
||||
import { queueFileCatalog } from './channelThreads';
|
||||
import { deviceIdentity, publishWorkspace } from './presentation';
|
||||
import { senderSnapshot } from './senderSnapshot';
|
||||
import { blobs, getSession } from './sessions';
|
||||
import { flushOutbox, markPipelined, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan, unmarkPipelined, uploadFrame } from './transfer';
|
||||
import { FrameStash } from './frameStash';
|
||||
import { estimateCipherBytes, FrameUploader, TransferMeter } from './uploadPipeline';
|
||||
|
||||
/** 受限文件内部同时在途的帧数。再多对单条连接的带宽提升有限,还会占满浏览器的并发连接 */
|
||||
const FRAME_CONCURRENCY = 4;
|
||||
const PROGRESS_INTERVAL_MS = 150;
|
||||
|
||||
interface Job {
|
||||
resourceId: string;
|
||||
file: File;
|
||||
options: FileUploadOptions | undefined;
|
||||
threadId: string;
|
||||
folderId: string | undefined;
|
||||
localId: string | undefined;
|
||||
controller: AbortController;
|
||||
/** 到运行时才建立的条目 id,取消时据此清理 */
|
||||
itemId?: string;
|
||||
run: () => Promise<void>;
|
||||
settle: { resolve: () => void; reject: (error: unknown) => void };
|
||||
}
|
||||
interface Lane { active?: Job; waiting: Job[] }
|
||||
|
||||
/** 每个空间一条上传队列:一次只传一个文件,其余排队;文件内部的帧可以并发(见 FrameUploader) */
|
||||
const lanes = new Map<string, Lane>();
|
||||
|
||||
const isAbort = (error: unknown): boolean => (error as Error | undefined)?.name === 'AbortError';
|
||||
|
||||
export function enqueueFileUploads(resourceId: string, files: File[], options: FileUploadOptions | undefined,
|
||||
threadId: string, folderId: string | undefined, localIds: string[] | undefined): Promise<void> {
|
||||
const lane = lanes.get(resourceId) ?? { waiting: [] };
|
||||
lanes.set(resourceId, lane);
|
||||
const done = files.map((file, index) => new Promise<void>((resolve, reject) => {
|
||||
const job: Job = { resourceId, file, options, threadId, folderId, localId: localIds?.[index], controller: new AbortController(),
|
||||
run: () => runJob(job), settle: { resolve, reject } };
|
||||
lane.waiting.push(job);
|
||||
}));
|
||||
drain(resourceId, lane);
|
||||
return Promise.allSettled(done).then((results) => {
|
||||
const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
if (failed) throw failed.reason;
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消排队中或上传中的文件。返回是否找到了这个任务 */
|
||||
export function cancelFileUpload(resourceId: string, localId: string): boolean {
|
||||
const lane = lanes.get(resourceId);
|
||||
if (!lane) return false;
|
||||
const waiting = lane.waiting.findIndex((job) => job.localId === localId);
|
||||
if (waiting >= 0) { const [job] = lane.waiting.splice(waiting, 1); job!.settle.resolve(); return true; }
|
||||
if (lane.active?.localId === localId) { lane.active.controller.abort(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function drain(resourceId: string, lane: Lane): void {
|
||||
if (lane.active) return;
|
||||
const job = lane.waiting.shift();
|
||||
if (!job) { lanes.delete(resourceId); return; }
|
||||
lane.active = job;
|
||||
job.run().then(job.settle.resolve, job.settle.reject).finally(() => { lane.active = undefined; drain(resourceId, lane); });
|
||||
}
|
||||
|
||||
async function runJob(job: Job): Promise<void> {
|
||||
const { resourceId, file, threadId, folderId, localId } = job;
|
||||
const signal = job.controller.signal;
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const options = job.options ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
|
||||
const device = await deviceIdentity(session.endpointSecret);
|
||||
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
const author = await senderSnapshot();
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
let stash: FrameStash | undefined;
|
||||
try {
|
||||
if (signal.aborted) return;
|
||||
const prepared = await prepareObject(resource, session, threadId, 'files');
|
||||
const plan = session.encryptFile(file, {}, {
|
||||
itemId: prepared.itemIdBytes,
|
||||
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
||||
ts, maxFrameData, threadId, folderId,
|
||||
});
|
||||
item = {
|
||||
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
...(author ? { author } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: plan.gated
|
||||
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
|
||||
: null,
|
||||
preview: previewKindOf(file.type, file.name), gated: plan.gated,
|
||||
};
|
||||
job.itemId = item.id;
|
||||
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
|
||||
await saveResource(resource);
|
||||
blobs.set(`${resourceId}:${item.id}`, file);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
markPipelined(resourceId, item.id);
|
||||
|
||||
const progress = createProgress(resourceId, () => item, (next) => { item = next; });
|
||||
// 帧先直接上传,来不及上传的才在后台写进离线队列(见 frameStash);离线队列由 stash 管理,上传成功后不用再去删
|
||||
const queued = new FrameStash(queueFrame, dequeueFrame);
|
||||
stash = queued;
|
||||
const uploader = new FrameUploader({
|
||||
gated: plan.gated, concurrency: FRAME_CONCURRENCY, signal,
|
||||
send: (frame, hooks, s) => uploadFrame(resource, session, frame, { ...hooks, manageQueue: false, onAccepted: () => queued.accepted(frame) }, s),
|
||||
onBytes: progress.sent,
|
||||
});
|
||||
const planned = await persistPlan(resource, plan, undefined,
|
||||
{ channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' },
|
||||
async (frame: QueuedFrame, final) => {
|
||||
if (frame.index === 0) progress.estimate(estimateCipherBytes(file.size, maxFrameData, frame.bytes.byteLength));
|
||||
await queued.add(frame);
|
||||
await uploader.push(frame, final);
|
||||
});
|
||||
progress.exact(planned.bytes);
|
||||
await queueFileCatalog(resource, session, item);
|
||||
await queueLocation(resourceId, item.id, threadId, folderId);
|
||||
const framesUploaded = await uploader.finish();
|
||||
// 没传完的(断网暂停)帧要先写进离线队列,flushOutbox 才续传得了
|
||||
await queued.drain();
|
||||
unmarkPipelined(resourceId, item.id);
|
||||
// 帧都确认后,再发目录条目和位置登记(以及上次没发完的遗留)
|
||||
const uploaded = framesUploaded && await flushOutbox(resource, session);
|
||||
await finish(resource, item, uploaded ? { kind: 'complete' } : { kind: 'incomplete' });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' });
|
||||
} catch (error) {
|
||||
if (item) unmarkPipelined(resourceId, item.id);
|
||||
if (signal.aborted || isAbort(error)) { await stash?.discard(); await discard(resource, item); return; }
|
||||
job.controller.abort();
|
||||
// 失败时已加密还没确认的帧要保住,之后才能续传
|
||||
await stash?.drain().catch(() => undefined);
|
||||
if (item) await finish(resource, item, { kind: 'incomplete' });
|
||||
publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function finish(resource: SavedResource, item: FileItem, state: FileItem['state']): Promise<void> {
|
||||
const next = { ...item, state };
|
||||
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === next.id ? next : entry);
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resource.id, { t: 'item', item: next });
|
||||
}
|
||||
|
||||
/** 取消:清掉离线队列里这个文件的帧和本地条目。服务端已收下的帧会随空间保留期自然过期 */
|
||||
async function discard(resource: SavedResource, item: FileItem | undefined): Promise<void> {
|
||||
if (!item) return;
|
||||
await dequeueItem(resource.id, item.id);
|
||||
resource.localItems = (resource.localItems ?? []).filter((entry) => entry.id !== item.id);
|
||||
await saveResource(resource);
|
||||
blobs.delete(`${resource.id}:${item.id}`);
|
||||
publishWorkspace(resource.id, { t: 'removed', id: item.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「已发送字节数」换算成一条 0–100% 的进度,并带上速度和剩余时间。
|
||||
* 总量先按第一帧估算,全部加密完成后换成精确值;最后一帧确认前封顶 99%。
|
||||
*/
|
||||
function createProgress(resourceId: string, current: () => FileItem | undefined, update: (item: FileItem) => void) {
|
||||
const meter = new TransferMeter();
|
||||
let total = 0; let lastSent = 0; let lastPublished = 0;
|
||||
const publish = (force: boolean) => {
|
||||
const item = current();
|
||||
if (!item || total <= 0) return;
|
||||
const now = performance.now();
|
||||
if (!force && now - lastPublished < PROGRESS_INTERVAL_MS) return;
|
||||
lastPublished = now;
|
||||
const speed = meter.speed(); const eta = meter.eta(lastSent, total);
|
||||
const next: FileItem = { ...item, state: {
|
||||
kind: 'receiving', progress: Math.max(0, Math.min(0.99, lastSent / total)), phase: lastSent > 0 ? 'uploading' : 'encrypting',
|
||||
sentBytes: Math.min(lastSent, total), totalBytes: total,
|
||||
...(speed === undefined ? {} : { speedBytesPerSecond: speed }), ...(eta === undefined ? {} : { etaSeconds: eta }),
|
||||
} };
|
||||
update(next);
|
||||
publishWorkspace(resourceId, { t: 'item', item: next });
|
||||
};
|
||||
return {
|
||||
estimate: (bytes: number) => { total = bytes; publish(true); },
|
||||
exact: (bytes: number) => { total = bytes; publish(true); },
|
||||
sent: (bytes: number) => { lastSent = bytes; meter.add(bytes); publish(false); },
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest } from '../generated/api';
|
||||
import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest, WorkspaceRoleCreatedBody, WorkspaceRoleRequest } from '../generated/api';
|
||||
import type { FileItem } from '../types';
|
||||
import { getResource, cacheResource } from '../database';
|
||||
import { getSession } from './sessions';
|
||||
@@ -40,7 +40,7 @@ export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
|
||||
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise<WorkspaceBody> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/${path}`, {
|
||||
method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value),
|
||||
method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify(value),
|
||||
}).then(r => r.json() as Promise<WorkspaceBody>);
|
||||
await publish(id, body);
|
||||
return body;
|
||||
@@ -59,6 +59,13 @@ export async function createWorkspaceGroup(id: string, value: WorkspaceNameReque
|
||||
await publish(id, body.workspace);
|
||||
return body.group.id;
|
||||
}
|
||||
export async function createWorkspaceRole(id: string, value: WorkspaceRoleRequest): Promise<string> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/roles`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
|
||||
.then(r => r.json() as Promise<WorkspaceRoleCreatedBody>);
|
||||
await publish(id, body.workspace);
|
||||
return body.role.id;
|
||||
}
|
||||
export async function registerItemLocation(id: string, item: Pick<FileItem, 'id' | 'threadId' | 'folderId'>): Promise<void> {
|
||||
if (!item.threadId) return;
|
||||
await changeWorkspace(id, 'locations', 'PUT', { itemIds: [item.id], threadId: item.threadId, folderId: item.folderId } satisfies WorkspaceMoveRequest);
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
height: min(360px, 70dvh);
|
||||
}
|
||||
|
||||
/* 身份组页内容多:加高,列表在滚动时固定在左边 */
|
||||
.tall {
|
||||
height: min(560px, 80dvh);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,17 +3,19 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { RolesEditor } from './RolesEditor';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
import { ttlLabel, withCurrent } from '@/lib/settingOptions';
|
||||
|
||||
type Tab = 'basic' | 'content' | 'join';
|
||||
type Tab = 'basic' | 'content' | 'join' | 'roles';
|
||||
const TABS: { id: Tab; label: string; icon: IconName }[] = [
|
||||
{ id: 'basic', label: '基本', icon: 'edit' },
|
||||
{ id: 'content', label: '内容', icon: 'history' },
|
||||
{ id: 'join', label: '加入', icon: 'users' },
|
||||
{ id: 'roles', label: '身份组', icon: 'users' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -66,9 +68,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
|
||||
return (
|
||||
<Sheet title="空间设置" onClose={onClose} wide>
|
||||
<div className={styles.layout}>
|
||||
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
|
||||
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
|
||||
{TABS.map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}>
|
||||
{TABS.filter((item) => owner || item.id !== 'roles').map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}>
|
||||
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
@@ -105,8 +107,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除加入密码</span></label> : null}
|
||||
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
|
||||
</> : null}
|
||||
{tab === 'roles' ? <RolesEditor /> : null}
|
||||
</section>
|
||||
{owner ? (
|
||||
{tab === 'roles' ? null : owner ? (
|
||||
<div className={styles.footer}>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
<Button variant="primary" disabled={saving || !dirty || invalid} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { formatSize, formatTime } from '@/lib/format';
|
||||
import { TransferProgress } from '@/features/item/TransferProgress';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||
import { NameWithBadges } from './RoleBadges';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
||||
@@ -16,6 +18,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
const retry = useWorkspace((s) => s.retrySend);
|
||||
const cancelUpload = useWorkspace((s) => s.cancelUpload);
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
@@ -49,7 +52,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.body}>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileCard}>
|
||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
@@ -59,7 +62,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||
</div>}
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <TransferProgress state={item.state} {...(self && item.localId ? { onCancel: () => { void cancelUpload(item.localId!); } } : {})} />
|
||||
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,13 @@ import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { permissionsOf } from '@/lib/roles';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { NameWithBadges } from './RoleBadges';
|
||||
import styles from './MembersPane.module.css';
|
||||
|
||||
interface Props { owner: boolean; refreshKey: number; onInvite: () => void }
|
||||
interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void }
|
||||
|
||||
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
|
||||
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
|
||||
@@ -22,10 +25,12 @@ function Section({ title, count, children }: { title: string; count: number; chi
|
||||
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
|
||||
|
||||
const PRESENCE_POLL_MS = 8000;
|
||||
export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const self = useAccount((s) => s.username);
|
||||
const listInvitations = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
@@ -37,10 +42,10 @@ export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
if (!channelId) return;
|
||||
try {
|
||||
setMembers(await channelAccessApi.members(channelId));
|
||||
setPending(owner ? await listInvitations() : []);
|
||||
setPending(canRemove ? await listInvitations() : []);
|
||||
setError('');
|
||||
} catch { setError('成员清单读取失败'); }
|
||||
}, [channelId, owner, listInvitations]);
|
||||
}, [channelId, canRemove, listInvitations]);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// 在线状态不走订阅流,成员页可见期间定时读取
|
||||
@@ -66,15 +71,17 @@ export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
const name = person?.displayName || username;
|
||||
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
|
||||
<Avatar person={person} name={name} />
|
||||
<span className={styles.who}><strong>{name}{isOwner ? <em>空间主</em> : null}</strong>
|
||||
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges></strong>
|
||||
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
|
||||
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
|
||||
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
|
||||
</li>;
|
||||
};
|
||||
// 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人
|
||||
const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove')));
|
||||
const member = (item: ChannelMember) => row(item.username,
|
||||
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
|
||||
false, owner && item.role !== 'owner', item.role === 'owner', item.online);
|
||||
false, removable(item), item.role === 'owner', item.online);
|
||||
|
||||
return <div className={styles.pane}>
|
||||
<div className={styles.head}>
|
||||
@@ -86,7 +93,7 @@ export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
|
||||
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{!owner ? <p className={styles.note}>只有空间主可以邀请或移除成员。</p> : null}
|
||||
{!owner ? <p className={styles.note}>{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}</p> : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.badge {
|
||||
--c: var(--text-dim);
|
||||
|
||||
display: inline-block;
|
||||
margin-inline: var(--space-1);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--c);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.owner {
|
||||
--c: var(--text);
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--c: var(--busy);
|
||||
}
|
||||
|
||||
.green {
|
||||
--c: var(--ok);
|
||||
}
|
||||
|
||||
.amber {
|
||||
--c: var(--warn);
|
||||
}
|
||||
|
||||
.red {
|
||||
--c: var(--danger);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { badgesOf, type Badge } from '@/lib/roles';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import styles from './RoleBadges.module.css';
|
||||
|
||||
function List({ items }: { items: Badge[] }) {
|
||||
return <>{items.map((badge) => <span key={badge.key} className={`${styles.badge} ${styles[badge.color]}`}>{badge.text}</span>)}</>;
|
||||
}
|
||||
|
||||
/** 名字前后的身份标签:空间主固定一个,其余是身份组里自定义的前缀、后缀 */
|
||||
export function useBadges(username: string | undefined): { prefix: Badge[]; suffix: Badge[] } {
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const creator = useWorkspace((s) => s.channel?.creatorAccount);
|
||||
return useMemo(() => badgesOf(roles, username, creator), [roles, username, creator]);
|
||||
}
|
||||
|
||||
export function NameWithBadges({ username, children }: { username: string | undefined; children: React.ReactNode }) {
|
||||
const { prefix, suffix } = useBadges(username);
|
||||
return <><List items={prefix} />{children}<List items={suffix} /></>;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 148px minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list button:hover,
|
||||
.list button[aria-current='true'] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.list span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list small {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
--c: var(--text-dim);
|
||||
|
||||
min-width: 36px;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--c);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatch[aria-checked='true'] {
|
||||
border-color: var(--c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--c: var(--busy);
|
||||
}
|
||||
|
||||
.green {
|
||||
--c: var(--ok);
|
||||
}
|
||||
|
||||
.amber {
|
||||
--c: var(--warn);
|
||||
}
|
||||
|
||||
.red {
|
||||
--c: var(--danger);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.group legend {
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
min-height: 32px;
|
||||
padding: var(--space-1) 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.check small {
|
||||
display: block;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (width <= 720px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.list {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list button {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
import { PERMISSIONS, ROLE_COLORS } from '@/lib/roles';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './RolesEditor.module.css';
|
||||
|
||||
const COLOR_LABEL = { gray: '灰', blue: '蓝', green: '绿', amber: '黄', red: '红', purple: '紫' } as const;
|
||||
|
||||
function RoleForm({ role, members, onDone }: { role?: RoleInfo; members: ChannelMember[]; onDone: (id?: string) => void }) {
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const createRole = useWorkspace((s) => s.createRole);
|
||||
const updateRole = useWorkspace((s) => s.updateRole);
|
||||
const deleteRole = useWorkspace((s) => s.deleteRole);
|
||||
const setRoleMembers = useWorkspace((s) => s.setRoleMembers);
|
||||
const [name, setName] = useState(role?.name ?? '');
|
||||
const [prefix, setPrefix] = useState(role?.prefix ?? '');
|
||||
const [suffix, setSuffix] = useState(role?.suffix ?? '');
|
||||
const [color, setColor] = useState<string>(role?.color ?? 'gray');
|
||||
const [permissions, setPermissions] = useState<ReadonlySet<string>>(new Set(role?.permissions));
|
||||
const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(role?.members));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const toggle = (set: ReadonlySet<string>, id: string) => { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); return next; };
|
||||
|
||||
async function run(task: () => Promise<string | undefined>) {
|
||||
setBusy(true);
|
||||
try { onDone(await task()); }
|
||||
catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const save = () => run(async () => {
|
||||
const request = { name: name.trim(), prefix: prefix.trim() || null, suffix: suffix.trim() || null, color, permissions: [...permissions] };
|
||||
let id = role?.id;
|
||||
if (id) await updateRole(id, request); else id = await createRole(request);
|
||||
await setRoleMembers(id, [...chosen]);
|
||||
useToast.getState().push({ kind: 'ok', text: '身份组已保存' });
|
||||
return id;
|
||||
});
|
||||
const remove = () => run(async () => { if (role) await deleteRole(role.id); return undefined; });
|
||||
const sample = `${prefix.trim()}${prefix.trim() ? ' ' : ''}你的名字${suffix.trim() ? ' ' : ''}${suffix.trim()}`;
|
||||
|
||||
return <form className={styles.form} onSubmit={(event) => { event.preventDefault(); if (name.trim()) void save(); }}>
|
||||
<label className={styles.field}>名称
|
||||
<input maxLength={48} value={name} placeholder="例如:管理组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<div className={styles.pair}>
|
||||
<label className={styles.field}>前缀(可选)
|
||||
<input maxLength={12} value={prefix} placeholder="例如:【组长】" onChange={(event) => setPrefix(event.target.value)} /></label>
|
||||
<label className={styles.field}>后缀(可选)
|
||||
<input maxLength={12} value={suffix} placeholder="例如:管理员" onChange={(event) => setSuffix(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className={styles.field}>颜色 · 聊天里显示为 <b>{sample}</b>
|
||||
<div className={styles.colors} role="radiogroup" aria-label="标签颜色">
|
||||
{ROLE_COLORS.map((item) => <button key={item} type="button" role="radio" aria-checked={color === item} className={`${styles.swatch} ${styles[item]}`}
|
||||
onClick={() => setColor(item)}>{COLOR_LABEL[item]}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className={styles.group}><legend>权限</legend>
|
||||
{PERMISSIONS.map((item) => <label key={item.id} className={styles.check}>
|
||||
<input type="checkbox" checked={permissions.has(item.id)} onChange={() => setPermissions(toggle(permissions, item.id))} />
|
||||
<span>{item.label}<small>{item.hint}</small></span></label>)}
|
||||
</fieldset>
|
||||
<fieldset className={styles.group}><legend>成员 · {chosen.size}</legend>
|
||||
{members.length ? members.map((member) => <label key={member.username} className={styles.check}>
|
||||
<input type="checkbox" checked={chosen.has(member.username)} onChange={() => setChosen(toggle(chosen, member.username))} />
|
||||
<span>{people[member.username]?.displayName || member.username}<small>{member.username}</small></span></label>)
|
||||
: <p className={styles.empty}>还没有其他成员。成员接受邀请后可以加入身份组。</p>}
|
||||
</fieldset>
|
||||
<div className={styles.actions}>
|
||||
{role ? (confirmDelete
|
||||
? <><Button variant="ghost" disabled={busy} onClick={() => setConfirmDelete(false)}>先不删</Button><Button variant="danger" disabled={busy} onClick={() => { void remove(); }}>确认删除</Button></>
|
||||
: <Button variant="ghost" icon="trash" disabled={busy} onClick={() => setConfirmDelete(true)}>删除身份组</Button>) : null}
|
||||
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : role ? '保存' : '创建身份组'}</Button>
|
||||
</div>
|
||||
</form>;
|
||||
}
|
||||
|
||||
/** 空间设置里的「身份组」页:左边是身份组列表,右边是编辑表单 */
|
||||
export function RolesEditor() {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (!channelId) return;
|
||||
let active = true;
|
||||
channelAccessApi.members(channelId).then((rows) => { if (active) setMembers(rows.filter((row) => row.role !== 'owner')); }).catch(() => {});
|
||||
return () => { active = false; };
|
||||
}, [channelId]);
|
||||
const role = roles.find((item) => item.id === selected);
|
||||
const first = role ?? (selected === '' ? roles[0] : undefined);
|
||||
const creating = !first;
|
||||
|
||||
return <div className={styles.layout}>
|
||||
<ul className={styles.list} aria-label="身份组">
|
||||
{roles.map((item) => <li key={item.id}><button type="button" aria-current={first?.id === item.id} onClick={() => setSelected(item.id)}>
|
||||
<Icon name="users" size={16} /><span>{item.name}</span><small>{item.members.length}</small></button></li>)}
|
||||
<li><button type="button" className={styles.add} aria-current={creating} onClick={() => setSelected('new')}><Icon name="plus" size={16} /><span>新建身份组</span></button></li>
|
||||
</ul>
|
||||
<RoleForm key={first?.id ?? 'new'} role={first} members={members} onDone={(id) => setSelected(id ?? '')} />
|
||||
</div>;
|
||||
}
|
||||
@@ -9,7 +9,10 @@ interface Props {
|
||||
threads: ThreadInfo[];
|
||||
groups: ChannelGroupInfo[];
|
||||
selectedThreadId?: string;
|
||||
/** 能重命名频道、管理分组(空间主,或被授予「管理频道和分组」) */
|
||||
enabled: boolean;
|
||||
/** 新建频道涉及密钥,只有空间主 */
|
||||
canCreate: boolean;
|
||||
owner: boolean;
|
||||
onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>;
|
||||
onSelectThread: (id: string) => void;
|
||||
@@ -72,7 +75,7 @@ export function ThreadSidebar(props: Props) {
|
||||
<button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}>
|
||||
<Icon name={collapsed.has(UNGROUPED) ? 'chevronRight' : 'chevronDown'} size={14} /><span>未分组</span>
|
||||
</button>
|
||||
{props.enabled ? <span className={styles.categoryActions}>
|
||||
{props.canCreate ? <span className={styles.categoryActions}>
|
||||
<Button variant="ghost" icon="plus" label="在未分组新建频道" disabled={busy} onClick={() => begin({ kind: 'channel' })} />
|
||||
</span> : null}
|
||||
</div>
|
||||
@@ -85,7 +88,7 @@ export function ThreadSidebar(props: Props) {
|
||||
<div className={styles.groupHeading}><button className={styles.groupToggle} type="button" aria-label={`分组 ${group.name}`} aria-expanded={!collapsed.has(group.id)} onClick={() => toggle(group.id)}>
|
||||
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span>
|
||||
</button>{props.enabled ? <span className={styles.categoryActions}>
|
||||
<Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
||||
{props.canCreate ? <Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} /> : null}
|
||||
<Button className={styles.groupMore} variant="ghost" icon="more" label={`分组设置 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
||||
</span> : null}</div>
|
||||
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||
import { MobileDrawer } from './MobileDrawer';
|
||||
import { MobileTopBar } from './MobileTopBar';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { permissionsOf } from '@/lib/roles';
|
||||
import { ThreadSidebar } from './ThreadSidebar';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileExplorer } from './FileExplorer';
|
||||
@@ -82,6 +84,9 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload);
|
||||
const owner = channel?.role === 'owner';
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const self = useAccount((s) => s.username);
|
||||
const perms = permissionsOf(roles, self ?? undefined, owner);
|
||||
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
|
||||
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
|
||||
@@ -126,7 +131,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const channelId = channel?.id;
|
||||
useEffect(() => { setDrawerOpen(false); }, [channelId]);
|
||||
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
||||
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
|
||||
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure) || perms.has('structure.manage')} canCreate={owner} owner={owner} onHistory={setChannelHistory}
|
||||
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />;
|
||||
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
|
||||
@@ -143,7 +148,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
|
||||
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
||||
const members = <MembersPane owner={owner} canRemove={perms.has('members.remove')} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
||||
function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); }
|
||||
const moreRows: { icon: IconName; label: string; run: () => void }[] = [
|
||||
{ icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
.transfer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.line small {
|
||||
margin-inline-start: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.line small + .cancel {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.line > span:first-child + .cancel {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.transfer progress {
|
||||
width: 100%;
|
||||
height: var(--space-1);
|
||||
accent-color: var(--busy);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { formatSize, formatTransferProgress, type TransferState } from '@/lib/format';
|
||||
import styles from './TransferProgress.module.css';
|
||||
|
||||
interface Props {
|
||||
state: TransferState & { sentBytes?: number; totalBytes?: number };
|
||||
/** 排队中或上传中的文件可以取消 */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/** 文件上传的进度:一行状态文字,一条进度条,可取消 */
|
||||
export function TransferProgress({ state, onCancel }: Props) {
|
||||
const queued = state.phase === 'queued';
|
||||
const detail = state.sentBytes !== undefined && state.totalBytes ? `${formatSize(state.sentBytes)} / ${formatSize(state.totalBytes)}` : '';
|
||||
return <div className={styles.transfer} role="status">
|
||||
<div className={styles.line}>
|
||||
<span>{formatTransferProgress(state)}</span>
|
||||
{detail && !queued ? <small>{detail}</small> : null}
|
||||
{onCancel ? <button type="button" className={styles.cancel} onClick={onCancel}>取消</button> : null}
|
||||
</div>
|
||||
{queued ? null : <progress max={1} value={state.progress} aria-label="上传进度" />}
|
||||
</div>;
|
||||
}
|
||||
+21
-8
@@ -10,16 +10,29 @@ export function formatSize(bytes: number): string {
|
||||
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatTransferProgress(state: {
|
||||
export interface TransferState {
|
||||
progress: number;
|
||||
phase?: 'encrypting' | 'uploading';
|
||||
phase?: 'queued' | 'encrypting' | 'uploading';
|
||||
speedBytesPerSecond?: number;
|
||||
}): string {
|
||||
const phase = state.phase === 'encrypting' ? '加密中' : state.phase === 'uploading' ? '上传中' : '传输中';
|
||||
const speed = state.speedBytesPerSecond && state.speedBytesPerSecond > 0
|
||||
? ` · ${formatSize(state.speedBytesPerSecond)}/s`
|
||||
: '';
|
||||
return `${phase} ${Math.round(state.progress * 100)}%${speed}`;
|
||||
etaSeconds?: number;
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds < 60) return `剩 ${Math.max(1, Math.round(seconds))} 秒`;
|
||||
if (seconds < 3600) return `剩 ${Math.ceil(seconds / 60)} 分钟`;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
return `剩 ${hours} 小时 ${Math.ceil((seconds % 3600) / 60)} 分钟`;
|
||||
}
|
||||
|
||||
/** 传输状态的一行文字:排队中 / 加密中 / 上传中 42% · 3.2 MB/s · 剩 12 秒 */
|
||||
export function formatTransferProgress(state: TransferState): string {
|
||||
if (state.phase === 'queued') return '排队中';
|
||||
const percent = Math.round(state.progress * 100);
|
||||
if (state.phase === 'encrypting' && percent === 0) return '加密中…';
|
||||
const parts = [`${state.phase === 'encrypting' ? '加密中' : '上传中'} ${percent}%`];
|
||||
if (state.speedBytesPerSecond && state.speedBytesPerSecond > 0) parts.push(`${formatSize(state.speedBytesPerSecond)}/s`);
|
||||
if (state.etaSeconds !== undefined) parts.push(formatEta(state.etaSeconds));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
|
||||
export const ROLE_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple'] as const;
|
||||
export type RoleColor = (typeof ROLE_COLORS)[number];
|
||||
|
||||
/** 与后端 WorkspacePermission 一致 */
|
||||
export const PERMISSIONS = [
|
||||
{ id: 'members.remove', label: '移除成员', hint: '移除成员、撤销邀请;不能移除空间主和同样有此权限的人' },
|
||||
{ id: 'structure.manage', label: '管理频道和分组', hint: '重命名频道、调整频道所属分组、新建和删除分组。新建频道涉及密钥,仍只有空间主' },
|
||||
] as const;
|
||||
|
||||
export interface Badge { key: string; text: string; color: RoleColor | 'owner' }
|
||||
|
||||
const hasText = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
||||
|
||||
/** 聊天里名字前后要显示的文字:空间主固定一个,其余按身份组的创建顺序 */
|
||||
export function badgesOf(roles: readonly RoleInfo[], username: string | undefined, creator: string | undefined): { prefix: Badge[]; suffix: Badge[] } {
|
||||
const prefix: Badge[] = []; const suffix: Badge[] = [];
|
||||
if (!username) return { prefix, suffix };
|
||||
if (username === creator) suffix.push({ key: 'owner', text: '空间主', color: 'owner' });
|
||||
for (const role of roles) {
|
||||
if (!role.members.includes(username)) continue;
|
||||
const color = (ROLE_COLORS as readonly string[]).includes(role.color ?? '') ? (role.color as RoleColor) : 'gray';
|
||||
if (hasText(role.prefix)) prefix.push({ key: role.id + ':p', text: role.prefix, color });
|
||||
if (hasText(role.suffix)) suffix.push({ key: role.id + ':s', text: role.suffix, color });
|
||||
}
|
||||
return { prefix, suffix };
|
||||
}
|
||||
|
||||
export function permissionsOf(roles: readonly RoleInfo[], username: string | undefined, owner: boolean): ReadonlySet<string> {
|
||||
if (owner) return new Set(PERMISSIONS.map((item) => item.id));
|
||||
const granted = new Set<string>();
|
||||
if (username) for (const role of roles) if (role.members.includes(username)) for (const id of role.permissions) granted.add(id);
|
||||
return granted;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const text = (id: string, ts: number, body = id): TextItem => ({
|
||||
|
||||
describe('reduce', () => {
|
||||
it('renders server structure and preserves server author and location across late decrypted content', () => {
|
||||
const stateEvent = { t: 'structure' as const, state: { revision: 1,
|
||||
const stateEvent = { t: 'structure' as const, state: { revision: 1, roles: [], rolesSeeded: true,
|
||||
channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true, fileBytes: 0 }],
|
||||
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
|
||||
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ChannelInfo,
|
||||
ChannelGroupInfo,
|
||||
RoleInfo,
|
||||
ConnectionState,
|
||||
FolderInfo,
|
||||
Item,
|
||||
@@ -16,6 +17,7 @@ export interface WorkspaceData {
|
||||
channel: ChannelInfo | null;
|
||||
threads: ThreadInfo[];
|
||||
groups: ChannelGroupInfo[];
|
||||
roles: RoleInfo[];
|
||||
folders: FolderInfo[];
|
||||
locations: Record<string, { threadId: string; folderId?: string; authorAccount?: string }>;
|
||||
byId: Record<string, Item>;
|
||||
@@ -34,6 +36,7 @@ export const initialData: WorkspaceData = {
|
||||
channel: null,
|
||||
threads: [],
|
||||
groups: [],
|
||||
roles: [],
|
||||
folders: [],
|
||||
locations: {},
|
||||
byId: {},
|
||||
@@ -71,7 +74,7 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
|
||||
}]));
|
||||
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
|
||||
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
|
||||
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
|
||||
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
|
||||
}
|
||||
case 'reset':
|
||||
|
||||
@@ -44,7 +44,8 @@ function settleLocal(id: string): void {
|
||||
async function deliver(id: string): Promise<void> {
|
||||
const job = pendingJobs.get(id);
|
||||
if (!job) return;
|
||||
useWorkspace.setState((s) => s.byId[id] ? { byId: { ...s.byId, [id]: { ...s.byId[id], state: { kind: 'receiving', progress: 0 } } } } : s);
|
||||
const phase = job.kind === 'file' ? 'queued' as const : undefined;
|
||||
useWorkspace.setState((s) => s.byId[id] ? { byId: { ...s.byId, [id]: { ...s.byId[id], state: { kind: 'receiving', progress: 0, ...(phase ? { phase } : {}) } } } } : s);
|
||||
try {
|
||||
if (job.kind === 'text') await api.sendText(job.text, job.threadId, id);
|
||||
else await api.sendFiles([job.file], job.options, job.threadId, job.folderId, [id]);
|
||||
@@ -61,12 +62,18 @@ interface Actions {
|
||||
sendText: (text: string, threadId?: string) => Promise<void>;
|
||||
/** 重试发送失败的本地占位项 */
|
||||
retrySend: (localId: string) => Promise<void>;
|
||||
/** 取消排队中或上传中的文件 */
|
||||
cancelUpload: (localId: string) => Promise<void>;
|
||||
updateChannelSettings: (patch: ChannelSettingsPatch) => Promise<void>;
|
||||
sendFiles: (files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string) => Promise<void>;
|
||||
createThread: (name: string, groupId?: string) => Promise<string>;
|
||||
createGroup: typeof api.createGroup;
|
||||
renameGroup: typeof api.renameGroup;
|
||||
deleteGroup: typeof api.deleteGroup;
|
||||
createRole: typeof api.createRole;
|
||||
updateRole: typeof api.updateRole;
|
||||
deleteRole: typeof api.deleteRole;
|
||||
setRoleMembers: typeof api.setRoleMembers;
|
||||
groupThread: typeof api.groupThread;
|
||||
renameThread: (threadId: string, name: string) => Promise<void>;
|
||||
setChannelHistory: typeof api.setChannelHistory;
|
||||
@@ -98,22 +105,32 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
|
||||
return deliver(id);
|
||||
},
|
||||
retrySend: (id) => deliver(id),
|
||||
cancelUpload: async (localId) => {
|
||||
pendingJobs.delete(localId);
|
||||
useWorkspace.setState((s) => withoutItem(s, localId));
|
||||
await api.cancelUpload(localId).catch(() => false);
|
||||
},
|
||||
updateChannelSettings: (patch) => api.updateChannelSettings(patch),
|
||||
sendFiles: async (files, options, threadId, folderId) => {
|
||||
const ids = files.map((file) => {
|
||||
const id = newLocalId();
|
||||
pendingJobs.set(id, { kind: 'file', file, ...(options ? { options } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}) });
|
||||
const item: FileItem = { id, kind: 'file', localId: id, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: null,
|
||||
preview: previewKindOf(file.type, file.name), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'encrypting' } };
|
||||
preview: previewKindOf(file.type, file.name), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'queued' } };
|
||||
putLocal(item);
|
||||
return id;
|
||||
});
|
||||
for (const id of ids) await deliver(id);
|
||||
// 全部交给 worker 的上传队列:一次传一个,其余显示「排队中」
|
||||
await Promise.all(ids.map(deliver));
|
||||
},
|
||||
createThread: (name, groupId) => api.createThread(name, groupId),
|
||||
createGroup: (name) => api.createGroup(name),
|
||||
renameGroup: (id, name) => api.renameGroup(id, name),
|
||||
deleteGroup: (id) => api.deleteGroup(id),
|
||||
createRole: (role) => api.createRole(role),
|
||||
updateRole: (id, role) => api.updateRole(id, role),
|
||||
deleteRole: (id) => api.deleteRole(id),
|
||||
setRoleMembers: (id, members) => api.setRoleMembers(id, members),
|
||||
groupThread: (id, groupId) => api.groupThread(id, groupId),
|
||||
renameThread: (threadId, name) => api.renameThread(threadId, name),
|
||||
setChannelHistory: (threadId, chatHistory, filesHistory) => api.setChannelHistory(threadId, chatHistory, filesHistory),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
--warn: #b25e00;
|
||||
--danger: #b3261e;
|
||||
--busy: #3b6ea8;
|
||||
--role-purple: #7b4fb3;
|
||||
|
||||
--surface-2: #efefed;
|
||||
--line-strong: rgb(0 0 0 / 14%);
|
||||
@@ -76,6 +77,7 @@
|
||||
--warn: #e0a050;
|
||||
--danger: #f2877f;
|
||||
--busy: #7fb0e6;
|
||||
--role-purple: #b79af0;
|
||||
|
||||
--surface-2: #262626;
|
||||
--line-strong: rgb(255 255 255 / 16%);
|
||||
@@ -104,6 +106,7 @@
|
||||
--warn: #e0a050;
|
||||
--danger: #f2877f;
|
||||
--busy: #7fb0e6;
|
||||
--role-purple: #b79af0;
|
||||
|
||||
--surface-2: #262626;
|
||||
--line-strong: rgb(255 255 255 / 16%);
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
@@ -34,6 +35,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: false },
|
||||
server: { proxy: { '/api/v1': { target: process.env.WEBDROP_DEV_API_TARGET ?? 'http://127.0.0.1:5000', ws: true } } },
|
||||
// 开发代理复用到后端的连接:默认每个请求新开一条 TCP,上传 1 MiB 的帧会被连接建立和慢启动拖得很慢
|
||||
server: { proxy: { '/api/v1': { target: process.env.WEBDROP_DEV_API_TARGET ?? 'http://127.0.0.1:5000', ws: true, agent: new http.Agent({ keepAlive: true, maxSockets: 64 }) } } },
|
||||
test: { environment: 'node' },
|
||||
});
|
||||
Reference in new issue
Block a user