feat: add plugin marketplace renderer
This commit is contained in:
253
src/stores/plugin-marketplace.ts
Normal file
253
src/stores/plugin-marketplace.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { createStore, type StoreApi } from 'zustand';
|
||||
import { useStore } from 'zustand';
|
||||
import {
|
||||
acquireMarketplacePlugin,
|
||||
installMarketplacePlugin,
|
||||
readMarketplaceCatalog,
|
||||
readMarketplaceDetail,
|
||||
readMarketplaceLibrary,
|
||||
removeMarketplacePlugin,
|
||||
uninstallMarketplacePlugin,
|
||||
updateMarketplacePlugin,
|
||||
type MarketplaceCatalogPage,
|
||||
type MarketplaceCatalogQuery,
|
||||
type MarketplaceInstallation,
|
||||
type MarketplaceLibraryProjection,
|
||||
type MarketplaceLibrarySnapshot,
|
||||
type MarketplacePluginDetail,
|
||||
} from '@/lib/plugin-marketplace';
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export type PluginMarketplaceDependencies = {
|
||||
readCatalog(input?: MarketplaceCatalogQuery): Promise<MarketplaceCatalogPage>;
|
||||
readDetail(pluginId: string): Promise<MarketplacePluginDetail>;
|
||||
readLibrary(): Promise<MarketplaceLibraryProjection>;
|
||||
acquire(pluginId: string): Promise<MarketplaceLibraryProjection>;
|
||||
remove(pluginId: string): Promise<MarketplaceLibraryProjection>;
|
||||
install(pluginId: string): Promise<MarketplaceInstallation>;
|
||||
update(pluginId: string): Promise<MarketplaceInstallation>;
|
||||
uninstall(pluginId: string): Promise<MarketplaceInstallation>;
|
||||
};
|
||||
|
||||
export type PluginMarketplaceState = {
|
||||
accountKey: string | null;
|
||||
catalog: MarketplaceCatalogPage | null;
|
||||
catalogState: LoadState;
|
||||
catalogError: string | null;
|
||||
catalogQuery: MarketplaceCatalogQuery;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
libraryState: LoadState;
|
||||
libraryError: string | null;
|
||||
installations: Record<string, MarketplaceInstallation>;
|
||||
details: Record<string, MarketplacePluginDetail>;
|
||||
detailState: Record<string, LoadState>;
|
||||
pending: Record<string, true>;
|
||||
activateAccount(accountKey: string | null): void;
|
||||
loadCatalog(input?: MarketplaceCatalogQuery): Promise<void>;
|
||||
loadDetail(pluginId: string): Promise<void>;
|
||||
loadLibrary(): Promise<void>;
|
||||
acquire(pluginId: string): Promise<void>;
|
||||
remove(pluginId: string): Promise<void>;
|
||||
install(pluginId: string): Promise<void>;
|
||||
update(pluginId: string): Promise<void>;
|
||||
uninstall(pluginId: string): Promise<void>;
|
||||
};
|
||||
|
||||
const defaults: PluginMarketplaceDependencies = {
|
||||
readCatalog: readMarketplaceCatalog,
|
||||
readDetail: readMarketplaceDetail,
|
||||
readLibrary: readMarketplaceLibrary,
|
||||
acquire: acquireMarketplacePlugin,
|
||||
remove: removeMarketplacePlugin,
|
||||
install: installMarketplacePlugin,
|
||||
update: updateMarketplacePlugin,
|
||||
uninstall: uninstallMarketplacePlugin,
|
||||
};
|
||||
|
||||
function message(error: unknown): string {
|
||||
const base = error instanceof Error ? error.message : String(error);
|
||||
if (!error || typeof error !== 'object') return base;
|
||||
const details = 'details' in error && error.details && typeof error.details === 'object'
|
||||
? error.details as Record<string, unknown>
|
||||
: null;
|
||||
const backendCode = details?.backendCode;
|
||||
return typeof backendCode === 'string' ? `${backendCode}: ${base}` : base;
|
||||
}
|
||||
|
||||
function installationMap(items: readonly MarketplaceInstallation[]): Record<string, MarketplaceInstallation> {
|
||||
return Object.fromEntries(items.map((item) => [item.pluginId, item]));
|
||||
}
|
||||
|
||||
export function createPluginMarketplaceStore(
|
||||
overrides: Partial<PluginMarketplaceDependencies> = {},
|
||||
): StoreApi<PluginMarketplaceState> {
|
||||
const deps = { ...defaults, ...overrides };
|
||||
let scopeGeneration = 0;
|
||||
let catalogGeneration = 0;
|
||||
let libraryMutationEpoch = 0;
|
||||
|
||||
return createStore<PluginMarketplaceState>((set, get) => {
|
||||
const pending = async <T>(key: string, operation: () => Promise<T>): Promise<T> => {
|
||||
set((state) => ({ pending: { ...state.pending, [key]: true } }));
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
set((state) => {
|
||||
const next = { ...state.pending };
|
||||
delete next[key];
|
||||
return { pending: next };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const libraryAction = async (
|
||||
key: string,
|
||||
pluginId: string,
|
||||
action: (pluginId: string) => Promise<MarketplaceLibraryProjection>,
|
||||
): Promise<void> => pending(key, async () => {
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
if (!accountKey) throw new Error('请先登录后管理我的插件');
|
||||
try {
|
||||
const projection = await action(pluginId);
|
||||
if (generation !== scopeGeneration || get().accountKey !== accountKey) return;
|
||||
libraryMutationEpoch += 1;
|
||||
set({
|
||||
library: projection.library,
|
||||
installations: installationMap(projection.installations),
|
||||
libraryState: 'ready',
|
||||
libraryError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey) {
|
||||
set({ libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const installAction = async (
|
||||
key: string,
|
||||
pluginId: string,
|
||||
action: (pluginId: string) => Promise<MarketplaceInstallation>,
|
||||
): Promise<void> => pending(key, async () => {
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
if (!accountKey) throw new Error('请先登录后管理设备插件');
|
||||
try {
|
||||
const result = await action(pluginId);
|
||||
if (generation !== scopeGeneration || get().accountKey !== accountKey) return;
|
||||
libraryMutationEpoch += 1;
|
||||
set((state) => {
|
||||
const installations = { ...state.installations };
|
||||
if (result.status === 'removed') delete installations[pluginId];
|
||||
else installations[pluginId] = result;
|
||||
return { installations, libraryError: null };
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey) {
|
||||
set({ libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accountKey: null,
|
||||
catalog: null,
|
||||
catalogState: 'idle',
|
||||
catalogError: null,
|
||||
catalogQuery: {},
|
||||
library: null,
|
||||
libraryState: 'idle',
|
||||
libraryError: null,
|
||||
installations: {},
|
||||
details: {},
|
||||
detailState: {},
|
||||
pending: {},
|
||||
activateAccount(accountKey) {
|
||||
if (get().accountKey === accountKey) return;
|
||||
scopeGeneration += 1;
|
||||
libraryMutationEpoch = 0;
|
||||
set({
|
||||
accountKey,
|
||||
library: null,
|
||||
libraryState: 'idle',
|
||||
libraryError: null,
|
||||
installations: {},
|
||||
pending: {},
|
||||
});
|
||||
},
|
||||
async loadCatalog(input = {}) {
|
||||
const generation = ++catalogGeneration;
|
||||
set({ catalogState: 'loading', catalogError: null, catalogQuery: input });
|
||||
try {
|
||||
const catalog = await deps.readCatalog(input);
|
||||
if (generation === catalogGeneration) set({ catalog, catalogState: 'ready', catalogError: null });
|
||||
} catch (error) {
|
||||
if (generation === catalogGeneration) set({ catalogState: 'error', catalogError: message(error) });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async loadDetail(pluginId) {
|
||||
const generation = scopeGeneration;
|
||||
set((state) => ({ detailState: { ...state.detailState, [pluginId]: 'loading' } }));
|
||||
try {
|
||||
const detail = await deps.readDetail(pluginId);
|
||||
if (generation !== scopeGeneration) return;
|
||||
set((state) => ({
|
||||
details: { ...state.details, [pluginId]: detail },
|
||||
detailState: { ...state.detailState, [pluginId]: 'ready' },
|
||||
}));
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration) {
|
||||
set((state) => ({
|
||||
detailState: { ...state.detailState, [pluginId]: 'error' },
|
||||
catalogError: message(error),
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async loadLibrary() {
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
const mutationEpoch = libraryMutationEpoch;
|
||||
if (!accountKey) {
|
||||
set({ library: null, installations: {}, libraryState: 'idle', libraryError: null });
|
||||
return;
|
||||
}
|
||||
set({ libraryState: 'loading', libraryError: null });
|
||||
try {
|
||||
const projection = await deps.readLibrary();
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey
|
||||
&& mutationEpoch === libraryMutationEpoch) {
|
||||
set({
|
||||
library: projection.library,
|
||||
installations: installationMap(projection.installations),
|
||||
libraryState: 'ready',
|
||||
libraryError: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey) {
|
||||
set({ libraryState: 'error', libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
acquire: (pluginId) => libraryAction(`acquire:${pluginId}`, pluginId, deps.acquire),
|
||||
remove: (pluginId) => libraryAction(`remove:${pluginId}`, pluginId, deps.remove),
|
||||
install: (pluginId) => installAction(`install:${pluginId}`, pluginId, deps.install),
|
||||
update: (pluginId) => installAction(`update:${pluginId}`, pluginId, deps.update),
|
||||
uninstall: (pluginId) => installAction(`uninstall:${pluginId}`, pluginId, deps.uninstall),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const pluginMarketplaceStore = createPluginMarketplaceStore();
|
||||
|
||||
export function usePluginMarketplaceStore<T>(selector: (state: PluginMarketplaceState) => T): T {
|
||||
return useStore(pluginMarketplaceStore, selector);
|
||||
}
|
||||
Reference in New Issue
Block a user