refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers
- dissolve core-bridge into api (wire contract), domain (models), services (what the UI calls) and worker (everything that runs in the Web Worker, grouped by account, crypto, space, files, shares, runtime); regroup features, lib and store by responsibility and delete unused components - the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the client are gone; tests fail if those rules are reimplemented - files go straight to the file domain (create, upload, download, delete, commit, cancel); the host sees one registration per file; creating a channel allocates a file domain and works even when the file service is down - file panel shows which channel the files belong to and its space usage; unlimited files preview in chat; videos get a stored cover; the file service status is shown - fix: console flooded by Retry-After reads on every upload frame
This commit is contained in:
1 parent
17638b6e20
commit
5489407241
260 files changed
+1503
-1246
No files matched your search
@@ -1,14 +1,14 @@
|
||||
import { create } from 'zustand';
|
||||
import { accountApi } from '@/core-bridge/account';
|
||||
import type { AccountStatus } from '@/core-bridge/account';
|
||||
import type { AvatarChange } from '@/core-bridge/protocol/account';
|
||||
import { useChannels } from './channels';
|
||||
import { useShares } from './shares';
|
||||
import { useWorkspace } from './workspace';
|
||||
import { initialData } from './reduce';
|
||||
import { useShareSession } from './shareSession';
|
||||
import { initialShareData } from './shareReduce';
|
||||
import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
|
||||
import { accountApi } from '@/services/account';
|
||||
import type { AccountStatus } from '@/services/account';
|
||||
import type { AvatarChange } from '@/domain/account';
|
||||
import { useChannels } from '@/store/space/channels';
|
||||
import { useShares } from '@/store/share/shares';
|
||||
import { useWorkspace } from '@/store/space/workspace';
|
||||
import { initialData } from '@/store/space/reduce';
|
||||
import { useShareSession } from '@/store/share/shareSession';
|
||||
import { initialShareData } from '@/store/share/shareReduce';
|
||||
import { loginWithPasskey, passkeyApi } from '@/services/passkeys';
|
||||
|
||||
interface AccountState extends AccountStatus {
|
||||
busy: boolean;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { accountApi, type AccountInvitation } from '@/services/account';
|
||||
import { errorMessage } from '@/api/errors';
|
||||
import { channelPath, navigate } from '@/app/navigation';
|
||||
import { useChannels } from './channels';
|
||||
import { useToast } from './toast';
|
||||
import { useChannels } from '@/store/space/channels';
|
||||
import { useToast } from '@/store/app/toast';
|
||||
|
||||
export function invitationLabel(row: AccountInvitation): string {
|
||||
return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { useEffect } from 'react';
|
||||
import { serverApi } from '@/core-bridge/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
import { serverApi } from '@/services/server';
|
||||
import type { ServerInfoBody } from '@/api/generated';
|
||||
|
||||
interface ServerState {
|
||||
info?: ServerInfoBody;
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Share, TextItem } from '@/core-bridge/types';
|
||||
import type { Share, TextItem } from '@/domain/types';
|
||||
import { initialShareData, reduceShare } from './shareReduce';
|
||||
|
||||
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, state: { kind: 'complete' }, text: id });
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Item, Share, ShareEvent } from '@/core-bridge/types';
|
||||
import type { Item, Share, ShareEvent } from '@/domain/types';
|
||||
|
||||
export interface ShareData {
|
||||
ready: boolean;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { shareApi } from '@/core-bridge/instance';
|
||||
import { shareApi } from '@/services';
|
||||
import { initialShareData, reduceShare, type ShareData } from './shareReduce';
|
||||
|
||||
interface Actions {
|
||||
@@ -1,9 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { shareApi } from '@/core-bridge/instance';
|
||||
import type { Share, ShareSettings } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import type { ParsedShareLink } from '@/lib/shareLink';
|
||||
import { useServer } from './server';
|
||||
import { shareApi } from '@/services';
|
||||
import type { Share, ShareSettings } from '@/domain/types';
|
||||
import type { ClipboardPayload } from '@/lib/platform/clipboard';
|
||||
import type { ParsedShareLink } from '@/lib/links/shareLink';
|
||||
import { useServer } from '@/store/app/server';
|
||||
|
||||
interface SharesState {
|
||||
/** 我的分享:服务端状态及本机保存的访问凭据。 */
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/core-bridge/instance';
|
||||
import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import { api } from '@/services';
|
||||
import type { ChannelInfo, CreateOptions } from '@/domain/types';
|
||||
import type { ParsedChannelLink } from '@/lib/links/channelLink';
|
||||
|
||||
interface ChannelsState {
|
||||
/** 服务端空间列表的界面投影,IndexedDB 提供离线缓存。 */
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TextItem } from '@/core-bridge/types';
|
||||
import type { TextItem } from '@/domain/types';
|
||||
import { initialData, reduce } from './reduce';
|
||||
|
||||
const text = (id: string, ts: number, body = id): TextItem => ({
|
||||
@@ -9,7 +9,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, 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 }],
|
||||
channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true, fileBytes: 0, fileQuota: 0 }],
|
||||
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
|
||||
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
|
||||
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
|
||||
@@ -9,13 +9,15 @@ import type {
|
||||
Snapshot,
|
||||
ThreadInfo,
|
||||
WorkspaceEvent,
|
||||
} from '@/core-bridge/types';
|
||||
} from '@/domain/types';
|
||||
|
||||
export interface WorkspaceData {
|
||||
ready: boolean;
|
||||
connection: ConnectionState;
|
||||
channel: ChannelInfo | null;
|
||||
threads: ThreadInfo[];
|
||||
/** 后端指定的默认频道 */
|
||||
defaultThreadId: string | undefined;
|
||||
/** 没有密钥、不能读写的频道 id */
|
||||
lockedThreads: string[];
|
||||
groups: ChannelGroupInfo[];
|
||||
@@ -37,6 +39,7 @@ export const initialData: WorkspaceData = {
|
||||
connection: 'offline',
|
||||
channel: null,
|
||||
threads: [],
|
||||
defaultThreadId: undefined,
|
||||
lockedThreads: [],
|
||||
groups: [],
|
||||
roles: [],
|
||||
@@ -81,7 +84,7 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
const author = fresh(authors[id] ?? item.author);
|
||||
return [id, { ...item, ...(locations[id] ?? {}), ...(author ? { author } : {}) }];
|
||||
}));
|
||||
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
|
||||
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [], defaultThreadId: e.state.defaultChannelId ?? undefined,
|
||||
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
|
||||
}
|
||||
case 'access':
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { capabilitiesOf, type Capabilities } from '@/domain/capabilities';
|
||||
import { useWorkspace } from './workspace';
|
||||
|
||||
/** 当前空间里调用者能做什么(后端给的能力,见 domain/capabilities) */
|
||||
export function useCapabilities(): Capabilities {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
return useMemo(() => capabilitiesOf(channel ?? undefined), [channel]);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { videoPoster } from '@/features/item/hooks/videoPoster';
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/core-bridge/instance';
|
||||
import type { ChannelSettingsPatch, FileUploadOptions } from '@/core-bridge/types';
|
||||
import type { FileItem, Item } from '@/core-bridge/types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useAccount } from './account';
|
||||
import { api } from '@/services';
|
||||
import type { ChannelSettingsPatch, FileUploadOptions } from '@/domain/types';
|
||||
import type { FileItem, Item } from '@/domain/types';
|
||||
import { previewKindOf } from '@/lib/files/fileKind';
|
||||
import { errorMessage } from '@/api/errors';
|
||||
import { useAccount } from '@/store/account/account';
|
||||
import { useChannels } from './channels';
|
||||
import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce';
|
||||
|
||||
@@ -67,7 +68,7 @@ interface Actions {
|
||||
cancelUpload: (localId: string, itemId?: 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>;
|
||||
createThread: (name: string, groupId?: string, fileQuota?: number) => Promise<string>;
|
||||
createGroup: typeof api.createGroup;
|
||||
renameGroup: typeof api.renameGroup;
|
||||
deleteGroup: typeof api.deleteGroup;
|
||||
@@ -119,18 +120,21 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
|
||||
},
|
||||
updateChannelSettings: (patch) => api.updateChannelSettings(patch),
|
||||
sendFiles: async (files, options, threadId, folderId) => {
|
||||
const ids = files.map((file) => {
|
||||
// 视频先截一张封面(很快,截不出来就没有封面),随文件目录一起同步
|
||||
const posters = await Promise.all(files.map((file) => videoPoster(file)));
|
||||
const ids = files.map((file, index) => {
|
||||
const id = newLocalId();
|
||||
pendingJobs.set(id, { kind: 'file', file, ...(options ? { options } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}) });
|
||||
const jobOptions = posters[index] ? { ...options, poster: posters[index]! } as FileUploadOptions : options;
|
||||
pendingJobs.set(id, { kind: 'file', file, ...(jobOptions ? { options: jobOptions } : {}), ...(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: 'queued' } };
|
||||
preview: previewKindOf(file.type, file.name), ...(posters[index] ? { poster: posters[index]! } : {}), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'queued' } };
|
||||
putLocal(item);
|
||||
return id;
|
||||
});
|
||||
// 全部交给 worker 的上传队列:一次传一个,其余显示「排队中」
|
||||
await Promise.all(ids.map(deliver));
|
||||
},
|
||||
createThread: (name, groupId) => api.createThread(name, groupId),
|
||||
createThread: (name, groupId, fileQuota) => api.createThread(name, groupId, fileQuota),
|
||||
createGroup: (name) => api.createGroup(name),
|
||||
renameGroup: (id, name) => api.renameGroup(id, name),
|
||||
deleteGroup: (id) => api.deleteGroup(id),
|
||||
Reference in new issue
Block a user