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:
justme committed 2026-10-19 13:38:51 +08:00
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':
+9
View File
@@ -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),