功能(桌面端):增加在线辅助本地缓存
This commit is contained in:
@@ -206,6 +206,7 @@ const verifySourceSafety = async () => {
|
||||
for (const path of files) {
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(rootDir, path);
|
||||
const isRuntimeFile = file.startsWith("frontend/src/runtime/");
|
||||
assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`);
|
||||
assert(
|
||||
!/console\.(log|debug|info|warn|error)\s*\([^)]*(?:token|access_token|authorization|bearer)/i.test(source),
|
||||
@@ -221,6 +222,12 @@ const verifySourceSafety = async () => {
|
||||
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
|
||||
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
|
||||
}
|
||||
if (!isRuntimeFile) {
|
||||
assert(
|
||||
!/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source),
|
||||
`${file}: local data cache storage must be routed through frontend/src/runtime.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ for (const path of await walk(sourceDir)) {
|
||||
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
|
||||
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
|
||||
}
|
||||
if (/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source)) {
|
||||
violations.push(`${file}: local data cache storage must be routed through src/runtime`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
default: {},
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
}));
|
||||
|
||||
describe("auth api", () => {
|
||||
it("does not deduplicate login key challenges", async () => {
|
||||
const { getLoginKey } = await import("./auth");
|
||||
|
||||
getLoginKey();
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/auth/login-key", { disableRequestDedupe: true });
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
|
||||
apiPost<LoginResponse>("/api/v1/auth/dev-login", payload);
|
||||
|
||||
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
|
||||
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
|
||||
apiGet<LoginKeyResponse>("/api/v1/auth/login-key", { disableRequestDedupe: true });
|
||||
|
||||
export const fetchMe = (config?: ApiRequestConfig): Promise<AxiosResponse<UserMeResponse>> =>
|
||||
apiGet<UserMeResponse>("/api/v1/auth/me", config);
|
||||
|
||||
@@ -34,10 +34,14 @@ vi.mock("../session/sessionManager", () => ({
|
||||
describe("api axios retry", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
errorMessage.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
const { clearApiResponseCache, setApiResponseCacheScope } = await import("./axios");
|
||||
setApiResponseCacheScope(null);
|
||||
clearApiResponseCache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -67,4 +71,152 @@ describe("api axios retry", () => {
|
||||
await expect(request).resolves.toMatchObject({ message: "Network Error" });
|
||||
expect(errorMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent apiGet calls for the same request", async () => {
|
||||
const { apiGet } = await import("./axios");
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
|
||||
data: { ok: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
}));
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
apiGet("/api/v1/studies/", { adapter }),
|
||||
apiGet("/api/v1/studies/", { adapter }),
|
||||
]);
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(1);
|
||||
expect(first.data).toEqual({ ok: true });
|
||||
expect(second.data).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("does not deduplicate requests when disabled by the caller", async () => {
|
||||
const { apiGet } = await import("./axios");
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
|
||||
data: { ok: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
}));
|
||||
|
||||
await Promise.all([
|
||||
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
|
||||
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
|
||||
]);
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps request headers in the dedupe key when they can affect the response", async () => {
|
||||
const { apiGet } = await import("./axios");
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
|
||||
data: { accept: (config.headers as any)?.Accept },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
}));
|
||||
|
||||
const [json, csv] = await Promise.all([
|
||||
apiGet("/api/v1/reports", { adapter, headers: { Accept: "application/json" } }),
|
||||
apiGet("/api/v1/reports", { adapter, headers: { Accept: "text/csv" } }),
|
||||
]);
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(2);
|
||||
expect(json.data).toEqual({ accept: "application/json" });
|
||||
expect(csv.data).toEqual({ accept: "text/csv" });
|
||||
});
|
||||
|
||||
it("serves configured apiGet calls from memory cache until ttl expires", async () => {
|
||||
const { apiGet } = await import("./axios");
|
||||
let sequence = 0;
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
|
||||
data: { sequence: (sequence += 1) },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { etag: "v1" },
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
}));
|
||||
|
||||
const first = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
const second = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
expect(first.data).toEqual({ sequence: 1 });
|
||||
expect(second.data).toEqual({ sequence: 1 });
|
||||
expect(adapter).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const third = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
expect(third.data).toEqual({ sequence: 2 });
|
||||
expect(adapter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears cached GET responses after successful mutations", async () => {
|
||||
const { apiGet, apiPost } = await import("./axios");
|
||||
let sequence = 0;
|
||||
const getAdapter = vi.fn<AxiosAdapter>(async (config) => ({
|
||||
data: { sequence: (sequence += 1) },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
}));
|
||||
const postAdapter = vi.fn<AxiosAdapter>(async (config) => ({
|
||||
data: { id: "study-1" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
}));
|
||||
|
||||
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
expect(getAdapter).toHaveBeenCalledTimes(1);
|
||||
|
||||
await apiPost("/api/v1/studies/", { name: "Study" }, { adapter: postAdapter });
|
||||
const afterMutation = await apiGet("/api/v1/studies/", {
|
||||
adapter: getAdapter,
|
||||
cache: { ttlMs: 1000, tags: ["studies"] },
|
||||
});
|
||||
|
||||
expect(afterMutation.data).toEqual({ sequence: 2 });
|
||||
expect(getAdapter).toHaveBeenCalledTimes(2);
|
||||
expect(postAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not write an in-flight GET response into cache after the cache generation changes", async () => {
|
||||
const { apiGet, clearApiResponseCache } = await import("./axios");
|
||||
let sequence = 0;
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const adapter = vi.fn<AxiosAdapter>((config) => {
|
||||
sequence += 1;
|
||||
const response = {
|
||||
data: { sequence },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
};
|
||||
if (sequence === 1) {
|
||||
return new Promise((resolve) => {
|
||||
resolveFirst = () => resolve(response);
|
||||
});
|
||||
}
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
|
||||
const firstRequest = apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
await vi.dynamicImportSettled();
|
||||
expect(adapter).toHaveBeenCalledTimes(1);
|
||||
|
||||
clearApiResponseCache();
|
||||
resolveFirst?.();
|
||||
await expect(firstRequest).resolves.toMatchObject({ data: { sequence: 1 } });
|
||||
|
||||
const afterClear = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
|
||||
expect(afterClear.data).toEqual({ sequence: 2 });
|
||||
expect(adapter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+215
-9
@@ -14,22 +14,195 @@ export const refreshApiBaseUrl = (): void => {
|
||||
instance.defaults.baseURL = clientRuntime.apiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
|
||||
}
|
||||
|
||||
const NETWORK_RETRY_LIMIT = 10;
|
||||
const NETWORK_RETRY_DELAY_MS = 30000;
|
||||
const DEFAULT_GET_CACHE_TTL_MS = 30_000;
|
||||
const API_RESPONSE_CACHE_SCHEMA_VERSION = 1;
|
||||
|
||||
export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
_networkRetryCount?: number;
|
||||
disableNetworkRetry?: boolean;
|
||||
disableRequestDedupe?: boolean;
|
||||
cache?: boolean | ApiCacheConfig;
|
||||
invalidateCache?: boolean | { tags?: string[] };
|
||||
};
|
||||
|
||||
export interface ApiCacheConfig {
|
||||
ttlMs?: number;
|
||||
key?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface CachedAxiosResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
const pendingGetRequests = new Map<string, Promise<AxiosResponse<any>>>();
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const sensitiveHeaderPattern = /\b(?:authorization|bearer|cookie|token|password|credential|secret)\b/i;
|
||||
|
||||
const stableSerialize = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (value instanceof URLSearchParams) {
|
||||
return stableSerialize(Object.fromEntries([...value.entries()].sort(([left], [right]) => left.localeCompare(right))));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const safeClone = <T>(value: T): T => {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
};
|
||||
|
||||
const rawHeaderEntries = (headers: AxiosRequestConfig["headers"]): Array<[string, unknown]> => {
|
||||
if (!headers) return [];
|
||||
if (typeof (headers as any).toJSON === "function") {
|
||||
return Object.entries((headers as any).toJSON());
|
||||
}
|
||||
if (typeof (headers as any).forEach === "function") {
|
||||
const entries: Array<[string, unknown]> = [];
|
||||
(headers as any).forEach((value: unknown, key: string) => entries.push([key, value]));
|
||||
return entries;
|
||||
}
|
||||
return Object.entries(headers as Record<string, unknown>);
|
||||
};
|
||||
|
||||
const hasSensitiveRequestIdentityConfig = (config?: ApiRequestConfig) =>
|
||||
Boolean(config?.auth) ||
|
||||
rawHeaderEntries(config?.headers).some(
|
||||
([key, value]) => sensitiveHeaderPattern.test(key) || sensitiveHeaderPattern.test(String(value)),
|
||||
);
|
||||
|
||||
const shouldSkipGetCache = (config?: ApiRequestConfig) =>
|
||||
config?.responseType === "blob" ||
|
||||
config?.responseType === "arraybuffer" ||
|
||||
config?.cache === false ||
|
||||
hasSensitiveRequestIdentityConfig(config);
|
||||
|
||||
const normalizeCacheConfig = (config?: ApiRequestConfig): ApiCacheConfig | null => {
|
||||
if (!config?.cache || shouldSkipGetCache(config)) return null;
|
||||
if (config.cache === true) return { ttlMs: DEFAULT_GET_CACHE_TTL_MS };
|
||||
return {
|
||||
ttlMs: config.cache.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS,
|
||||
key: config.cache.key,
|
||||
tags: config.cache.tags,
|
||||
};
|
||||
};
|
||||
|
||||
const createGetCacheKey = (url: string, config?: ApiRequestConfig, explicitKey?: string) => {
|
||||
if (explicitKey) return explicitKey;
|
||||
return stableSerialize({
|
||||
baseURL: config?.baseURL || instance.defaults.baseURL || "",
|
||||
headers: Object.fromEntries(
|
||||
rawHeaderEntries(config?.headers)
|
||||
.filter(([key, value]) => !sensitiveHeaderPattern.test(key) && !sensitiveHeaderPattern.test(String(value)))
|
||||
.map(([key, value]) => [key.toLowerCase(), String(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
method: "get",
|
||||
params: config?.params || null,
|
||||
paramsSerializer: config?.paramsSerializer ? String(config.paramsSerializer) : null,
|
||||
responseType: config?.responseType || "json",
|
||||
schemaVersion: API_RESPONSE_CACHE_SCHEMA_VERSION,
|
||||
url,
|
||||
withCredentials: config?.withCredentials || false,
|
||||
});
|
||||
};
|
||||
|
||||
const createPendingGetKey = (url: string, config?: ApiRequestConfig) =>
|
||||
createGetCacheKey(url, config, typeof config?.cache === "object" ? config.cache.key : undefined);
|
||||
|
||||
const cacheableResponseHeaders = (headers: AxiosResponse["headers"]): Record<string, string> => {
|
||||
const rawHeaders =
|
||||
headers && typeof (headers as any).toJSON === "function" ? (headers as any).toJSON() : (headers as Record<string, unknown>);
|
||||
const allowed = ["cache-control", "etag", "last-modified"];
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawHeaders || {})
|
||||
.filter(([key]) => allowed.includes(key.toLowerCase()))
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
};
|
||||
|
||||
const toCachedResponse = <T>(response: AxiosResponse<T>): CachedAxiosResponse<T> => ({
|
||||
data: safeClone(response.data),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: cacheableResponseHeaders(response.headers),
|
||||
});
|
||||
|
||||
const fromCachedResponse = <T>(cached: CachedAxiosResponse<T>, config?: ApiRequestConfig): AxiosResponse<T> => ({
|
||||
data: safeClone(cached.data),
|
||||
status: cached.status,
|
||||
statusText: cached.statusText,
|
||||
headers: cached.headers,
|
||||
config: (config || {}) as InternalAxiosRequestConfig,
|
||||
});
|
||||
|
||||
export const clearApiResponseCache = (): void => {
|
||||
pendingGetRequests.clear();
|
||||
clientRuntime.dataCache.clearDesktopDataCache();
|
||||
};
|
||||
|
||||
export const setApiResponseCacheScope = (scope?: string | null): void => {
|
||||
pendingGetRequests.clear();
|
||||
clientRuntime.dataCache.setDesktopDataCacheScope(scope);
|
||||
};
|
||||
|
||||
const requestWithDedupe = <T>(url: string, config?: ApiRequestConfig): Promise<AxiosResponse<T>> => {
|
||||
if (config?.disableRequestDedupe || shouldSkipGetCache(config)) {
|
||||
return instance.get<T>(url, config);
|
||||
}
|
||||
const pendingKey = createPendingGetKey(url, config);
|
||||
const existing = pendingGetRequests.get(pendingKey) as Promise<AxiosResponse<T>> | undefined;
|
||||
if (existing) return existing;
|
||||
const request = instance.get<T>(url, config).finally(() => {
|
||||
pendingGetRequests.delete(pendingKey);
|
||||
});
|
||||
pendingGetRequests.set(pendingKey, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const invalidateCacheAfterMutation = async <T>(
|
||||
request: Promise<AxiosResponse<T>>,
|
||||
config?: ApiRequestConfig,
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const response = await request;
|
||||
const invalidation = config?.invalidateCache;
|
||||
if (invalidation !== false) {
|
||||
if (typeof invalidation === "object" && invalidation.tags?.length) {
|
||||
clientRuntime.dataCache.invalidateDesktopDataCacheByTags(invalidation.tags);
|
||||
} else {
|
||||
clearApiResponseCache();
|
||||
}
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, () => {
|
||||
refreshApiBaseUrl();
|
||||
setApiResponseCacheScope(null);
|
||||
});
|
||||
}
|
||||
|
||||
const clearInvalidStudyAndNavigate = async () => {
|
||||
try {
|
||||
const [{ useStudyStore }, { default: router }] = await Promise.all([
|
||||
@@ -88,6 +261,7 @@ instance.interceptors.response.use(
|
||||
}
|
||||
if (status === 401) {
|
||||
const config = error.config as ApiRequestConfig;
|
||||
let clearedCacheForAuthFailure = false;
|
||||
if (config && !config._retry) {
|
||||
const { extendAccessToken } = await import("../session/sessionManager");
|
||||
const result = await extendAccessToken("response-401");
|
||||
@@ -98,12 +272,21 @@ instance.interceptors.response.use(
|
||||
return instance(config);
|
||||
}
|
||||
if (result.authFailed) {
|
||||
clearApiResponseCache();
|
||||
clearedCacheForAuthFailure = true;
|
||||
await forceAuthExpiredLogout();
|
||||
}
|
||||
}
|
||||
if (!clearedCacheForAuthFailure) {
|
||||
clearApiResponseCache();
|
||||
}
|
||||
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
|
||||
clearApiResponseCache();
|
||||
await forceAuthExpiredLogout();
|
||||
} else {
|
||||
if (status === 403) {
|
||||
clearApiResponseCache();
|
||||
}
|
||||
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
|
||||
if (!suppressErrorMessage) {
|
||||
if (data?.message || (data as any)?.detail) {
|
||||
@@ -120,14 +303,37 @@ instance.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
export const apiGet = <T = any>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
|
||||
export const apiGet = async <T = any>(url: string, config?: ApiRequestConfig) => {
|
||||
const cacheConfig = normalizeCacheConfig(config);
|
||||
const cacheKey = cacheConfig ? createGetCacheKey(url, config, cacheConfig.key) : null;
|
||||
const cacheGeneration = cacheConfig ? clientRuntime.dataCache.getDesktopDataCacheGeneration() : null;
|
||||
if (cacheConfig && cacheKey) {
|
||||
const cached = clientRuntime.dataCache.readDesktopDataCache<CachedAxiosResponse<T>>(cacheKey);
|
||||
if (cached) {
|
||||
return fromCachedResponse(cached, config);
|
||||
}
|
||||
}
|
||||
const response = await requestWithDedupe<T>(url, config);
|
||||
if (
|
||||
cacheConfig &&
|
||||
cacheKey &&
|
||||
cacheGeneration !== null &&
|
||||
clientRuntime.dataCache.isDesktopDataCacheGenerationCurrent(cacheGeneration)
|
||||
) {
|
||||
clientRuntime.dataCache.writeDesktopDataCache(cacheKey, toCachedResponse(response), {
|
||||
ttlMs: cacheConfig.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS,
|
||||
tags: cacheConfig.tags,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
};
|
||||
export const apiPost = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.post<T>(url, data, config);
|
||||
invalidateCacheAfterMutation(instance.post<T>(url, data, config), config);
|
||||
export const apiPatch = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.patch<T>(url, data, config);
|
||||
invalidateCacheAfterMutation(instance.patch<T>(url, data, config), config);
|
||||
export const apiPut = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.put<T>(url, data, config);
|
||||
invalidateCacheAfterMutation(instance.put<T>(url, data, config), config);
|
||||
export const apiDelete = <T = any>(url: string, config?: ApiRequestConfig) =>
|
||||
instance.delete<T>(url, config);
|
||||
invalidateCacheAfterMutation(instance.delete<T>(url, config), config);
|
||||
|
||||
export default instance;
|
||||
|
||||
@@ -13,16 +13,28 @@ export interface CenterSummaryItem {
|
||||
}
|
||||
|
||||
export const fetchProgress = (studyId: string) =>
|
||||
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`);
|
||||
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`, {
|
||||
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`] },
|
||||
});
|
||||
|
||||
export const fetchFinanceSummary = (studyId: string) =>
|
||||
apiGet(`/api/v1/studies/${studyId}/finance/summary`);
|
||||
apiGet(`/api/v1/studies/${studyId}/finance/summary`, {
|
||||
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:finance`] },
|
||||
});
|
||||
|
||||
export const fetchOverdueAesCount = (studyId: string) =>
|
||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
|
||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, {
|
||||
params: { overdue: true, limit: 1 },
|
||||
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:aes`] },
|
||||
});
|
||||
|
||||
export const fetchLostVisits = (studyId: string, params?: Record<string, any>) =>
|
||||
apiGet<VisitLostItem[]>(`/api/v1/studies/${studyId}/dashboard/lost-visits`, { params });
|
||||
apiGet<VisitLostItem[]>(`/api/v1/studies/${studyId}/dashboard/lost-visits`, {
|
||||
params,
|
||||
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:visits`] },
|
||||
});
|
||||
|
||||
export const fetchCenterSummary = (studyId: string) =>
|
||||
apiGet<CenterSummaryItem[]>(`/api/v1/studies/${studyId}/dashboard/center-summary`);
|
||||
apiGet<CenterSummaryItem[]>(`/api/v1/studies/${studyId}/dashboard/center-summary`, {
|
||||
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:sites`] },
|
||||
});
|
||||
|
||||
@@ -2,10 +2,17 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||
import type { StudyMember, UserInfo } from "../types/api";
|
||||
|
||||
export const listMembers = (studyId: string, params?: Record<string, any>) =>
|
||||
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, { params });
|
||||
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, {
|
||||
params,
|
||||
cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] },
|
||||
});
|
||||
|
||||
export const listMemberCandidates = (studyId: string, params?: Record<string, any>) =>
|
||||
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, { params, suppressErrorMessage: true });
|
||||
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, {
|
||||
params,
|
||||
suppressErrorMessage: true,
|
||||
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] },
|
||||
});
|
||||
|
||||
export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) =>
|
||||
apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { apiGet } from "./axios";
|
||||
|
||||
export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`);
|
||||
export const fetchProjectOverview = (studyId: string) =>
|
||||
apiGet(`/api/v1/studies/${studyId}/overview`, {
|
||||
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:overview`] },
|
||||
});
|
||||
|
||||
@@ -17,10 +17,16 @@ import type {
|
||||
|
||||
// 接口级权限
|
||||
export const fetchApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
|
||||
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, config);
|
||||
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, {
|
||||
...(config || {}),
|
||||
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
|
||||
});
|
||||
|
||||
export const fetchMyApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
|
||||
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, config);
|
||||
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, {
|
||||
...(config || {}),
|
||||
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
|
||||
});
|
||||
|
||||
export const updateApiEndpointPermissions = (
|
||||
studyId: string,
|
||||
@@ -32,14 +38,20 @@ export const updateApiEndpointPermissions = (
|
||||
});
|
||||
|
||||
export const fetchApiOperations = () =>
|
||||
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`);
|
||||
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`, {
|
||||
cache: { ttlMs: 24 * 60 * 60_000, tags: ["permission-operations"] },
|
||||
});
|
||||
|
||||
// 系统监测API
|
||||
export const fetchPermissionMetrics = () =>
|
||||
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
|
||||
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`, {
|
||||
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
|
||||
});
|
||||
|
||||
export const fetchCacheStats = () =>
|
||||
apiGet<CacheStatsResponse>(`/api/v1/permission-monitoring/cache-stats`);
|
||||
apiGet<CacheStatsResponse>(`/api/v1/permission-monitoring/cache-stats`, {
|
||||
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
|
||||
});
|
||||
|
||||
export const fetchPermissionAlerts = (level?: string, limit?: number) =>
|
||||
apiGet<AlertsResponse>(`/api/v1/permission-monitoring/alerts`, {
|
||||
@@ -47,7 +59,9 @@ export const fetchPermissionAlerts = (level?: string, limit?: number) =>
|
||||
});
|
||||
|
||||
export const fetchPermissionHealth = () =>
|
||||
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`);
|
||||
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`, {
|
||||
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
|
||||
});
|
||||
|
||||
export const resetPermissionMetrics = () =>
|
||||
apiPost<void>(`/api/v1/permission-monitoring/reset-metrics`);
|
||||
@@ -87,7 +101,9 @@ export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
|
||||
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
|
||||
|
||||
export const fetchStatsSummary = () =>
|
||||
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
|
||||
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`, {
|
||||
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
|
||||
});
|
||||
|
||||
// 权限模板API
|
||||
export interface PermissionTemplate {
|
||||
@@ -129,10 +145,15 @@ export interface ApplyTemplateResponse {
|
||||
}
|
||||
|
||||
export const fetchPermissionTemplates = (params?: { template_type?: string; category?: string }) =>
|
||||
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, { params });
|
||||
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, {
|
||||
params,
|
||||
cache: { ttlMs: 5 * 60_000, tags: ["permission-templates"] },
|
||||
});
|
||||
|
||||
export const fetchPermissionTemplate = (templateId: string) =>
|
||||
apiGet<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`);
|
||||
apiGet<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`, {
|
||||
cache: { ttlMs: 5 * 60_000, tags: ["permission-templates", `permission-template:${templateId}`] },
|
||||
});
|
||||
|
||||
export const createPermissionTemplate = (payload: PermissionTemplateCreate) =>
|
||||
apiPost<PermissionTemplate>(`/api/v1/permission-templates`, payload);
|
||||
@@ -165,11 +186,15 @@ export interface SystemPermissionsResponse {
|
||||
}
|
||||
|
||||
export const fetchSystemPermissions = () =>
|
||||
apiGet<SystemPermissionsResponse>(`/api/v1/system-permissions`);
|
||||
apiGet<SystemPermissionsResponse>(`/api/v1/system-permissions`, {
|
||||
cache: { ttlMs: 24 * 60 * 60_000, tags: ["system-permissions"] },
|
||||
});
|
||||
|
||||
// 项目角色生效管理
|
||||
export const fetchActiveRoles = (studyId: string) =>
|
||||
apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`);
|
||||
apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, {
|
||||
cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
|
||||
});
|
||||
|
||||
export const updateActiveRoles = (studyId: string, activeRoles: string[]) =>
|
||||
apiPut<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, { active_roles: activeRoles });
|
||||
|
||||
@@ -5,6 +5,7 @@ export const fetchSites = (studyId: string, params?: Record<string, any>, config
|
||||
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
|
||||
...(config || {}),
|
||||
params: { include_inactive: true, ...(params || {}), ...(config?.params || {}) },
|
||||
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:sites`] },
|
||||
});
|
||||
|
||||
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
|
||||
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
} from "../types/setupConfig";
|
||||
|
||||
// Backend expects trailing slash to avoid 307 redirect
|
||||
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
|
||||
export const fetchStudies = () =>
|
||||
apiGet<ApiListResponse<Study>>("/api/v1/studies/", { cache: { ttlMs: 5 * 60_000, tags: ["studies"] } });
|
||||
|
||||
export const createStudy = (payload: Partial<Study> & { name: string }) =>
|
||||
apiPost<Study>("/api/v1/studies/", payload);
|
||||
@@ -20,7 +21,8 @@ export const createStudy = (payload: Partial<Study> & { name: string }) =>
|
||||
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
|
||||
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
|
||||
|
||||
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
|
||||
export const fetchStudyDetail = (studyId: string) =>
|
||||
apiGet<Study>(`/api/v1/studies/${studyId}`, { cache: { ttlMs: 5 * 60_000, tags: ["studies", `study:${studyId}`] } });
|
||||
|
||||
export const deleteStudy = (studyId: string) =>
|
||||
apiDelete(`/api/v1/studies/${studyId}`);
|
||||
@@ -29,7 +31,10 @@ export const lockStudy = (studyId: string) =>
|
||||
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
|
||||
|
||||
export const fetchStudySetupConfig = (studyId: string) =>
|
||||
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true });
|
||||
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, {
|
||||
suppressErrorMessage: true,
|
||||
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] },
|
||||
});
|
||||
|
||||
export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) =>
|
||||
apiPut<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true });
|
||||
@@ -38,7 +43,10 @@ export const publishStudySetupConfig = (studyId: string, payload: StudySetupConf
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const fetchStudySetupConfigVersions = (studyId: string) =>
|
||||
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true });
|
||||
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, {
|
||||
suppressErrorMessage: true,
|
||||
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] },
|
||||
});
|
||||
|
||||
export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) =>
|
||||
apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveApiBaseUrl } from "./apiBaseUrl";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import * as dataCache from "./desktopDataCache";
|
||||
import * as files from "./files";
|
||||
import * as notifications from "./notifications";
|
||||
import * as updates from "./updates";
|
||||
@@ -33,6 +34,7 @@ export interface ClientRuntime {
|
||||
files: typeof files;
|
||||
notifications: typeof notifications;
|
||||
updates: typeof updates;
|
||||
dataCache: typeof dataCache;
|
||||
}
|
||||
|
||||
export const clientRuntime: ClientRuntime = {
|
||||
@@ -47,6 +49,7 @@ export const clientRuntime: ClientRuntime = {
|
||||
files,
|
||||
notifications,
|
||||
updates,
|
||||
dataCache,
|
||||
capabilities: () => ({
|
||||
serverConfiguration: isTauriRuntime(),
|
||||
nativeFiles: isTauriRuntime(),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearDesktopDataCache,
|
||||
getDesktopDataCacheGeneration,
|
||||
getDesktopDataCacheStats,
|
||||
invalidateDesktopDataCacheByTags,
|
||||
isDesktopDataCacheGenerationCurrent,
|
||||
readDesktopDataCache,
|
||||
setDesktopDataCacheScope,
|
||||
writeDesktopDataCache,
|
||||
} from "./desktopDataCache";
|
||||
|
||||
describe("desktopDataCache", () => {
|
||||
afterEach(() => {
|
||||
setDesktopDataCacheScope(null);
|
||||
clearDesktopDataCache();
|
||||
});
|
||||
|
||||
it("isolates records by scope and clears records when scope changes", () => {
|
||||
setDesktopDataCacheScope("user-a");
|
||||
writeDesktopDataCache("studies", { items: [{ id: "study-a" }] }, { ttlMs: 1000, now: 100 });
|
||||
|
||||
expect(readDesktopDataCache("studies", 200)).toEqual({ items: [{ id: "study-a" }] });
|
||||
|
||||
setDesktopDataCacheScope("user-b");
|
||||
expect(readDesktopDataCache("studies", 200)).toBeNull();
|
||||
expect(getDesktopDataCacheStats()).toMatchObject({ scope: "user-b", entries: 0 });
|
||||
});
|
||||
|
||||
it("expires records by ttl", () => {
|
||||
writeDesktopDataCache("overview", { total: 3 }, { ttlMs: 100, now: 1000 });
|
||||
|
||||
expect(readDesktopDataCache("overview", 1099)).toEqual({ total: 3 });
|
||||
expect(readDesktopDataCache("overview", 1100)).toBeNull();
|
||||
});
|
||||
|
||||
it("invalidates records by tag", () => {
|
||||
writeDesktopDataCache("sites", { items: [] }, { ttlMs: 1000, tags: ["study:1:sites"], now: 100 });
|
||||
writeDesktopDataCache("members", { items: [] }, { ttlMs: 1000, tags: ["study:1:members"], now: 100 });
|
||||
|
||||
invalidateDesktopDataCacheByTags(["study:1:sites"]);
|
||||
|
||||
expect(readDesktopDataCache("sites", 200)).toBeNull();
|
||||
expect(readDesktopDataCache("members", 200)).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it("bumps generation when records are invalidated", () => {
|
||||
const initialGeneration = getDesktopDataCacheGeneration();
|
||||
writeDesktopDataCache("sites", { items: [] }, { ttlMs: 1000, tags: ["study:1:sites"], now: 100 });
|
||||
|
||||
invalidateDesktopDataCacheByTags(["study:1:sites"]);
|
||||
|
||||
expect(isDesktopDataCacheGenerationCurrent(initialGeneration)).toBe(false);
|
||||
const afterTagInvalidation = getDesktopDataCacheGeneration();
|
||||
|
||||
clearDesktopDataCache();
|
||||
expect(isDesktopDataCacheGenerationCurrent(afterTagInvalidation)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
export interface DesktopDataCacheWriteOptions {
|
||||
ttlMs: number;
|
||||
tags?: string[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface DesktopDataCacheStats {
|
||||
scope: string;
|
||||
entries: number;
|
||||
estimatedBytes: number;
|
||||
oldestUpdatedAt: number | null;
|
||||
newestUpdatedAt: number | null;
|
||||
}
|
||||
|
||||
interface DesktopDataCacheRecord {
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastAccessedAt: number;
|
||||
tags: string[];
|
||||
estimatedBytes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE = "anonymous";
|
||||
const MAX_MEMORY_CACHE_ENTRIES = 300;
|
||||
|
||||
const records = new Map<string, DesktopDataCacheRecord>();
|
||||
let currentScope = DEFAULT_SCOPE;
|
||||
let cacheGeneration = 0;
|
||||
|
||||
const normalizeScope = (scope?: string | null) => {
|
||||
const normalized = String(scope || "").trim();
|
||||
return normalized || DEFAULT_SCOPE;
|
||||
};
|
||||
|
||||
const scopedKey = (key: string) => `${currentScope}:${key}`;
|
||||
|
||||
const cloneValue = <T>(value: T): T => {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
};
|
||||
|
||||
const estimateBytes = (value: unknown) => {
|
||||
try {
|
||||
return new Blob([JSON.stringify(value)]).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const pruneExpired = (now: number) => {
|
||||
for (const [key, record] of records.entries()) {
|
||||
if (record.expiresAt <= now) {
|
||||
records.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const enforceEntryLimit = () => {
|
||||
if (records.size <= MAX_MEMORY_CACHE_ENTRIES) return;
|
||||
const sorted = [...records.entries()].sort(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt);
|
||||
const removeCount = records.size - MAX_MEMORY_CACHE_ENTRIES;
|
||||
sorted.slice(0, removeCount).forEach(([key]) => records.delete(key));
|
||||
};
|
||||
|
||||
export const setDesktopDataCacheScope = (scope?: string | null): void => {
|
||||
const nextScope = normalizeScope(scope);
|
||||
if (nextScope === currentScope) return;
|
||||
records.clear();
|
||||
currentScope = nextScope;
|
||||
cacheGeneration += 1;
|
||||
};
|
||||
|
||||
export const getDesktopDataCacheScope = (): string => currentScope;
|
||||
|
||||
export const getDesktopDataCacheGeneration = (): number => cacheGeneration;
|
||||
|
||||
export const isDesktopDataCacheGenerationCurrent = (generation: number): boolean => generation === cacheGeneration;
|
||||
|
||||
export const readDesktopDataCache = <T = unknown>(key: string, now: number = Date.now()): T | null => {
|
||||
const recordKey = scopedKey(key);
|
||||
const record = records.get(recordKey);
|
||||
if (!record) return null;
|
||||
if (record.expiresAt <= now) {
|
||||
records.delete(recordKey);
|
||||
return null;
|
||||
}
|
||||
record.lastAccessedAt = now;
|
||||
return cloneValue(record.value as T);
|
||||
};
|
||||
|
||||
export const writeDesktopDataCache = <T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
options: DesktopDataCacheWriteOptions,
|
||||
): void => {
|
||||
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) return;
|
||||
const now = options.now ?? Date.now();
|
||||
pruneExpired(now);
|
||||
records.set(scopedKey(key), {
|
||||
value: cloneValue(value),
|
||||
expiresAt: now + options.ttlMs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastAccessedAt: now,
|
||||
tags: [...(options.tags || [])],
|
||||
estimatedBytes: estimateBytes(value),
|
||||
});
|
||||
enforceEntryLimit();
|
||||
};
|
||||
|
||||
export const invalidateDesktopDataCacheByTags = (tags: string[]): void => {
|
||||
if (!tags.length) return;
|
||||
const tagSet = new Set(tags);
|
||||
for (const [key, record] of records.entries()) {
|
||||
if (record.tags.some((tag) => tagSet.has(tag))) {
|
||||
records.delete(key);
|
||||
}
|
||||
}
|
||||
cacheGeneration += 1;
|
||||
};
|
||||
|
||||
export const clearDesktopDataCache = (): void => {
|
||||
records.clear();
|
||||
cacheGeneration += 1;
|
||||
};
|
||||
|
||||
export const getDesktopDataCacheStats = (): DesktopDataCacheStats => {
|
||||
const updatedAtValues = [...records.values()].map((record) => record.updatedAt);
|
||||
return {
|
||||
scope: currentScope,
|
||||
entries: records.size,
|
||||
estimatedBytes: [...records.values()].reduce((sum, record) => sum + record.estimatedBytes, 0),
|
||||
oldestUpdatedAt: updatedAtValues.length ? Math.min(...updatedAtValues) : null,
|
||||
newestUpdatedAt: updatedAtValues.length ? Math.max(...updatedAtValues) : null,
|
||||
};
|
||||
};
|
||||
@@ -27,6 +27,19 @@ export {
|
||||
listenDesktopMenuCommand,
|
||||
type DesktopMenuCommand,
|
||||
} from "./desktopMenu";
|
||||
export {
|
||||
clearDesktopDataCache,
|
||||
getDesktopDataCacheGeneration,
|
||||
getDesktopDataCacheScope,
|
||||
getDesktopDataCacheStats,
|
||||
invalidateDesktopDataCacheByTags,
|
||||
isDesktopDataCacheGenerationCurrent,
|
||||
readDesktopDataCache,
|
||||
setDesktopDataCacheScope,
|
||||
writeDesktopDataCache,
|
||||
type DesktopDataCacheStats,
|
||||
type DesktopDataCacheWriteOptions,
|
||||
} from "./desktopDataCache";
|
||||
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
||||
export {
|
||||
cleanupTemporaryFiles,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth";
|
||||
import type { ApiRequestConfig } from "../api/axios";
|
||||
import { clearApiResponseCache, setApiResponseCacheScope, type ApiRequestConfig } from "../api/axios";
|
||||
import { setToken, clearToken, getToken } from "../utils/auth";
|
||||
import { encryptLoginPayload } from "../utils/loginCrypto";
|
||||
import type { UserInfo } from "../types/api";
|
||||
@@ -21,6 +21,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const login = async (email: string, password: string, options: { restoreStudy?: boolean } = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
clearApiResponseCache();
|
||||
const { data } =
|
||||
allowInsecureDevLogin && !window.isSecureContext
|
||||
? await devLogin({ email, password })
|
||||
@@ -59,6 +60,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const fetchMeAction = async (config?: ApiRequestConfig) => {
|
||||
const { data } = await fetchMe(config);
|
||||
user.value = data;
|
||||
setApiResponseCacheScope(data?.id || data?.email || null);
|
||||
if (data?.email) {
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
setApiResponseCacheScope(null);
|
||||
sessionStore.resetActivity();
|
||||
await clearToken();
|
||||
studyStore.clearCurrentStudy();
|
||||
|
||||
Reference in New Issue
Block a user